mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
CRITICAL (5): - Hub panic recovery now calls h.Stop() after 3 panics (ws/hub.go) - Ring buffer EventsSince returns non-nil empty slice for current seq (ws/ringbuffer.go) - PTT event listener stores unsubscribe handle to prevent leak (ptt.ts) - verifyTotp respects config.allowSelfSigned instead of hardcoding (api.ts) - ptt_listen_for_key uses spawn_blocking to avoid thread pool starvation (ptt.rs) HIGH - Server (13): - TOTP rate-limit checked after body decode; counters reset on success - TOTP enable returns 409 if already enabled (must disable first) - Global search pre-computes accessible channel IDs for FTS WHERE clause - DeleteAccount queries roles by name instead of hard-coded IDs - BackupToSafe uses absClean in VACUUM INTO - Voice camera slot uses atomic EnableCameraIfUnderLimit DB method - readPump snapshots voiceChID before unregister for TOCTOU safety - Voice join sets state after token send; rollback takes broadcast flag - Updater download uses probe pattern instead of overflow write - Webhook checks Authorization header before reading body - Storage.Save adds fsync and fixes double-close - Default WS origin denies cross-origin (was: accept all) HIGH - Client (6): - WS reconnect uses generation counter to discard stale events - AudioPipeline uses generation counter against stale worklet callbacks - Screenshare mute state preserved across reconnect (not full leave) - handleVoiceToken uses iterative loop instead of unbounded recursion - store.ts re-entrancy guard with pending update queue - Notification AudioContext cleaned up on logout Reviewed by 4 parallel agents across Server Core, Server Realtime, Client & Tauri, and Security. 55 total findings; 24 CRITICAL+HIGH fixed here, 31 MEDIUM+LOW tracked in vault backlog (T-265–T-295).
144 lines
4.7 KiB
Go
144 lines
4.7 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// DeleteAccount anonymises and disables a user account within a single
|
|
// transaction. Because the messages, invites, emoji, and sounds tables
|
|
// reference users(id) with no ON DELETE CASCADE, we cannot simply DELETE
|
|
// the row. Instead we:
|
|
//
|
|
// 1. Verify the user is not the last admin/owner (return ErrLastAdmin).
|
|
// 2. Invalidate all sessions so existing tokens stop working.
|
|
// 3. Remove DM participation and open-state rows.
|
|
// 4. Remove reactions.
|
|
// 5. Remove read states.
|
|
// 6. Soft-delete all messages (mark deleted, clear content).
|
|
// 7. Anonymise the user row: clear password, avatar, TOTP, set
|
|
// username to "[deleted-{id}]", status to "offline", banned to 1.
|
|
//
|
|
// After this the account is completely unusable and all personal data is
|
|
// removed while preserving referential integrity for historical records.
|
|
func (d *DB) DeleteAccount(ctx context.Context, userID int64) error {
|
|
tx, err := d.sqlDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteAccount begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
// ── Guard: last admin/owner check ────────────────────────────────────
|
|
// Dynamically resolve admin-class role IDs from the roles table.
|
|
adminRows, err := tx.QueryContext(ctx,
|
|
`SELECT id FROM roles WHERE name IN ('Owner', 'Admin')`,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteAccount fetch admin roles: %w", err)
|
|
}
|
|
var adminRoleIDs []int64
|
|
for adminRows.Next() {
|
|
var rid int64
|
|
if scanErr := adminRows.Scan(&rid); scanErr != nil {
|
|
adminRows.Close() //nolint:errcheck
|
|
return fmt.Errorf("DeleteAccount scan admin role: %w", scanErr)
|
|
}
|
|
adminRoleIDs = append(adminRoleIDs, rid)
|
|
}
|
|
adminRows.Close() //nolint:errcheck
|
|
if adminRows.Err() != nil {
|
|
return fmt.Errorf("DeleteAccount admin roles rows: %w", adminRows.Err())
|
|
}
|
|
|
|
if len(adminRoleIDs) == 0 {
|
|
// No admin-class roles defined; skip the guard.
|
|
} else {
|
|
var userRoleID int64
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT role_id FROM users WHERE id = ?`, userID,
|
|
).Scan(&userRoleID); err != nil {
|
|
return fmt.Errorf("DeleteAccount fetch role: %w", err)
|
|
}
|
|
|
|
isAdminClass := false
|
|
for _, rid := range adminRoleIDs {
|
|
if userRoleID == rid {
|
|
isAdminClass = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if isAdminClass {
|
|
// Build IN clause dynamically for the admin role IDs.
|
|
placeholders := make([]string, len(adminRoleIDs))
|
|
args := make([]any, 0, len(adminRoleIDs)+1)
|
|
for i, rid := range adminRoleIDs {
|
|
placeholders[i] = "?"
|
|
args = append(args, rid)
|
|
}
|
|
args = append(args, userID)
|
|
|
|
var adminCount int
|
|
if err := tx.QueryRowContext(ctx,
|
|
fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`,
|
|
strings.Join(placeholders, ",")),
|
|
args...,
|
|
).Scan(&adminCount); err != nil {
|
|
return fmt.Errorf("DeleteAccount count admins: %w", err)
|
|
}
|
|
if adminCount == 0 {
|
|
return ErrLastAdmin
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Purge related data ───────────────────────────────────────────────
|
|
stmts := []struct {
|
|
label string
|
|
query string
|
|
}{
|
|
{"sessions", `DELETE FROM sessions WHERE user_id = ?`},
|
|
{"dm_participants", `DELETE FROM dm_participants WHERE user_id = ?`},
|
|
{"dm_open_state", `DELETE FROM dm_open_state WHERE user_id = ?`},
|
|
{"reactions", `DELETE FROM reactions WHERE user_id = ?`},
|
|
{"read_states", `DELETE FROM read_states WHERE user_id = ?`},
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := tx.ExecContext(ctx, s.query, userID); err != nil {
|
|
return fmt.Errorf("DeleteAccount %s: %w", s.label, err)
|
|
}
|
|
}
|
|
|
|
// Soft-delete messages: mark as deleted and clear content so the rows
|
|
// remain for conversation continuity but contain no personal data.
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE messages SET deleted = 1, content = '' WHERE user_id = ?`,
|
|
userID,
|
|
); err != nil {
|
|
return fmt.Errorf("DeleteAccount messages: %w", err)
|
|
}
|
|
|
|
// ── Anonymise user row ───────────────────────────────────────────────
|
|
anonUsername := fmt.Sprintf("[deleted-%d]", userID)
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE users
|
|
SET username = ?,
|
|
password = '',
|
|
avatar = NULL,
|
|
totp_secret = NULL,
|
|
status = 'offline',
|
|
banned = 1,
|
|
ban_reason = 'account deleted'
|
|
WHERE id = ?`,
|
|
anonUsername, userID,
|
|
); err != nil {
|
|
return fmt.Errorf("DeleteAccount anonymise: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("DeleteAccount commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|