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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
|||||||
# Felhom Hub — Changelog
|
# 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)
|
## v0.3.6 (2026-02-21)
|
||||||
|
|
||||||
**Human-friendly retrieval passwords**
|
**Human-friendly retrieval passwords**
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ COPY --from=builder /felhom-hub /usr/local/bin/felhom-hub
|
|||||||
|
|
||||||
RUN mkdir -p /data /etc/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
|
ENV TZ=Europe/Budapest
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
"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/monitor"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
"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)
|
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)
|
// Initialize handlers — pass templateFetcher as interface (nil-safe)
|
||||||
var templateProvider api.ConfigTemplateProvider
|
var templateProvider api.ConfigTemplateProvider
|
||||||
if templateFetcher != nil {
|
if templateFetcher != nil {
|
||||||
templateProvider = templateFetcher
|
templateProvider = templateFetcher
|
||||||
}
|
}
|
||||||
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger)
|
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger)
|
||||||
|
apiHandler.SetAssetManager(assetsMgr)
|
||||||
|
|
||||||
// Initialize notification dispatcher
|
// Initialize notification dispatcher
|
||||||
dispatcher := notify.NewDispatcher(
|
dispatcher := notify.NewDispatcher(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||||
@@ -31,6 +32,7 @@ type Handler struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
templateProvider ConfigTemplateProvider
|
templateProvider ConfigTemplateProvider
|
||||||
dispatcher *notify.Dispatcher
|
dispatcher *notify.Dispatcher
|
||||||
|
assetsMgr *assets.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new API handler.
|
// New creates a new API handler.
|
||||||
@@ -51,6 +53,11 @@ func (h *Handler) SetDispatcher(d *notify.Dispatcher) {
|
|||||||
h.dispatcher = d
|
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.
|
// checkAuth verifies the Bearer token against the global API key or a per-customer API key.
|
||||||
// Returns true if authorized.
|
// Returns true if authorized.
|
||||||
func (h *Handler) checkAuth(r *http.Request) bool {
|
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/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||||
customerID := strings.TrimPrefix(path, "/config/")
|
customerID := strings.TrimPrefix(path, "/config/")
|
||||||
h.handleConfigRetrieve(w, r, customerID)
|
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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -755,3 +767,40 @@ Felhom.eu monitoring`
|
|||||||
|
|
||||||
return subject, emailText
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "", " ")
|
||||||
|
}
|
||||||
+1
-1
@@ -117,7 +117,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: hub
|
- name: hub
|
||||||
image: gitea.dooplex.hu/admin/felhom-hub:v0.3.6
|
image: gitea.dooplex.hu/admin/felhom-hub:v0.3.7
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: http
|
name: http
|
||||||
|
|||||||
Reference in New Issue
Block a user