af1dd14933
Second-pass logging cleanup: consistent [LEVEL] [module] format across all 41 files. Remove stale prefixes ([CF], [SYNC], [SCHED], [API], [STORAGE], [HEALTH], [ROLLBACK]). Remove 5 duplicate log lines. Gate ungated DEBUG lines. Fix wrong log levels (restore start WARN→INFO). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
9.2 KiB
Go
346 lines
9.2 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] [assets] 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] [assets] 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] [assets] 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] [assets] 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("[ERROR] [assets] 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 {
|
|
s.logger.Printf("[ERROR] [assets] Failed to save local manifest: %v", err)
|
|
return
|
|
}
|
|
path := filepath.Join(s.assetsDir, "manifest.json")
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
s.logger.Printf("[ERROR] [assets] Failed to save local manifest: %v", err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
s.logger.Printf("[ERROR] [assets] Failed to save local manifest: %v", err)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|