83 lines
2.8 KiB
Go
83 lines
2.8 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 ----------------------------------------------------------------
|