Files
OwnCord/Server/db/migrate.go
T
J3vbandClaude Opus 5 ea0430c5b0 fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)

* fix(service): 1 defect(s) (OC-0202)

HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.

* fix(client): 2 defect(s) (OC-0203, OC-0224)

* fix(server): 1 defect(s) (OC-0204)

* fix(ws): 2 defect(s) (OC-0205, OC-0211)

* fix(admin): 2 defect(s) (OC-0209, OC-0212)

* fix(client): 1 defect(s) (OC-0210)

* fix(db): 1 defect(s) (OC-0213)

* fix(ws): 1 defect(s) (OC-0214)

Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.

* fix(admin): 1 defect(s) (OC-0215)

PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.

* fix(db): 1 defect(s) (OC-0216)

LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.

* fix(emoji): 1 defect(s) (OC-0217)

* fix(client): 1 defect(s) (OC-0218)

The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.

Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.

Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.

Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0219)

* fix(client): 1 defect(s) (OC-0221)

UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().

* fix(dm): 1 defect(s) (OC-0222)

* fix(client): 1 defect(s) (OC-0223)

* fix(voice): 1 defect(s) (OC-0225)

The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().

* fix(admin): 1 defect(s) (OC-0226)

handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.

* fix(admin): 1 defect(s) (OC-0227)

PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.

* fix(identity): 1 defect(s) (OC-0228)

* fix(admin): run deferred cleanup before the update restart exits

The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.

applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.

Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* test(ws): pin the live presence path against the invisible custom-status leak

OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.

This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0206)

* test(ws): silence a contextcheck false positive in the reconnect race test

RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.

golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 16:30:05 +02:00

350 lines
11 KiB
Go

