3a9d744360
The offsite restore list rendered up to five inline forms per app row. Two of them —
„Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and „Teljes visszaállítás
(fájlok + adatbázis)" — were sibling buttons whose difference is whether the customer's
data comes back at all. That mis-selection CAUSED the round-2 incident: an operator who
had read the source pressed the missing-only button and /backup/offbox/reconstitute was
never hit (DIAG-immich-restore-round2-2026-07-19, finding 1).
The rule this establishes: two adjacent controls whose difference is "your data comes
back" vs "your data cannot come back" must never be distinguishable only by layout.
Each row now carries ONE „Visszaállítás…" entry linking to a per-app wizard at
GET /backups/restore/app?name=<app>: three intent CARDS with consequence sentences, a
visible phase strip, danger styling plus the R-43 double-confirm carried over verbatim on
the destructive one, and the pair-honesty panel bound to real OffsiteScratchPair facts.
deriveWizardStep is pure over (op running, size-gate flash, scratch ready); the step is
never accepted from the request, and a running op outranks a stale ?full_prep= so no
commit button survives into a restore. While ANY op runs every mutation form is suppressed
server-side instead of being offered and refused.
No new mutation endpoint: every card posts to the pre-existing /backup/offbox/* handler
with the same field names and gates, and the page works with JavaScript disabled.
internal/{backup,appbackup,selfupdate} untouched. R-45's job registry stays its own item.
Fixes a latent bug found on the way: offboxRedirectTo hardcoded "?" when appending its
flash, which against the wizard's ?name=<app> target would have buried the flash inside
the name value.
No new agent coupling — MinAgent stays 0.90.0.
716 lines
29 KiB
Go
716 lines
29 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
stackMgr *stacks.Manager
|
|
cpuCollector *system.CPUCollector
|
|
backupMgr *backup.Manager
|
|
scheduler *scheduler.Scheduler
|
|
settings *settings.Settings
|
|
alertManager *AlertManager
|
|
notifier *notify.Notifier
|
|
updater *selfupdate.Updater
|
|
logger *log.Logger
|
|
version string
|
|
encKey []byte // AES-256 key for decrypting app.yaml values
|
|
tmpl *template.Template
|
|
|
|
sessions map[string]*session
|
|
sessionsMu sync.RWMutex
|
|
loginAttempts map[string]*loginAttempt
|
|
loginAttemptMu sync.Mutex
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
|
|
// Customer-claim arc (v0.122.0, F-4): the claim/reset code brute-force limiter. Per-source
|
|
// (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now).
|
|
claimMu sync.Mutex
|
|
claimAttempts map[string]*claimAttempt
|
|
claimGlobal claimAttempt
|
|
claimClock func() time.Time
|
|
|
|
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
|
|
fileBrowserMu sync.Mutex
|
|
|
|
// Shared agent local-API client (built once, reused). cfg.LocalAPI is static per process (a
|
|
// config-apply triggers a graceful self-restart), so the client is memoized via agentCliOnce —
|
|
// this kills the per-call http.Transport leak that exhausted the controller's ephemeral ports to
|
|
// the agent's :8443 after ~5 days of uptime (see agentClient()).
|
|
agentCli *agentapi.Client
|
|
agentCliErr error
|
|
agentCliOnce sync.Once
|
|
|
|
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
|
hubPushStatusFn func() HubPushStatusData
|
|
|
|
// Out-of-cycle hub report trigger (v0.139.0, Direction 1) — set via SetReportTrigger to
|
|
// report.Trigger.Fire. Fired via reportTriggerNow() AFTER a successful hub-relevant local
|
|
// commit (escrow claim, notification/app-email save, offsite config/toggle, customer
|
|
// claim) so the hub reflects the new state in seconds instead of the next ~15-min cycle.
|
|
// nil (hub reporting off / tests) = strict no-op.
|
|
reportTriggerFn func()
|
|
|
|
// Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to
|
|
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
|
wipeStagedEscrowFn func(ctx context.Context) error
|
|
|
|
// Controller-driven escrow ceremony (v0.127.0) seams. escrowAgentFn nil → the shared
|
|
// agentClient(); escrowStageFn nil → PushOffboxPasswordForEscrow over the client;
|
|
// escrowStaleFn is the Scenario-F stale-blob flag (report.EscrowAutoConfirmer.StaleBlob,
|
|
// wired via SetEscrowStale; nil → never stale).
|
|
escrowAgentFn func() (escrowAgent, error)
|
|
escrowStageFn func(ctx context.Context) error
|
|
escrowStaleFn func() bool
|
|
|
|
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
|
|
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
|
netAdd netAddState
|
|
storageInit storageInitState
|
|
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
|
|
sambaEnsure sambaEnsureState
|
|
// sambaAddrFn is the connect-address seam (v0.151.0, S-2/S-5): the guest's LAN IPv4 for the
|
|
// „Csatlakozás a megosztáshoz" card, or "" when it cannot be read. nil → stackMgr.SambaLANAddress.
|
|
// Called PER RENDER and stored nowhere — the address is a DHCP lease (see sambaLANAddress).
|
|
sambaAddrFn func() string
|
|
netAgentFn func() (netAgent, error)
|
|
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
|
fabUpload uploadState
|
|
netProbeFn func(ctx context.Context, dir string) probeOutcome
|
|
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
|
|
// agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared
|
|
// agentClient().DebugLogs; tests inject (incl. the pre-0.83 typed-404 path).
|
|
agentLogsFn func(ctx context.Context) (agentapi.AgentLogsResponse, error)
|
|
// classifyFSPath classifies a network path's filesystem in THIS process's namespace (the stub
|
|
// badge, RCA fix 2). Set to system.ClassifyPathFSTimeout by NewServer; tests inject fake classes.
|
|
classifyFSPath func(path string) string
|
|
// netFeatures caches the agent-capability probe (agentapi features.go) for the coupled NAS add
|
|
// semantics — the add gate + the settings-page banner read it. Zero value ready.
|
|
netFeatures agentapi.SupportCache
|
|
|
|
// memFeatures caches the guest-memory-resize capability probe (agent v0.90.0, R-24) — the resize
|
|
// handler gate + the settings-page render read it. Zero value ready. memAgentFn is the test seam.
|
|
memFeatures agentapi.SupportCache
|
|
memAgentFn func() (memAgent, error)
|
|
|
|
// Asset syncer for Hub-managed assets (optional)
|
|
assetsSyncer *assets.Syncer
|
|
|
|
// App-to-app integration manager (optional).
|
|
// M25: atomic because the constructor launches the SyncFileBrowserMounts
|
|
// goroutine (which reads this) BEFORE SetIntegrationManager runs in main.go —
|
|
// so a plain field would be a data race (the init-only happens-before that
|
|
// covers the other Set* fields does NOT hold for this one).
|
|
integrationMgr atomic.Pointer[integrations.Manager]
|
|
|
|
// App export/import engine (optional)
|
|
appExporter *appexport.Exporter
|
|
|
|
// Whole-guest backup trigger (the quiesce loop; optional — nil on an unprovisioned guest or when
|
|
// quiesce is disabled). Drives the "Mentés most" button: the CONTROLLER owns quiescing, so the
|
|
// manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call.
|
|
backupTrigger BackupTrigger
|
|
|
|
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
|
|
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
|
|
mailShim MailShimController
|
|
|
|
// Debug mode support
|
|
logBuffer *LogBuffer
|
|
debugCallbacks *DebugCallbacks
|
|
startTime time.Time
|
|
}
|
|
|
|
// MailShimController is the lifecycle handle the settings toggle uses to start/stop the
|
|
// app-email SMTP shim at runtime. Satisfied by *mailrelay.Lifecycle (kept as an interface
|
|
// to avoid a web→mailrelay import cycle risk and to allow a fake in tests).
|
|
type MailShimController interface {
|
|
Apply(enabled bool) error
|
|
Running() bool
|
|
}
|
|
|
|
// SetMailShim wires the app-email shim lifecycle (optional).
|
|
func (s *Server) SetMailShim(c MailShimController) { s.mailShim = c }
|
|
|
|
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
stackMgr: stackMgr,
|
|
cpuCollector: cpuCollector,
|
|
backupMgr: backupMgr,
|
|
scheduler: sched,
|
|
settings: sett,
|
|
alertManager: alertMgr,
|
|
notifier: notif,
|
|
updater: updater,
|
|
logger: logger,
|
|
version: version,
|
|
sessions: make(map[string]*session),
|
|
loginAttempts: make(map[string]*loginAttempt),
|
|
done: make(chan struct{}),
|
|
}
|
|
s.classifyFSPath = system.ClassifyPathFSTimeout
|
|
|
|
if cfg.Logging.Level == "debug" {
|
|
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
|
|
logger.Printf("[DEBUG] [web] NewServer: backup=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
|
|
backupMgr != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
|
|
}
|
|
|
|
s.loadTemplates()
|
|
go s.cleanupSessions()
|
|
// .fab download staging (v0.124.0): sweep aged bundles left by a crash/abandoned download.
|
|
if cfg.Paths.DataDir != "" {
|
|
if n := sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, logger); n > 0 {
|
|
logger.Printf("[INFO] [web] download-export startup sweep removed %d staged bundle(s)", n)
|
|
}
|
|
}
|
|
// Drive-absent gate reconcile (intermediary-mount model): the periodic absent/return detector that
|
|
// replaced the retired slice-8C watchdog. Stops+blocks apps whose drive vanished, auto-restarts them
|
|
// when it returns. No-op when the agent is unreachable.
|
|
go s.driveGateLoop()
|
|
|
|
// Log auth source on startup
|
|
if sett != nil && sett.GetPasswordHash() != "" {
|
|
logger.Printf("[INFO] [web] Auth: using password from settings.json")
|
|
} else if cfg.Web.PasswordHash != "" {
|
|
logger.Printf("[INFO] [web] Auth: using password from controller.yaml")
|
|
} else {
|
|
logger.Printf("[INFO] [web] Auth: no password configured — dashboard is open")
|
|
}
|
|
|
|
// Sync FileBrowser config on startup to ensure mounts and sources are current.
|
|
// After a restore, a flag file signals that the database should be reset
|
|
// (stale source prefs from initial install). Consume the flag and reset.
|
|
fbResetFlag := filepath.Join(cfg.Paths.DataDir, ".fb-reset")
|
|
if _, err := os.Stat(fbResetFlag); err == nil {
|
|
os.Remove(fbResetFlag)
|
|
go s.SyncFileBrowserMountsReset()
|
|
} else {
|
|
go s.SyncFileBrowserMounts()
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display.
|
|
// Must be called before ListenAndServe (all Set* methods are init-time only).
|
|
func (s *Server) SetEncryptionKey(key []byte) {
|
|
s.encKey = key
|
|
}
|
|
|
|
func (s *Server) loadTemplates() {
|
|
s.tmpl = template.Must(
|
|
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
|
|
)
|
|
if s.isDebug() {
|
|
names := s.tmpl.Templates()
|
|
s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names))
|
|
}
|
|
}
|
|
|
|
// HubPushStatusData holds hub push status for the monitoring page.
|
|
type HubPushStatusData struct {
|
|
LastAttempt time.Time
|
|
LastSuccess time.Time
|
|
LastError string
|
|
Consecutive int
|
|
}
|
|
|
|
// SetHubPushStatus sets the hub push status callback for the monitoring page.
|
|
func (s *Server) SetHubPushStatus(fn func() HubPushStatusData) {
|
|
s.hubPushStatusFn = fn
|
|
}
|
|
|
|
// SetAssetsSyncer sets the Hub asset syncer for resolving app assets.
|
|
func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
|
|
s.assetsSyncer = as
|
|
}
|
|
|
|
// SetReportTrigger wires the out-of-cycle hub report trigger (report.Trigger.Fire). The
|
|
// provided func MUST be non-blocking — it is called from request handlers (the trigger's
|
|
// worker does the waiting/pushing). Init-time only, like every Set* here.
|
|
func (s *Server) SetReportTrigger(fn func()) {
|
|
s.reportTriggerFn = fn
|
|
}
|
|
|
|
// reportTriggerNow fires the out-of-cycle report trigger if wired (mirrors
|
|
// api.Router.reportPushNow). Call AFTER a successful hub-relevant local commit — never
|
|
// before it, never on an error path. Nil-safe no-op when hub reporting is off.
|
|
func (s *Server) reportTriggerNow() {
|
|
if s.reportTriggerFn != nil {
|
|
s.reportTriggerFn()
|
|
}
|
|
}
|
|
|
|
// SetIntegrationManager sets the app-to-app integration manager.
|
|
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
|
|
s.integrationMgr.Store(mgr)
|
|
}
|
|
|
|
// SetLogBuffer sets the in-memory log ring buffer for the debug log viewer.
|
|
func (s *Server) SetLogBuffer(lb *LogBuffer) {
|
|
s.logBuffer = lb
|
|
}
|
|
|
|
// SetDebugCallbacks sets the callbacks for debug endpoints that need main.go wiring.
|
|
func (s *Server) SetDebugCallbacks(dc *DebugCallbacks) {
|
|
s.debugCallbacks = dc
|
|
}
|
|
|
|
// SetAppExporter sets the app export/import engine.
|
|
func (s *Server) SetAppExporter(e *appexport.Exporter) {
|
|
s.appExporter = e
|
|
}
|
|
|
|
// BackupTrigger forces an app-consistent whole-guest backup NOW (the quiesce loop satisfies it).
|
|
// Returns quiesce.ErrBackupInProgress when a cycle is already running (single-flight).
|
|
type BackupTrigger interface {
|
|
TriggerNow() error
|
|
}
|
|
|
|
// SetBackupTrigger wires the whole-guest backup trigger (the quiesce loop) for the "Mentés most"
|
|
// button. Optional — left nil on an unprovisioned guest or when quiesce is disabled (the button then
|
|
// renders disabled with an explanatory note).
|
|
func (s *Server) SetBackupTrigger(t BackupTrigger) {
|
|
s.backupTrigger = t
|
|
}
|
|
|
|
// SetStartTime records the controller start time for uptime calculation.
|
|
func (s *Server) SetStartTime(t time.Time) {
|
|
s.startTime = t
|
|
}
|
|
|
|
// isDebug returns true if the controller is running in debug mode.
|
|
func (s *Server) isDebug() bool {
|
|
return s.cfg.Logging.Level == "debug"
|
|
}
|
|
|
|
// ServeDebugAPI handles /api/debug/* routes (JSON API for debug operations).
|
|
// v0.116.1: NO logging-level gate — the capture ring always exists now, and gating
|
|
// the viewer on logging.level=debug was exactly the motivating incident's blind
|
|
// spot (an info box 404'd the whole debug surface). Auth: RequireAuth at the mux.
|
|
func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
|
|
s.handleDebugAPI(w, r)
|
|
}
|
|
|
|
// ServeHTTP handles all non-API web requests.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
|
|
}
|
|
|
|
switch {
|
|
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
|
|
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
|
|
case path == "/claim" && r.Method == http.MethodGet:
|
|
s.handleClaimPage(w, r, "", r.URL.Query().Get("flash"))
|
|
case path == "/claim" && r.Method == http.MethodPost:
|
|
s.handleClaimSubmit(w, r)
|
|
case path == "/claim/request-new-code" && r.Method == http.MethodPost:
|
|
s.handleClaimRequestNewCode(w, r)
|
|
case path == "/" || path == "/dashboard":
|
|
s.dashboardHandler(w, r)
|
|
case path == "/stacks":
|
|
s.stacksHandler(w, r)
|
|
case path == "/backups":
|
|
s.backupsHandler(w, r)
|
|
// v0.124.0 IA split: the backups page's four sub-pages (old /backups deep links keep working —
|
|
// /backups itself is the Áttekintés page).
|
|
case path == "/backups/remote":
|
|
s.backupsRemoteHandler(w, r)
|
|
case path == "/backups/apps":
|
|
s.backupsAppsHandler(w, r)
|
|
case path == "/backups/restore":
|
|
s.backupsRestoreHandler(w, r)
|
|
// R-48: the per-app offsite restore wizard. A GET-only page — every action inside it posts to the
|
|
// pre-existing /backup/offbox/* endpoints, so this adds no mutation surface.
|
|
case path == "/backups/restore/app" && r.Method == http.MethodGet:
|
|
s.backupsRestoreWizardHandler(w, r)
|
|
case path == "/monitoring":
|
|
s.monitoringHandler(w, r)
|
|
case path == "/settings":
|
|
s.settingsHandler(w, r)
|
|
case path == "/storage" && r.Method == http.MethodGet:
|
|
s.storagePageHandler(w, r)
|
|
case path == "/storage/network" && r.Method == http.MethodGet:
|
|
s.storageNetworkPageHandler(w, r)
|
|
// „Megosztás" — LAN network sharing (R-7 slice 1)
|
|
case path == "/sharing" && r.Method == http.MethodGet:
|
|
s.sharingPageHandler(w, r)
|
|
case path == "/sharing/enable" && r.Method == http.MethodPost:
|
|
s.sharingEnableHandler(w, r)
|
|
case path == "/sharing/status" && r.Method == http.MethodGet:
|
|
s.sharingStatusHandler(w, r)
|
|
case path == "/sharing/password" && r.Method == http.MethodPost:
|
|
s.sharingPasswordHandler(w, r)
|
|
case path == "/sharing/shares" && r.Method == http.MethodPost:
|
|
s.sharingShareCreateHandler(w, r)
|
|
case path == "/sharing/shares/delete" && r.Method == http.MethodPost:
|
|
s.sharingShareDeleteHandler(w, r)
|
|
case path == "/sharing/shares/offsite" && r.Method == http.MethodPost:
|
|
s.sharingShareOffsiteHandler(w, r)
|
|
case path == "/settings/notifications" && r.Method == http.MethodGet:
|
|
s.settingsNotificationsPageHandler(w, r)
|
|
case path == "/settings/security" && r.Method == http.MethodGet:
|
|
s.settingsSecurityPageHandler(w, r)
|
|
case path == "/settings/password" && r.Method == http.MethodPost:
|
|
s.settingsPasswordHandler(w, r)
|
|
case path == "/settings/notifications" && r.Method == http.MethodPost:
|
|
s.settingsNotificationsHandler(w, r)
|
|
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
|
s.settingsNotificationsTestHandler(w, r)
|
|
case path == "/settings/app-email" && r.Method == http.MethodPost:
|
|
s.settingsAppEmailHandler(w, r)
|
|
case path == "/settings/storage/add" && r.Method == http.MethodPost:
|
|
s.settingsStorageAddHandler(w, r)
|
|
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
|
|
s.settingsStorageRemoveHandler(w, r)
|
|
case path == "/settings/storage/default" && r.Method == http.MethodPost:
|
|
s.settingsStorageDefaultHandler(w, r)
|
|
case path == "/settings/storage/schedulable" && r.Method == http.MethodPost:
|
|
s.settingsStorageSchedulableHandler(w, r)
|
|
case path == "/settings/storage/label" && r.Method == http.MethodPost:
|
|
s.settingsStorageLabelHandler(w, r)
|
|
case path == "/storage/init" && r.Method == http.MethodGet:
|
|
s.storageWizardPageHandler(w, r, "storage_init")
|
|
case path == "/storage/attach" && r.Method == http.MethodGet:
|
|
s.storageWizardPageHandler(w, r, "storage_attach")
|
|
// D1: the wizard pages moved under /storage — permanent redirects keep old links working.
|
|
case path == "/settings/storage/init" && r.Method == http.MethodGet:
|
|
http.Redirect(w, r, "/storage/init", http.StatusMovedPermanently)
|
|
case path == "/settings/storage/attach" && r.Method == http.MethodGet:
|
|
http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
|
|
case path == "/backup/restore" && r.Method == http.MethodPost:
|
|
s.backupRestoreHandler(w, r)
|
|
// C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files)
|
|
case path == "/backup/tier2/restore" && r.Method == http.MethodPost:
|
|
s.backupTier2RestoreHandler(w, r)
|
|
// Off-box (NAS) restic-SFTP backup (Part B)
|
|
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
|
|
s.offboxConfigHandler(w, r)
|
|
case path == "/backup/offbox/toggle" && r.Method == http.MethodPost:
|
|
s.offboxToggleHandler(w, r)
|
|
case path == "/backup/offbox/run" && r.Method == http.MethodPost:
|
|
s.offboxRunHandler(w, r)
|
|
case path == "/backup/offbox/reset" && r.Method == http.MethodPost:
|
|
s.offboxResetHandler(w, r)
|
|
case path == "/backup/offbox/status" && r.Method == http.MethodGet:
|
|
s.offboxStatusHandler(w, r)
|
|
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
|
|
s.offboxRestoreHandler(w, r)
|
|
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
|
|
s.offboxPlaceHandler(w, r)
|
|
case path == "/backup/offbox/reconstitute" && r.Method == http.MethodPost:
|
|
s.offboxReconstituteHandler(w, r)
|
|
// v0.147.0 (4a): remove ONE verification copy. The only delete path this slice adds — see
|
|
// DeleteOffsiteRestoreCopy for the prefix guard.
|
|
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
|
|
s.offboxVerifyCopyDeleteHandler(w, r)
|
|
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
|
|
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
|
|
s.sharesRestoreHandler(w, r)
|
|
case path == "/backup/shares/place" && r.Method == http.MethodPost:
|
|
s.sharesPlaceHandler(w, r)
|
|
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
|
|
case path == "/backup/escrow" && r.Method == http.MethodGet:
|
|
s.escrowWizardPageHandler(w, r)
|
|
// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
|
|
case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost:
|
|
s.offboxConfirmEscrowHandler(w, r)
|
|
case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
|
|
s.offboxInjectPasswordHandler(w, r)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
|
|
name := strings.TrimPrefix(path, "/stacks/")
|
|
name = strings.TrimSuffix(name, "/export")
|
|
s.exportPageHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
|
|
name := strings.TrimPrefix(path, "/stacks/")
|
|
name = strings.TrimSuffix(name, "/logs")
|
|
s.logsHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
|
|
name := strings.TrimPrefix(path, "/stacks/")
|
|
name = strings.TrimSuffix(name, "/deploy")
|
|
s.deployHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
|
|
s.tier2ConfigPageHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
|
|
s.tier2ConfigSaveHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/app-email") && r.Method == http.MethodPost:
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/app-email")
|
|
s.appEmailToggleHandler(w, r, name)
|
|
case path == "/import":
|
|
s.importPageHandler(w, r)
|
|
case path == "/static/style.css":
|
|
s.serveCSSHandler(w, r)
|
|
case path == "/static/chart.min.js":
|
|
s.serveChartJSHandler(w, r)
|
|
case strings.HasPrefix(path, "/static/fonts/"):
|
|
s.serveFontHandler(w, r, strings.TrimPrefix(path, "/static/fonts/"))
|
|
case path == "/static/felhom-logo.svg":
|
|
s.serveLogoHandler(w, r)
|
|
case path == "/static/favicon.svg":
|
|
s.serveFaviconHandler(w, r)
|
|
case path == "/static/infra-logo.svg":
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, InfraLogoSVG)
|
|
case strings.HasPrefix(path, "/static/assets/"):
|
|
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
|
case strings.HasPrefix(path, "/apps/"):
|
|
slug := strings.TrimPrefix(path, "/apps/")
|
|
s.appDetailHandler(w, r, slug)
|
|
case path == "/debug":
|
|
// v0.116.1: available at ANY logging.level (the ring always captures; see ServeDebugAPI).
|
|
s.debugPageHandler(w, r)
|
|
default:
|
|
s.logger.Printf("[WARN] [web] 404 Not Found: %s %s", r.Method, path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// CatchAllMiddleware intercepts requests to non-controller hosts and serves
|
|
// a branded error page (for stopped/undeployed app subdomains). Requests to
|
|
// the controller host (felhom.DOMAIN) pass through normally.
|
|
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
|
|
controllerHost := "felhom." + s.cfg.Customer.Domain
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: controller host=%s", controllerHost)
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
host := r.Host
|
|
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
|
host = host[:idx]
|
|
}
|
|
// Pass through: controller host, localhost (healthcheck/internal), or empty
|
|
if strings.EqualFold(host, controllerHost) || host == "" ||
|
|
host == "localhost" || host == "127.0.0.1" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
|
|
}
|
|
s.serveCatchAll(w, r, host)
|
|
})
|
|
}
|
|
|
|
// serveCatchAll renders a branded page for requests reaching a stopped/undeployed
|
|
// app subdomain. Served without auth since the user has no session on this host.
|
|
func (s *Server) serveCatchAll(w http.ResponseWriter, r *http.Request, host string) {
|
|
domain := s.cfg.Customer.Domain
|
|
subdomain := ""
|
|
suffix := "." + domain
|
|
if strings.HasSuffix(host, suffix) {
|
|
subdomain = strings.TrimSuffix(host, suffix)
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Domain": domain,
|
|
"ControllerURL": "https://felhom." + domain,
|
|
"Host": host,
|
|
}
|
|
|
|
if subdomain != "" {
|
|
if stack, ok := s.findStackBySubdomain(subdomain); ok {
|
|
data["AppName"] = stack.Meta.DisplayName
|
|
data["AppSlug"] = stack.Meta.Slug
|
|
data["AppLogoURL"] = s.cfg.AppLogoURL(stack.Meta.Slug)
|
|
if stack.Deployed {
|
|
data["Status"] = "stopped"
|
|
data["StatusText"] = "Az alkalmazás jelenleg le van állítva"
|
|
} else {
|
|
data["Status"] = "not_deployed"
|
|
data["StatusText"] = "Az alkalmazás nincs telepítve"
|
|
}
|
|
} else {
|
|
data["Status"] = "unknown"
|
|
data["StatusText"] = "Ez az oldal nem található"
|
|
}
|
|
} else {
|
|
data["Status"] = "unknown"
|
|
data["StatusText"] = "Ez az oldal nem található"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
if err := s.tmpl.ExecuteTemplate(w, "catchall", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Catch-all template error: %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// findStackBySubdomain looks up the stack that owns the given subdomain.
|
|
func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
// Check deployed app.yaml SUBDOMAIN env first
|
|
if stack.Deployed {
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
|
|
return &stack, true
|
|
}
|
|
}
|
|
}
|
|
// Fallback to metadata subdomain
|
|
if stack.Meta.Subdomain == subdomain {
|
|
return &stack, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// primaryHDDPath returns the default storage path, or the legacy config value.
|
|
func (s *Server) primaryHDDPath() string {
|
|
if p := s.settings.GetDefaultStoragePath(); p != "" {
|
|
return p
|
|
}
|
|
return s.cfg.Paths.HDDPath
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
// executeTemplate renders a template with CSRF data auto-injected into the data map.
|
|
// Use this instead of render() for all authenticated page handlers.
|
|
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
|
|
if data == nil {
|
|
data = make(map[string]interface{})
|
|
}
|
|
data["CSRFField"] = s.csrfField(r)
|
|
data["CSRFToken"] = s.csrfToken(r)
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
// --- Static file / asset serving ---
|
|
|
|
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
|
|
data, err := templateFS.ReadFile("templates/style.css")
|
|
if err != nil {
|
|
http.Error(w, "CSS not found", 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(data)
|
|
}
|
|
|
|
func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Write(chartJS)
|
|
}
|
|
|
|
// serveFontHandler serves the vendored woff2 files embedded in the binary
|
|
// (self-hosted fonts — no Google Fonts CDN dependency on offline nodes).
|
|
func (s *Server) serveFontHandler(w http.ResponseWriter, r *http.Request, filename string) {
|
|
filename = filepath.Base(filename)
|
|
data, err := fontFS.ReadFile("static/fonts/" + filename)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "font/woff2")
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Write(data)
|
|
}
|
|
|
|
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Try synced asset first (allows logo updates via Hub without rebuild)
|
|
if s.assetsSyncer != nil {
|
|
path := s.assetsSyncer.Resolve("felhom-logo.svg")
|
|
if _, err := os.Stat(path); err == nil {
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
http.ServeFile(w, r, path)
|
|
return
|
|
}
|
|
}
|
|
// Fallback to embedded logo
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, FelhomLogoSVG)
|
|
}
|
|
|
|
func (s *Server) serveFaviconHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Try synced asset first
|
|
if s.assetsSyncer != nil {
|
|
path := s.assetsSyncer.Resolve("felhom-favicon.svg")
|
|
if _, err := os.Stat(path); err == nil {
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
http.ServeFile(w, r, path)
|
|
return
|
|
}
|
|
}
|
|
// Fallback to embedded favicon
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, FelhomFaviconSVG)
|
|
}
|
|
|
|
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
|
|
const assetsDir = "/usr/share/felhom/assets"
|
|
|
|
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
|
|
filename = filepath.Base(filename)
|
|
|
|
var path string
|
|
if s.assetsSyncer != nil {
|
|
path = s.assetsSyncer.Resolve(filename)
|
|
} else {
|
|
path = filepath.Join(assetsDir, filename)
|
|
}
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
http.ServeFile(w, r, path)
|
|
}
|