95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
8.9 KiB
Go
342 lines
8.9 KiB
Go
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 // <dataDir>/assets — downloaded cache
|
|
fallbackDir string // /usr/share/felhom/assets — baked-in fallback
|
|
httpClient *http.Client
|
|
logger *log.Logger
|
|
debug bool
|
|
mu sync.Mutex
|
|
running bool
|
|
status SyncStatus
|
|
}
|
|
|
|
// New creates a Syncer that downloads assets from the Hub.
|
|
func New(hubURL, apiKey, assetsDir, fallbackDir string, logger *log.Logger, debug bool) *Syncer {
|
|
return &Syncer{
|
|
hubURL: strings.TrimSuffix(hubURL, "/"),
|
|
apiKey: apiKey,
|
|
assetsDir: assetsDir,
|
|
fallbackDir: fallbackDir,
|
|
httpClient: &http.Client{Timeout: 60 * time.Second},
|
|
logger: logger,
|
|
debug: debug,
|
|
status: SyncStatus{LastStatus: "never"},
|
|
}
|
|
}
|
|
|
|
func (s *Syncer) dbg(format string, args ...interface{}) {
|
|
if s.debug {
|
|
s.logger.Printf("[DEBUG] [assets] "+format, args...)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
if s.running {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("asset sync already in progress")
|
|
}
|
|
s.running = true
|
|
s.mu.Unlock()
|
|
|
|
defer func() {
|
|
s.mu.Lock()
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
s.logger.Println("[INFO] Asset sync starting...")
|
|
syncStart := time.Now()
|
|
|
|
if err := os.MkdirAll(s.assetsDir, 0755); err != nil {
|
|
s.setError(fmt.Errorf("create assets dir: %w", err))
|
|
return err
|
|
}
|
|
|
|
// 1. Fetch Hub manifest
|
|
s.dbg("fetching manifest from %s/api/v1/assets/manifest", s.hubURL)
|
|
manifestStart := time.Now()
|
|
manifest, err := s.fetchManifest(ctx)
|
|
if err != nil {
|
|
s.setError(fmt.Errorf("fetch manifest: %w", err))
|
|
return err
|
|
}
|
|
s.dbg("manifest fetched in %s: %d files, generated=%s",
|
|
time.Since(manifestStart).Round(time.Millisecond), len(manifest.Files), manifest.Generated)
|
|
|
|
// 2. Build local hash map
|
|
hashStart := time.Now()
|
|
localHashes, err := s.buildLocalHashes()
|
|
if err != nil {
|
|
s.setError(fmt.Errorf("scan local assets: %w", err))
|
|
return err
|
|
}
|
|
s.dbg("local hash scan: %d files in %s", len(localHashes), time.Since(hashStart).Round(time.Millisecond))
|
|
|
|
// 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 {
|
|
s.dbg("file %s: hash match (%s), skipping", entry.Filename, entry.SHA256[:12]+"...")
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
reason := "new"
|
|
if localHash, ok := localHashes[entry.Filename]; ok {
|
|
reason = fmt.Sprintf("hash mismatch (local=%s remote=%s)", localHash[:12]+"...", entry.SHA256[:12]+"...")
|
|
}
|
|
s.dbg("file %s: downloading (%s, %d bytes)", entry.Filename, reason, entry.Size)
|
|
dlStart := time.Now()
|
|
if err := s.downloadFile(ctx, entry.Filename); err != nil {
|
|
s.logger.Printf("[WARN] Failed to download asset %s: %v", entry.Filename, err)
|
|
continue
|
|
}
|
|
s.dbg("file %s: downloaded in %s", entry.Filename, time.Since(dlStart).Round(time.Millisecond))
|
|
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)
|
|
s.dbg("removing stale file %s", 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 (under lock)
|
|
s.mu.Lock()
|
|
s.status = SyncStatus{
|
|
LastSync: time.Now().UTC().Format(time.RFC3339),
|
|
LastStatus: "ok",
|
|
FileCount: len(manifest.Files),
|
|
TotalBytes: totalBytes,
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Asset sync complete: %d downloaded, %d unchanged, %d removed (%d total files)",
|
|
downloaded, skipped, removed, len(manifest.Files))
|
|
s.dbg("sync completed in %s", time.Since(syncStart).Round(time.Millisecond))
|
|
|
|
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.mu.Lock()
|
|
s.status = SyncStatus{
|
|
LastSync: time.Now().UTC().Format(time.RFC3339),
|
|
LastStatus: "error",
|
|
LastError: err.Error(),
|
|
FileCount: s.status.FileCount,
|
|
TotalBytes: s.status.TotalBytes,
|
|
}
|
|
s.mu.Unlock()
|
|
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 {
|
|
s.dbg("fetchManifest: HTTP request failed: %v", err)
|
|
return nil, fmt.Errorf("HTTP request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
s.dbg("fetchManifest: HTTP %d, content-length=%d", resp.StatusCode, resp.ContentLength)
|
|
|
|
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 {
|
|
s.dbg("downloadFile %s: HTTP request failed: %v", filename, err)
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
s.dbg("downloadFile %s: unexpected HTTP %d", filename, resp.StatusCode)
|
|
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, filename)
|
|
}
|
|
s.dbg("downloadFile %s: HTTP %d, content-length=%d", filename, resp.StatusCode, resp.ContentLength)
|
|
|
|
// 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
|
|
}
|