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 // /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 }