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, "", " ") }