Fix logo assets and license heartbeat

This commit is contained in:
ShukeBta
2026-06-17 23:30:31 +08:00
parent fb362e1fec
commit 29ac7765d2
11 changed files with 199 additions and 43 deletions
+7
View File
@@ -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))
+16
View File
@@ -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("<svg></svg>"), 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",
+112 -27
View File
@@ -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
+28
View File
@@ -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")
}
}
+8
View File
@@ -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) {
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#faf8f5" />
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
<link rel="icon" type="image/svg+xml" href="/brand/mgo-emby-icon.svg" />
<link rel="apple-touch-icon" href="/brand/mgo-emby-icon.svg" />
<link rel="icon" type="image/svg+xml" href="/brand/mediastationgo-logo.svg" />
<link rel="apple-touch-icon" href="/brand/mediastationgo-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
+12
View File
@@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MediaStationGo">
<rect width="64" height="64" rx="16" fill="url(#mgoPurpleGrad)" />
<path d="M22 20 L42 32 L22 44" stroke="white" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M22 36 L22 48 L32 42" stroke="white" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="46" cy="18" r="4" fill="#06B6D4" />
<defs>
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 742 B

+6 -6
View File
@@ -1,10 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MediaStationGo">
<rect width="64" height="64" rx="16" fill="url(#mgoPurpleGrad)" />
<path d="M22 20 L42 32 L22 44" stroke="white" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M22 36 L22 48 L32 42" stroke="white" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="46" cy="18" r="4" fill="#06B6D4" />
<defs>
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 742 B

+6 -6
View File
@@ -1,10 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MediaStationGo">
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MediaStationGo">
<rect width="64" height="64" rx="16" fill="url(#mgoPurpleGrad)" />
<path d="M22 20 L42 32 L22 44" stroke="white" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M22 36 L22 48 L32 42" stroke="white" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="46" cy="18" r="4" fill="#06B6D4" />
<defs>
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 742 B

+1 -1
View File
@@ -192,7 +192,7 @@ export function Layout() {
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
<Link to="/" className="flex items-center gap-3">
<img
src="/brand/mgo-emby-icon.svg"
src="/brand/mediastationgo-logo.svg"
alt="MediaStationGo"
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
/>
+1 -1
View File
@@ -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"
/>