From 538d367cc4db77befbf2e3f0ffc0b7e1edfbee9f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 21 Feb 2026 15:29:23 +0100 Subject: [PATCH] feat(controller): Hub asset syncer for logos and screenshots Add internal/assets package that downloads and caches app assets from Hub API with SHA-256 change detection. Assets resolve from synced cache first, falling back to baked-in directory. Daily sync schedule + on-demand POST /api/assets/sync endpoint. Config: assets.sync_enabled + assets.sync_schedule (default 05:00) Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 10 + controller/README.md | 1 + controller/cmd/controller/main.go | 24 +++ controller/internal/api/router.go | 41 ++++ controller/internal/assets/syncer.go | 295 +++++++++++++++++++++++++++ controller/internal/config/config.go | 5 +- controller/internal/web/server.go | 17 +- 7 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 controller/internal/assets/syncer.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 64c9d2f..6718899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Changelog +### v0.22.3 — Hub Asset Sync (2026-02-21) + +**Hub-managed asset downloads** + +- New `internal/assets` package: downloads and caches app assets (logos, screenshots) from the Hub API with SHA-256 change detection. +- Asset syncer resolves files from downloaded cache first, falls back to baked-in `/usr/share/felhom/assets/` directory. +- Config: `assets.sync_enabled: true` + `assets.sync_schedule: "05:00"` to enable daily sync. +- API: `POST /api/assets/sync` triggers on-demand sync, `GET /api/assets/status` returns sync status. +- Web server's `serveAsset()` now routes through syncer's `Resolve()` when available. + ### v0.22.2 — Setup Logo Fix (2026-02-21) - **Fix setup wizard logo**: Logo failed to load because `handleLogo()` tried to read it as a file from the filesystem, but it only exists as an embedded string constant. Now imports and serves `web.FelhomLogoSVG` directly. diff --git a/controller/README.md b/controller/README.md index f881c86..dc9066c 100644 --- a/controller/README.md +++ b/controller/README.md @@ -93,6 +93,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s | **SelfUpdate** | `internal/selfupdate/` | Version checking (registry), update trigger, state persistence, startup verification | | **Notify** | `internal/notify/` | Email notifications via hub relay, preference sync, per-event cooldowns | | **Report** | `internal/report/` | Hub report builder + HTTP pusher (system, stacks, backup, health) | +| **Assets** | `internal/assets/` | Hub-managed asset syncer: downloads logos/screenshots with SHA-256 change detection | | **API** | `internal/api/` | REST JSON endpoints | | **Web** | `internal/web/` | Hungarian dashboard, auth, page handlers, template functions, alerts | diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 9b77141..3fc3ade 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -16,6 +16,7 @@ import ( "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/api" + "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/metrics" @@ -425,6 +426,23 @@ func main() { return storageWatchdog.Check(ctx) }) + // --- Asset syncer (download from Hub) --- + var assetsSyncer *assets.Syncer + if cfg.Hub.Enabled && cfg.Assets.SyncEnabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" { + assetsDir := filepath.Join(cfg.Paths.DataDir, "assets") + assetsSyncer = assets.New(cfg.Hub.URL, cfg.Hub.APIKey, assetsDir, "/usr/share/felhom/assets", logger) + go func() { + time.Sleep(10 * time.Second) + if err := assetsSyncer.Sync(ctx); err != nil { + logger.Printf("[WARN] Initial asset sync failed: %v", err) + } + }() + sched.Daily("asset-sync", cfg.Assets.SyncSchedule, func(ctx context.Context) error { + return assetsSyncer.Sync(ctx) + }) + logger.Printf("[INFO] Asset sync enabled (daily at %s from Hub)", cfg.Assets.SyncSchedule) + } + sched.Start(ctx) defer sched.Stop() @@ -552,10 +570,16 @@ func main() { pushInfraBackup(cfg, sett, stackProv, hubPusher, logger) } } + if assetsSyncer != nil { + apiRouter.SetAssetsSyncer(assetsSyncer) + } // --- Initialize web server --- webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version) webServer.SetStorageWatchdog(storageWatchdog) + if assetsSyncer != nil { + webServer.SetAssetsSyncer(assetsSyncer) + } if hubPusher != nil { webServer.SetHubPushStatus(func() web.HubPushStatusData { s := hubPusher.GetStatus() diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 2574502..89449b4 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "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/metrics" @@ -41,6 +42,14 @@ type Router struct { // OnConfigApplied is called after a successful config apply (e.g., to push infra backup). OnConfigApplied func() + + // Asset syncer for on-demand Hub asset sync + assetsSyncer *assets.Syncer +} + +// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers. +func (r *Router) SetAssetsSyncer(as *assets.Syncer) { + r.assetsSyncer = as } func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router { @@ -197,6 +206,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/metrics/sysinfo" && req.Method == http.MethodGet: r.metricsSysInfo(w, req) + // POST /api/assets/sync — trigger immediate asset sync from Hub + case path == "/assets/sync" && req.Method == http.MethodPost: + r.triggerAssetSync(w, req) + + // GET /api/assets/status — get asset sync status + case path == "/assets/status" && req.Method == http.MethodGet: + r.assetSyncStatus(w, req) + default: writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"}) } @@ -1005,6 +1022,30 @@ func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) { w.Write(data) } +// --- Asset sync handlers --- + +func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) { + if r.assetsSyncer == nil { + writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"}) + return + } + r.logger.Println("[API] Manual asset sync requested") + go func() { + if err := r.assetsSyncer.Sync(context.Background()); err != nil { + r.logger.Printf("[WARN] Manual asset sync failed: %v", err) + } + }() + writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"}) +} + +func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) { + if r.assetsSyncer == nil { + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()}) +} + func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/controller/internal/assets/syncer.go b/controller/internal/assets/syncer.go new file mode 100644 index 0000000..f038f38 --- /dev/null +++ b/controller/internal/assets/syncer.go @@ -0,0 +1,295 @@ +package assets + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ManifestEntry mirrors the Hub's manifest entry. +type ManifestEntry struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// HubManifest is the response from GET /api/v1/assets/manifest. +type HubManifest struct { + Version int `json:"version"` + Generated string `json:"generated"` + Files []ManifestEntry `json:"files"` +} + +// SyncStatus reports the state of the last sync. +type SyncStatus struct { + LastSync string `json:"last_sync"` // RFC3339 + LastStatus string `json:"last_status"` // "ok", "error", "never" + LastError string `json:"last_error,omitempty"` + FileCount int `json:"file_count"` + TotalBytes int64 `json:"total_bytes"` +} + +// Syncer downloads and caches app assets from the Hub API. +type Syncer struct { + hubURL string + apiKey string + assetsDir string // /assets — downloaded cache + fallbackDir string // /usr/share/felhom/assets — baked-in fallback + httpClient *http.Client + logger *log.Logger + mu sync.Mutex + status SyncStatus +} + +// New creates a Syncer that downloads assets from the Hub. +func New(hubURL, apiKey, assetsDir, fallbackDir string, logger *log.Logger) *Syncer { + return &Syncer{ + hubURL: strings.TrimSuffix(hubURL, "/"), + apiKey: apiKey, + assetsDir: assetsDir, + fallbackDir: fallbackDir, + httpClient: &http.Client{Timeout: 60 * time.Second}, + logger: logger, + status: SyncStatus{LastStatus: "never"}, + } +} + +// Sync fetches the manifest from the Hub, compares checksums, and downloads +// changed/new files. It also removes local files not in the Hub manifest. +func (s *Syncer) Sync(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.logger.Println("[INFO] Asset sync starting...") + + if err := os.MkdirAll(s.assetsDir, 0755); err != nil { + s.setError(fmt.Errorf("create assets dir: %w", err)) + return err + } + + // 1. Fetch Hub manifest + manifest, err := s.fetchManifest(ctx) + if err != nil { + s.setError(fmt.Errorf("fetch manifest: %w", err)) + return err + } + + // 2. Build local hash map + localHashes, err := s.buildLocalHashes() + if err != nil { + s.setError(fmt.Errorf("scan local assets: %w", err)) + return err + } + + // 3. Download changed/new files + hubFiles := make(map[string]bool, len(manifest.Files)) + var downloaded, skipped int + var totalBytes int64 + + for _, entry := range manifest.Files { + hubFiles[entry.Filename] = true + totalBytes += entry.Size + + if localHash, ok := localHashes[entry.Filename]; ok && localHash == entry.SHA256 { + skipped++ + continue + } + + if err := s.downloadFile(ctx, entry.Filename); err != nil { + s.logger.Printf("[WARN] Failed to download asset %s: %v", entry.Filename, err) + continue + } + downloaded++ + } + + // 4. Remove local files not in Hub manifest + var removed int + for name := range localHashes { + if !hubFiles[name] { + path := filepath.Join(s.assetsDir, name) + if err := os.Remove(path); err != nil { + s.logger.Printf("[WARN] Failed to remove stale asset %s: %v", name, err) + } else { + removed++ + } + } + } + + // 5. Save local manifest copy + s.saveLocalManifest(manifest) + + // 6. Update status + s.status = SyncStatus{ + LastSync: time.Now().UTC().Format(time.RFC3339), + LastStatus: "ok", + FileCount: len(manifest.Files), + TotalBytes: totalBytes, + } + + s.logger.Printf("[INFO] Asset sync complete: %d downloaded, %d unchanged, %d removed (%d total files)", + downloaded, skipped, removed, len(manifest.Files)) + + return nil +} + +// Resolve returns the full path for an asset: checks assetsDir first, fallbackDir second. +func (s *Syncer) Resolve(filename string) string { + filename = filepath.Base(filename) // sanitize + + // Check synced cache first + cached := filepath.Join(s.assetsDir, filename) + if _, err := os.Stat(cached); err == nil { + return cached + } + + // Fallback to baked-in assets + fallback := filepath.Join(s.fallbackDir, filename) + if _, err := os.Stat(fallback); err == nil { + return fallback + } + + // Not found — return cached path so caller gets a clean 404 + return cached +} + +// Status returns the current sync status. +func (s *Syncer) Status() SyncStatus { + s.mu.Lock() + defer s.mu.Unlock() + return s.status +} + +func (s *Syncer) setError(err error) { + s.status = SyncStatus{ + LastSync: time.Now().UTC().Format(time.RFC3339), + LastStatus: "error", + LastError: err.Error(), + FileCount: s.status.FileCount, + TotalBytes: s.status.TotalBytes, + } + s.logger.Printf("[WARN] Asset sync failed: %v", err) +} + +func (s *Syncer) fetchManifest(ctx context.Context) (*HubManifest, error) { + url := s.hubURL + "/api/v1/assets/manifest" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var manifest HubManifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + return &manifest, nil +} + +func (s *Syncer) buildLocalHashes() (map[string]string, error) { + entries, err := os.ReadDir(s.assetsDir) + if err != nil { + return nil, err + } + + hashes := make(map[string]string) + for _, e := range entries { + if e.IsDir() || e.Name() == "manifest.json" { + continue + } + path := filepath.Join(s.assetsDir, e.Name()) + h, err := fileSHA256(path) + if err != nil { + continue + } + hashes[e.Name()] = h + } + return hashes, nil +} + +func (s *Syncer) downloadFile(ctx context.Context, filename string) error { + url := s.hubURL + "/api/v1/assets/file/" + filename + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, filename) + } + + // Atomic write: write to .tmp, rename + dst := filepath.Join(s.assetsDir, filepath.Base(filename)) + tmp := dst + ".tmp" + + out, err := os.Create(tmp) + if err != nil { + return err + } + + if _, err := io.Copy(out, resp.Body); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + + return os.Rename(tmp, dst) +} + +func (s *Syncer) saveLocalManifest(manifest *HubManifest) { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return + } + path := filepath.Join(s.assetsDir, "manifest.json") + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return + } + os.Rename(tmp, path) +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index bd59603..04356ea 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -138,7 +138,9 @@ type LoggingConfig struct { } type AssetsConfig struct { - SourceURL string `yaml:"source_url"` // Only used during build, not runtime + SourceURL string `yaml:"source_url"` // Only used during build, not runtime + SyncEnabled bool `yaml:"sync_enabled"` // Download assets from Hub API + SyncSchedule string `yaml:"sync_schedule"` // Daily sync time (HH:MM), default "05:00" } type HubConfig struct { @@ -263,6 +265,7 @@ func applyDefaults(cfg *Config) { di(&cfg.Logging.MaxSizeMB, 10) di(&cfg.Logging.MaxFiles, 3) d(&cfg.Assets.SourceURL, "https://felhom.eu") + d(&cfg.Assets.SyncSchedule, "05:00") di(&cfg.System.ReservedMemoryMB, 384) } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index f01ef74..ebb175e 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "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/monitor" @@ -61,6 +62,9 @@ type Server struct { // Hub push status callback — set via SetHubPushStatus for monitoring page hubPushStatusFn func() HubPushStatusData + + // Asset syncer for Hub-managed assets (optional) + assetsSyncer *assets.Syncer } 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 { @@ -134,6 +138,11 @@ 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 +} + // InRestoreMode returns true if the server is in DR restore mode. func (s *Server) InRestoreMode() bool { s.restoreMu.RLock() @@ -286,7 +295,13 @@ 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) + + 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)