6479933e8e
gates / gates (push) Successful in 25s
Use my kept data / Load consider the off-site snapshot when it is newer than every local copy or the only one; the unit is downloaded alone, judged (drive, data, recorded data version) and only then restored. The page names the copy and its date. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
269 lines
10 KiB
Go
269 lines
10 KiB
Go
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
|
|
}
|