From 29ac7765d234aa3aeb8c7c085b367282fc3b8b05 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:30:31 +0800 Subject: [PATCH] Fix logo assets and license heartbeat --- cmd/server/main.go | 7 ++ cmd/server/main_test.go | 16 +++ internal/handler/license.go | 139 ++++++++++++++++++----- internal/handler/license_test.go | 28 +++++ internal/service/service.go | 8 ++ web/index.html | 4 +- web/public/brand/mediastationgo-logo.svg | 12 ++ web/public/brand/mgo-emby-icon.svg | 12 +- web/public/favicon.svg | 12 +- web/src/components/Layout.tsx | 2 +- web/src/pages/LoginPage.tsx | 2 +- 11 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 web/public/brand/mediastationgo-logo.svg diff --git a/cmd/server/main.go b/cmd/server/main.go index d4bc57d..80594ef 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -120,6 +120,7 @@ func main() { } }() go services.Boot() + go handler.RunLicenseHeartbeatLoop(services.Context(), services) go services.TelegramBot.StartPolling(context.Background()) // Graceful shutdown. @@ -201,6 +202,12 @@ func serveSPA(r *gin.Engine, webDir string) { c.Next() }) assets.Static("/", filepath.Join(webDir, "assets")) + brand := r.Group("/brand") + brand.Use(func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=86400") + c.Next() + }) + brand.Static("/", filepath.Join(webDir, "brand")) for _, icon := range []string{"/favicon.ico", "/favicon.svg"} { iconPath := filepath.Join(webDir, strings.TrimPrefix(icon, "/")) r.GET(icon, serveNoCacheFile(iconPath)) diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 917fb71..93ab70b 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -52,12 +52,18 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil { t.Fatal(err) } + if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("index"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(webDir, "brand", "mediastationgo-logo.svg"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } router := gin.New() serveSPA(router, webDir) @@ -72,6 +78,16 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { t.Fatalf("asset Cache-Control = %q, want immutable", got) } + brandReq := httptest.NewRequest(http.MethodGet, "/brand/mediastationgo-logo.svg", nil) + brandResp := httptest.NewRecorder() + router.ServeHTTP(brandResp, brandReq) + if brandResp.Code != http.StatusOK { + t.Fatalf("brand asset status = %d, want 200", brandResp.Code) + } + if strings.Contains(brandResp.Body.String(), "index") { + t.Fatalf("brand asset should not serve SPA index: %q", brandResp.Body.String()) + } + for _, path := range []string{ "/api/missing", "/emby", diff --git a/internal/handler/license.go b/internal/handler/license.go index a4a74cd..7dcd6ed 100644 --- a/internal/handler/license.go +++ b/internal/handler/license.go @@ -18,6 +18,7 @@ import ( "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -27,6 +28,10 @@ const ( licenseHMACSecretSetting = "license.hmac_secret" // #nosec G101 -- setting key name, not the HMAC secret value. licenseDeviceIDSetting = "license.device_id" licenseDeviceNameSetting = "license.device_name" + + licenseHeartbeatInterval = 12 * time.Hour + licenseHeartbeatCheckInterval = 30 * time.Minute + licenseHeartbeatStartupDelay = 2 * time.Minute ) type licenseActivateReq struct { @@ -150,41 +155,121 @@ func licenseStatusHandler(svc *service.Container) gin.HandlerFunc { func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - client, err := newLicenseClient(c.Request.Context(), svc) + state, err := sendLicenseHeartbeat(c.Request.Context(), svc) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - oldState, _ := loadLicenseState(c.Request.Context(), svc) - deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, oldState.DeviceID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) - if strings.TrimSpace(deviceName) == "" { - deviceName = defaultLicenseDeviceName() - _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) - } - var upstream licenseServerSignedResp - if err := client.post(c.Request.Context(), "/api/v1/heartbeat", licenseHeartbeatPayload(oldState, deviceID, deviceName), &upstream); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - if err := client.verifySigned(&upstream); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - state := licenseStateFromSigned(upstream, deviceID, deviceName) - state.LicenseKey = oldState.LicenseKey - if err := persistLicenseState(c.Request.Context(), svc, state); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } c.JSON(http.StatusOK, licenseActivationView(state)) } } +// RunLicenseHeartbeatLoop keeps the license server aware of active deployments. +// The loop checks periodically, but only sends when the last stored heartbeat is +// older than licenseHeartbeatInterval. +func RunLicenseHeartbeatLoop(ctx context.Context, svc *service.Container) { + if svc == nil { + return + } + run := func() { + state, sent, err := maybeSendLicenseHeartbeat(ctx, svc, licenseHeartbeatInterval) + if err != nil { + if svc.Log != nil { + svc.Log.Warn("license heartbeat failed", zap.Error(err)) + } + return + } + if sent && svc.Log != nil { + svc.Log.Info("license heartbeat sent", zap.String("device_id", state.DeviceID)) + } + } + + timer := time.NewTimer(licenseHeartbeatStartupDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + run() + } + + ticker := time.NewTicker(licenseHeartbeatCheckInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} + +func maybeSendLicenseHeartbeat(ctx context.Context, svc *service.Container, interval time.Duration) (service.LicenseActivationState, bool, error) { + state, err := loadLicenseState(ctx, svc) + if err != nil { + return state, false, nil + } + if !licenseHeartbeatEligible(state) || !licenseHeartbeatDue(state, interval) { + return state, false, nil + } + next, err := sendLicenseHeartbeat(ctx, svc) + if err != nil { + return state, false, err + } + return next, true, nil +} + +func licenseHeartbeatEligible(state service.LicenseActivationState) bool { + return strings.TrimSpace(state.LicenseKey) != "" || state.Valid +} + +func licenseHeartbeatDue(state service.LicenseActivationState, interval time.Duration) bool { + if interval <= 0 { + return true + } + updatedAt := strings.TrimSpace(state.UpdatedAt) + if updatedAt == "" { + return true + } + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, updatedAt); err == nil { + return time.Since(t) >= interval + } + } + return true +} + +func sendLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { + client, err := newLicenseClient(ctx, svc) + if err != nil { + return service.LicenseActivationState{}, err + } + oldState, _ := loadLicenseState(ctx, svc) + deviceID, err := ensureLicenseDeviceID(ctx, svc, oldState.DeviceID) + if err != nil { + return service.LicenseActivationState{}, err + } + deviceName, _ := svc.Repo.Setting.Get(ctx, licenseDeviceNameSetting) + if strings.TrimSpace(deviceName) == "" { + deviceName = defaultLicenseDeviceName() + _ = svc.Repo.Setting.Set(ctx, licenseDeviceNameSetting, deviceName) + } + var upstream licenseServerSignedResp + if err := client.post(ctx, "/api/v1/heartbeat", licenseHeartbeatPayload(oldState, deviceID, deviceName), &upstream); err != nil { + return service.LicenseActivationState{}, err + } + if err := client.verifySigned(&upstream); err != nil { + return service.LicenseActivationState{}, err + } + state := licenseStateFromSigned(upstream, deviceID, deviceName) + state.LicenseKey = oldState.LicenseKey + if err := persistLicenseState(ctx, svc, state); err != nil { + return service.LicenseActivationState{}, err + } + return state, nil +} + type licenseClient struct { baseURL string hmacSecret string diff --git a/internal/handler/license_test.go b/internal/handler/license_test.go index 9157efe..27dfb46 100644 --- a/internal/handler/license_test.go +++ b/internal/handler/license_test.go @@ -2,6 +2,7 @@ package handler import ( "testing" + "time" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -90,3 +91,30 @@ func TestLicenseHeartbeatPayloadIncludesStoredLicenseKey(t *testing.T) { t.Fatalf("heartbeat should include stored license key for server-side backfill: %#v", payload) } } + +func TestLicenseHeartbeatDueUsesTwelveHourWindow(t *testing.T) { + state := service.LicenseActivationState{ + Valid: true, + UpdatedAt: time.Now().Add(-11 * time.Hour).Format(time.RFC3339), + } + if licenseHeartbeatDue(state, 12*time.Hour) { + t.Fatalf("heartbeat should not be due before interval") + } + + state.UpdatedAt = time.Now().Add(-13 * time.Hour).Format(time.RFC3339) + if !licenseHeartbeatDue(state, 12*time.Hour) { + t.Fatalf("heartbeat should be due after interval") + } +} + +func TestLicenseHeartbeatEligibleRequiresActivationState(t *testing.T) { + if licenseHeartbeatEligible(service.LicenseActivationState{DeviceID: "device-only"}) { + t.Fatalf("device id alone should not trigger automatic license heartbeat") + } + if !licenseHeartbeatEligible(service.LicenseActivationState{LicenseKey: "MS-KEY"}) { + t.Fatalf("stored license key should trigger automatic license heartbeat") + } + if !licenseHeartbeatEligible(service.LicenseActivationState{Valid: true}) { + t.Fatalf("valid license state should trigger automatic license heartbeat") + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 7e2ea62..e22c50d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -420,6 +420,14 @@ func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error { return nil } +// Context is canceled when the service container is closing. +func (c *Container) Context() context.Context { + if c == nil || c.stopCtx == nil { + return context.Background() + } + return c.stopCtx +} + // runInactivitySweeper periodically runs the account-cleanup policy. Kept with // the historical name to avoid churn in callers. func (c *Container) runInactivitySweeper(ctx context.Context) { diff --git a/web/index.html b/web/index.html index db5bc97..ce08c73 100644 --- a/web/index.html +++ b/web/index.html @@ -5,8 +5,8 @@ - - + + + + + + + + + + + + + diff --git a/web/public/brand/mgo-emby-icon.svg b/web/public/brand/mgo-emby-icon.svg index b4f69ee..f3a6ed4 100644 --- a/web/public/brand/mgo-emby-icon.svg +++ b/web/public/brand/mgo-emby-icon.svg @@ -1,10 +1,10 @@ - - - - - + + + + + - + diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 83453c6..f3a6ed4 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,10 +1,10 @@ - - - - - + + + + + - + diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 73d2098..533c4b3 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -192,7 +192,7 @@ export function Layout() {
MediaStationGo diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 62b71a9..1ce307d 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -54,7 +54,7 @@ export function LoginPage() { initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.15, type: 'spring', stiffness: 200 }} - src="/brand/mgo-emby-icon.svg" + src="/brand/mediastationgo-logo.svg" alt="MediaStationGo" className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm" />