Files
felhom.eu/hub/internal/assets/assets.go
T
admin 1e354cbd41 feat(hub): Configuration page, asset seedOrUpdate, English UI
- Add Configuration page with "Refresh Assets" button
- Replace seedIfEmpty with seedOrUpdate (SHA-256 compare on startup)
- Translate all Hungarian text on Apps pages to English
- Add Configuration tab to all template navigation
- Expand isAssetFile to match favicon patterns
- Add felhom-logo.svg to website assets for the pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:34:43 +01:00

296 lines
7.3 KiB
Go

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, syncs assets from the image seed directory,
// 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.seedOrUpdate()
if err := m.RebuildManifest(); err != nil {
logger.Printf("[ERROR] Failed to build asset manifest: %v", err)
}
return m
}
// seedOrUpdate copies new or changed assets from the seed directory to the PVC directory.
// On first run (empty PVC) this seeds all files. On subsequent runs it updates only files
// whose SHA-256 checksums differ, ensuring redeployed images propagate asset changes.
func (m *Manager) seedOrUpdate() {
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
}
// Build hash map of existing PVC assets for comparison
existingHashes := make(map[string]string)
pvcEntries, _ := os.ReadDir(m.assetsDir)
for _, e := range pvcEntries {
if e.IsDir() || !isAssetFile(e.Name()) {
continue
}
h, err := fileSHA256(filepath.Join(m.assetsDir, e.Name()))
if err == nil {
existingHashes[e.Name()] = h
}
}
copied, skipped := 0, 0
for _, e := range seedEntries {
if e.IsDir() || !isAssetFile(e.Name()) {
continue
}
src := filepath.Join(m.seedDir, e.Name())
// Compare checksums — skip if unchanged
seedHash, err := fileSHA256(src)
if err != nil {
m.logger.Printf("[WARN] Cannot hash seed asset %s: %v", e.Name(), err)
continue
}
if existingHashes[e.Name()] == seedHash {
skipped++
continue
}
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++
}
if copied > 0 {
m.logger.Printf("[INFO] Asset seed: %d updated, %d unchanged", copied, skipped)
} else {
m.logger.Printf("[INFO] Asset seed: all %d files up-to-date", skipped)
}
}
// ReSeed re-runs the seed-or-update process and rebuilds the manifest.
// Used by the web UI "Refresh Assets" button.
func (m *Manager) ReSeed() error {
m.mu.Lock()
defer m.mu.Unlock()
m.seedOrUpdate()
return m.rebuildManifestLocked()
}
// RebuildManifest rescans the assets directory and recomputes SHA-256 hashes.
func (m *Manager) RebuildManifest() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.rebuildManifestLocked()
}
// rebuildManifestLocked does the actual manifest rebuild. Caller must hold m.mu.
func (m *Manager) rebuildManifestLocked() 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.manifest = manifest
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 asset naming convention.
// Matches: *-logo.svg, *-logo.png, *-screenshot-*.webp, felhom-logo.svg, felhom-favicon.svg
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")
}
if strings.Contains(name, "-favicon.") {
return strings.HasSuffix(name, ".svg") || strings.HasSuffix(name, ".ico")
}
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, "", " ")
}