mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
Fix logo assets and license heartbeat
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user