From 5c59664ed270e731602b8416a72eb4c9115b5b27 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:32:49 +0800 Subject: [PATCH] split license handler helpers --- internal/handler/license.go | 362 -------------------------- internal/handler/license_heartbeat.go | 172 ++++++++++++ internal/handler/license_state.go | 211 +++++++++++++++ 3 files changed, 383 insertions(+), 362 deletions(-) create mode 100644 internal/handler/license_heartbeat.go create mode 100644 internal/handler/license_state.go diff --git a/internal/handler/license.go b/internal/handler/license.go index 7cc027d..46a646a 100644 --- a/internal/handler/license.go +++ b/internal/handler/license.go @@ -1,18 +1,10 @@ package handler import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" "net/http" - "net/url" - "os" "strings" - "time" "github.com/gin-gonic/gin" - "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -22,10 +14,6 @@ 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 { @@ -177,353 +165,3 @@ func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, licenseActivationView(state)) } } - -func refreshLicenseCapacityBestEffort(ctx context.Context, svc *service.Container) { - if svc == nil || svc.Repo == nil || svc.Repo.Setting == nil { - return - } - _, _, _ = maybeSendLicenseHeartbeat(ctx, svc, 0) -} - -// 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(interval time.Duration) { - state, sent, err := maybeSendLicenseHeartbeat(ctx, svc, interval) - logLicenseHeartbeatResult(svc, state, sent, err) - } - runStartup := func() { - state, sent, err := maybeSendStartupLicenseHeartbeat(ctx, svc) - logLicenseHeartbeatResult(svc, state, sent, err) - } - - timer := time.NewTimer(licenseHeartbeatStartupDelay) - defer timer.Stop() - select { - case <-ctx.Done(): - return - case <-timer.C: - runStartup() - } - - ticker := time.NewTicker(licenseHeartbeatCheckInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - run(licenseHeartbeatInterval) - } - } -} - -func logLicenseHeartbeatResult(svc *service.Container, state service.LicenseActivationState, sent bool, err error) { - if svc == nil || svc.Log == nil { - return - } - if err != nil { - svc.Log.Warn("license heartbeat failed", zap.Error(err)) - return - } - if sent { - svc.Log.Info("license heartbeat sent", zap.String("device_id", state.DeviceID)) - } -} - -func maybeSendStartupLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, bool, error) { - state, err := loadLicenseState(ctx, svc) - if err != nil { - return state, false, nil - } - if strings.TrimSpace(state.LicenseKey) == "" { - return state, false, nil - } - return maybeSendLicenseHeartbeat(ctx, svc, 0) -} - -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) { - return state, false, nil - } - if !licenseHeartbeatDue(state, interval) { - client, clientErr := newLicenseClient(ctx, svc) - if clientErr != nil { - return state, false, nil - } - deviceID, idErr := ensureLicenseDeviceID(ctx, svc, state.DeviceID) - if idErr != nil { - return state, false, idErr - } - refreshed, ok, requested, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID) - if refreshErr == nil && ok { - state = refreshed - _ = persistLicenseState(ctx, svc, state) - } - if !requested { - 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) != "" -} - -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 refreshed, ok, _, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID); refreshErr == nil && ok { - refreshed.LicenseKey = state.LicenseKey - state = refreshed - } - if err := persistLicenseState(ctx, svc, state); err != nil { - return service.LicenseActivationState{}, err - } - return state, nil -} - -func refreshLicenseServerStatus(ctx context.Context, client *licenseClient, state service.LicenseActivationState, deviceID string) (service.LicenseActivationState, bool, bool, error) { - var upstream licenseServerStatusResp - if err := client.get(ctx, "/api/v1/status/"+url.PathEscape(deviceID), &upstream); err != nil { - return state, false, false, err - } - if !upstream.Valid { - state.Valid = false - state.UpdatedAt = time.Now().Format(time.RFC3339) - return state, false, upstream.HeartbeatRequested, nil - } - applyLicenseStatus(&state, upstream, deviceID) - return state, true, upstream.HeartbeatRequested, nil -} - -func ensureLicenseDeviceID(ctx context.Context, svc *service.Container, candidate string) (string, error) { - if strings.TrimSpace(candidate) != "" { - return strings.TrimSpace(candidate), svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(candidate)) - } - existing, err := svc.Repo.Setting.Get(ctx, licenseDeviceIDSetting) - if err != nil { - return "", err - } - if strings.TrimSpace(existing) != "" { - return strings.TrimSpace(existing), nil - } - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", err - } - id := "msgo-" + hex.EncodeToString(buf[:]) - return id, svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, id) -} - -func defaultLicenseDeviceName() string { - host, _ := os.Hostname() - if strings.TrimSpace(host) == "" { - return "MediaStationGo Server" - } - return "MediaStationGo - " + host -} - -func licenseStateFromSigned(resp licenseServerSignedResp, deviceID, deviceName string) service.LicenseActivationState { - expiry := "" - if resp.ExpiryDate != nil { - expiry = *resp.ExpiryDate - } - return service.LicenseActivationState{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: expiry, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - UnlimitedUsers: !resp.LegacySignature && resp.MaxUsers == nil, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - DeviceID: deviceID, - DeviceName: deviceName, - UpdatedAt: time.Now().Format(time.RFC3339), - } -} - -func licenseHeartbeatPayload(state service.LicenseActivationState, deviceID, deviceName string) map[string]any { - payload := map[string]any{ - "fingerprint": deviceID, - "instance_id": deviceID, - "device_name": deviceName, - } - if key := strings.TrimSpace(state.LicenseKey); key != "" { - payload["key"] = key - } - return payload -} - -func licenseStatusMaxUsers(state service.LicenseActivationState) any { - active := state.Valid && !licenseStateExpired(state.ExpiryDate) - if active { - if state.UnlimitedUsers { - return nil - } - if state.MaxUsers != nil && *state.MaxUsers > 0 { - return *state.MaxUsers - } - return service.LicensedUserLimit - } - return service.OpenSourceUserLimit -} - -func applyLicenseStatus(state *service.LicenseActivationState, upstream licenseServerStatusResp, deviceID string) { - state.Valid = upstream.Valid - if upstream.LicenseType != nil { - state.LicenseType = *upstream.LicenseType - } - if upstream.ExpiryDate != nil { - state.ExpiryDate = *upstream.ExpiryDate - } else { - state.ExpiryDate = "" - } - if upstream.MaxDevices > 0 { - state.MaxDevices = upstream.MaxDevices - } - state.MaxUsers = upstream.MaxUsers - state.UnlimitedUsers = upstream.UnlimitedUsers - state.DaysRemaining = upstream.DaysRemaining - if upstream.DeviceName != "" { - state.DeviceName = upstream.DeviceName - } - state.DeviceID = deviceID - state.UpdatedAt = time.Now().Format(time.RFC3339) -} - -func persistLicenseState(ctx context.Context, svc *service.Container, state service.LicenseActivationState) error { - data, err := json.Marshal(state) - if err != nil { - return err - } - if strings.TrimSpace(state.DeviceID) != "" { - _ = svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(state.DeviceID)) - } - return svc.Repo.Setting.Set(ctx, service.LicenseSettingActivation, string(data)) -} - -func loadLicenseState(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { - raw, err := svc.Repo.Setting.Get(ctx, service.LicenseSettingActivation) - if err != nil || raw == "" { - return service.LicenseActivationState{}, err - } - var state service.LicenseActivationState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - return service.LicenseActivationState{}, err - } - return state, nil -} - -func licenseActivationView(state service.LicenseActivationState) gin.H { - updatedAt := state.UpdatedAt - if strings.TrimSpace(updatedAt) == "" { - updatedAt = time.Now().Format(time.RFC3339) - } - return gin.H{ - "id": state.DeviceID, - "key_id": state.LicenseType, - "key": maskLicenseKey(state.LicenseKey), - "device_id": state.DeviceID, - "device_name": state.DeviceName, - "plan": state.LicenseType, - "max_activations": state.MaxDevices, - "max_users": state.MaxUsers, - "unlimited_users": state.UnlimitedUsers, - "expires_at": emptyAsNil(state.ExpiryDate), - "valid": state.Valid && !licenseStateExpired(state.ExpiryDate), - "heartbeat_at": updatedAt, - "created_at": updatedAt, - } -} - -func maskLicenseKey(key string) string { - key = strings.TrimSpace(key) - if key == "" { - return "" - } - if len(key) <= 8 { - return key - } - return key[:5] + "..." + key[len(key)-4:] -} - -func licenseStatusMessage(active bool, clientErr error) string { - if active { - return "已激活" - } - if clientErr != nil && !strings.Contains(clientErr.Error(), "not configured") { - return clientErr.Error() - } - return "开源版:最多 20 个用户" -} - -func licenseStateExpired(expiry string) bool { - if expiry == "" { - return false - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, expiry); err == nil { - return time.Now().After(t) - } - } - return false -} - -func emptyAsNil(v string) any { - if strings.TrimSpace(v) == "" { - return nil - } - return v -} diff --git a/internal/handler/license_heartbeat.go b/internal/handler/license_heartbeat.go new file mode 100644 index 0000000..fc109bc --- /dev/null +++ b/internal/handler/license_heartbeat.go @@ -0,0 +1,172 @@ +package handler + +import ( + "context" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +const ( + licenseHeartbeatInterval = 12 * time.Hour + licenseHeartbeatCheckInterval = 30 * time.Minute + licenseHeartbeatStartupDelay = 2 * time.Minute +) + +func refreshLicenseCapacityBestEffort(ctx context.Context, svc *service.Container) { + if svc == nil || svc.Repo == nil || svc.Repo.Setting == nil { + return + } + _, _, _ = maybeSendLicenseHeartbeat(ctx, svc, 0) +} + +// 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(interval time.Duration) { + state, sent, err := maybeSendLicenseHeartbeat(ctx, svc, interval) + logLicenseHeartbeatResult(svc, state, sent, err) + } + runStartup := func() { + state, sent, err := maybeSendStartupLicenseHeartbeat(ctx, svc) + logLicenseHeartbeatResult(svc, state, sent, err) + } + + timer := time.NewTimer(licenseHeartbeatStartupDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + runStartup() + } + + ticker := time.NewTicker(licenseHeartbeatCheckInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run(licenseHeartbeatInterval) + } + } +} + +func logLicenseHeartbeatResult(svc *service.Container, state service.LicenseActivationState, sent bool, err error) { + if svc == nil || svc.Log == nil { + return + } + if err != nil { + svc.Log.Warn("license heartbeat failed", zap.Error(err)) + return + } + if sent { + svc.Log.Info("license heartbeat sent", zap.String("device_id", state.DeviceID)) + } +} + +func maybeSendStartupLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, bool, error) { + state, err := loadLicenseState(ctx, svc) + if err != nil { + return state, false, nil + } + if strings.TrimSpace(state.LicenseKey) == "" { + return state, false, nil + } + return maybeSendLicenseHeartbeat(ctx, svc, 0) +} + +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) { + return state, false, nil + } + if !licenseHeartbeatDue(state, interval) { + client, clientErr := newLicenseClient(ctx, svc) + if clientErr != nil { + return state, false, nil + } + deviceID, idErr := ensureLicenseDeviceID(ctx, svc, state.DeviceID) + if idErr != nil { + return state, false, idErr + } + refreshed, ok, requested, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID) + if refreshErr == nil && ok { + state = refreshed + _ = persistLicenseState(ctx, svc, state) + } + if !requested { + 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) != "" +} + +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 refreshed, ok, _, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID); refreshErr == nil && ok { + refreshed.LicenseKey = state.LicenseKey + state = refreshed + } + if err := persistLicenseState(ctx, svc, state); err != nil { + return service.LicenseActivationState{}, err + } + return state, nil +} diff --git a/internal/handler/license_state.go b/internal/handler/license_state.go new file mode 100644 index 0000000..549d537 --- /dev/null +++ b/internal/handler/license_state.go @@ -0,0 +1,211 @@ +package handler + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/url" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func refreshLicenseServerStatus(ctx context.Context, client *licenseClient, state service.LicenseActivationState, deviceID string) (service.LicenseActivationState, bool, bool, error) { + var upstream licenseServerStatusResp + if err := client.get(ctx, "/api/v1/status/"+url.PathEscape(deviceID), &upstream); err != nil { + return state, false, false, err + } + if !upstream.Valid { + state.Valid = false + state.UpdatedAt = time.Now().Format(time.RFC3339) + return state, false, upstream.HeartbeatRequested, nil + } + applyLicenseStatus(&state, upstream, deviceID) + return state, true, upstream.HeartbeatRequested, nil +} + +func ensureLicenseDeviceID(ctx context.Context, svc *service.Container, candidate string) (string, error) { + if strings.TrimSpace(candidate) != "" { + return strings.TrimSpace(candidate), svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(candidate)) + } + existing, err := svc.Repo.Setting.Get(ctx, licenseDeviceIDSetting) + if err != nil { + return "", err + } + if strings.TrimSpace(existing) != "" { + return strings.TrimSpace(existing), nil + } + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + id := "msgo-" + hex.EncodeToString(buf[:]) + return id, svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, id) +} + +func defaultLicenseDeviceName() string { + host, _ := os.Hostname() + if strings.TrimSpace(host) == "" { + return "MediaStationGo Server" + } + return "MediaStationGo - " + host +} + +func licenseStateFromSigned(resp licenseServerSignedResp, deviceID, deviceName string) service.LicenseActivationState { + expiry := "" + if resp.ExpiryDate != nil { + expiry = *resp.ExpiryDate + } + return service.LicenseActivationState{ + Valid: resp.Valid, + LicenseType: resp.LicenseType, + ExpiryDate: expiry, + MaxDevices: resp.MaxDevices, + MaxUsers: resp.MaxUsers, + UnlimitedUsers: !resp.LegacySignature && resp.MaxUsers == nil, + DaysRemaining: resp.DaysRemaining, + NextHeartbeat: resp.NextHeartbeat, + DeviceID: deviceID, + DeviceName: deviceName, + UpdatedAt: time.Now().Format(time.RFC3339), + } +} + +func licenseHeartbeatPayload(state service.LicenseActivationState, deviceID, deviceName string) map[string]any { + payload := map[string]any{ + "fingerprint": deviceID, + "instance_id": deviceID, + "device_name": deviceName, + } + if key := strings.TrimSpace(state.LicenseKey); key != "" { + payload["key"] = key + } + return payload +} + +func licenseStatusMaxUsers(state service.LicenseActivationState) any { + active := state.Valid && !licenseStateExpired(state.ExpiryDate) + if active { + if state.UnlimitedUsers { + return nil + } + if state.MaxUsers != nil && *state.MaxUsers > 0 { + return *state.MaxUsers + } + return service.LicensedUserLimit + } + return service.OpenSourceUserLimit +} + +func applyLicenseStatus(state *service.LicenseActivationState, upstream licenseServerStatusResp, deviceID string) { + state.Valid = upstream.Valid + if upstream.LicenseType != nil { + state.LicenseType = *upstream.LicenseType + } + if upstream.ExpiryDate != nil { + state.ExpiryDate = *upstream.ExpiryDate + } else { + state.ExpiryDate = "" + } + if upstream.MaxDevices > 0 { + state.MaxDevices = upstream.MaxDevices + } + state.MaxUsers = upstream.MaxUsers + state.UnlimitedUsers = upstream.UnlimitedUsers + state.DaysRemaining = upstream.DaysRemaining + if upstream.DeviceName != "" { + state.DeviceName = upstream.DeviceName + } + state.DeviceID = deviceID + state.UpdatedAt = time.Now().Format(time.RFC3339) +} + +func persistLicenseState(ctx context.Context, svc *service.Container, state service.LicenseActivationState) error { + data, err := json.Marshal(state) + if err != nil { + return err + } + if strings.TrimSpace(state.DeviceID) != "" { + _ = svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(state.DeviceID)) + } + return svc.Repo.Setting.Set(ctx, service.LicenseSettingActivation, string(data)) +} + +func loadLicenseState(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { + raw, err := svc.Repo.Setting.Get(ctx, service.LicenseSettingActivation) + if err != nil || raw == "" { + return service.LicenseActivationState{}, err + } + var state service.LicenseActivationState + if err := json.Unmarshal([]byte(raw), &state); err != nil { + return service.LicenseActivationState{}, err + } + return state, nil +} + +func licenseActivationView(state service.LicenseActivationState) gin.H { + updatedAt := state.UpdatedAt + if strings.TrimSpace(updatedAt) == "" { + updatedAt = time.Now().Format(time.RFC3339) + } + return gin.H{ + "id": state.DeviceID, + "key_id": state.LicenseType, + "key": maskLicenseKey(state.LicenseKey), + "device_id": state.DeviceID, + "device_name": state.DeviceName, + "plan": state.LicenseType, + "max_activations": state.MaxDevices, + "max_users": state.MaxUsers, + "unlimited_users": state.UnlimitedUsers, + "expires_at": emptyAsNil(state.ExpiryDate), + "valid": state.Valid && !licenseStateExpired(state.ExpiryDate), + "heartbeat_at": updatedAt, + "created_at": updatedAt, + } +} + +func maskLicenseKey(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + if len(key) <= 8 { + return key + } + return key[:5] + "..." + key[len(key)-4:] +} + +func licenseStatusMessage(active bool, clientErr error) string { + if active { + return "已激活" + } + if clientErr != nil && !strings.Contains(clientErr.Error(), "not configured") { + return clientErr.Error() + } + return "开源版:最多 20 个用户" +} + +func licenseStateExpired(expiry string) bool { + if expiry == "" { + return false + } + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, expiry); err == nil { + return time.Now().After(t) + } + } + return false +} + +func emptyAsNil(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +}