Files
felhom-controller/controller/internal/web/handlers.go
T
admin 15206314ab v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)
Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
2026-07-24 12:08:43 +02:00

2381 lines
93 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"golang.org/x/crypto/bcrypt"
)
// protectedStackSubdomains maps programmatically managed protected stacks
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
var protectedStackSubdomains = map[string]string{
"filebrowser": "files",
}
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
type StorageBarInfo struct {
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
Path string // e.g., "/mnt/hdd_1"
Purpose string // Hungarian explanation of what this drive holds (monitoring page)
TotalGB float64
UsedGB float64
Percent float64
Disconnected bool
}
// storageBarPurpose is the Hungarian description for the registered user-data drives shown in the
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
// purpose text on the storage-management page (Phase 4C).
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."
// buildStorageBars returns usage bars for all registered storage paths, in a stable order
// (by path) with a purpose description.
func (s *Server) buildStorageBars() []StorageBarInfo {
var bars []StorageBarInfo
for _, sp := range s.settings.GetStoragePaths() {
// Skip decommissioned drives — they are no longer in active use
if sp.Decommissioned {
continue
}
if sp.Disconnected {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
Disconnected: true,
})
continue
}
di := system.GetDiskUsage(sp.Path)
if di == nil {
continue
}
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
TotalGB: di.TotalGB,
UsedGB: di.UsedGB,
Percent: di.UsedPercent,
})
}
// Deterministic order regardless of registry insertion order.
sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path })
return bars
}
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
type DeployStoragePath struct {
settings.StoragePath
FreeHuman string // "234.5 GB"
FreePercent float64 // 67.5
}
// StorageAppDetail holds info about an app using a specific storage path.
type StorageAppDetail struct {
Name string // Display name (e.g., "Immich")
Stack string // Stack name (for link)
SizeHuman string // Data size on this path
}
// StoragePathView extends StoragePath with display data for the settings page.
type StoragePathView struct {
settings.StoragePath
DiskInfo *system.DiskUsageInfo
AppCount int
IsMounted bool
AppDetails []StorageAppDetail
FSInfo *system.FSInfo
IsUSB bool // true if this is a USB-attached device (safe disconnect available)
StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI)
MigratedToLabel string // label of the drive data was migrated to
HasOtherPaths bool // true if other connected non-decommissioned paths exist
IsEnrolled bool // enrolled via the wizard (stable /mnt/felhom-drives/ path) — lifecycle is disconnect/decommission, not list-removal
}
func (s *Server) baseData(page, title string) map[string]interface{} {
data := map[string]interface{}{
"Page": page,
"Title": title,
"CustomerName": s.cfg.Customer.Name,
"Domain": s.cfg.Customer.Domain,
"Version": s.version,
"AuthEnabled": s.authEnabled(),
"DebugMode": s.isDebug(),
// Customer-claim arc (v0.122.0, F-4): the transitional legacy-open banner — no password,
// no code hash yet. Cleared the moment the hub delivers a code hash (gate flips on).
"ClaimLegacyOpen": s.claimLegacyOpen(),
}
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
}
return data
}
func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
stackList := s.stackMgr.GetStacks()
// Filter to deployed + protected stacks first
var deployedStacks []stacks.Stack
for _, st := range stackList {
if st.Deployed || st.Protected {
deployedStacks = append(deployedStacks, st)
}
}
// Count from the DISPLAYED set only
running, stopped := 0, 0
for _, st := range deployedStacks {
switch st.State {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
running++
// R-51: degraded counts with stopped — the dashboard counter answers "how many of my apps
// work", and a stack with a dead supervised member does not.
case stacks.StateStopped, stacks.StateExited, stacks.StateDegraded:
stopped++
}
}
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data := s.baseData("dashboard", "Vezérlőpult")
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
data["Stacks"] = deployedStacks
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
nw, ns := s.networkStorageWarnings(deployedStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
data["NetworkWarnings"] = nw
data["NetworkStubs"] = ns
data["RunningCount"] = running
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
data["SystemInfo"] = sysInfo
data["StorageBars"] = s.buildStorageBars()
// Backup status
data["BackupEnabled"] = s.cfg.Backup.Enabled
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
data["DBDumpStatus"] = fullStatus.LastDBDump
// F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on
// .BackupStatus, which was never passed — so the {{if}} arm was unreachable and EVERY box
// rendered "Még nem futott" regardless of history. *DBDumpStatus nil/non-nil maps exactly
// onto the template's branch, so a fresh box still reads "Még nem futott" honestly.
data["BackupStatus"] = fullStatus.LastDBDump
data["BackupRunning"] = fullStatus.Running
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
}
// Build subdomain map for "Megnyitás" buttons
data["Subdomains"] = s.subdomainMap(deployedStacks)
if s.alertManager != nil {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
}
s.executeTemplate(w, r, "dashboard", data)
}
// subdomainMap builds the stack-name → subdomain lookup used by the "Megnyitás" open links (dashboard
// + Alkalmazások) and the Indítópult launcher. Priority — unchanged from the two inline copies it
// replaces: the deployed app.yaml SUBDOMAIN env wins, then the .felhom.yml Meta.Subdomain, then the
// well-known protectedStackSubdomains fallback. A stack with no source of a subdomain gets no entry.
func (s *Server) subdomainMap(stackList []stacks.Stack) map[string]string {
subdomains := make(map[string]string)
for _, stack := range stackList {
if stack.Deployed {
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
subdomains[stack.Name] = sd
continue
}
}
}
if stack.Meta.Subdomain != "" {
subdomains[stack.Name] = stack.Meta.Subdomain
} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
subdomains[stack.Name] = sd
}
}
return subdomains
}
// controllerStackName is the controller's own stack — it IS this dashboard, so it never appears as a
// launchable app even if a subdomain mapping somehow exists for it.
const controllerStackName = "felhom-controller"
// LauncherApp is one openable app tile for the Indítópult (launcher) page.
type LauncherApp struct {
Name string
DisplayName string
Slug string
State stacks.ContainerState
Subdomain string
OpenPath string
BrandColor string
}
// buildLauncherApps builds the sorted launcher tile list from the deployed/protected stacks and the
// subdomain lookup. A tile exists exactly when a "Megnyitás" button would — i.e. the stack has a
// subdomain entry — minus the controller itself. Sorted by DisplayName (tie-broken by Name) so the
// grid order is stable regardless of Go's map iteration order.
func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) []LauncherApp {
var apps []LauncherApp
for _, st := range stackList {
if st.Name == controllerStackName {
continue
}
sd, ok := subdomains[st.Name]
if !ok || sd == "" {
continue
}
dn := st.Meta.DisplayName
if dn == "" {
dn = st.Name
}
apps = append(apps, LauncherApp{
Name: st.Name,
DisplayName: dn,
Slug: st.Meta.Slug,
State: st.State,
Subdomain: sd,
OpenPath: st.Meta.OpenPath,
BrandColor: st.Meta.BrandColor,
})
}
sort.Slice(apps, func(i, j int) bool {
if apps[i].DisplayName == apps[j].DisplayName {
return apps[i].Name < apps[j].Name
}
return apps[i].DisplayName < apps[j].DisplayName
})
return apps
}
// launcherApps assembles the sorted launcher tile list (deployed/protected stacks with a subdomain,
// controller excluded). Extracted from launcherHandler so the guest share page (v0.165.0) renders
// the EXACT same app slice as the admin launcher.
func (s *Server) launcherApps() []LauncherApp {
var eligible []stacks.Stack
for _, st := range s.stackMgr.GetStacks() {
if st.Deployed || st.Protected {
eligible = append(eligible, st)
}
}
return buildLauncherApps(eligible, s.subdomainMap(eligible))
}
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult. It also carries the
// "Indítópult megosztása" share state (v0.165.0) for the modal.
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("launcher", "Indítópult")
data["Apps"] = s.launcherApps()
// Share modal state. The share URL is built from the request Host at render time (the canonical
// controller subdomain is not persisted anywhere reachable here); it carries the live token, which
// is fine to show the authed admin inside the modal — the ONE admin surface allowed to reveal it.
token := s.settings.GetLauncherShareToken()
data["ShareEnabled"] = token != ""
if token != "" {
data["ShareURL"] = "https://" + r.Host + "/s/" + token
}
data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != ""
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
data["ShareFlash"] = f
}
s.executeTemplate(w, r, "launcher", data)
}
// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle
// `hidden` or `abandoned`) AND are not deployed on this box.
//
// The `Deployed || Protected` half is the load-bearing part: a customer already running an app must
// keep seeing and managing it, whatever the catalog now says about offering it to new customers.
// Withdrawing an app must never take a working app away from someone — that is precisely the failure
// the short-lived `retired/` directory move would have caused, and why lifecycle is a metadata field
// rather than a deletion.
//
// Filtered here, in the handler, rather than in the template: the template already carries five
// conditional badges per card, and a visibility rule buried among them is a rule nobody can test.
func visibleCatalogStacks(in []stacks.Stack) []stacks.Stack {
out := make([]stacks.Stack, 0, len(in))
for _, st := range in {
if st.Deployed || st.Protected || st.Meta.CanInstall() {
out = append(out, st)
}
}
return out
}
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("stacks", "Alkalmazások")
allStacks := visibleCatalogStacks(s.stackMgr.GetStacks())
data["Stacks"] = allStacks
data["MissingStorage"] = s.missingStorageMap(allStacks)
nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
data["NetworkWarnings"] = nw
data["NetworkStubs"] = ns
// Build storage label lookup for deployed apps
storageLabels := make(map[string]string) // stack name → storage label
storagePaths := s.settings.GetStoragePaths()
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" {
for _, sp := range storagePaths {
if sp.Path == hddPath {
storageLabels[stack.Name] = sp.Label
break
}
}
}
}
}
data["StorageLabels"] = storageLabels
// Build effective subdomain lookup (stored env > metadata > well-known fallback)
data["Subdomains"] = s.subdomainMap(s.stackMgr.GetStacks())
s.executeTemplate(w, r, "stacks", data)
}
func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
stack, ok := s.stackMgr.GetStack(name)
if !ok {
http.NotFound(w, r)
return
}
logs, err := s.stackMgr.GetLogs(name, 200)
if err != nil {
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
}
// Raw mode: return plain text for AJAX polling
if r.URL.Query().Get("raw") == "1" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, logs)
return
}
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
data["Stack"] = stack
data["Logs"] = logs
s.executeTemplate(w, r, "logs", data)
}
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
}
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
}
http.NotFound(w, r)
return
}
stack, _ := s.stackMgr.GetStack(name)
alreadyDeployed := appCfg != nil && appCfg.Deployed
pageTitle := meta.DisplayName + " — Telepítés"
if alreadyDeployed {
pageTitle = meta.DisplayName + " — Beállítások"
}
data := s.baseData("deploy", pageTitle)
data["Stack"] = stack
data["Meta"] = meta
data["AppConfig"] = appCfg
data["AlreadyDeployed"] = alreadyDeployed
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
data["UserFields"] = meta.UserFacingFields()
data["AutoFields"] = meta.AutoGeneratedFields()
// Auto-generated field values: existing values for deployed apps, pre-generated for new deploys
autoFieldValues := make(map[string]string)
var decryptedEnv map[string]string
if appCfg != nil {
decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env)
}
if alreadyDeployed && appCfg != nil {
for _, f := range meta.AutoGeneratedFields() {
if val, ok := decryptedEnv[f.EnvVar]; ok {
autoFieldValues[f.EnvVar] = val
}
}
} else if !alreadyDeployed {
// Pre-generate values so the user sees (and can note down) domain/passwords before deploying.
// These same values are submitted back in the form and saved to app.yaml.
if preview, err := s.stackMgr.PreviewDeployValues(name); err == nil {
autoFieldValues = preview
}
}
data["AutoFieldValues"] = autoFieldValues
// For deployed apps, pass stored field values (decrypted) so fields show current values
if alreadyDeployed && decryptedEnv != nil {
data["DeployedFieldValues"] = decryptedEnv
}
// Storage paths with free space info for deploy dropdown
var deployPaths []DeployStoragePath
for _, sp := range s.settings.GetSchedulableStoragePaths() {
dp := DeployStoragePath{StoragePath: sp}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
if di.TotalGB > 0 {
dp.FreePercent = di.AvailGB / di.TotalGB * 100
}
}
deployPaths = append(deployPaths, dp)
}
data["StoragePaths"] = deployPaths
// RCA fix 4: a deployed app's read-only storage select must show the app's STORED HDD_PATH —
// never the default drive (the pre-fix render selected by IsDefault only, so the settings view
// lied about where the data lives). When the stored path is no longer schedulable, an extra
// disabled option names it verbatim rather than silently showing a different storage.
data["CurrentHDDPath"] = ""
data["CurrentHDDPathMissing"] = false
if alreadyDeployed && appCfg != nil {
if hdd := appCfg.Env["HDD_PATH"]; hdd != "" {
data["CurrentHDDPath"] = hdd
inList := false
for _, dp := range deployPaths {
if dp.Path == hdd {
inList = true
break
}
}
data["CurrentHDDPathMissing"] = !inList
}
}
// Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the
// customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only
// meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space).
if !alreadyDeployed {
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
data["DockerBelowReserve"] = hr.BelowReserve
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
}
}
// Effective subdomain for "Megnyitás" button
if alreadyDeployed && appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
data["EffectiveSubdomain"] = sd
}
}
// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
// backup) has moved to the host agent (slice 8C); the deploy page no longer
// renders those sections.
if alreadyDeployed {
// App-to-app integrations
if im := s.integrationMgr.Load(); meta.HasIntegrations() && im != nil {
data["HasIntegrations"] = true
data["Integrations"] = im.ListForProvider(meta.Slug)
}
// Geo-restriction per-app data
geo := s.settings.GetGeoRestriction()
if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
data["GeoGlobalEnabled"] = true
data["GeoGlobalCountries"] = geo.AllowedCountries
if ov, ok := geo.AppOverrides[name]; ok {
data["GeoAppOverride"] = true
data["GeoAppOverrideCountries"] = ov.AllowedCountries
} else {
data["GeoAppOverrideCountries"] = []string{}
}
} else {
data["GeoGlobalCountries"] = []string{}
data["GeoAppOverrideCountries"] = []string{}
}
// Optional config (metadata providers, etc.)
if meta.HasOptionalConfig() {
data["HasOptionalConfig"] = true
data["OptionalConfig"] = meta.OptionalConfig
optValues := make(map[string]string)
if decryptedEnv != nil {
for _, group := range meta.OptionalConfig {
for _, field := range group.Fields {
if val, ok := decryptedEnv[field.EnvVar]; ok {
optValues[field.EnvVar] = val
}
}
}
}
data["CurrentValues"] = optValues
}
// App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with
// honest context whether or not the global toggle is on.
if supported, enabled := s.stackMgr.AppEmailStatus(name); supported {
data["AppEmailSupported"] = true
data["AppEmailAppOn"] = enabled
data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled()
local := meta.SMTPMapping.FromLocal
if local == "" {
local = meta.Slug
}
domain := "felhom.eu"
if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" {
domain = s.cfg.MailRelay.FromDomains[0]
}
data["AppEmailFromAddress"] = local + "@" + domain
}
}
// Memory info for deploy page (only for non-deployed apps)
if !alreadyDeployed {
memInfo := map[string]interface{}{"Available": false}
totalMB, usedMB, memErr := system.GetMemoryMB()
if memErr == nil {
reservedMB := s.cfg.System.ReservedMemoryMB
usableMB := totalMB - reservedMB
newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
afterMB := usedMB + newReqMB
percent := 0
if usableMB > 0 {
percent = afterMB * 100 / usableMB
}
usedPercent := 0
if usableMB > 0 {
usedPercent = usedMB * 100 / usableMB
}
// Overcommit warning still uses declared limits
_, committedLimitMB := s.stackMgr.CommittedMemory()
newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
afterLimitMB := committedLimitMB + newLimitMB
memInfo["Available"] = true
memInfo["TotalMB"] = totalMB
memInfo["ReservedMB"] = reservedMB
memInfo["UsableMB"] = usableMB
memInfo["UsedMB"] = usedMB
memInfo["NewRequestMB"] = newReqMB
memInfo["AfterMB"] = afterMB
memInfo["Percent"] = percent
memInfo["UsedPercent"] = usedPercent
memInfo["Blocked"] = newReqMB > 0 && afterMB > usableMB
memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
}
data["MemoryInfo"] = memInfo
}
// Flash messages from cross-drive backup save redirect
if flash := r.URL.Query().Get("flash"); flash != "" {
data["FlashSuccess"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
s.executeTemplate(w, r, "deploy", data)
}
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
var found *stacks.Stack
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
found = &stack
break
}
}
if found == nil {
http.NotFound(w, r)
return
}
// Determine effective subdomain (stored env > metadata fallback)
effectiveSubdomain := found.Meta.Subdomain
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
effectiveSubdomain = sd
}
}
data := s.baseData("stacks", found.Meta.DisplayName)
data["Stack"] = found
data["Meta"] = found.Meta
data["AppInfo"] = found.Meta.AppInfo
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["EffectiveSubdomain"] = effectiveSubdomain
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
if found.Deployed && found.Meta.InitialCreds != nil {
if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil {
s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
} else if creds != nil && creds.Available {
data["InitialCreds"] = creds
}
}
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
if found.Deployed {
current := ""
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
current = appCfg.Env["HDD_PATH"]
}
var targets []settings.StoragePath
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
continue
}
targets = append(targets, sp)
}
data["MigrateTargets"] = targets
data["MigrateCurrent"] = current
if label, missing := s.missingStorageLabel(current); missing {
data["MissingStorageLabel"] = label
}
}
s.executeTemplate(w, r, "app_info", data)
}
func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("monitoring", "Rendszermonitor")
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
}
// Hub connection status section
data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != ""
data["HubURL"] = s.cfg.Hub.URL
data["CustomerID"] = s.cfg.Customer.ID
if s.hubPushStatusFn != nil {
ps := s.hubPushStatusFn()
data["HubLastAttempt"] = ps.LastAttempt
data["HubLastSuccess"] = ps.LastSuccess
data["HubLastError"] = ps.LastError
data["HubConsecutiveFailures"] = ps.Consecutive
// Connected if last success was within 2x the push interval (or 30min default)
connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute
data["HubConnected"] = connected
}
// Legacy ping status section (still shown for backward compat during transition)
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
if s.cfg.Monitoring.Enabled {
pings := []map[string]interface{}{
{"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"},
{"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"},
{"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
{"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
{"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasarnap)"},
}
allConfigured := true
for _, p := range pings {
if !p["Configured"].(bool) {
allConfigured = false
break
}
}
data["PingStatus"] = pings
data["AllPingsConfigured"] = allConfigured
}
s.executeTemplate(w, r, "monitoring", data)
}
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
func isPingConfigured(uuid string) bool {
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
}
// backupsCommonData builds what every backups sub-page shares (v0.124.0 IA split): the page
// chrome + the backup full-status with the redirect flash. Backup stays nil (empty-state) when
// the manager is absent. Each page handler adds ONLY the data its sections render — no
// duplicated computation across the four pages.
func (s *Server) backupsCommonData(page, title string, r *http.Request) map[string]interface{} {
data := s.baseData(page, title)
if s.backupMgr == nil {
data["Backup"] = nil
return data
}
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
// Pass flash messages from query params (set by redirect handlers)
if flash := r.URL.Query().Get("flash"); flash != "" {
fullStatus.FlashSuccess = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
fullStatus.FlashError = flashErr
}
data["Backup"] = fullStatus
return data
}
// backupsOffboxData adds the offbox target + per-app toggle state (the remote, apps and restore
// pages all render some of it: status card / toggle list / tier-3 rows / restore-to-verify).
func (s *Server) backupsOffboxData(data map[string]interface{}) {
offboxTgt := s.settings.GetOffboxTarget()
data["Offbox"] = offboxTgt
data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured()
// R-70 (v0.161.0): hub-managed offsite is enabled in controller.yaml — the descriptor exists
// and the apply-bridge WILL configure the target automatically. The empty state must say so
// instead of the bare "nincs beállítva" (a box stuck pre-apply looked identical to one that
// was never provisioned — DIAG-f10: that ambiguity hid a burned credential for 2 days).
data["OffsiteHubEnabled"] = s.cfg != nil && s.cfg.Offsite.Enabled
offboxApps := s.buildOffboxApps()
data["OffboxApps"] = offboxApps
// Zero-toggle hint (take-two obs.): configured + escrowed but no app selected — nothing is
// actually covered by the offsite leg until the customer toggles at least one.
offboxToggled := 0
for _, a := range offboxApps {
if a.Enabled {
offboxToggled++
}
}
data["OffboxToggledCount"] = offboxToggled
// Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation.
if offboxTgt != nil {
data["OffboxWarningDisplay"] = offboxWarningDisplay(offboxTgt.LastWarning, offboxToggled)
} else {
data["OffboxWarningDisplay"] = ""
}
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
// 3a: per-app "config+DB only" note set — apps whose enlarged push the quota gate blocked last run.
blocked := map[string]bool{}
if offboxTgt != nil {
for _, a := range offboxTgt.EnlargedBlocked {
blocked[a] = true
}
}
data["OffboxBlockedSet"] = blocked
}
// offboxStaleWarningMarker is the substring the zero-toggled offbox run writes into
// LastWarning (backup/offbox.go); offboxSelectionChangedLine replaces it once the
// selection has moved on.
const (
offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás"
offboxSelectionChangedLine = "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza."
)
// offboxWarningDisplay picks what the Távoli mentés page shows for the persisted
// Offbox.LastWarning. A zero-toggled run records "Sikeres — nincs mentésre jelölt
// alkalmazás…"; once the customer HAS toggled apps that line is stale and misleading —
// replace it with the honest "selection changed, the next run covers it" note.
// Pure display logic: the persisted LastWarning is never touched, and every other
// warning (quota, partial failure) passes through verbatim.
func offboxWarningDisplay(lastWarning string, toggledCount int) string {
if toggledCount >= 1 && strings.Contains(lastWarning, offboxStaleWarningMarker) {
return offboxSelectionChangedLine
}
return lastWarning
}
// backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and
// the status stat cards.
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups", "Biztonsági mentés", r)
// System info for storage overview bars
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
data["GuestBackup"] = s.loadGuestBackup(r.Context())
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
// DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.):
// "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded".
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
}
s.executeTemplate(w, r, "backups", data)
}
// escrowCeremonyGraceWindow (v0.138.0) bounds how long the "awaiting hub confirmation" card is
// shown after a completed escrow ceremony before it degrades to the "confirmation did not arrive"
// warning. Two report cycles (2×15m) + slack — long enough for the normal report-ACK confirm
// (Phase-0 verdict A: the demo confirmed on the next ACK ~14m out), short enough that a genuinely
// stuck ceremony never renders as an indefinite wait.
const escrowCeremonyGraceWindow = 35 * time.Minute
// offboxCeremonyWaitState classifies the post-ceremony wait for the remote page's escrow card:
// awaiting (stamped, within the grace window, still pending) vs timedOut (stamped, past the window,
// still pending). Both false when escrowed, unstamped, or the timestamp is unparseable — fail to the
// plain pending CTA rather than render a phantom wait.
func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool) {
if t == nil || t.EscrowState == "escrowed" || t.CeremonyCompletedAt == "" {
return false, false
}
ts, err := time.Parse(time.RFC3339, t.CeremonyCompletedAt)
if err != nil {
return false, false
}
if time.Since(ts) >= escrowCeremonyGraceWindow {
return false, true
}
return true, false
}
// backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the
// participation toggles and the manual-target form.
func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r)
s.backupsOffboxData(data)
// Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate.
data["EscrowStale"] = s.escrowStale()
agentVer := ""
if agent, err := s.escrowAgentConn(); err == nil {
agentVer = agent.AgentVersion()
}
data["EscrowAgentOK"] = escrowAgentSupported(agentVer)
// v0.138.0: the post-ceremony "awaiting hub confirmation" card (and its timeout degrade) —
// bridges the report-cycle gap between the ceremony and the auto-confirmer's pending→escrowed flip.
awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget())
data["OffboxCeremonyAwaiting"] = awaiting
data["OffboxCeremonyTimedOut"] = timedOut
// v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger.
data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned()
s.executeTemplate(w, r, "backups_remote", data)
}
// backupsAppsHandler renders the Alkalmazások page: schedule, databases and the per-app
// 1./2./3. tier rows.
func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r)
s.backupsOffboxData(data) // the tier-3 rows render $.Offbox status
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
// Enrich AppDataInfo with storage labels
storagePaths := s.settings.GetStoragePaths()
for i := range fullStatus.AppDataInfo {
app := &fullStatus.AppDataInfo[i]
if len(app.HDDPaths) > 0 {
hddPath := app.HDDPaths[0].HostPath
// Match HDD path prefix against registered storage paths
for _, sp := range storagePaths {
if strings.HasPrefix(hddPath, sp.Path) {
app.StorageLabel = sp.Label
break
}
}
}
}
// Build unified per-app backup rows for the app-data backup UI.
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
}
s.executeTemplate(w, r, "backups_apps", data)
}
// backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox
// restore-to-verify list and the .fab export/import loop.
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-restore", "Biztonsági mentés — Visszaállítás", r)
s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps
// Full-restore two-step reveal (§7.2): after the size+headroom prepare step, offboxRestoreHandler
// redirects here with the app + human size so the confirm section can show the size BEFORE starting.
if fp := strings.TrimSpace(r.URL.Query().Get("full_prep")); fp != "" {
data["FullPrepApp"] = fp
data["FullPrepSize"] = r.URL.Query().Get("full_size")
}
// Per-app place-to-live availability (a completed full scratch exists → offer the merge action).
ready := map[string]bool{}
if s.backupMgr != nil {
if apps, ok := data["OffboxApps"].([]OffboxAppRow); ok {
for _, a := range apps {
if a.Enabled && s.backupMgr.OffboxFullScratchReady(a.Name) {
ready[a.Name] = true
}
}
}
}
data["OffboxScratchReady"] = ready
// R-43: the same prepared scratch also enables the TRUE restore (files + database). Its confirm
// has to state what the pair actually IS — how old the DB half is, whether the two halves even
// come from the same run, and whether the dump looks customer-empty — because a restore is the
// one operation whose result the customer cannot inspect until after committing to it.
pairs := map[string]backup.OffsitePairInfo{}
if s.backupMgr != nil {
for name := range ready {
pairs[name] = s.backupMgr.OffsiteScratchPair(name)
}
}
data["OffboxPairInfo"] = pairs
// R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it
// gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it
// appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong).
if s.backupMgr != nil {
data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
data["SharesDisplayName"] = backup.SharesDisplayName
}
// v0.147.0 (4a): what verification restores have actually left on disk. Previously nothing on any
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
}
s.executeTemplate(w, r, "backups_restore", data)
}
// OffboxAppRow is one deployed app's off-box toggle state for the backups page.
type OffboxAppRow struct {
Name string
DisplayName string
Slug string // catalog slug for the shared app-row icon (logoURL)
Enabled bool
}
// buildOffboxApps lists deployed, non-protected apps with their off-box toggle state.
func (s *Server) buildOffboxApps() []OffboxAppRow {
var out []OffboxAppRow
if s.stackMgr == nil {
return out
}
for _, st := range s.stackMgr.GetStacks() {
if !st.Deployed || st.Protected {
continue
}
dn := st.Meta.DisplayName
if dn == "" {
dn = st.Name
}
out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Slug: st.Meta.Slug, Enabled: s.settings.IsAppOffbox(st.Name)})
}
return out
}
// AppBackupRow holds per-tier backup information for one app on the backup page.
type AppBackupRow struct {
StackName string
DisplayName string
Slug string // catalog slug for the aligned header icon (logoURL)
Status string // "green", "yellow", "red", "auto"
StatusText string // short Hungarian tooltip
// App characteristics
HasHDDData bool
HasDB bool
HasVolumeData bool
StorageLabel string
HDDSizeHuman string
// What this app's backup contains (for display)
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
BackupContents string
// Tier 1: Nightly backup (always exists)
Tier1LastRun string // RFC3339 time of the newest recovery-unit artifact ("" = no unit yet)
Tier1LastStatus string // "ok", "error", ""
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
// Tier 2: Cross-drive backup (configurable for all apps)
Tier2Configured bool
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
Tier2LastError string
Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
// Drive disconnected — app's home drive is currently disconnected
DriveDisconnected bool
// Tier2 destination drive is currently disconnected (backup paused, not failed)
Tier2DestDisconnected bool
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
Tier2DestInactive bool
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
Tier2UserDisabled bool
// Tier 3: Off-box (NAS) restic-SFTP backup — the off-site 3-2-1 leg.
OffboxEnabled bool // this app toggled for off-box inclusion (IsAppOffbox)
Tier3State string // "unconfigured" | "off" | "escrow_pending" | "active" (see tier3State)
// Warnings accumulated for this app
Warnings []string
}
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
// reflects only the app-data backup (DB dumps + Docker-volume tars).
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
dbStacks[db.StackName] = true
}
for _, f := range status.DumpFiles {
dbStacks[f.StackName] = true
}
tier1DBStatus := ""
if status.LastDBDump != nil {
if status.LastDBDump.Success {
tier1DBStatus = "ok"
} else {
tier1DBStatus = "error"
}
}
// Build disconnected paths set for drive-disconnected detection
disconnectedPaths := make(map[string]bool)
for _, dp := range s.settings.GetDisconnectedPaths() {
disconnectedPaths[dp.Path] = true
}
// Off-box (Tier 3) globals — resolved once for all rows. The per-app state is
// (global configured) × (global escrow) × (per-app toggle); see tier3State.
offboxConfigured := s.backupMgr != nil && s.backupMgr.OffboxConfigured()
offboxEscrowState := ""
if t := s.settings.GetOffboxTarget(); t != nil {
offboxEscrowState = t.EscrowState
}
var rows []AppBackupRow
for _, app := range status.AppDataInfo {
hasDB := dbStacks[app.StackName] || app.HasDBDump
// Check if this app's home drive is disconnected
driveDisconnected := false
if app.HasHDDData && len(app.HDDPaths) > 0 {
for dp := range disconnectedPaths {
for _, hp := range app.HDDPaths {
if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
driveDisconnected = true
break
}
}
if driveDisconnected {
break
}
}
}
// Build backup contents label
var parts []string
if hasDB {
parts = append(parts, "DB")
}
parts = append(parts, "Konfig")
if app.HasHDDData || app.HasVolumeData {
parts = append(parts, "Adatok")
}
contents := strings.Join(parts, " + ")
slug := ""
if s.stackMgr != nil {
if st, ok := s.stackMgr.GetStack(app.StackName); ok {
slug = st.Meta.Slug
}
}
row := AppBackupRow{
StackName: app.StackName,
DisplayName: app.DisplayName,
Slug: slug,
HasHDDData: app.HasHDDData,
HasDB: hasDB,
HasVolumeData: app.HasVolumeData,
DriveDisconnected: driveDisconnected,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
BackupContents: contents,
Tier1DBStatus: tier1DBStatus,
}
// Tier 1: newest recovery-unit artifact time. ListRestorePoints does the correct
// per-drive namespace resolution (do NOT re-derive paths — the offbox DIAG trap).
// A known stack with no unit yet returns an empty list → no fabricated time.
if s.backupMgr != nil {
if pts, ok := s.backupMgr.ListRestorePoints(app.StackName); ok && len(pts) > 0 {
row.Tier1LastRun = pts[0].Time
// A unit exists: green unless the DB dump failed (keep tier1DBStatus as the source).
if status.LastDBDump != nil && !status.LastDBDump.Success {
row.Tier1LastStatus = "error"
} else {
row.Tier1LastStatus = "ok"
}
}
}
// Tier 3: off-box (NAS) inclusion state for this app.
row.OffboxEnabled = s.settings.IsAppOffbox(app.StackName)
row.Tier3State = tier3State(offboxConfigured, row.OffboxEnabled, offboxEscrowState)
// Status dot — app-data backup status
row.Status = "green"
row.StatusText = "Alkalmazás-adat mentés rendben"
if hasDB && tier1DBStatus == "error" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
}
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
row.Tier2UserDisabled = cd.UserDisabled
if cd.UserDisabled {
// Customer turned Tier 2 off — show nothing more; the panel button still appears.
} else if cd.LastStatus == "no_target" {
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
row.Tier2Configured = false
row.Tier2StatusBadge = "Nincs 2. meghajtó"
row.Tier2LastError = cd.LastError
} else if cd.Enabled {
row.Tier2Configured = true
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
row.Tier2Schedule = "Naponta"
row.Tier2LastRun = cd.LastRun
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastError = cd.LastError
row.Tier2LastWarning = cd.LastWarning
row.Tier2SizeHuman = cd.LastSizeHuman
switch cd.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
case "error":
row.Tier2StatusBadge = "Hiba"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
}
}
}
rows = append(rows, row)
}
return rows
}
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
func tier2DestLabel(destPath, systemDataPath string) string {
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
return "belső SSD (csak DB/konfiguráció)"
}
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
}
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
stackName := r.FormValue("stack_name")
snapshotID := r.FormValue("snapshot_id")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
}
if stackName == "" || snapshotID == "" {
http.Redirect(w, r, "/backups/restore?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
}
// F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any
// restore work — never let it reach RestoreFromRecoveryUnit.
if !validStackName(stackName) {
s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
http.Redirect(w, r, "/backups/restore?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
// Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer
// got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in
// a BACKGROUND goroutine (survives the request; the poll banner shows progress → result).
if s.backupMgr.IsRunning() {
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("restore", stackName)
go func() {
start := time.Now()
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
// on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil {
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start))
s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").")
}()
http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
stackName := r.FormValue("stack_name")
if stackName == "" {
http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
}
// Same F2-defense as the unit restore: a stack name is a single segment, never a path.
if !validStackName(stackName) {
s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
// Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine.
if s.backupMgr.IsRunning() {
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("tier2-restore", stackName)
go func() {
n, err := s.backupMgr.RestoreTier2Files(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
return
}
msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
if n > 0 {
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
}
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n)
s.backupMgr.EndRestoreOp(true, msg)
}()
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}
// settingsBaseData is the shared identity block used by every settings-family subpage
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
data := s.baseData(page, title)
data["CustomerID"] = s.cfg.Customer.ID
data["CustomerDomain"] = s.cfg.Customer.Domain
return data
}
// systemPageData builds the Rendszer subpage: read-only configuration, version/update,
// controller + server restart.
func (s *Server) systemPageData() map[string]interface{} {
data := s.settingsBaseData("settings", "Beállítások")
data["GitRepoURL"] = s.cfg.Git.RepoURL
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
data["BackupEnabled"] = s.cfg.Backup.Enabled
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
data["HubEnabled"] = s.cfg.Hub.Enabled
// Self-update status
data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
if s.updater != nil {
// Registry mode (v0.112.0): credential-less is a SUPPORTED mode (anonymous pull of
// the public package), not an error state — the panel says which mode is active.
data["RegistryAnonymous"] = s.updater.RegistryAnonymous()
status := s.updater.GetStatus()
data["UpdateRunning"] = status.Running
if status.LastCheck != nil {
data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
data["LatestVersion"] = status.LastCheck.LatestVersion
data["LastCheckTime"] = status.LastCheck.CheckedAt
data["LastCheckError"] = status.LastCheck.Error
}
if status.LastState != nil {
data["LastUpdateState"] = status.LastState
}
data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates
// to. Empty = none set by the operator.
data["ControllerFloor"] = s.updater.GetFloor()
}
// Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability.
s.memoryCardData(data)
// „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the
// guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an
// address-less row („—") beats a wrong address. Hálózati név renders ONLY while Megosztás is
// enabled: the NetBIOS name exists only while samba runs — showing \\FELHOM otherwise would be
// a wrong promise.
data["NetLANAddress"] = s.sambaLANAddress()
data["NetGateway"] = s.guestGateway()
if smb := s.settings.GetSMBSettings(); smb.Enabled {
data["NetSMBName"] = smb.EffectiveServerName()
}
return data
}
// storagePageData builds the Tárhely page: physical drive registry, NAS shares, and the
// data the unified agent-enriched drive view needs.
func (s *Server) storagePageData() map[string]interface{} {
data := s.settingsBaseData("storage", "Tárhely")
// Storage paths with display data
storagePaths := s.settings.GetStoragePaths()
connectedCount := 0
for _, sp := range storagePaths {
if !sp.Disconnected && !sp.Decommissioned {
connectedCount++
}
}
var storageViews []StoragePathView
for _, sp := range storagePaths {
// NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's
// per-share health — never in the physical-drive list (which offers eject/decommission/wipe).
if sp.IsNetwork() {
continue
}
view := StoragePathView{
StoragePath: sp,
StoppedApps: sp.StoppedStacks,
HasOtherPaths: connectedCount > 1,
IsEnrolled: strings.HasPrefix(sp.Path, "/mnt/felhom-drives/"),
}
if sp.Disconnected {
// Skip I/O calls on disconnected drives — they'd hang or fail
view.IsMounted = false
} else if sp.Decommissioned {
view.IsMounted = false
view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo)
} else {
view.IsMounted = system.IsMountPoint(sp.Path)
view.AppDetails = s.appDetailsForPath(sp.Path)
view.FSInfo = system.GetFSInfo(sp.Path)
view.AppCount = len(view.AppDetails)
if di := system.GetDiskUsage(sp.Path); di != nil {
view.DiskInfo = di
}
// Detect USB for safe disconnect button
if view.FSInfo != nil && view.FSInfo.Device != "" {
view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
}
}
storageViews = append(storageViews, view)
}
data["StoragePaths"] = storageViews
return data
}
// networkStoragePageData builds the Tárhely → Hálózati tárhely (NAS) subpage: the NAS shares
// with the agent's per-share health (ok/idle/unreachable/unknown). Split from the physical
// drive page — the two storage classes were confusingly interleaved on one page.
func (s *Server) networkStoragePageData() map[string]interface{} {
data := s.settingsBaseData("storage-network", "Hálózati tárhely")
data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
// Capability banner: "no" swaps the add form for the agent-outdated notice (yes/unknown render
// the form — flaky states belong to the add-time gate). Short-budget probe, cache-backed.
data["NetAddSupport"] = s.netAddSupport()
return data
}
// notificationsPageData builds the Értesítések subpage: notification prefs + app-email.
func (s *Server) notificationsPageData() map[string]interface{} {
data := s.settingsBaseData("settings-notifications", "Értesítések")
data["HubEnabled"] = s.cfg.Hub.Enabled
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
// App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay
// path runs through the hub); the template hides the control otherwise.
appEmail := s.settings.GetAppEmail()
data["AppEmailEnabled"] = appEmail.Enabled
data["AppEmailFromName"] = appEmail.FromName
data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled()
return data
}
// securityPageData builds the Biztonság és hozzáférés subpage: password, geo-restriction,
// emergency/recovery info.
func (s *Server) securityPageData() map[string]interface{} {
data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")
// Recovery info for emergency section
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
data["HubURL"] = s.cfg.Hub.URL
data["SupportEmail"] = "support@felhom.eu"
data["SupportURL"] = "https://felhom.eu/kapcsolat"
// Geo-restriction data
data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != ""
geo := s.settings.GetGeoRestriction()
if geo != nil {
data["GeoEnabled"] = geo.Enabled
data["GeoAllowedCountries"] = geo.AllowedCountries
data["GeoAppOverrides"] = geo.AppOverrides
data["GeoLastSync"] = geo.LastSync
data["GeoLastError"] = geo.LastSyncError
} else {
data["GeoEnabled"] = false
data["GeoAllowedCountries"] = []string{"HU"}
data["GeoAppOverrides"] = map[string]interface{}{}
}
// Deployed apps for per-app override selector
var deployedApps []map[string]string
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) {
continue
}
deployedApps = append(deployedApps, map[string]string{
"Name": stack.Name,
"Display": stack.Meta.DisplayName,
})
}
data["DeployedApps"] = deployedApps
return data
}
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "settings_system", s.systemPageData())
}
// storagePageHandler serves the Tárhely main-nav page (D1). Storage action flashes land here.
func (s *Server) storagePageHandler(w http.ResponseWriter, r *http.Request) {
data := s.storagePageData()
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
}
s.executeTemplate(w, r, "storage", data)
}
// storageNetworkPageHandler serves the Tárhely → Hálózati tárhely (NAS) subpage.
func (s *Server) storageNetworkPageHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "storage_network", s.networkStoragePageData())
}
// settingsNotificationsPageHandler serves GET /settings/notifications (the POST on the same
// path is the save handler — dispatch is split in the router).
func (s *Server) settingsNotificationsPageHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "settings_notifications", s.notificationsPageData())
}
// settingsSecurityPageHandler serves GET /settings/security.
func (s *Server) settingsSecurityPageHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "settings_security", s.securityPageData())
}
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
}
data := s.securityPageData()
// Validate current password
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
}
data["PasswordError"] = "Hibás jelenlegi jelszó"
s.executeTemplate(w, r, "settings_security", data)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
s.executeTemplate(w, r, "settings_security", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
s.executeTemplate(w, r, "settings_security", data)
return
}
// Generate bcrypt hash
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.executeTemplate(w, r, "settings_security", data)
return
}
// Save to settings.json
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.executeTemplate(w, r, "settings_security", data)
return
}
s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)
// Invalidate all sessions (force re-login)
s.invalidateAllSessions()
// Redirect to login with flash message
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
}
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
}
email := strings.TrimSpace(r.FormValue("notification_email"))
cooldownStr := r.FormValue("cooldown_hours")
cooldownHours := 6
if cooldownStr != "" {
if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil {
cooldownHours = 6
}
}
if cooldownHours < 1 {
cooldownHours = 1
}
if cooldownHours > 168 {
cooldownHours = 168
}
// Collect enabled events from checkboxes
var enabledEvents []string
// Single-event checkboxes
for _, evt := range []string{
"backup_failed", "db_dump_failed", "backup_integrity_failed",
"crossdrive_failed", "offbox_enlarge_blocked", "storage_disconnected",
"node_down", "health_critical",
"storage_reconnected", "health_recovered",
} {
if r.FormValue("event_"+evt) == "on" {
enabledEvents = append(enabledEvents, evt)
}
}
// Compound toggles: one checkbox → two event types
if r.FormValue("event_disk_alerts") == "on" {
enabledEvents = append(enabledEvents, "disk_warning", "disk_critical")
}
if r.FormValue("event_expected_missed") == "on" {
enabledEvents = append(enabledEvents, "expected_backup_missed", "expected_dbdump_missed")
}
// EMPTY-EMAIL WIPE GUARD (2026-07-15 demo incident): a blank email box saved while events are
// still enabled would store an empty Email AND push it to the hub via SyncPreferences, wiping
// the customer's provisioning-seeded alert address — enabled events with nowhere to send them.
// The only way to reach this state is the bug, so refuse the save outright (BEFORE
// SetNotificationPrefs and BEFORE any hub sync), leaving the stored email untouched, and ask for
// an address. The intentional "turn everything off" case (empty email + ZERO events) falls
// through below — clearing the email is legitimate there and the empty hub push is correct.
if email == "" && len(enabledEvents) > 0 {
s.logger.Printf("[WARN] [web] Refused notification save: empty email with %d enabled event(s) — would wipe hub-side alert delivery", len(enabledEvents))
data := s.notificationsPageData()
// Repaint the customer's just-submitted intent (their ticked events + chosen cooldown, empty
// email) so they only need to add an address, not re-tick everything. Overlay the stored
// prefs — do NOT persist this; it is render-only.
data["NotificationPrefs"] = &settings.NotificationPrefs{
Email: email,
EnabledEvents: enabledEvents,
CooldownHours: cooldownHours,
}
data["NotificationError"] = "Adj meg egy értesítési e-mail címet bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket."
s.executeTemplate(w, r, "settings_notifications", data)
return
}
prefs := &settings.NotificationPrefs{
Email: email,
EnabledEvents: enabledEvents,
CooldownHours: cooldownHours,
}
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err)
data := s.notificationsPageData()
data["NotificationError"] = "Hiba a beállítások mentésekor"
s.executeTemplate(w, r, "settings_notifications", data)
return
}
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle
// Sync preferences to hub
data := s.notificationsPageData()
if s.notifier != nil && s.notifier.IsEnabled() {
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
}
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
}
s.executeTemplate(w, r, "settings_notifications", data)
}
// settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box
// SMTP shim to match (no controller restart needed).
func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
fromName := strings.TrimSpace(r.FormValue("app_email_from_name"))
data := s.notificationsPageData()
if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
s.executeTemplate(w, r, "settings_notifications", data)
return
}
// v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the
// setting) — report out-of-cycle so the hub sees it in seconds.
s.reportTriggerNow()
// Reconcile the shim's running state with the new toggle.
if s.mailShim != nil {
if err := s.mailShim.Apply(enabled); err != nil {
s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
data = s.notificationsPageData()
data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
s.executeTemplate(w, r, "settings_notifications", data)
return
}
}
s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
data = s.notificationsPageData()
if enabled {
data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
} else {
data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
}
s.executeTemplate(w, r, "settings_notifications", data)
}
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
data := s.notificationsPageData()
if s.notifier == nil {
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
s.executeTemplate(w, r, "settings_notifications", data)
return
}
err := s.notifier.SendTest()
if err != nil {
s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
s.executeTemplate(w, r, "settings_notifications", data)
return
}
data["NotificationSuccess"] = "Teszt email elküldve."
s.executeTemplate(w, r, "settings_notifications", data)
}
// --- Storage path management handlers ---
func (s *Server) countAppsUsingPath(storagePath string) int {
count := 0
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if appCfg.Env["HDD_PATH"] == storagePath {
count++
}
}
}
return count
}
func (s *Server) appsUsingPath(storagePath string) []string {
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
}
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
// with no HDD_PATH (SSD-resident) is never "missing".
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
if hddPath == "" {
return "", false
}
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == hddPath {
// A NAS network path is NEVER "missing": its availability is the agent's per-share liveness
// (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop
// cascade. An `unreachable` NAS must not look like a removed drive.
if sp.IsNetwork() {
return "", false
}
if sp.Decommissioned || sp.Disconnected {
return s.settings.GetStorageLabel(hddPath), true
}
return "", false // present + available
}
}
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
}
// networkStorageWarnings returns two stack-name → share-label maps for deployed apps on NAS
// network paths:
// - warnings: the agent reports the share `unreachable` — RECOVERABLE ("hálózati tárhely nem
// elérhető"), explicitly NOT the drive missing/stop-cascade; clears when the NAS returns.
// - stubs: the path is a plain local STUB in the controller's namespace (RCA fix 2 — the
// guest-reboot state where apps silently see an empty dir while the agent's host-side view is
// healthy). Checked from THIS process (the consuming namespace), independent of the agent.
//
// Stub wins: a stack never appears in both. An idle autofs trigger is HEALTHY and is never
// force-mounted from here (classification reads the fs magic only). Best-effort: an agent error
// drops the unreachable leg but the stub leg still runs.
func (s *Server) networkStorageWarnings(list []stacks.Stack) (warnings, stubs map[string]string) {
warnings, stubs = map[string]string{}, map[string]string{}
if s.settings == nil || s.stackMgr == nil {
return warnings, stubs
}
netPaths := map[string]settings.StoragePath{}
for _, sp := range s.settings.GetStoragePaths() {
if sp.IsNetwork() {
netPaths[sp.Path] = sp
}
}
if len(netPaths) == 0 {
return warnings, stubs
}
// Stub leg — the consuming namespace's own verdict (no agent, no force-mount).
stubPaths := s.stubNetworkPaths(netPaths)
// Unreachable leg — the agent's host-side liveness view (unchanged behavior).
unreachable := map[string]string{} // path → label
if agent, err := s.agentClient(); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if mounts, err := agent.ListNetStorage(ctx); err == nil {
for _, m := range mounts {
if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign
continue
}
p := settings.NetworkMountRoot + "/" + m.Name
if sp, ok := netPaths[p]; ok {
lbl := sp.Label
if lbl == "" {
lbl = m.Name
}
unreachable[p] = lbl
}
}
} else {
s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err)
}
}
return networkStorageWarningsIn(list, s.stackMgr.LoadAppConfigByName, unreachable, stubPaths)
}
// stubNetworkPaths classifies each registered network path in the controller's namespace and
// returns path→label for every STUB (RCA fix 2). Idle autofs is healthy and classification never
// force-mounts (fs magic read only); unknown (timeout/statfs error) is NOT a stub — fail open.
func (s *Server) stubNetworkPaths(netPaths map[string]settings.StoragePath) map[string]string {
out := map[string]string{}
for p, sp := range netPaths {
if s.classifyFSPath(p) == system.FSClassStub {
lbl := sp.Label
if lbl == "" {
lbl = strings.TrimPrefix(p, settings.NetworkMountRoot+"/")
}
out[p] = lbl
}
}
return out
}
// networkStorageWarningsIn is the pure per-stack mapping core (the appsUsingPathIn pattern): given
// the path→label verdict sets, assign each deployed stack its badge. Stub WINS over unreachable —
// a stack never appears in both maps.
func networkStorageWarningsIn(list []stacks.Stack, load func(string) *stacks.AppConfig, unreachable, stubPaths map[string]string) (warnings, stubs map[string]string) {
warnings, stubs = map[string]string{}, map[string]string{}
if len(unreachable) == 0 && len(stubPaths) == 0 {
return warnings, stubs
}
for _, st := range list {
if !st.Deployed {
continue
}
if cfg := load(st.Name); cfg != nil {
hdd := cfg.Env["HDD_PATH"]
if lbl, bad := stubPaths[hdd]; bad {
stubs[st.Name] = lbl // stub wins over unreachable
continue
}
if lbl, bad := unreachable[hdd]; bad {
warnings[st.Name] = lbl
}
}
}
return warnings, stubs
}
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
out := map[string]string{}
for _, st := range list {
if !st.Deployed {
continue
}
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
out[st.Name] = label
}
}
}
return out
}
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
var names []string
for _, stack := range allStacks {
if !stack.Deployed {
continue
}
if appCfg := loadCfg(stack.Name); appCfg != nil {
if appCfg.Env["HDD_PATH"] == storagePath {
names = append(names, stack.Meta.DisplayName)
}
}
}
return names
}
func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
var details []StorageAppDetail
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
if appCfg == nil {
continue
}
hddPath := appCfg.Env["HDD_PATH"]
if hddPath != storagePath {
continue
}
detail := StorageAppDetail{
Name: stack.Meta.DisplayName,
Stack: stack.Meta.Slug,
}
// Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is
// NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s).
// Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A).
var total int64
var any bool
for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) {
d := backup.AppDataDir(storagePath, name)
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
total += dirSizeBytesWalk(d)
any = true
}
}
if any {
detail.SizeHuman = humanizeDirBytes(total)
}
details = append(details, detail)
}
return details
}
// dirSizeBytesWalk returns the total size in bytes of the regular files under path (0 if absent).
func dirSizeBytesWalk(path string) int64 {
var total int64
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
return total
}
// humanizeDirBytes formats a byte count as B/KB/MB/GB (the storage-page display convention).
func humanizeDirBytes(total int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case total >= GB:
return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
case total >= MB:
return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
case total >= KB:
return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
default:
return fmt.Sprintf("%d B", total)
}
}
// dirSizeHuman returns a human-readable size for a single directory.
func dirSizeHuman(path string) string {
return humanizeDirBytes(dirSizeBytesWalk(path))
}
func formatFreeSpace(gb float64) string {
if gb >= 1000 {
return fmt.Sprintf("%.1f TB", gb/1024)
}
return fmt.Sprintf("%.1f GB", gb)
}
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
path := filepath.Clean(r.FormValue("storage_path"))
label := strings.TrimSpace(r.FormValue("storage_label"))
isDefault := r.FormValue("storage_default") == "true"
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
}
if label == "" {
label = settings.InferStorageLabel(path)
}
data := s.storagePageData()
// 1. Exists and is directory
fi, err := os.Stat(path)
if err != nil || !fi.IsDir() {
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
s.executeTemplate(w, r, "storage", data)
return
}
// 2. Is mount point
if !system.IsMountPoint(path) {
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
s.executeTemplate(w, r, "storage", data)
return
}
// 3. Writable
if !system.IsWritable(path) {
data["StorageError"] = "Az útvonal nem írható."
s.executeTemplate(w, r, "storage", data)
return
}
// 4. No overlap with existing paths
for _, existing := range s.settings.GetStoragePaths() {
if system.PathsOverlap(path, existing.Path) {
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
s.executeTemplate(w, r, "storage", data)
return
}
}
// 5. Soft warning if not under /mnt/
if !strings.HasPrefix(path, "/mnt/") {
s.logger.Printf("[WARN] [web] Storage path %s is not under /mnt/ — unusual but allowed", path)
}
sp := settings.StoragePath{
Path: path,
Label: label,
IsDefault: isDefault,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.settings.AddStoragePath(sp); err != nil {
s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
data["StorageError"] = "Hiba a mentés során."
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
go s.SyncFileBrowserMounts()
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
path := r.FormValue("storage_path")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
}
data := s.storagePageData()
// Check: apps using this path
apps := s.appsUsingPath(path)
if len(apps) > 0 {
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
s.executeTemplate(w, r, "storage", data)
return
}
// Check: cannot remove default
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == path && sp.IsDefault {
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
s.executeTemplate(w, r, "storage", data)
return
}
}
// Check: last path
if len(s.settings.GetStoragePaths()) <= 1 {
data["StorageError"] = "Az utolsó adattároló nem törölhető."
s.executeTemplate(w, r, "storage", data)
return
}
if err := s.settings.RemoveStoragePath(path); err != nil {
data["StorageError"] = "Hiba a törlés során."
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage path removed: %s", path)
// Sync FileBrowser mounts after storage path removal
go s.SyncFileBrowserMounts()
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
path := r.FormValue("storage_path")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
}
if err := s.settings.SetDefaultStoragePath(path); err != nil {
s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err)
http.Redirect(w, r, "/storage", http.StatusFound)
return
}
s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
path := r.FormValue("storage_path")
schedulable := r.FormValue("schedulable") == "true"
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
}
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err)
http.Redirect(w, r, "/storage", http.StatusFound)
return
}
s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
path := r.FormValue("storage_path")
label := strings.TrimSpace(r.FormValue("storage_label"))
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
}
if label == "" || len(label) > 50 {
data := s.storagePageData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
s.executeTemplate(w, r, "storage", data)
return
}
if err := s.settings.SetStorageLabel(path, label); err != nil {
s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err)
data := s.storagePageData()
data["StorageError"] = "Hiba a megnevezés mentésekor."
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
}
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
// with volume mounts and sources for all registered storage paths, then recreates the container.
func (s *Server) SyncFileBrowserMounts() {
s.syncFileBrowserMounts(false)
}
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
// database when sources change. Use only after restore — normal operations should use
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
func (s *Server) SyncFileBrowserMountsReset() {
s.syncFileBrowserMounts(true)
}
// skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser
// sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is
// detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be
// mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never
// skipped. Pure + isMount-injected for testability.
func skipFileBrowserPath(path string, isMount func(string) bool) bool {
return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
}
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
s.fileBrowserMu.Lock()
defer s.fileBrowserMu.Unlock()
stackDir := "/opt/docker/stacks/filebrowser"
composePath := stackDir + "/docker-compose.yml"
// Check if FileBrowser stack exists
if _, err := os.Stat(composePath); os.IsNotExist(err) {
s.logger.Printf("[WARN] [web] FileBrowser stack not found at %s — skipping mount sync", composePath)
return
}
// Get all active storage paths
paths := s.settings.GetStoragePaths()
// Use domain from controller config
domain := s.cfg.Customer.Domain
if domain == "" {
s.logger.Printf("[WARN] [web] Cannot sync FileBrowser mounts — customer domain not configured")
return
}
// Build volume mount lines + the config source set (R-67: the two must agree — a source with
// no mount behind it renders a broken sidebar entry).
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
isMount: system.IsMountPoint,
classify: s.classifyFSPath,
ensureSkeleton: appbackup.EnsureUserdataSkeleton,
logger: s.logger,
})
// Generate and write config.yaml (sources + sidebar entries per drive/share)
configPath := stackDir + "/config.yaml"
fbConfig := generateFileBrowserConfig(configPaths)
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
// after we write it, so the recreate decision is made AFTER the writes against the final files.
oldConfig, _ := os.ReadFile(configPath)
oldCompose, _ := os.ReadFile(composePath)
// Detect if sources changed — if so, the database must be reset so
// FileBrowser picks up the new source list (user prefs cache old sources).
sourcesChanged := string(oldConfig) != fbConfig
if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
return
}
// Re-apply active integrations into config.yaml (before container restart)
if im := s.integrationMgr.Load(); im != nil {
im.ReapplyConfigForTarget("filebrowser")
}
// Generate and write compose (includes config.yaml mount)
compose := generateFileBrowserCompose(domain, storageMounts)
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
return
}
// Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2):
// a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing
// actually changed. The recreate only happens when config.yaml or the compose file truly differ.
finalConfig, _ := os.ReadFile(configPath)
finalCompose, _ := os.ReadFile(composePath)
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
// If sources changed and caller requested a DB reset (restore flow),
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
// Normal operations skip this to preserve user accounts, permissions, and share links.
if sourcesChanged && resetDBOnChange {
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer resetCancel()
stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
stop.Dir = stackDir
if out, err := stop.CombinedOutput(); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
}
changed = true // a DB reset removed the container — it must be recreated
}
// Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when
// something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
args := []string{"compose", "up", "-d", "--remove-orphans"}
if changed {
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
}
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = stackDir
if out, err := cmd.CombinedOutput(); err != nil {
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
} else if changed {
s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
} else {
s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
}
}
// fbPathDeps are the injectable edges of buildFileBrowserPaths — everything that would otherwise
// touch the real mount table, the real filesystem or a real statfs, so the A/B/C/D scenarios run
// without a drive, a NAS or docker.
type fbPathDeps struct {
isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint)
classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open)
ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY
logger *log.Logger
}
// buildFileBrowserPaths computes one FileBrowser sync pass's volume mount lines + the source-list
// paths, with the per-kind gates applied. Two storage classes, two DIFFERENT gates:
//
// DRIVES (v0.66.0 semantics, unchanged byte-for-byte): scope to the `userdata/` subtree, pre-create
// the skeleton, and apply the drive-absent gate — a detached external drive must not get a skeleton
// written onto the rootfs. Drives always stay in the config source list (pre-R-67 behavior kept).
//
// NETWORK SHARES (R-67): the drive-absent gate does NOT apply — an idle automount trigger is
// HEALTHY (first access mounts it; the old gate skipped an idle share forever). The gate here is
// the STUB classifier instead, and it is a data-safety gate, not cosmetics: exposing a local stub
// dir lets a customer upload into a directory the real mount will later SHADOW — their files
// silently vanish from view. A stub share is excluded from BOTH the mounts and the source list
// this pass (a source without a mount is a broken sidebar entry). autofs-trigger / real network fs
// / unknown all include (unknown fails open — a wedged NAS must not hide the share forever).
// The bind is the share ROOT with :rslave — load-bearing: host-side automount wake and
// idle-unmount events must propagate into the RUNNING container (Phase-0 probe 2026-07-22 proved
// an in-container access through an rslave bind wakes the idle trigger). Never a skeleton, never
// any write toward the NAS — Felhom conventions must not be written onto a customer's own NAS.
func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageMounts []string, configPaths []settings.StoragePath) {
configPaths = make([]settings.StoragePath, 0, len(paths))
for _, sp := range paths {
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"; ".../Felhom-Share" → "Felhom-Share"
if sp.IsNetwork() {
if d.classify != nil && d.classify(sp.Path) == system.FSClassStub {
if d.logger != nil {
d.logger.Printf("[WARN] [web] FileBrowser: %s namespace sees a local stub, not the NAS — excluded until propagation recovers", mountName)
}
continue
}
storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s:rslave", sp.Path, mountName))
configPaths = append(configPaths, sp)
continue
}
// Drives are ALWAYS in the source list (pre-R-67 behavior: the config listed every
// registered path; only the mount obeys the drive-absent gate).
configPaths = append(configPaths, sp)
// Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached —
// don't create its userdata skeleton (would write onto the rootfs) and don't mount it into
// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
// external-only rule (system paths, not under StableParentDir, are never skipped).
if skipFileBrowserPath(sp.Path, d.isMount) {
if d.logger != nil {
d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
}
continue
}
// SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata —
// app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted
// into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as
// uid 1000 → it can create folders + upload files (the old appdata mount was guest-root 0755
// → permission-denied). Pre-create the full skeleton with the convention.
if err := d.ensureSkeleton(sp.Path); err != nil {
if d.logger != nil {
d.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
}
}
userdataSrc := appbackup.UserdataDir(sp.Path)
storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName))
}
return storageMounts, configPaths
}
// fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either
// the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the
// first-ever run the old files are empty → differs from the freshly generated content → true (creates
// it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker.
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
}
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
// pinned image and the base-infra bring-up path can never diverge).
func generateFileBrowserCompose(domain string, storageMounts []string) string {
return infra.RenderFileBrowserCompose(domain, storageMounts)
}
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
// registered storage path. Delegates to internal/infra (single source of truth).
func generateFileBrowserConfig(paths []settings.StoragePath) string {
return infra.RenderFileBrowserConfig(paths)
}