Files
volumen/internal/admin/settings_backup.go
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

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 ----------------------------------------------------------------