Files
felhom-controller/controller/internal/web/kept_copies.go
T

259 lines
11 KiB
Go

package web
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
)
// D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is
// gone and lets the household release one with „Eltávolítás". Nothing else removes them.
// keptCopyRow is one line of the „Megőrzött másolatok" list.
type keptCopyRow struct {
ID string
Source string
Line string // „a(z) <drive> meghajtóról megőrzött másolat · <why>"
SizeHuman string
Since string
// Restorable (D47): a set-aside recovery unit of an earlier install — the row offers „Visszaállítás" too.
Restorable bool
}
func (s *Server) keptCopyRows(lang string) []keptCopyRow {
if s.backupMgr == nil {
return nil
}
var out []keptCopyRow
for _, k := range s.backupMgr.KeptCopies() {
why := s.msgLang(lang, "kept.reason."+k.Reason)
out = append(out, keptCopyRow{
ID: k.ID,
Source: k.Source,
Line: s.msgLang(lang, "kept.from_drive", k.Drive) + " · " + why,
SizeHuman: backup.HumanizeBytes(k.SizeBytes),
Since: k.Since,
Restorable: k.Kind == backup.KeptKindUnit,
})
}
return out
}
// keptCopyRemoveHandler is „Eltávolítás": POST /backup/kept/remove (id, confirm=1). It removes ONE kept copy by its
// record (backup.RemoveKeptCopy: the recorded path, inside its copy tree, same device + inode) and nothing else.
func (s *Server) keptCopyRemoveHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true)
return
}
_ = r.ParseForm()
id := strings.TrimSpace(r.FormValue("id"))
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.needs_confirmation", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
s.logger.Printf("[WARN] [web] kept-copy removal refused (%s): a backup/restore op is running", id)
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
switch err := s.backupMgr.RemoveKeptCopy(id); {
case err == nil:
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.removed", false)
case errors.Is(err, backup.ErrKeptCopyNotFound):
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.not_found", true)
case errors.Is(err, backup.ErrKeptCopyChanged):
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.changed", true)
default:
s.logger.Printf("[WARN] [web] kept-copy removal %s failed: %v", id, err)
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.remove_failed", true, err.Error())
}
}
// keptUnitRestoreHandler is „Visszaállítás" on a set-aside unit (D47): POST /backup/kept/restore (id, confirm=1). The
// unit is named only by its record (backup.SetAsideUnitFor: the recorded folder, inside its set-aside tree, same device
// + inode), and restored through the one unit-restore body, in the background like every restore.
func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true)
return
}
_ = r.ParseForm()
id := strings.TrimSpace(r.FormValue("id"))
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.restore_needs_confirmation", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
k, err := s.backupMgr.SetAsideUnitFor(id)
switch {
case errors.Is(err, backup.ErrKeptCopyNotFound):
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.not_found", true)
return
case err != nil:
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.changed", true)
return
}
if legs := s.backupMgr.DeclaredDriveFileLegs(k.Source); len(legs) > 0 {
msg := s.missingFileLegsRefusal(r.Context(), s.langFor(r), k.Source)
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Restore of %s from a set-aside unit requested (async): %s from %s", k.Source, k.Path, r.RemoteAddr)
if msg, ok := s.beginRestoreOp("restore", k.Source); !ok {
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
go func() {
app, res, err := s.backupMgr.RestoreSetAsideUnit(id)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore from the set-aside unit failed (async): stack=%s: %v", app, err)
s.endRestoreFailed("note.restore.unit_failed", err, "")
return
}
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(app, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(app, res))
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// ── D62 (operator ruling 2026-10-29): restore copies in the same list ───────────────────────────────────────────────
//
// Each restore copy shows the app, the date of the restore it protects, its size and the day the box deletes it by
// itself (30 days after the restore). „Törlés most" deletes it earlier: a warning that names the app and the date,
// and the app's name typed. A copy a hold or a pending put-back needs shows why in one line and has no button — and
// the handler refuses it too. Pinned by TestD62_* (web).
// restoreCopyRow is one restore copy on the page.
type restoreCopyRow struct {
App string
Stamp string
Name string // the app's display name — what the household types
Line string // „visszaállítás: <date> · <size> · automatikusan törlődik: <date>"
NeededText string // why it cannot be deleted; "" = the button is offered
Warning string // the confirm step's sentence (app and date)
TypePrompt string
}
// restoreCopyAppName is the app's display name, or its slug when the app is gone.
func (s *Server) restoreCopyAppName(app string) string {
if s.stackMgr != nil {
if st, ok := s.stackMgr.GetStack(app); ok && st.Meta.DisplayName != "" {
return st.Meta.DisplayName
}
}
return app
}
func (s *Server) restoreCopyRows(lang string) []restoreCopyRow {
if s.backupMgr == nil {
return nil
}
var out []restoreCopyRow
for _, c := range s.backupMgr.RestoreCopies() {
name := s.restoreCopyAppName(c.App)
at := c.At.In(getTimezone())
line := s.msgLang(lang, "restore_copy.restored_on", fmtLocalTime(lang, c.At)) + " · " +
i18n.LocalizeDecimals(lang, backup.HumanizeBytes(c.SizeBytes))
row := restoreCopyRow{App: c.App, Stamp: c.Stamp, Name: name}
if c.Needed == "" {
line += " · " + s.msgLang(lang, "restore_copy.auto_delete", i18n.FormatDate(lang, c.DeleteOn.In(getTimezone())))
row.Warning = s.msgLang(lang, "restore_copy.warning", name, strings.TrimSuffix(i18n.FormatDate(lang, at), "."))
row.TypePrompt = s.msgLang(lang, "kept.delete.type", name)
} else {
row.NeededText = s.msgLang(lang, "restore_copy.needed."+c.Needed)
}
row.Line = line
out = append(out, row)
}
return out
}
// restoreCopyJSON is the delete's answer: ok with the page to go to, or the refusal's sentence.
func restoreCopyJSON(w http.ResponseWriter, code int, ok bool, msg, redirect string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "error": msg, "redirect": redirect})
}
// restoreCopyDeleteHandler is „Törlés most": POST /backup/restore-copy/delete (app, stamp, confirm). JSON answer:
// 200 deleted; 400 the typed name does not match; 404 no such copy; 409 the copy is needed, or an operation runs on
// the box or the app; 500 the removal failed. CSRF as every state-changing form (CsrfProtect).
func (s *Server) restoreCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
lang := s.langFor(r)
if s.backupMgr == nil {
restoreCopyJSON(w, http.StatusServiceUnavailable, false, s.msgLang(lang, "flash.offbox.mgr_unreachable"), "")
return
}
_ = r.ParseForm()
app, stamp := strings.TrimSpace(r.FormValue("app")), strings.TrimSpace(r.FormValue("stamp"))
var found *backup.RestoreCopy
for _, c := range s.backupMgr.RestoreCopies() {
if c.App == app && c.Stamp == stamp {
c := c
found = &c
break
}
}
if found == nil {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED: %q/%q is not a listed copy (from %s)", app, stamp, clientIP(r))
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
return
}
if found.Needed != "" {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the copy is needed (%s)", found.Path, found.Needed)
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+found.Needed), "")
return
}
name := s.restoreCopyAppName(app)
if strings.TrimSpace(r.FormValue("confirm")) != name {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the typed confirmation did not match", found.Path)
restoreCopyJSON(w, http.StatusBadRequest, false, s.msgLang(lang, "kept.delete.mismatch", name), "")
return
}
if s.stackMgr != nil && s.stackMgr.IsUpdating(app) {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: an update of %s runs", found.Path, app)
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.app_busy"), "")
return
}
who := "the household (dashboard sign-in) from " + clientIP(r)
c, err := s.backupMgr.DeleteRestoreCopy(app, stamp)
switch {
case errors.Is(err, backup.ErrRestoreCopyBusy):
busy, mirror := s.backupMgr.RestoreBusy()
msg, blocked := s.restoreBusyMessage(busy, mirror)
if !blocked {
msg = s.msgLang(lang, "restore_copy.app_busy")
}
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: a backup or restore operation runs", found.Path)
restoreCopyJSON(w, http.StatusConflict, false, msg, "")
return
case errors.Is(err, backup.ErrRestoreCopyNeeded):
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+c.Needed), "")
return
case errors.Is(err, backup.ErrRestoreCopyNotFound):
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
return
case err != nil:
s.logger.Printf("[ERROR] [web] restore-copy delete of %s failed: %v", found.Path, err)
restoreCopyJSON(w, http.StatusInternalServerError, false, s.msgLang(lang, "restore_copy.failed", err.Error()), "")
return
}
restoreAt := c.At.UTC().Format(time.RFC3339)
s.logger.Printf("[WARN] [web] restore copy DELETED by hand (D62): app=%s restore_at=%s size=%d path=%s who=%s",
app, restoreAt, c.SizeBytes, c.Path, who)
if s.notifier != nil {
s.notifier.NotifyRestoreCopyDeleted(notify.RestoreCopyDeletedDetails{App: app, RestoreAt: restoreAt, SizeBytes: c.SizeBytes, Who: who, Path: c.Path})
}
restoreCopyJSON(w, http.StatusOK, true, "", "/backups/restore?"+flashQuery("flash", "flash.restore_copy.deleted", name))
}