package db
// migrate.go — tracked migration runner for the OwnCord server.
//
// Each .sql file in the provided FS is applied exactly once. The
// schema_versions table records every applied migration filename and the UTC
// timestamp at which it was applied.
//
// Every statement here — including the schema_versions bookkeeping reads —
// runs on the writer pool so migration DDL and its tracking records are
// applied and observed on the single write connection.
//
// Seeding for existing databases
// --------------------------------
// When the server is first upgraded to include migration tracking, existing
// databases will have all schema tables in place but no schema_versions table.
// Without seeding, every migration would re-run and could destroy data.
//
// The seeding heuristic: if schema_versions does not exist AND the "users"
// table already exists, we assume all migrations in the current FS have
// already been applied. We create schema_versions and insert every migration
// filename without executing the SQL, so subsequent runs treat them as done.
import (
"database/sql"
"fmt"
"io/fs"
"sort"
"strings"
)
const createSchemaVersions = `
CREATE TABLE IF NOT EXISTS schema_versions (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
// ensureSchemaVersions creates the tracking table if it does not yet exist.
func ensureSchemaVersions(d *DB) error {
if _, err := d.writer.Exec(createSchemaVersions); err != nil {
return fmt.Errorf("creating schema_versions: %w", err)
}
return nil
}
// isExistingDatabase reports whether the database was previously migrated
// without tracking — detected by the presence of the "users" table.
func isExistingDatabase(d *DB) (bool, error) {
var name string
err := d.writer.QueryRow(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'",
).Scan(&name)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("isExistingDatabase: %w", err)
}
return true, nil
}
// schemaVersionsExists reports whether the schema_versions table is present.
func schemaVersionsExists(d *DB) (bool, error) {
var name string
err := d.writer.QueryRow(
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'",
).Scan(&name)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("schemaVersionsExists: %w", err)
}
return true, nil
}
// isApplied reports whether a migration filename has already been recorded.
func isApplied(d *DB, filename string) (bool, error) {
var v string
err := d.writer.QueryRow(
"SELECT version FROM schema_versions WHERE version = ?", filename,
).Scan(&v)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("isApplied: %w", err)
}
return true, nil
}
// sqlFilenames returns all .sql entries from the FS sorted lexicographically.
func sqlFilenames(fsys fs.FS) ([]string, error) {
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
return nil, fmt.Errorf("reading migrations dir: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
return names, nil
}
// seedExistingDatabase creates schema_versions (if absent) and inserts all
// migration filenames into it without executing them, atomically. This is
// called once when upgrading a pre-tracking database.
//
// The CREATE TABLE runs inside the same transaction as the INSERTs — SQLite
// DDL is transactional — so a failure or interruption partway through
// leaves no schema_versions table behind at all, rather than an empty one.
// An empty-but-present table would make the next MigrateFS call believe
// tracking is already in place, permanently skip seeding, and replay every
// migration against the live, already-populated database.
func seedExistingDatabase(d *DB, filenames []string) error {
tx, err := d.writer.Begin()
if err != nil {
return fmt.Errorf("begin seed tx: %w", err)
}
if _, execErr := tx.Exec(createSchemaVersions); execErr != nil {
_ = tx.Rollback()
return fmt.Errorf("creating schema_versions in seed tx: %w", execErr)
}
for _, name := range filenames {
if _, execErr := tx.Exec(
"INSERT INTO schema_versions (version) VALUES (?)", name,
); execErr != nil {
_ = tx.Rollback()
return fmt.Errorf("seeding %s: %w", name, execErr)
}
}
if commitErr := tx.Commit(); commitErr != nil {
return fmt.Errorf("commit seed tx: %w", commitErr)
}
return nil
}
// MigrateFS runs tracked migrations from the provided FS.
//
// Behaviour:
// 1. If this is the first run with tracking on an existing database (no
// schema_versions table yet, but the "users" table already exists),
// atomically create schema_versions and seed it with every filename so
// none of them are re-executed. Creation and seeding happen in one
// transaction: a failure or interruption partway through leaves no
// schema_versions table behind, so the next run retries seeding instead
// of silently treating tracking as already in place.
// 2. Otherwise, create schema_versions if absent (idempotent — the correct
// state for a fresh database is an empty tracking table) and apply any
// .sql file in lexicographic order that is not yet recorded.
func MigrateFS(database *DB, fsys fs.FS) error {
// Determine tracking state before touching schema_versions at all — the
// seeding path below must be the one to create it, atomically with the
// seed rows, so do not call ensureSchemaVersions before this check.
svExists, err := schemaVersionsExists(database)
if err != nil {
return err
}
// Collect filenames first — needed for both seeding and normal application.
filenames, err := sqlFilenames(fsys)
if err != nil {
return err
}
// Seeding path: schema_versions did not exist AND users table does, which
// means this is an existing database being upgraded to tracked migrations.
if !svExists {
existing, checkErr := isExistingDatabase(database)
if checkErr != nil {
return checkErr
}
if existing {
return seedExistingDatabase(database, filenames)
}
}
// Non-seeding paths: schema_versions already exists, or this is a fresh
// database with no prior schema — either way, an idempotent create is
// the correct next step before applying migrations normally.
if err := ensureSchemaVersions(database); err != nil {
return err
}
// Normal path: apply any migration not yet recorded.
for _, name := range filenames {
applied, applyErr := isApplied(database, name)
if applyErr != nil {
return applyErr
}
if applied {
continue
}
raw, readErr := fs.ReadFile(fsys, name)
if readErr != nil {
return fmt.Errorf("reading migration %s: %w", name, readErr)
}
if err := applyMigration(database, name, string(raw)); err != nil {
return err
}
}
return nil
}
// applyMigration executes a single migration and records it. If the
// migration contains multiple statements (e.g. several ALTER TABLE ADD
// COLUMN), each is executed individually so that "duplicate column" errors
// from a prior partial run can be skipped — the column already exists and
// the intent is satisfied.
func applyMigration(database *DB, name, rawSQL string) error {
stmts := splitStatements(rawSQL)
tx, txErr := database.writer.Begin()
if txErr != nil {
return fmt.Errorf("begin tx for %s: %w", name, txErr)
}
for _, stmt := range stmts {
if _, execErr := tx.Exec(stmt); execErr != nil {
if isDuplicateColumn(execErr) {
continue // column already exists — skip
}
_ = tx.Rollback()
return fmt.Errorf("executing migration %s: %w", name, execErr)
}
}
// Record the migration inside the same transaction so the migration
// and its tracking record are atomic.
if _, execErr := tx.Exec(
"INSERT INTO schema_versions (version) VALUES (?)", name,
); execErr != nil {
_ = tx.Rollback()
return fmt.Errorf("recording migration %s: %w", name, execErr)
}
if commitErr := tx.Commit(); commitErr != nil {
return fmt.Errorf("commit migration %s: %w", name, commitErr)
}
return nil
}
// splitStatements splits raw SQL into individual statements on semicolons,
// correctly handling BEGIN...END blocks used by CREATE TRIGGER definitions.
// Empty/comment-only fragments are discarded.
func splitStatements(raw string) []string {
out := make([]string, 0)
var buf strings.Builder
depth := 0
for line := range strings.SplitSeq(raw, "\n") {
trimmed := strings.TrimSpace(line)
// Track BEGIN...END depth for trigger bodies.
upperTrimmed := strings.ToUpper(trimmed)
if depth > 0 && (upperTrimmed == "END;" || upperTrimmed == "END") {
depth--
buf.WriteString(line)
buf.WriteString("\n")
if depth == 0 {
// END; closes the trigger — flush the entire block as one statement.
s := strings.TrimSpace(buf.String())
// Strip trailing semicolons so the executor doesn't choke.
s = strings.TrimRight(s, ";")
s = strings.TrimSpace(s)
if s != "" && !isCommentOnly(s) {
out = append(out, s)
}
buf.Reset()
}
continue
}
// Detect BEGIN that opens a trigger body. The keyword appears at
// the end of a CREATE TRIGGER line (e.g. "... BEGIN") or on its
// own line inside a trigger definition.
if strings.HasSuffix(upperTrimmed, " BEGIN") || upperTrimmed == "BEGIN" {
depth++
buf.WriteString(line)
buf.WriteString("\n")
continue
}
if depth > 0 {
// Inside a BEGIN...END block — accumulate without splitting.
buf.WriteString(line)
buf.WriteString("\n")
continue
}
// Outside any block — split on semicolons within this line.
buf.WriteString(line)
buf.WriteString("\n")
// Check whether the accumulated buffer contains a semicolon to split on.
// We split the full buffer content, not just the current line, because a
// statement may span multiple lines before its terminating semicolon.
content := buf.String()
if strings.Contains(content, ";") {
parts := strings.Split(content, ";")
// All parts except the last are complete statements.
for _, p := range parts[:len(parts)-1] {
s := strings.TrimSpace(p)
if s == "" || isCommentOnly(s) {
continue
}
out = append(out, s)
}
// The last part is the remainder after the final semicolon.
buf.Reset()
buf.WriteString(parts[len(parts)-1])
}
}
// Flush any remaining content (statement without trailing semicolon).
s := strings.TrimSpace(buf.String())
if s != "" && !isCommentOnly(s) {
out = append(out, s)
}
return out
}
// isCommentOnly returns true if every line is a SQL comment or blank.
func isCommentOnly(s string) bool {
for line := range strings.SplitSeq(s, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "--") {
return false
}
}
return true
}
// isDuplicateColumn reports whether a SQLite error indicates a duplicate
// column name from an ALTER TABLE ADD COLUMN statement.
func isDuplicateColumn(err error) bool {
return err != nil && strings.Contains(err.Error(), "duplicate column name")
}