// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "fmt" "log/slog" "net/http" "sourcedock.dev/petrbalvin/volumen/internal/backup" "sourcedock.dev/petrbalvin/volumen/internal/i18n" ) func (a *Admin) handleSettingsExport(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Disposition", `attachment; filename="volumen-backup.tar.gz"`) if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } // The archive streams straight to the client: the app routes it past // the buffering wrappers, so no copy of it waits in memory. An error // before the first byte still answers as a plain 500; after it, the // download ends truncated and the gzip footer makes that visible. sent := false if err := backup.Write(writeTracker{w, &sent}, a.deps.Backup); err != nil { slog.Error("admin: backup export failed", "error", err) if !sent { w.Header().Del("Content-Type") w.Header().Del("Content-Disposition") http.Error(w, "The backup could not be written: "+err.Error(), http.StatusInternalServerError) } } } // writeTracker records whether anything reached the client, so a failure // can still choose between a clean error page and a logged truncation. type writeTracker struct { w http.ResponseWriter sent *bool } func (t writeTracker) Write(p []byte) (int, error) { *t.sent = true return t.w.Write(p) } func (a *Admin) handleSettingsImport(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } file, _, err := r.FormFile("backup") if err != nil { a.renderSettings(w, r, a.tr(r, "No backup file selected."), "", http.StatusUnprocessableEntity) return } defer file.Close() written, err := backup.Restore(file, a.deps.Backup) if written > 0 { // A partial restore changed files on disk; the caches must drop // even when a later entry failed, or the admin keeps serving the // pre-import state until an unrelated write invalidates them. a.deps.Store.InvalidateCache() a.deps.Users.Invalidate() a.deps.Templates.Invalidate() a.deps.Tokens.Invalidate() } if err != nil { slog.Warn("admin: backup import failed", "error", err) a.renderSettings(w, r, a.trf(r, "Could not restore backup: %s", err.Error()), "", http.StatusUnprocessableEntity) return } if written == 0 { a.renderSettings(w, r, a.tr(r, "The archive holds no files this deployment recognises."), "", http.StatusUnprocessableEntity) return } a.record(r, "backup.imported", fmt.Sprintf("%d files", written), nil) a.renderSettings(w, r, "", i18n.Admin.N(a.langFor(r), "backup.files", written), http.StatusOK) } // --- updates ----------------------------------------------------------------