feat(controller): Hub asset syncer for logos and screenshots
Add internal/assets package that downloads and caches app assets from Hub API with SHA-256 change detection. Assets resolve from synced cache first, falling back to baked-in directory. Daily sync schedule + on-demand POST /api/assets/sync endpoint. Config: assets.sync_enabled + assets.sync_schedule (default 05:00) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.22.3 — Hub Asset Sync (2026-02-21)
|
||||||
|
|
||||||
|
**Hub-managed asset downloads**
|
||||||
|
|
||||||
|
- New `internal/assets` package: downloads and caches app assets (logos, screenshots) from the Hub API with SHA-256 change detection.
|
||||||
|
- Asset syncer resolves files from downloaded cache first, falls back to baked-in `/usr/share/felhom/assets/` directory.
|
||||||
|
- Config: `assets.sync_enabled: true` + `assets.sync_schedule: "05:00"` to enable daily sync.
|
||||||
|
- API: `POST /api/assets/sync` triggers on-demand sync, `GET /api/assets/status` returns sync status.
|
||||||
|
- Web server's `serveAsset()` now routes through syncer's `Resolve()` when available.
|
||||||
|
|
||||||
### v0.22.2 — Setup Logo Fix (2026-02-21)
|
### v0.22.2 — Setup Logo Fix (2026-02-21)
|
||||||
|
|
||||||
- **Fix setup wizard logo**: Logo failed to load because `handleLogo()` tried to read it as a file from the filesystem, but it only exists as an embedded string constant. Now imports and serves `web.FelhomLogoSVG` directly.
|
- **Fix setup wizard logo**: Logo failed to load because `handleLogo()` tried to read it as a file from the filesystem, but it only exists as an embedded string constant. Now imports and serves `web.FelhomLogoSVG` directly.
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s
|
|||||||
| **SelfUpdate** | `internal/selfupdate/` | Version checking (registry), update trigger, state persistence, startup verification |
|
| **SelfUpdate** | `internal/selfupdate/` | Version checking (registry), update trigger, state persistence, startup verification |
|
||||||
| **Notify** | `internal/notify/` | Email notifications via hub relay, preference sync, per-event cooldowns |
|
| **Notify** | `internal/notify/` | Email notifications via hub relay, preference sync, per-event cooldowns |
|
||||||
| **Report** | `internal/report/` | Hub report builder + HTTP pusher (system, stacks, backup, health) |
|
| **Report** | `internal/report/` | Hub report builder + HTTP pusher (system, stacks, backup, health) |
|
||||||
|
| **Assets** | `internal/assets/` | Hub-managed asset syncer: downloads logos/screenshots with SHA-256 change detection |
|
||||||
| **API** | `internal/api/` | REST JSON endpoints |
|
| **API** | `internal/api/` | REST JSON endpoints |
|
||||||
| **Web** | `internal/web/` | Hungarian dashboard, auth, page handlers, template functions, alerts |
|
| **Web** | `internal/web/` | Hungarian dashboard, auth, page handlers, template functions, alerts |
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||||
@@ -425,6 +426,23 @@ func main() {
|
|||||||
return storageWatchdog.Check(ctx)
|
return storageWatchdog.Check(ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Asset syncer (download from Hub) ---
|
||||||
|
var assetsSyncer *assets.Syncer
|
||||||
|
if cfg.Hub.Enabled && cfg.Assets.SyncEnabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
||||||
|
assetsDir := filepath.Join(cfg.Paths.DataDir, "assets")
|
||||||
|
assetsSyncer = assets.New(cfg.Hub.URL, cfg.Hub.APIKey, assetsDir, "/usr/share/felhom/assets", logger)
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
if err := assetsSyncer.Sync(ctx); err != nil {
|
||||||
|
logger.Printf("[WARN] Initial asset sync failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sched.Daily("asset-sync", cfg.Assets.SyncSchedule, func(ctx context.Context) error {
|
||||||
|
return assetsSyncer.Sync(ctx)
|
||||||
|
})
|
||||||
|
logger.Printf("[INFO] Asset sync enabled (daily at %s from Hub)", cfg.Assets.SyncSchedule)
|
||||||
|
}
|
||||||
|
|
||||||
sched.Start(ctx)
|
sched.Start(ctx)
|
||||||
defer sched.Stop()
|
defer sched.Stop()
|
||||||
|
|
||||||
@@ -552,10 +570,16 @@ func main() {
|
|||||||
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
|
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if assetsSyncer != nil {
|
||||||
|
apiRouter.SetAssetsSyncer(assetsSyncer)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Initialize web server ---
|
// --- Initialize web server ---
|
||||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||||
webServer.SetStorageWatchdog(storageWatchdog)
|
webServer.SetStorageWatchdog(storageWatchdog)
|
||||||
|
if assetsSyncer != nil {
|
||||||
|
webServer.SetAssetsSyncer(assetsSyncer)
|
||||||
|
}
|
||||||
if hubPusher != nil {
|
if hubPusher != nil {
|
||||||
webServer.SetHubPushStatus(func() web.HubPushStatusData {
|
webServer.SetHubPushStatus(func() web.HubPushStatusData {
|
||||||
s := hubPusher.GetStatus()
|
s := hubPusher.GetStatus()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||||
@@ -41,6 +42,14 @@ type Router struct {
|
|||||||
|
|
||||||
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
|
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
|
||||||
OnConfigApplied func()
|
OnConfigApplied func()
|
||||||
|
|
||||||
|
// Asset syncer for on-demand Hub asset sync
|
||||||
|
assetsSyncer *assets.Syncer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
|
||||||
|
func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
|
||||||
|
r.assetsSyncer = as
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
|
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
|
||||||
@@ -197,6 +206,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
|
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
|
||||||
r.metricsSysInfo(w, req)
|
r.metricsSysInfo(w, req)
|
||||||
|
|
||||||
|
// POST /api/assets/sync — trigger immediate asset sync from Hub
|
||||||
|
case path == "/assets/sync" && req.Method == http.MethodPost:
|
||||||
|
r.triggerAssetSync(w, req)
|
||||||
|
|
||||||
|
// GET /api/assets/status — get asset sync status
|
||||||
|
case path == "/assets/status" && req.Method == http.MethodGet:
|
||||||
|
r.assetSyncStatus(w, req)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1022,30 @@ func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
|
|||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Asset sync handlers ---
|
||||||
|
|
||||||
|
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.assetsSyncer == nil {
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Println("[API] Manual asset sync requested")
|
||||||
|
go func() {
|
||||||
|
if err := r.assetsSyncer.Sync(context.Background()); err != nil {
|
||||||
|
r.logger.Printf("[WARN] Manual asset sync failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if r.assetsSyncer == nil {
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()})
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
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
|
||||||
|
mu sync.Mutex
|
||||||
|
status SyncStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Syncer that downloads assets from the Hub.
|
||||||
|
func New(hubURL, apiKey, assetsDir, fallbackDir string, logger *log.Logger) *Syncer {
|
||||||
|
return &Syncer{
|
||||||
|
hubURL: strings.TrimSuffix(hubURL, "/"),
|
||||||
|
apiKey: apiKey,
|
||||||
|
assetsDir: assetsDir,
|
||||||
|
fallbackDir: fallbackDir,
|
||||||
|
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
logger: logger,
|
||||||
|
status: SyncStatus{LastStatus: "never"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.logger.Println("[INFO] Asset sync starting...")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(s.assetsDir, 0755); err != nil {
|
||||||
|
s.setError(fmt.Errorf("create assets dir: %w", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch Hub manifest
|
||||||
|
manifest, err := s.fetchManifest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.setError(fmt.Errorf("fetch manifest: %w", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build local hash map
|
||||||
|
localHashes, err := s.buildLocalHashes()
|
||||||
|
if err != nil {
|
||||||
|
s.setError(fmt.Errorf("scan local assets: %w", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.downloadFile(ctx, entry.Filename); err != nil {
|
||||||
|
s.logger.Printf("[WARN] Failed to download asset %s: %v", entry.Filename, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
s.status = SyncStatus{
|
||||||
|
LastSync: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
LastStatus: "ok",
|
||||||
|
FileCount: len(manifest.Files),
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Asset sync complete: %d downloaded, %d unchanged, %d removed (%d total files)",
|
||||||
|
downloaded, skipped, removed, len(manifest.Files))
|
||||||
|
|
||||||
|
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.status = SyncStatus{
|
||||||
|
LastSync: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
LastStatus: "error",
|
||||||
|
LastError: err.Error(),
|
||||||
|
FileCount: s.status.FileCount,
|
||||||
|
TotalBytes: s.status.TotalBytes,
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return nil, fmt.Errorf("HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -138,7 +138,9 @@ type LoggingConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AssetsConfig struct {
|
type AssetsConfig struct {
|
||||||
SourceURL string `yaml:"source_url"` // Only used during build, not runtime
|
SourceURL string `yaml:"source_url"` // Only used during build, not runtime
|
||||||
|
SyncEnabled bool `yaml:"sync_enabled"` // Download assets from Hub API
|
||||||
|
SyncSchedule string `yaml:"sync_schedule"` // Daily sync time (HH:MM), default "05:00"
|
||||||
}
|
}
|
||||||
|
|
||||||
type HubConfig struct {
|
type HubConfig struct {
|
||||||
@@ -263,6 +265,7 @@ func applyDefaults(cfg *Config) {
|
|||||||
di(&cfg.Logging.MaxSizeMB, 10)
|
di(&cfg.Logging.MaxSizeMB, 10)
|
||||||
di(&cfg.Logging.MaxFiles, 3)
|
di(&cfg.Logging.MaxFiles, 3)
|
||||||
d(&cfg.Assets.SourceURL, "https://felhom.eu")
|
d(&cfg.Assets.SourceURL, "https://felhom.eu")
|
||||||
|
d(&cfg.Assets.SyncSchedule, "05:00")
|
||||||
di(&cfg.System.ReservedMemoryMB, 384)
|
di(&cfg.System.ReservedMemoryMB, 384)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||||
@@ -61,6 +62,9 @@ type Server struct {
|
|||||||
|
|
||||||
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
||||||
hubPushStatusFn func() HubPushStatusData
|
hubPushStatusFn func() HubPushStatusData
|
||||||
|
|
||||||
|
// Asset syncer for Hub-managed assets (optional)
|
||||||
|
assetsSyncer *assets.Syncer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
|
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
|
||||||
@@ -134,6 +138,11 @@ func (s *Server) SetHubPushStatus(fn func() HubPushStatusData) {
|
|||||||
s.hubPushStatusFn = fn
|
s.hubPushStatusFn = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAssetsSyncer sets the Hub asset syncer for resolving app assets.
|
||||||
|
func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
|
||||||
|
s.assetsSyncer = as
|
||||||
|
}
|
||||||
|
|
||||||
// InRestoreMode returns true if the server is in DR restore mode.
|
// InRestoreMode returns true if the server is in DR restore mode.
|
||||||
func (s *Server) InRestoreMode() bool {
|
func (s *Server) InRestoreMode() bool {
|
||||||
s.restoreMu.RLock()
|
s.restoreMu.RLock()
|
||||||
@@ -286,7 +295,13 @@ const assetsDir = "/usr/share/felhom/assets"
|
|||||||
|
|
||||||
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
|
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
|
||||||
filename = filepath.Base(filename)
|
filename = filepath.Base(filename)
|
||||||
path := filepath.Join(assetsDir, filename)
|
|
||||||
|
var path string
|
||||||
|
if s.assetsSyncer != nil {
|
||||||
|
path = s.assetsSyncer.Resolve(filename)
|
||||||
|
} else {
|
||||||
|
path = filepath.Join(assetsDir, filename)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
Reference in New Issue
Block a user