mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Server fixes: - voice_leave: broadcast voice_leave even on DB error so peers don't see ghost users (H1) - migrate: record migration inside transaction for atomicity (H2) - password: use init() with panic for dummyHash to catch bcrypt init failures (M1) - password: replace //nolint:errcheck with explicit _ = discard (M2) - handlers: clarify edit permission comment, fix error message wording (M3) - auth_handler: distinguish duplicate username (400) from DB error (500) (M4) Client fixes: - media: revert YouTube oEmbed to browser fetch — no need to disable cert verification (C1) - messages.store: fix prependMessages cap to keep newest messages, not oldest (H5) - ChannelSidebar: ref-count globalDragAc to prevent multi-instance teardown race (H6) - attachments: replace console.error with project logger (M6) - ws: clarify lastSeq reset comment to match actual behavior (M5)
204 lines
6.1 KiB
Go
204 lines
6.1 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.
|
|
//
|
|
// 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 (
|
|
"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.sqlDB.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.sqlDB.QueryRow(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'",
|
|
).Scan(&name)
|
|
if err != nil {
|
|
// sql.ErrNoRows means the table does not exist.
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// schemaVersionsExists reports whether the schema_versions table is present.
|
|
func schemaVersionsExists(d *DB) (bool, error) {
|
|
var name string
|
|
err := d.sqlDB.QueryRow(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'",
|
|
).Scan(&name)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
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.sqlDB.QueryRow(
|
|
"SELECT version FROM schema_versions WHERE version = ?", filename,
|
|
).Scan(&v)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// recordApplied inserts a migration filename into schema_versions.
|
|
func recordApplied(d *DB, filename string) error {
|
|
_, err := d.sqlDB.Exec(
|
|
"INSERT INTO schema_versions (version) VALUES (?)", filename,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("recording migration %s: %w", filename, err)
|
|
}
|
|
return 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 inserts all migration filenames into schema_versions
|
|
// without executing them. This is called once when upgrading a pre-tracking
|
|
// database.
|
|
func seedExistingDatabase(d *DB, filenames []string) error {
|
|
for _, name := range filenames {
|
|
if err := recordApplied(d, name); err != nil {
|
|
return fmt.Errorf("seeding %s: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MigrateFS runs tracked migrations from the provided FS.
|
|
//
|
|
// Behaviour:
|
|
// 1. Create schema_versions if absent.
|
|
// 2. If this is the first run with tracking on an existing database (users
|
|
// table exists but schema_versions was just created), seed all filenames
|
|
// so they are not re-executed.
|
|
// 3. For each .sql file in lexicographic order: skip if already recorded,
|
|
// otherwise execute the SQL and record the filename.
|
|
func MigrateFS(database *DB, fsys fs.FS) error {
|
|
// Determine tracking state before we create schema_versions.
|
|
svExists, err := schemaVersionsExists(database)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create the tracking table (idempotent).
|
|
if err := ensureSchemaVersions(database); 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
tx, txErr := database.sqlDB.Begin()
|
|
if txErr != nil {
|
|
return fmt.Errorf("begin tx for %s: %w", name, txErr)
|
|
}
|
|
|
|
raw, readErr := fs.ReadFile(fsys, name)
|
|
if readErr != nil {
|
|
_ = tx.Rollback() // error ignored: already handling the triggering error
|
|
return fmt.Errorf("reading migration %s: %w", name, readErr)
|
|
}
|
|
|
|
if _, execErr := tx.Exec(string(raw)); execErr != nil {
|
|
_ = tx.Rollback() // error ignored: already handling the triggering error
|
|
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. A crash between commit and
|
|
// record would otherwise cause re-application on next startup.
|
|
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
|
|
}
|