mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
`Server/go.mod` declared `github.com/owncord/server` while the public repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is no `owncord` GitHub org and no vanity-import host serving go-import metadata for it — so every import line in the tree named a location that does not exist. It compiles because a main module's own path is never fetched, which is exactly why it went unnoticed. The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) — is wrong here, and provably so. Six of the 722 occurrences are not imports at all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern), `telemetry/metrics.go:17-19` (three OTel instrumentation-scope names), `invariants/syncutil_locks.go:73` (a diagnostic message), and `invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go fixture). An import rewriter touches none of them, and the compiler cannot see any of them either. Done as one scripted substitution over `git ls-files`, anchored on the full `github.com/owncord/server` string. The anchor matters: `owncord-server` is a different identifier — the OTel `service.name` (`config/config.go`, `telemetry/telemetry_otel.go`) and the GHCR image name (`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern would have moved it. It is untouched: 10 occurrences across 9 files, before and after. 350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files, plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`, `docs/architecture/server.md:5`, and the ledger pair (`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of `FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in `Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard` rule keys on the module path, so import grouping is not configured anywhere). The plan's blast-radius estimate missed one thing, and it is the one that would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase letter, so in the 36 files where a module-local import shares a contiguous group with a third-party one, the module's imports must move above `github.com/go-chi/...`. `gofmt -l` was clean before the substitution and listed exactly 36 files after it; `gofmt -w` on those 36 restores it to clean. `gofmt` is an enforced gate — the `formatters` block in `Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails Lint. Verified: both directions, and the line accounting is exact. Every added line in this diff contains the new module path (728) and every removed line contains the old one (728); the count of changed lines containing neither is **zero**, so the gofmt re-sort moved module-path lines only and touched no third-party import. The residual check (`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns exactly two hits, both deliberately out of scope: the RL-13 row in `docs/audit-2026-08-23-repository-layout.md` and the measurement row in this phase's own plan. The compiler-invisible half was proven by reverting *only* `api/main_test.go:20` to the old path on the otherwise-renamed tree: `go build ./...` and `go vet ./api/` both still pass — they see nothing wrong — while `go test ./api/` FAILS, because the runtime function name now carries the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and none was needed). All four build-tag variants compile; `go vet ./...`, `go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass; `go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...` passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel) runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally against Go 1.26 because the packaged binary cannot load a 1.26 config — reports **0 issues**. `go run ./cmd/genprotocol` leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the rename does not reach the generated protocol constants. `npx prettier --check .` and `node .superpowers/render-ledger.mjs --check` pass. Not included: `docs/audit-2026-08-23-repository-layout.md` and `docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they are the audit row and the measurement that motivated this change, and rewriting them would erase the record of what was measured. They are why the residual check needs a two-path allowance rather than being empty; that allowance is stated above rather than hidden in a pathspec. `telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package that does not exist; the substitution carried the dead path forward verbatim as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because correcting a real observability bug inside a mechanical rename would hide it in a 350-file diff. It needs its own item. No `go.work`, no second module, and no vanity-import host was set up — the new path resolves against the real repository, but nothing imports this module as a library, so `go get` reachability was not exercised either way. Refs RL-13, L-12
380 lines
15 KiB
Go
380 lines
15 KiB
Go
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()
|
|
}
|