kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
gates / gates (push) Successful in 26s

An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-25 13:32:30 +02:00
parent 5a731b45b9
commit 43e99d160c
44 changed files with 3311 additions and 37 deletions
+209
View File
@@ -0,0 +1,209 @@
package web
import (
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── „Megőrzött adatok" / "Kept data" (`09` §3 decision 36) ────────────────────────────────────────
//
// Every leftover app folder on a connected drive, with the three things a household can do with it:
// Load (install the app with it — only with a database copy), Look (read only, in the file browser)
// and Delete (typed confirmation — the ONLY deletion of kept data in the product; D3 is open, so the box
// never deletes one by itself).
// keptRow is one row of the page.
type keptRow struct {
DisplayName string
App string
Path string
Drive string
Date string
Size string
Backup string // which copy can bring it back, or none — rendered in the reader's language
CanLoad bool
LookURL string
Installed bool // the app is installed again: Load is not offered
DeleteText string // „A(z) %s megőrzött adatai (%s) véglegesen törlődnek…" in the reader's language
TypePrompt string // what to type to confirm
}
// keptDrives are the drive roots kept data can sit on: every registered, connected, local drive.
func (s *Server) keptDrives() []string {
var out []string
if s.settings == nil {
return nil
}
for _, sp := range s.settings.GetStoragePaths() {
if sp.IsNetwork() || sp.Disconnected || sp.Path == "" {
continue
}
out = append(out, sp.Path)
}
return out
}
// KeptViewName is a kept item's folder name inside the file browser's „Megőrzött adatok" source: the
// drive, the app (or folder) and, for a dated folder, its date — unique per item, stable across syncs.
func KeptViewName(it stacks.KeptItem) string {
base := filepath.Base(it.Drive) + "-"
if it.Kind == stacks.KeptKindDated {
return base + filepath.Base(filepath.Dir(it.Path)) + "-" + filepath.Base(it.Path)
}
return base + filepath.Base(it.Path)
}
// keptBackupFor names the copy a Load would use for it, and whether one exists.
func (s *Server) keptBackupFor(lang string, it stacks.KeptItem) (string, string, bool) {
if s.backupMgr == nil {
return s.msgLang(lang, "kept.backup.none"), "", false
}
if it.Kind == stacks.KeptKindDated {
if it.UnitDir == "" {
return s.msgLang(lang, "kept.backup.none"), "", false
}
if c, ok := s.backupMgr.KeptCopyAt(it.UnitDir, it.Drive, 1); ok {
return s.msgLang(lang, "kept.backup.with", c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c.UnitDir, true
}
return s.msgLang(lang, "kept.backup.none"), "", false
}
if it.App == "" {
return s.msgLang(lang, "kept.backup.none"), "", false
}
c, ok := s.backupMgr.KeptDBCopy(it.App, it.Drive)
if !ok {
return s.msgLang(lang, "kept.backup.none"), "", false
}
key := "kept.backup.own"
if c.Tier == 2 {
key = "kept.backup.second"
}
return s.msgLang(lang, key, c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c.UnitDir, true
}
func (s *Server) keptPageHandler(w http.ResponseWriter, r *http.Request) {
lang := s.langFor(r)
data := s.baseData("kept-data", "Megőrzött adatok")
data["TitleKey"] = "page.title.kept_data"
var rows []keptRow
if s.stackMgr != nil {
for _, it := range s.stackMgr.ListKept(s.keptDrives()) {
backup, _, can := s.keptBackupFor(lang, it)
row := keptRow{
DisplayName: it.DisplayName, App: it.App, Path: it.Path, Drive: it.Drive,
Date: it.Date.In(getTimezone()).Format("2006-01-02 15:04"),
Size: appbackup.HumanizeBytes(it.SizeBytes), Backup: backup, CanLoad: can && it.App != "",
LookURL: fileBrowserLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)),
}
row.DeleteText = s.msgLang(lang, "kept.delete.confirm", it.DisplayName, row.Size)
row.TypePrompt = s.msgLang(lang, "kept.delete.type", it.DisplayName)
if st, ok := s.stackMgr.GetStack(it.App); ok && st.Deployed {
row.Installed, row.CanLoad = true, false
}
rows = append(rows, row)
}
}
data["KeptRows"] = rows
go s.SyncFileBrowserMounts() // the read-only view follows the list (no recreate when nothing changed)
data["KeptFlash"] = r.URL.Query().Get("flash")
data["KeptError"] = r.URL.Query().Get("flash_error")
s.executeTemplate(w, r, "kept_data", data)
}
func (s *Server) keptRedirect(w http.ResponseWriter, r *http.Request, key, val string) {
http.Redirect(w, r, "/kept-data?"+key+"="+url.QueryEscape(val), http.StatusFound)
}
// keptDeleteHandler — the household's Delete. The typed confirmation must equal the item's name.
func (s *Server) keptDeleteHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
lang := s.langFor(r)
path, confirm := r.FormValue("path"), strings.TrimSpace(r.FormValue("confirm"))
it, ok := s.stackMgr.FindKept(s.keptDrives(), path)
if !ok {
s.logger.Printf("[WARN] [web] kept delete REFUSED: %q is not a listed kept item (from %s)", path, r.RemoteAddr)
s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "err.kept.not_listed"))
return
}
if confirm != it.DisplayName {
s.logger.Printf("[WARN] [web] kept delete REFUSED for %s: the typed confirmation did not match", it.Path)
s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.delete.mismatch", it.DisplayName))
return
}
if _, err := s.stackMgr.DeleteKept(s.keptDrives(), path); err != nil {
s.keptRedirect(w, r, "flash_error", s.errText(r, err))
return
}
go s.SyncFileBrowserMounts() // the view follows the list
s.keptRedirect(w, r, "flash", s.msgLang(lang, "kept.deleted", it.DisplayName))
}
// keptLoadHandler — Load: install the app with this data (the same act as „use my kept data").
func (s *Server) keptLoadHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
lang := s.langFor(r)
it, ok := s.stackMgr.FindKept(s.keptDrives(), r.FormValue("path"))
if !ok {
s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "err.kept.not_listed"))
return
}
if st, ok := s.stackMgr.GetStack(it.App); it.App == "" || !ok || st.Deployed {
s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.load.installed", it.DisplayName))
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
s.keptRedirect(w, r, "flash_error", msg)
return
}
_, unit, can := s.keptBackupFor(lang, it)
if !can {
s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.choice.use_off"))
return
}
if it.Kind == stacks.KeptKindDated {
if _, err := s.stackMgr.RestoreKeptFiles(it); err != nil {
s.keptRedirect(w, r, "flash_error", s.errText(r, err))
return
}
}
s.logger.Printf("[INFO] [web] kept LOAD %s: %s from %s (from %s)", it.App, it.Path, unit, r.RemoteAddr)
app, disp := it.App, it.DisplayName
s.backupMgr.LoadKeptApp(app, unit,
func(error) string { return s.msgLang(lang, "kept.load.done", disp) },
func(err error) string { return s.msgLang(lang, "kept.load.failed", disp, err) },
func(ok bool) {
if ok {
if ran, err := s.stackMgr.RunAfterLoad(app, 10*time.Minute); ran && err != nil {
s.logger.Printf("[WARN] [web] kept load %s: after_load failed: %v", app, err)
}
s.stackMgr.FinishKeptLoad(it, s.backupMgr.PrimaryUnitHome(app, it.Drive))
}
s.SyncFileBrowserMounts()
})
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// keptFileBrowserBinds are the read-only binds of the „Megőrzött adatok" source: one per listed item,
// `:ro`, under /srv/<source dir> — never a live app's folder (ListKept never lists one).
func (s *Server) keptFileBrowserBinds() []string {
if s.stackMgr == nil {
return nil
}
return keptBindLines(s.stackMgr.ListKept(s.keptDrives()))
}
// keptBindLines renders the compose bind lines — each READ-ONLY. Pinned by TestKept_FileBrowserBindsAreReadOnly.
func keptBindLines(items []stacks.KeptItem) []string {
var out []string
for _, it := range items {
out = append(out, " - "+it.Path+":/srv/"+infra.FileBrowserKeptMount+"/"+KeptViewName(it)+":ro")
}
return out
}