package admin import ( "context" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/J3vb/OwnCord/Server/db" "github.com/go-chi/chi/v5" ) // backupBaseDir is the directory for backup files, resolved to an absolute // path at package init time so handlers don't depend on the process CWD (L14). // Overridden at startup via SetBackupDir with cfg.Backup.Dir. var backupBaseDir string func init() { backupBaseDir = absOrRaw(filepath.Join("data", "backups")) } // SetBackupDir points every backup handler and the scheduled-backup // maintenance at the operator-configured directory. Call once at startup with // cfg.Backup.Dir (main.go, next to SetDatabasePath); tests use it to isolate // a temp dir. Mirrors SetDatabasePath: without it, a configured backup.dir // would be ignored while backups keep landing in the default location. func SetBackupDir(dir string) { if dir == "" { return } backupBaseDir = absOrRaw(dir) } func absOrRaw(p string) string { if abs, err := filepath.Abs(p); err == nil { return abs } return p } // dbFilePath is the live SQLite database file that "Restore backup" // overwrites. It defaults to the historical "data/chatserver.db" but must be // pointed at cfg.Database.Path via SetDatabasePath before the server starts // serving requests (main.go, right after db.Open): without that call, a // server configured with a non-default database.path would open its real // database at cfg.Database.Path while restore keeps copying backups over an // unrelated (possibly newly created) file at the default path, reporting // success while the live database is never touched. var dbFilePath = filepath.Join("data", "chatserver.db") // SetDatabasePath points the restore handler at the SQLite file the server // actually opened. Call once at startup with cfg.Database.Path; tests use it // to point restore at an isolated temp file. func SetDatabasePath(path string) { dbFilePath = path } // ─── Backup Handlers ───────────────────────────────────────────────────────── func handleBackup(database *db.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { backupDir := backupBaseDir if err := os.MkdirAll(backupDir, 0o750); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create backup directory") return } timestamp := time.Now().UTC().Format("20060102_150405") backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db") // Detached like the restore path's safety backup: an interrupted // VACUUM INTO leaves a truncated .db that handleListBackups would // present as restorable. BackupToSafe is rooted at the configured // backup dir (SetBackupDir), not the historical hardcoded default. if err := database.BackupToSafe(context.WithoutCancel(r.Context()), backupPath, backupDir); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed") return } // Verify before reporting success: a backup that fails integrity_check // is worse than no backup, because the operator believes they have one. if err := db.CheckBackupIntegrity(context.WithoutCancel(r.Context()), backupPath); err != nil { slog.Error("backup failed integrity check — removing", "path", backupPath, "err", err) _ = os.Remove(backupPath) writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed verification") return } actor := actorFromContext(r) backupName := filepath.Base(backupPath) slog.Info("database backup created", "actor_id", actor, "name", backupName) db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "backup_create", "server", 0, fmt.Sprintf("backup saved: %s", backupName)) writeJSON(w, http.StatusOK, map[string]string{ "path": filepath.Base(backupPath), "created": timestamp, }) }) } // backupEntry is the JSON shape returned by GET /admin/api/backups. type backupEntry struct { Name string `json:"name"` Size int64 `json:"size"` Date string `json:"date"` } func handleListBackups() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { backupDir := backupBaseDir entries, err := os.ReadDir(backupDir) if err != nil { if os.IsNotExist(err) { writeJSON(w, http.StatusOK, []backupEntry{}) return } writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list backups") return } var backups []backupEntry for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".db" { continue } info, err := e.Info() if err != nil { continue } backups = append(backups, backupEntry{ Name: e.Name(), Size: info.Size(), Date: info.ModTime().UTC().Format(time.RFC3339), }) } if backups == nil { backups = []backupEntry{} } // Sort newest first. sort.Slice(backups, func(i, j int) bool { return backups[i].Date > backups[j].Date }) writeJSON(w, http.StatusOK, backups) } } func handleDeleteBackup(database *db.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid backup name") return } target := filepath.Join(backupBaseDir, name) if !strings.HasPrefix(target, backupBaseDir+string(filepath.Separator)) { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid backup name") return } if _, err := os.Stat(target); os.IsNotExist(err) { //nolint:gosec // G703: path sanitized by HasPrefix check above writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found") return } if err := os.Remove(target); err != nil { //nolint:gosec // G703: path sanitized by HasPrefix check above writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete backup") return } actor := actorFromContext(r) slog.Info("backup deleted", "actor_id", actor, "name", name) db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "backup_delete", "server", 0, "deleted backup "+name) w.WriteHeader(http.StatusNoContent) }) } func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid backup name") return } target := filepath.Join(backupBaseDir, name) if !strings.HasPrefix(target, backupBaseDir+string(filepath.Separator)) { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid backup name") return } // Serialize against a concurrent update apply (which stages a new // binary and then restarts) and an already-requested restart — a // restore must not close the database out from under either. The // deferred release is a busy→idle CAS, so it covers every failure // return below and harmlessly no-ops once a branch has promoted the // state to restart-pending via commitRestartPending. if !beginRestartSensitiveOp() { writeRestartConflict(w) return } defer abortRestartSensitiveOp() if _, err := os.Stat(target); os.IsNotExist(err) { //nolint:gosec // G703: path sanitized by HasPrefix check above writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found") return } // Refuse to overwrite the live database with a file SQLite itself // rejects — a truncated pre-crash backup, a stray non-database .db. // The pre-restore safety copy would make this survivable, but "restore // succeeded" followed by a broken server is still the worst UX here. if err := db.CheckBackupIntegrity(context.WithoutCancel(r.Context()), target); err != nil { slog.Error("restore refused: backup failed integrity check", "backup", name, "err", err) writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "backup file failed integrity verification") return } dbPath := dbFilePath actor := actorFromContext(r) // Audit the restore BEFORE the pre-restore safety copy is taken, and // synchronously (LogAudit, not WriteAudit): the restore overwrites the // live database file, so the only durable home for this row is the // pre_restore_* backup captured below — an entry enqueued on the async // WriteAudit path could still be sitting in the writer's buffer when // BackupTo snapshots the DB. Best-effort per policy D8: a failed write // is logged, never a reason to refuse the restore. if err := database.LogAudit(context.WithoutCancel(r.Context()), actor, "backup_restore", "server", 0, fmt.Sprintf("restoring backup %s", name)); err != nil { slog.Error("audit log write failed", "action", "backup_restore", "actor_id", actor, "error", err) } // Safety: create a pre-restore backup before overwriting. WithoutCancel: // the restore proceeds regardless of client disconnect (Close/copyFile // below are not ctx-aware), so the safety backup must not be skippable // by a canceled request ctx. // backupBaseDir, not a cwd-relative path: the safety copy has to land in // the same directory the rest of the backup handlers read and write, or // a server started from another working directory writes it somewhere // the operator will never find it. preRestore := filepath.Join(backupBaseDir, "pre_restore_"+time.Now().UTC().Format("20060102_150405")+".db") if err := database.BackupToSafe(context.WithoutCancel(r.Context()), preRestore, backupBaseDir); err != nil { // Fail closed. The admin panel promises "a pre-restore backup will // be created" before an irreversible overwrite; proceeding without // one takes away the safety net the operator was shown, exactly // when they need it (restoring the wrong or a corrupt backup). slog.Error("pre-restore backup failed — aborting restore", "err", err) writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "could not create the pre-restore safety backup — restore aborted, database untouched") return } // Notify clients that the server is restarting. hub.BroadcastServerRestart("backup_restore", 5) // Checkpoint the WAL and close the database connection before overwriting // to prevent corruption from concurrent writes (BUG-096). if _, checkpointErr := database.SQLDb().ExecContext(context.WithoutCancel(r.Context()), "PRAGMA wal_checkpoint(TRUNCATE)"); checkpointErr != nil { slog.Warn("pre-restore WAL checkpoint failed", "err", checkpointErr) } slog.Warn("database restored from backup — closing DB", "actor_id", actor, "backup", name) if err := closeDatabase(database); err != nil { // database.Close() closes the writer and reader pools regardless of // the error it returns (Server/db/db.go), so this process cannot // serve anything more either way — every other failure path below // (copyFile failing, and the success path itself) restarts for // exactly that reason. The live database file is still intact here // (copyFile hasn't run yet), so the restarted process comes back on // the pre-restore data rather than leaving clients pinned on // "Reconnecting..." against a process that never actually restarts. slog.Error("failed to close database before restore — restarting anyway, DB pools are closed either way", "err", err) writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to close database") commitRestartPending() go requestRestart("backup_restore_close_failed") return } // Stream the backup file over the (now closed) database to avoid loading // the entire DB into memory (could be hundreds of MiB). if err := copyBackupFile(target, dbPath); err != nil { // copyFile truncates the destination with os.Create before it can know // whether the read will succeed, so the live database file is already // destroyed by the time we get here — and the DB is closed, so nothing // is holding the old contents. Put the safety copy back rather than // leaving the operator with a zero-byte database. slog.Error("restore copy failed — rolling back to the pre-restore safety copy", "backup", name, "err", err) msg := "failed to restore database file — the pre-restore safety copy was put back, server restarting" if rbErr := copyBackupFile(preRestore, dbPath); rbErr != nil { slog.Error("rollback from the pre-restore safety copy failed — recover manually", "safety_copy", preRestore, "err", rbErr) msg = "failed to restore database file AND failed to roll back — recover manually from " + filepath.Base(preRestore) } writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", msg) // The database was closed before the copy: this process cannot serve // anything more either way, so it must restart exactly as it does on // the success path. commitRestartPending() go requestRestart("backup_restore_failed") return } slog.Warn("database file replaced — restarting to load the restored data", "backup", name) writeJSON(w, http.StatusOK, map[string]string{ "message": "database restored — server restarting", "backup": name, }) // The database is closed and the file underneath it has been swapped: // this process can serve nothing more. It used to stop here, leaving a // live server answering every request against a closed DB while the // response and the restart broadcast both claimed a restart was // happening. Restart for real, the same way applying an update does. commitRestartPending() go requestRestart("backup_restore") }) } // dbCloser is swappable in tests to simulate database.Close() returning an // error. modernc.org/sqlite's sqlite3_close_v2 essentially never fails on a // normally-open connection, so there is no portable way to provoke a genuine // Close() error from a real driver in a unit test; this seam lets tests // exercise that branch directly. Guarded like restartSelf, for the same // reason (swap happens on the test goroutine, read on the handler's). var ( closeMu sync.Mutex dbCloser = func(database *db.DB) error { return database.Close() } ) // closeDatabase invokes the current close hook. func closeDatabase(database *db.DB) error { closeMu.Lock() fn := dbCloser closeMu.Unlock() return fn(database) } // copyBackupFile is the restore path's file-copy hook. It exists as a var so // tests can inject the hard-to-simulate mid-copy failure (truncate-then-fail) // the rollback branch exists for; production never swaps it. var copyBackupFile = copyFile // copyFile streams src to dst without loading the entire file into memory. func copyFile(src, dst string) error { in, err := os.Open(src) //nolint:gosec // G703: src is from sanitized backup path if err != nil { return fmt.Errorf("open source: %w", err) } defer in.Close() //nolint:errcheck out, err := os.Create(dst) if err != nil { return fmt.Errorf("create destination: %w", err) } if _, err := io.Copy(out, in); err != nil { _ = out.Close() return fmt.Errorf("copy: %w", err) } if err := out.Sync(); err != nil { _ = out.Close() return fmt.Errorf("sync: %w", err) } return out.Close() }