95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
559 lines
18 KiB
Go
559 lines
18 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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/monitor"
|
|
"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/storage"
|
|
"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
|
|
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
|
|
|
|
// 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
|
|
|
|
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
|
|
fileBrowserMu sync.Mutex
|
|
|
|
// Drive migration
|
|
driveMigrator *storage.DriveMigrator
|
|
|
|
// DR restore mode state
|
|
restoreMu sync.RWMutex
|
|
restorePlan *backup.RestorePlan
|
|
|
|
// Storage watchdog (set after construction to break init ordering)
|
|
storageWatchdog *monitor.StorageWatchdog
|
|
|
|
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
|
hubPushStatusFn func() HubPushStatusData
|
|
|
|
// Asset syncer for Hub-managed assets (optional)
|
|
assetsSyncer *assets.Syncer
|
|
|
|
// App-to-app integration manager (optional)
|
|
integrationMgr *integrations.Manager
|
|
|
|
// App export/import engine (optional)
|
|
appExporter *appexport.Exporter
|
|
|
|
// Debug mode support
|
|
logBuffer *LogBuffer
|
|
debugCallbacks *DebugCallbacks
|
|
startTime time.Time
|
|
}
|
|
|
|
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, updater *selfupdate.Updater, 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,
|
|
updater: updater,
|
|
logger: logger,
|
|
version: version,
|
|
sessions: make(map[string]*session),
|
|
loginAttempts: make(map[string]*loginAttempt),
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
if cfg.Logging.Level == "debug" {
|
|
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
|
|
logger.Printf("[DEBUG] [web] NewServer: backup=%v crossDrive=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
|
|
backupMgr != nil, crossDrive != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
|
|
}
|
|
|
|
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.
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetStorageWatchdog sets the storage watchdog for disconnect/reconnect operations.
|
|
func (s *Server) SetStorageWatchdog(w *monitor.StorageWatchdog) {
|
|
s.storageWatchdog = w
|
|
}
|
|
|
|
// SetDriveMigrator sets the drive migration engine for full drive migration.
|
|
func (s *Server) SetDriveMigrator(dm *storage.DriveMigrator) {
|
|
s.driveMigrator = dm
|
|
}
|
|
|
|
// 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 = 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
|
|
}
|
|
|
|
// 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).
|
|
// Called from the mux carve-out; debug mode check is done here.
|
|
func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
|
|
if !s.isDebug() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
s.handleDebugAPI(w, r)
|
|
}
|
|
|
|
// 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
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
|
|
}
|
|
|
|
// 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 path == "/settings/storage/migrate-drive":
|
|
s.migrateDrivePageHandler(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, "/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 == "/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 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":
|
|
if !s.isDebug() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
s.debugPageHandler(w, r)
|
|
default:
|
|
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] 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
|
|
}
|
|
|
|
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
|
|
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] ServeStorageAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
|
}
|
|
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{}) {
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
s.logger.Printf("[ERROR] 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] 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)
|
|
}
|
|
|
|
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)
|
|
}
|