package web import ( "context" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "syscall" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "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. off is the off-site copy // per app (backup.KeptOffsiteCopies, asked once per page): it is used when it is newer than every local // copy, or the only one (R-691 (2)). func (s *Server) keptBackupFor(lang string, it stacks.KeptItem, off map[string]backup.KeptCopy) (string, backup.KeptCopy, bool) { none := s.msgLang(lang, "kept.backup.none") if s.backupMgr == nil || it.App == "" && it.Kind != stacks.KeptKindDated { return none, backup.KeptCopy{}, false } var local backup.KeptCopy var lok bool if it.Kind == stacks.KeptKindDated { // A dated folder's own unit is the copy taken with those files. if it.UnitDir != "" { 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, true } } } else { local, lok = s.backupMgr.KeptDBCopy(it.App, it.Drive) } oc, ook := off[it.App] c, ok := backup.KeptNewer(local, lok, oc, ook && it.App != "") if !ok { return none, backup.KeptCopy{}, false } return s.msgLang(lang, backup.KeptCopyKey(c.Tier), c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c, true } // keptOffsite asks the off-site repository ONCE for every listed app. func (s *Server) keptOffsite(ctx context.Context, items []stacks.KeptItem) map[string]backup.KeptCopy { if s.backupMgr == nil { return nil } var apps []string for _, it := range items { apps = append(apps, it.App) } return s.backupMgr.KeptOffsiteCopies(ctx, apps) } 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 { items := s.stackMgr.ListKept(s.keptDrives()) off := s.keptOffsite(r.Context(), items) for _, it := range items { backupName, _, can := s.keptBackupFor(lang, it, off) 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: backupName, 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 } _, c, can := s.keptBackupFor(lang, it, s.keptOffsite(r.Context(), []stacks.KeptItem{it})) if !can { s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.choice.use_off")) return } app, disp := it.App, it.DisplayName okMsg := func(error) string { return s.msgLang(lang, "kept.load.done", disp) } failMsg := func(err error) string { return s.msgLang(lang, "kept.load.failed", disp, err) } after := 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() } // A dated folder's files move back BEFORE the load — for the off-site copy only after its unit is // downloaded and judged, so a failed download or a refusal leaves the kept folder as it was. moveBack := func() error { if it.Kind != stacks.KeptKindDated { return nil } _, err := s.stackMgr.RestoreKeptFiles(it) return err } if c.Tier == backup.KeptTierOffsite { s.logger.Printf("[INFO] [web] kept LOAD %s: %s from the off-site snapshot %s (from %s)", it.App, it.Path, c.SnapshotID, r.RemoteAddr) s.backupMgr.LoadKeptOffsite(app, it.Drive, c, moveBack, okMsg, failMsg, after) } else { if err := moveBack(); 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, c.UnitDir, r.RemoteAddr) s.backupMgr.LoadKeptApp(app, c.UnitDir, okMsg, failMsg, after) } http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound) } // 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 } // keptReadGroups is the R-691 rule — decided by CC unattended 2026-09-26, operator may reverse (`07` §6.5): // the read-only view reads a kept folder another user owns by joining that folder's OWNING GROUP, never by // changing the household's files or their permissions (nextcloud checks its data folder's mode after a // Load). A group is added only when the folder is group-READABLE and the group is neither root's (0 — it // would reach every root-group file in the view's other mounts) nor the view's own (1000). The kept binds // are `:ro`, so the added group cannot write kept data. Sorted, unique. Pinned by TestR691_KeptReadGroups. func keptReadGroups(items []stacks.KeptItem, owner func(path string) (gid int, mode os.FileMode, ok bool)) []int { seen := map[int]bool{} var out []int for _, it := range items { gid, mode, ok := owner(it.Path) if !ok || gid == 0 || gid == fileBrowserUID || mode&0o040 == 0 || seen[gid] { continue } seen[gid] = true out = append(out, gid) } sort.Ints(out) return out } // fileBrowserUID is the uid:gid the file-browser image runs as (gtstef/filebrowser: `filebrowser`, 1000). const fileBrowserUID = 1000 // statOwner is keptReadGroups' production owner reader. func statOwner(path string) (int, os.FileMode, bool) { fi, err := os.Stat(path) if err != nil { return 0, 0, false } st, ok := fi.Sys().(*syscall.Stat_t) if !ok { return 0, 0, false } return int(st.Gid), fi.Mode().Perm(), true }