package web import ( "path" "path/filepath" "sort" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // „Hova tegyem a fájlokat?" — the app-page folder card (R-75). // // Rendered only for DEPLOYED apps that declare data_paths or media_library (Fork-2: all catalog apps get the // skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed // is the thing that would actually confuse someone). // DataPathCard is one folder row on the app page. type DataPathCard struct { Label string // the catalog's Hungarian label Link string // FileBrowser deep link; "" = the folder's source is not on the box now — shown as text Consequence string // class-DRIVEN copy (never hand-written per app) IsImport bool // drives the free-space line FreeSpace string // system-drive headroom, import rows only ("" when unreadable) Role stacks.DataPathRole IsMedia bool // a per-drive media folder (media_library), not a declared data_path } // consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4). // // It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes // can never drift from what the backup engines actually do. `excluded` means the tier filter drops it // at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is // temporary and unbacked. Saying anything softer would be a false promise about their files. // R-589/R-590 (v0.258.0): `msg` renders the sentence in the HOUSEHOLD'S language. It is a // parameter rather than a package-level lookup because this sentence is a PROMISE ABOUT THE // CUSTOMER'S FILES — an English household reading „nem készül róla biztonsági mentés" as decoration // may leave originals in a drop-zone the backup filter discards at every tier. Found live on // 2026-09-20 with the folder's own label already English above it, so the line read half and half. func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole, msg func(key string) string) string { switch class { case appbackup.ClassExcluded: if role == stacks.RoleImport { return msg("datapath.consequence.import_excluded") } return msg("datapath.consequence.excluded") case appbackup.ClassMandatory, appbackup.ClassOptional: return msg("datapath.consequence.kept") default: // No classification (legacy app, or a bind with no backup block). Say nothing rather than // guess — an unverified backup promise is worse than no sentence at all. return "" } } // buildDataPathCards turns an app's validated data_paths into rendered rows. // // classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence // line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead. func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCard { msg := func(key string) string { return s.msgLang(lang, key) } if st == nil || !st.Deployed || (len(st.Meta.DataPaths) == 0 && st.Meta.MediaLibrary == nil) { return nil } domain := s.cfg.Customer.Domain if domain == "" { return nil } classOf := map[string]appbackup.BindClass{} if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has { for _, b := range binds { classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class } } var freeSpace string if s.stackMgr != nil { if root := s.stackMgr.GetImportRoot(); root != "" { if du := system.GetDiskUsage(root); du != nil { freeSpace = s.msgLang(lang, "datapath.free_space", du.AvailGB) } } } cards := make([]DataPathCard, 0, len(st.Meta.DataPaths)) for _, dp := range st.Meta.DataPaths { var link string switch dp.Root { case appbackup.RootImport: link = importFolderLink(domain, dp.Path) case appbackup.RootUserdata: // A userdata folder lives on the app's OWN drive, so its FileBrowser source is that // drive's sidebar entry. Resolve it from the app's HDD_PATH. v0.320.0: a source that is not // on the box now (no drive resolved, or the drive is not present, so FileBrowser does not // bind it) is TEXT, never a link — a link to a missing source opens an empty or broken view. src, drive := s.fbSourceForApp(st.Name) if src != "" && s.drivePresent(drive) { link = fileBrowserLink(domain, src, dp.Path) } else { s.logger.Printf("[WARN] [web] app page %s: folder %q has no FileBrowser source on this box now (drive %q) — shown without a link", st.Name, dp.Path, drive) } default: // hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it. // Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate // an hdd path with a customer-facing role. continue } isImport := dp.Role == stacks.RoleImport card := DataPathCard{ Label: dp.Label, Link: link, Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role, msg), IsImport: isImport, Role: dp.Role, } if isImport { // The system SSD filling is a different severity from a data drive filling — it can take // the whole guest down — and the customer has no other signal that the drop-zone is not // bottomless. card.FreeSpace = freeSpace } cards = append(cards, card) } return append(cards, s.mediaFolderCards(st, domain, lang, cards)...) } // mediaLibraryFolders are the `media/` subfolders a whole-media app's library uses — the folders internal/mediawire // wires into it (Sonarr `tv`, Radarr `movies`, the players both). An app the table does not name gets the `media` // folder itself. A download client's `media_library.folder` (qBittorrent `torrents`) is its one folder. var mediaLibraryFolders = map[string][]string{ "sonarr": {"tv"}, "radarr": {"movies"}, "plex": {"movies", "tv"}, "jellyfin": {"movies", "tv"}, "emby": {"movies", "tv"}, } // mediaFolderLabelKeys name each media subfolder for the household. A folder with no key is shown by its name. var mediaFolderLabelKeys = map[string]string{ "": "datapath.media.root", "movies": "datapath.media.movies", "tv": "datapath.media.tv", "torrents": "datapath.media.downloads", "transmission": "datapath.media.downloads", } // mediaFolderCards lists, for a media app (`media_library:`), each enrolled drive's media folders the app uses // (`15-media-library.md` §2). A present drive's folder is a link into that drive's FileBrowser source; a drive that // is not present is text, with one WARN. No consequence line: the bind's class says the folder is not THIS app's // data (Sonarr/Radarr/the players declare it `excluded`), which is not the sentence „temporary, not backed up" — // and the per-drive binds are in no classifier at all (`15` §7). An unverified backup promise is worse than none. // A folder a declared data_path already links to is not listed twice. func (s *Server) mediaFolderCards(st *stacks.Stack, domain, lang string, have []DataPathCard) []DataPathCard { spec := st.Meta.MediaLibrary if spec == nil || s.settings == nil { return nil } subs := mediaLibraryFolders[st.Meta.Slug] if f := strings.Trim(path.Clean("/"+spec.Folder), "/"); f != "" { subs = []string{f} } else if len(subs) == 0 { subs = []string{""} } seen := map[string]bool{} for _, c := range have { if c.Link != "" { seen[c.Link] = true } } var out []DataPathCard for _, sp := range s.mediaStoragePaths() { src := fbSourceName(sp) present := s.drivePresent(sp.Path) if !present { s.logger.Printf("[WARN] [web] app page %s: drive %q is not present — its media folders are shown without a link", st.Name, sp.Path) } for _, sub := range subs { label := sub if k, ok := mediaFolderLabelKeys[sub]; ok { label = s.msgLang(lang, k) } card := DataPathCard{Label: s.msgLang(lang, "datapath.media.row", label, src), Role: stacks.RoleLibrary, IsMedia: true} if present { card.Link = fileBrowserLink(domain, src, path.Join("media", sub)) if seen[card.Link] { continue } seen[card.Link] = true } out = append(out, card) } } return out } // mediaStoragePaths are the drives a media app binds: enrolled, not network, not decommissioned (the same set // stacks.mediaDrives starts from), sorted by path so the first drive is stable. func (s *Server) mediaStoragePaths() []settings.StoragePath { var out []settings.StoragePath seen := map[string]bool{} for _, sp := range s.settings.GetStoragePaths() { p := filepath.Clean(sp.Path) if sp.Kind == "network" || sp.Decommissioned || sp.Path == "" || seen[p] { continue } seen[p] = true sp.Path = p out = append(out, sp) } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // drivePresent reports whether a drive is bound into FileBrowser now: the stack manager's present media drives // (enrolled, mounted, not dead, not safely removed) — the same gates buildFileBrowserPaths applies. func (s *Server) drivePresent(drive string) bool { if drive == "" { return false } var present []string switch { case s.presentDrivesFn != nil: present = s.presentDrivesFn() case s.stackMgr != nil: present = s.stackMgr.PresentMediaDrives() } for _, p := range present { if filepath.Clean(p) == filepath.Clean(drive) { return true } } return false } // filesButtonLink is the target of the app header's „Fajlok" button: the first folder with the library role, else // the first declared folder, else (a media app) the first present drive's `media` folder. Only folders that are a // link count. "" = no button (an app with no folder on the box, e.g. Immich's data in a named volume). func (s *Server) filesButtonLink(st *stacks.Stack, cards []DataPathCard) string { for _, c := range cards { if !c.IsMedia && c.Link != "" && c.Role == stacks.RoleLibrary { return c.Link } } for _, c := range cards { if !c.IsMedia && c.Link != "" { return c.Link } } if st == nil || st.Meta.MediaLibrary == nil || s.settings == nil || s.cfg.Customer.Domain == "" { return "" } for _, sp := range s.mediaStoragePaths() { if s.drivePresent(sp.Path) { return fileBrowserLink(s.cfg.Customer.Domain, fbSourceName(sp), "media") } } return "" } // fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed // on: the storage path's label when it has one, else the mount basename — exactly what // RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree. func (s *Server) fbSourceNameForApp(stackName string) string { src, _ := s.fbSourceForApp(stackName) return src } // fbSourceForApp is fbSourceNameForApp plus the drive path it resolved ("" when none). func (s *Server) fbSourceForApp(stackName string) (source, drive string) { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return "", "" } hdd := appCfg.Env["HDD_PATH"] if hdd == "" { return "", "" } for _, sp := range s.settings.GetStoragePaths() { if sp.Path != hdd || sp.Decommissioned { continue } return fbSourceName(sp), sp.Path } return "", hdd } // fbSourceName is a drive's FileBrowser sidebar name: its label, else the mount basename — exactly what // infra.RenderFileBrowserConfig emits, so a link and the sidebar cannot disagree. func fbSourceName(sp settings.StoragePath) string { if sp.Label != "" { return sp.Label } return filepath.Base(sp.Path) }