From 3690c5028e4e6e7bd47008f8e94b45e269caccfa Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 21 Feb 2026 15:22:45 +0100 Subject: [PATCH] feat(hub): asset management API with PVC storage and image seed Add internal/assets package that manages app assets (logos, screenshots) on Hub PVC with automatic seeding from baked-in image copy on first run. Two new API endpoints: GET /assets/manifest (JSON with SHA-256 checksums) and GET /assets/file/{name} for controllers to sync assets. Co-Authored-By: Claude Opus 4.6 --- hub/CHANGELOG.md | 11 ++ hub/Dockerfile | 3 + hub/assets/.gitkeep | 0 hub/cmd/hub/main.go | 6 + hub/internal/api/handler.go | 49 +++++++ hub/internal/assets/assets.go | 267 ++++++++++++++++++++++++++++++++++ manifests/hub.yaml | 2 +- 7 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 hub/assets/.gitkeep create mode 100644 hub/internal/assets/assets.go diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index b12f3c2..0ffd077 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,16 @@ # Felhom Hub — Changelog +## v0.3.7 (2026-02-21) + +**Asset management API** + +- New `internal/assets` package: manages app assets (logos, screenshots) on Hub PVC (`/data/assets/`) with automatic seeding from baked-in image copy on first run. +- Two new authenticated API endpoints for controllers to sync assets: + - `GET /api/v1/assets/manifest` — returns JSON manifest with filenames + SHA-256 checksums + - `GET /api/v1/assets/file/{filename}` — serves individual asset files +- Dockerfile updated to `COPY assets/ /usr/share/felhom/assets-seed/` for first-run seeding. +- Build script syncs website assets (`*-logo.{svg,png}`, `*-screenshot-*.webp`) into Docker build context. + ## v0.3.6 (2026-02-21) **Human-friendly retrieval passwords** diff --git a/hub/Dockerfile b/hub/Dockerfile index 1ff32d3..8cbbcaf 100644 --- a/hub/Dockerfile +++ b/hub/Dockerfile @@ -23,6 +23,9 @@ COPY --from=builder /felhom-hub /usr/local/bin/felhom-hub RUN mkdir -p /data /etc/felhom-hub +# Seed assets: baked into image, copied to PVC on first run +COPY assets/ /usr/share/felhom/assets-seed/ + ENV TZ=Europe/Budapest EXPOSE 8080 diff --git a/hub/assets/.gitkeep b/hub/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 87c3c1e..0c5878e 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -13,6 +13,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-hub/internal/api" + "gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/monitor" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" @@ -117,12 +118,17 @@ func main() { logger.Printf("[INFO] Template fetcher started (every %s)", cfg.Registry.TemplateInterval) } + // Initialize asset manager (PVC storage with image seed) + assetsDir := filepath.Join(cfg.Server.DataDir, "assets") + assetsMgr := assets.New(assetsDir, "/usr/share/felhom/assets-seed", logger) + // Initialize handlers — pass templateFetcher as interface (nil-safe) var templateProvider api.ConfigTemplateProvider if templateFetcher != nil { templateProvider = templateFetcher } apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger) + apiHandler.SetAssetManager(assetsMgr) // Initialize notification dispatcher dispatcher := notify.NewDispatcher( diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 2a26fea..aaade05 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" @@ -31,6 +32,7 @@ type Handler struct { httpClient *http.Client templateProvider ConfigTemplateProvider dispatcher *notify.Dispatcher + assetsMgr *assets.Manager } // New creates a new API handler. @@ -51,6 +53,11 @@ func (h *Handler) SetDispatcher(d *notify.Dispatcher) { h.dispatcher = d } +// SetAssetManager sets the asset manager for serving app assets to controllers. +func (h *Handler) SetAssetManager(am *assets.Manager) { + h.assetsMgr = am +} + // checkAuth verifies the Bearer token against the global API key or a per-customer API key. // Returns true if authorized. func (h *Handler) checkAuth(r *http.Request) bool { @@ -115,6 +122,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"): customerID := strings.TrimPrefix(path, "/config/") h.handleConfigRetrieve(w, r, customerID) + case r.Method == http.MethodGet && path == "/assets/manifest": + h.handleAssetsManifest(w, r) + case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"): + filename := strings.TrimPrefix(path, "/assets/file/") + h.handleAssetFile(w, r, filename) default: http.NotFound(w, r) } @@ -755,3 +767,40 @@ Felhom.eu monitoring` return subject, emailText } + +// --- Asset endpoints --- + +func (h *Handler) handleAssetsManifest(w http.ResponseWriter, r *http.Request) { + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if h.assetsMgr == nil { + http.Error(w, "Assets not configured", http.StatusServiceUnavailable) + return + } + + data, err := h.assetsMgr.MarshalManifestJSON() + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func (h *Handler) handleAssetFile(w http.ResponseWriter, r *http.Request, filename string) { + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if h.assetsMgr == nil { + http.Error(w, "Assets not configured", http.StatusServiceUnavailable) + return + } + + h.assetsMgr.ServeFile(w, r, filename) +} diff --git a/hub/internal/assets/assets.go b/hub/internal/assets/assets.go new file mode 100644 index 0000000..420ae1e --- /dev/null +++ b/hub/internal/assets/assets.go @@ -0,0 +1,267 @@ +package assets + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ManifestEntry describes a single asset file with its checksum. +type ManifestEntry struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// Manifest lists all available asset files with checksums for change detection. +type Manifest struct { + Version int `json:"version"` + Generated string `json:"generated"` + Files []ManifestEntry `json:"files"` +} + +// Manager handles serving app assets (logos, screenshots) from a PVC directory, +// with automatic seeding from a baked-in image directory on first run. +type Manager struct { + assetsDir string // PVC path: /data/assets/ + seedDir string // Image path: /usr/share/felhom/assets-seed/ + manifest *Manifest + mu sync.RWMutex + logger *log.Logger +} + +// New creates a Manager, seeds assets from the image if the PVC dir is empty, +// and builds the initial manifest. +func New(assetsDir, seedDir string, logger *log.Logger) *Manager { + m := &Manager{ + assetsDir: assetsDir, + seedDir: seedDir, + logger: logger, + } + + if err := os.MkdirAll(assetsDir, 0755); err != nil { + logger.Printf("[ERROR] Failed to create assets dir %s: %v", assetsDir, err) + } + + m.seedIfEmpty() + + if err := m.RebuildManifest(); err != nil { + logger.Printf("[ERROR] Failed to build asset manifest: %v", err) + } + + return m +} + +// seedIfEmpty copies assets from the seed directory to the PVC directory +// if the PVC directory has no asset files. +func (m *Manager) seedIfEmpty() { + entries, err := os.ReadDir(m.assetsDir) + if err != nil { + m.logger.Printf("[WARN] Cannot read assets dir: %v", err) + return + } + + // Check if there are any asset files (not just directories or manifest.json) + hasAssets := false + for _, e := range entries { + if !e.IsDir() && isAssetFile(e.Name()) { + hasAssets = true + break + } + } + + if hasAssets { + m.logger.Printf("[INFO] Assets directory has files, skipping seed") + return + } + + // Seed from image + seedEntries, err := os.ReadDir(m.seedDir) + if err != nil { + m.logger.Printf("[WARN] Cannot read seed dir %s: %v (no assets will be available until uploaded)", m.seedDir, err) + return + } + + copied := 0 + for _, e := range seedEntries { + if e.IsDir() || !isAssetFile(e.Name()) { + continue + } + src := filepath.Join(m.seedDir, e.Name()) + dst := filepath.Join(m.assetsDir, e.Name()) + if err := copyFile(src, dst); err != nil { + m.logger.Printf("[WARN] Failed to seed asset %s: %v", e.Name(), err) + continue + } + copied++ + } + + m.logger.Printf("[INFO] Seeded %d assets from image to PVC", copied) +} + +// RebuildManifest rescans the assets directory and recomputes SHA-256 hashes. +func (m *Manager) RebuildManifest() error { + entries, err := os.ReadDir(m.assetsDir) + if err != nil { + return fmt.Errorf("reading assets dir: %w", err) + } + + var files []ManifestEntry + for _, e := range entries { + if e.IsDir() || !isAssetFile(e.Name()) { + continue + } + + path := filepath.Join(m.assetsDir, e.Name()) + info, err := e.Info() + if err != nil { + m.logger.Printf("[WARN] Cannot stat asset %s: %v", e.Name(), err) + continue + } + + hash, err := fileSHA256(path) + if err != nil { + m.logger.Printf("[WARN] Cannot hash asset %s: %v", e.Name(), err) + continue + } + + files = append(files, ManifestEntry{ + Filename: e.Name(), + Size: info.Size(), + SHA256: hash, + }) + } + + manifest := &Manifest{ + Version: 1, + Generated: time.Now().UTC().Format(time.RFC3339), + Files: files, + } + + m.mu.Lock() + m.manifest = manifest + m.mu.Unlock() + + m.logger.Printf("[INFO] Asset manifest built: %d files", len(files)) + return nil +} + +// GetManifest returns the cached manifest. +func (m *Manager) GetManifest() *Manifest { + m.mu.RLock() + defer m.mu.RUnlock() + return m.manifest +} + +// ServeFile serves a single asset file by name. +func (m *Manager) ServeFile(w http.ResponseWriter, r *http.Request, filename string) { + // Sanitize: only base name, no path traversal + filename = filepath.Base(filename) + if filename == "." || filename == "/" { + http.NotFound(w, r) + return + } + + path := filepath.Join(m.assetsDir, filename) + if _, err := os.Stat(path); os.IsNotExist(err) { + http.NotFound(w, r) + return + } + + // Set content type based on extension + switch { + case strings.HasSuffix(filename, ".svg"): + w.Header().Set("Content-Type", "image/svg+xml") + case strings.HasSuffix(filename, ".png"): + w.Header().Set("Content-Type", "image/png") + case strings.HasSuffix(filename, ".webp"): + w.Header().Set("Content-Type", "image/webp") + } + + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeFile(w, r, path) +} + +// FileCount returns the number of asset files in the manifest. +func (m *Manager) FileCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + if m.manifest == nil { + return 0 + } + return len(m.manifest.Files) +} + +// isAssetFile returns true if the filename matches the app asset naming convention. +func isAssetFile(name string) bool { + if strings.Contains(name, "-logo.") { + return strings.HasSuffix(name, ".svg") || strings.HasSuffix(name, ".png") + } + if strings.Contains(name, "-screenshot-") { + return strings.HasSuffix(name, ".webp") + } + return false +} + +// fileSHA256 computes the SHA-256 hex digest of a file. +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 +} + +// copyFile copies src to dst using atomic write (write to .tmp, rename). +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + tmp := dst + ".tmp" + out, err := os.Create(tmp) + if err != nil { + return err + } + + if _, err := io.Copy(out, in); 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) +} + +// MarshalManifestJSON returns the manifest as indented JSON. +func (m *Manager) MarshalManifestJSON() ([]byte, error) { + m.mu.RLock() + manifest := m.manifest + m.mu.RUnlock() + + if manifest == nil { + manifest = &Manifest{Version: 1, Generated: time.Now().UTC().Format(time.RFC3339)} + } + return json.MarshalIndent(manifest, "", " ") +} diff --git a/manifests/hub.yaml b/manifests/hub.yaml index 6e96909..393b1f6 100644 --- a/manifests/hub.yaml +++ b/manifests/hub.yaml @@ -117,7 +117,7 @@ spec: spec: containers: - name: hub - image: gitea.dooplex.hu/admin/felhom-hub:v0.3.6 + image: gitea.dooplex.hu/admin/felhom-hub:v0.3.7 ports: - containerPort: 8080 name: http