package store import ( "context" "database/sql" "fmt" "sort" "strings" ) // WipeCount is one table and the number of rows a wipe would remove from it. type WipeCount struct { Table string Rows int } // WipeCounts reports every table in the database and its row count, so a caller // can show what a wipe is about to destroy before it destroys it. Tables with // no rows are included: a QA reader needs to see that the table was considered, // not guess whether it was missed. func (s *Store) WipeCounts(ctx context.Context) ([]WipeCount, error) { names, err := userTables(ctx, s.db) if err != nil { return nil, err } out := make([]WipeCount, 0, len(names)) for _, name := range names { var n int // name comes from sqlite_master, not from a caller, and is quoted. q := fmt.Sprintf(`SELECT count(*) FROM %s`, quoteIdent(name)) if err := s.db.QueryRowContext(ctx, q).Scan(&n); err != nil { return nil, fmt.Errorf("count %s: %w", name, err) } out = append(out, WipeCount{Table: name, Rows: n}) } return out, nil } // Wipe removes every piece of personal data and leaves the install standing. // // It drops every object in the database and rebuilds the schema from // schema.sql plus the migration list, rather than deleting from a hand-written // list of tables. That is the whole point: a list has to be edited every time a // table is added, and the one time it is not, a wipe leaves personal data // behind while reporting success. Dropping what the database says exists cannot // miss a table (Vikunja #494). // // Nothing outside the database is touched. Config, models, the WebAuthn // credentials in passkeys.json and the encryption key all live in files, so // this leaves a working install with no memory of anyone. // // The caller is responsible for the daemon being the only thing holding the // store, and for asking whoever ran it whether they meant it. func (s *Store) Wipe(ctx context.Context) error { names, err := userTables(ctx, s.db) if err != nil { return err } // Foreign keys are on per connection. Dropping tables in an arbitrary order // trips them, and the drop order does not matter once everything goes. if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { return fmt.Errorf("wipe: disable foreign keys: %w", err) } defer func() { if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { // The handle is about to be closed by the caller either way. _ = err } }() var drops strings.Builder for _, name := range names { fmt.Fprintf(&drops, "DROP TABLE IF EXISTS %s;\n", quoteIdent(name)) } // user_version back to zero, or migrate() applies nothing to the fresh // schema and every ALTER that a later migration assumes is missing. drops.WriteString("PRAGMA user_version = 0;\n") if _, err := s.db.ExecContext(ctx, drops.String()); err != nil { return fmt.Errorf("wipe: drop tables: %w", err) } if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil { return fmt.Errorf("wipe: reapply schema: %w", err) } if err := migrate(ctx, s.db); err != nil { return fmt.Errorf("wipe: re-migrate: %w", err) } // The file keeps the pages the dropped rows used until it is vacuumed, and // a wipe that leaves readable text in free pages is not a wipe. if _, err := s.db.ExecContext(ctx, `VACUUM`); err != nil { return fmt.Errorf("wipe: vacuum: %w", err) } return nil } // userTables lists every table the schema owns, sorted, excluding sqlite's own // internal tables (sqlite_sequence and friends, which cannot be dropped). func userTables(ctx context.Context, db *sql.DB) ([]string, error) { rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) if err != nil { return nil, fmt.Errorf("list tables: %w", err) } defer rows.Close() var names []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, fmt.Errorf("list tables: %w", err) } names = append(names, name) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list tables: %w", err) } sort.Strings(names) return names, nil } func quoteIdent(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` }