0ece2ba85c
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
622 lines
24 KiB
Go
622 lines
24 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
|
|
|
|
// 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
|
|
|
|
// 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
|
|
netAgentFn func() (netAgent, error)
|
|
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
|
|
|
|
// 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()
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
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/restore" && r.Method == http.MethodPost:
|
|
s.offboxRestoreHandler(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 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)
|
|
}
|