package web
import (
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"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"
)
type Server struct {
cfg *config.Config
stackMgr *stacks.Manager
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
crossDriveRunner *backup.CrossDriveRunner
scheduler *scheduler.Scheduler
settings *settings.Settings
alertManager *AlertManager
notifier *notify.Notifier
logger *log.Logger
version string
tmpl *template.Template
sessions map[string]*session
sessionsMu sync.RWMutex
done chan struct{}
// Disk operation state (format/migrate jobs)
diskJobMu sync.Mutex
diskJob *activeDiskJob
// Active raw mount for the attach wizard (empty when not in use)
activeRawMount string
// DR restore mode state
restoreMu sync.RWMutex
restorePlan *backup.RestorePlan
}
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
stackMgr: stackMgr,
cpuCollector: cpuCollector,
backupMgr: backupMgr,
crossDriveRunner: crossDrive,
scheduler: sched,
settings: sett,
alertManager: alertMgr,
notifier: notif,
logger: logger,
version: version,
sessions: make(map[string]*session),
done: make(chan struct{}),
}
s.loadTemplates()
go s.cleanupSessions()
// Log auth source on startup
if sett != nil && sett.GetPasswordHash() != "" {
logger.Printf("[INFO] Auth: using password from settings.json")
} else if cfg.Web.PasswordHash != "" {
logger.Printf("[INFO] Auth: using password from controller.yaml")
} else {
logger.Printf("[INFO] Auth: no password configured — dashboard is open")
}
// Sync FileBrowser config on startup to ensure mounts and sources are current
go s.syncFileBrowserMounts()
return s
}
func (s *Server) loadTemplates() {
s.tmpl = template.Must(
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
)
}
// SetRestoreState puts the server into DR restore mode with the given plan.
func (s *Server) SetRestoreState(plan *backup.RestorePlan) {
s.restoreMu.Lock()
defer s.restoreMu.Unlock()
s.restorePlan = plan
}
// InRestoreMode returns true if the server is in DR restore mode.
func (s *Server) InRestoreMode() bool {
s.restoreMu.RLock()
defer s.restoreMu.RUnlock()
return s.restorePlan != nil
}
// ServeHTTP handles all non-API web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// DR restore mode: intercept all routes except restore page, static, and restore API
if s.InRestoreMode() {
switch {
case path == "/restore":
s.restorePageHandler(w, r)
return
case path == "/api/restore/status":
s.apiRestoreStatus(w, r)
return
case path == "/api/restore/all" && r.Method == http.MethodPost:
s.apiRestoreAll(w, r)
return
case path == "/api/restore/skip" && r.Method == http.MethodPost:
s.apiRestoreSkip(w, r)
return
case strings.HasPrefix(path, "/static/"):
// Allow static assets through
default:
// Redirect everything else to the restore page
http.Redirect(w, r, "/restore", http.StatusFound)
return
}
}
switch {
case path == "/" || path == "/dashboard":
s.dashboardHandler(w, r)
case path == "/stacks":
s.stacksHandler(w, r)
case path == "/backups":
s.backupsHandler(w, r)
case path == "/monitoring":
s.monitoringHandler(w, r)
case path == "/settings":
s.settingsHandler(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/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 strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
name := strings.TrimPrefix(path, "/settings/cross-backup/")
s.settingsCrossBackupHandler(w, r, name)
case path == "/backup/restore" && r.Method == http.MethodPost:
s.backupRestoreHandler(w, r)
case path == "/settings/storage/init":
s.storageInitHandler(w, r)
case path == "/settings/storage/attach":
s.storageAttachHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/migrate")
s.migratePageHandler(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 path == "/static/style.css":
s.serveCSSHandler(w, r)
case path == "/static/chart.min.js":
s.serveChartJSHandler(w, r)
case path == "/static/felhom-logo.svg":
s.serveLogoHandler(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)
default:
http.NotFound(w, r)
}
}
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.storageAPIHandler(w, r)
}
// 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{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// --- 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)
}
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, felhomLogoSVG)
}
// 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)
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)
}