Files
OwnCord/Server/admin/handlers_backup.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

382 lines
14 KiB
Go

package admin
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"syscall"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/db"
"github.com/owncord/server/updater"
)
const (
// restartGraceDelay lets the HTTP response and the server_restart
// broadcast reach clients before the process goes away.
restartGraceDelay = 2 * time.Second
// shutdownGraceDelay is how long SIGTERM gets before the os.Exit backstop.
shutdownGraceDelay = 10 * time.Second
)
// 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).
var backupBaseDir string
func init() {
abs, err := filepath.Abs(filepath.Join("data", "backups"))
if err == nil {
backupBaseDir = abs
} else {
backupBaseDir = filepath.Join("data", "backups")
}
}
// 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.
if err := database.BackupTo(context.WithoutCancel(r.Context()), backupPath); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed")
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
}
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
}
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.BackupTo(context.WithoutCancel(r.Context()), preRestore); 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) respawns for
// exactly that reason. The live database file is still intact here
// (copyFile hasn't run yet), so the respawned 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")
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 := copyFile(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 := copyFile(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 respawn exactly as it does on
// the success path.
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. Respawn for real, the same way applying an update does.
go requestRestart("backup_restore")
})
}
// restartSelf is the process-restart hook, swappable in tests (which must not
// respawn or exit the test binary). Guarded because the swap happens on the
// test goroutine while the restore handler reads it from its own.
var (
restartMu sync.Mutex
restartSelf = restartProcess
)
// 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)
}
// requestRestart invokes the current restart hook.
func requestRestart(reason string) {
restartMu.Lock()
fn := restartSelf
restartMu.Unlock()
fn(reason)
}
// restartProcess spawns a fresh copy of this server and shuts the current one
// down. Mirrors the update-apply path (update_handlers.go): SIGTERM first so
// main.go's graceful shutdown runs, os.Exit as the backstop.
func restartProcess(reason string) {
// Give the HTTP response and the restart broadcast a moment to flush.
time.Sleep(restartGraceDelay)
exePath, err := os.Executable()
if err != nil {
slog.Error("restart: cannot determine executable path — manual restart required",
"reason", reason, "error", err)
return
}
if resolved, symErr := filepath.EvalSymlinks(exePath); symErr == nil {
exePath = resolved
}
if err := updater.SpawnDetached(exePath, os.Args[1:]); err != nil {
slog.Error("restart: spawning the replacement process failed — manual restart required",
"reason", reason, "error", err)
return
}
slog.Info("restart: replacement process spawned, shutting down", "reason", reason)
if p, findErr := os.FindProcess(os.Getpid()); findErr == nil {
_ = p.Signal(syscall.SIGTERM)
time.Sleep(shutdownGraceDelay)
}
os.Exit(0) //nolint:gocritic // backstop if the SIGTERM handler didn't exit
}
// 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()
}