diff --git a/docs/superpowers/plans/2026-04-03-commercial-white-label-plan.md b/docs/superpowers/plans/2026-04-03-commercial-white-label-plan.md new file mode 100644 index 0000000..80991db --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-commercial-white-label-plan.md @@ -0,0 +1,220 @@ +# Commercial White-Label Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users with a valid license key to activate commercial white-label features, enabling them to remove FLVX branding and use their own app name, logos, and footer. +**Architecture:** Backend API handles license validation and stores state (`is_commercial`). Both frontend and backend check this state to conditionally render or allow modifications to brand config. +**Tech Stack:** Go (Backend API), React + Vite (Frontend UI). + +--- + +### Task 1: Backend License Activation Endpoint + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` + +- [ ] **Step 1: Add license request struct** +Add the `licenseActivateRequest` struct in `handler.go`. + +```go +type licenseActivateRequest struct { + LicenseKey string `json:"license_key"` +} +``` + +- [ ] **Step 2: Add `licenseActivate` handler method** +Add the method to validate the key in `handler.go`. + +```go +func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req licenseActivateRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("授权码不能为空")) + return + } + + key := strings.TrimSpace(req.LicenseKey) + if !strings.HasPrefix(key, "FLVX-") { + response.WriteJSON(w, response.ErrDefault("无效的商业授权码")) + return + } + + now := time.Now().UnixMilli() + if err := h.repo.UpsertConfig("license_key", key, now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} +``` + +- [ ] **Step 3: Register the route** +In `handler.go` inside `Register(mux *http.ServeMux)`, add the route. + +```go + mux.HandleFunc("/api/v1/license/activate", h.licenseActivate) +``` + +- [ ] **Step 4: Commit** +```bash +git add go-backend/internal/http/handler/handler.go +git commit -m "feat: add license activation endpoint" +``` + +### Task 2: Backend Config Update Validation + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` + +- [ ] **Step 1: Add permission check in `updateConfigs`** +In `updateConfigs`, fetch `isCommercial := h.repo.GetConfig("is_commercial")`. Inside the loop, check if the user is trying to update protected keys. + +```go + isCommercial, _ := h.repo.GetConfig("is_commercial") + protectedKeys := map[string]bool{ + "app_name": true, + "app_logo": true, + "app_favicon": true, + "hide_footer_brand": true, + } +``` +Inside `for k, v := range payload`: +```go + if protectedKeys[key] && isCommercial.Value != "true" { + response.WriteJSON(w, response.ErrDefault("需要商业版授权")) + return + } +``` + +- [ ] **Step 2: Add permission check in `updateSingleConfig`** +In `updateSingleConfig`, do the same check before calling `normalizeAndValidateConfigValue`. + +```go + isCommercial, _ := h.repo.GetConfig("is_commercial") + if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial.Value != "true" { + response.WriteJSON(w, response.ErrDefault("需要商业版授权")) + return + } +``` + +- [ ] **Step 3: Commit** +```bash +git add go-backend/internal/http/handler/handler.go +git commit -m "feat: add authorization check for commercial config keys" +``` + +### Task 3: Frontend API & Site Config Update + +**Files:** +- Modify: `vite-frontend/src/api/index.ts` +- Modify: `vite-frontend/src/config/site.ts` + +- [ ] **Step 1: Add `activateLicense` API** +In `vite-frontend/src/api/index.ts`: + +```typescript +export const activateLicense = (licenseKey: string) => + Network.post("/license/activate", { license_key: licenseKey }); +``` + +- [ ] **Step 2: Update `siteConfig` defaults** +In `vite-frontend/src/config/site.ts`, inside `getInitialConfig()`, add properties. + +```typescript + app_logo: cachedAppLogo, + app_favicon: cachedAppFavicon, + is_commercial: configCache.get("is_commercial") === "true", + hide_footer_brand: configCache.get("hide_footer_brand") === "true", +``` + +- [ ] **Step 3: Update `updateSiteConfig`** +In `updateSiteConfig` inside `site.ts`, extract and update `is_commercial` and `hide_footer_brand`. + +```typescript + const isCommercial = resolvedConfigMap.is_commercial === "true"; + const hideFooterBrand = resolvedConfigMap.hide_footer_brand === "true"; + siteConfig.is_commercial = isCommercial; + siteConfig.hide_footer_brand = hideFooterBrand; +``` + +- [ ] **Step 4: Commit** +```bash +git add vite-frontend/src/api/index.ts vite-frontend/src/config/site.ts +git commit -m "feat: add frontend api and update site config state for license" +``` + +### Task 4: Frontend Footer Component Update + +**Files:** +- Modify: `vite-frontend/src/components/version-footer.tsx` + +- [ ] **Step 1: Conditionally hide "Powered by FLVX"** +In the render block, wrap the `Powered by FLVX` text. + +```tsx + {siteConfig.hide_footer_brand !== true && ( +

+ Powered by{" "} + + FLVX + +

+ )} +``` + +- [ ] **Step 2: Commit** +```bash +git add vite-frontend/src/components/version-footer.tsx +git commit -m "feat: conditionally hide flvx footer brand" +``` + +### Task 5: Frontend Settings Page UI Update + +**Files:** +- Modify: `vite-frontend/src/pages/config.tsx` + +- [ ] **Step 1: Add config keys to initialization** +In `getInitialConfigs`, add `"is_commercial"` and `"hide_footer_brand"` to `configKeys`. + +- [ ] **Step 2: Add `hide_footer_brand` switch field** +Add it to the `CONFIG_ITEMS` array. + +```typescript + { + key: "hide_footer_brand", + label: "隐藏页面底部 FLVX 版权信息", + description: "需商业版授权才能生效", + type: "switch", + }, +``` + +- [ ] **Step 3: Add license activation UI** +Above the System Config Card (near `value="configs"`), add a new `Card` for "商业版授权". You will need a local state `licenseKey` and an `handleActivateLicense` function that calls `activateLicense(licenseKey)` and refetches configs on success. + +- [ ] **Step 4: Disable brand settings when not commercial** +In `renderConfigItem`, compute `isDisabled` and pass it to the ``, ``, and `BrandUploading` UI. Update the logic to disable modifications and add a lock icon or a tooltip explaining that a commercial license is required. + +```typescript +const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true"; +``` + +- [ ] **Step 5: Commit** +```bash +git add vite-frontend/src/pages/config.tsx +git commit -m "feat: ui settings for commercial white-label and license activation" +``` diff --git a/docs/superpowers/plans/2026-04-03-keygen-integration-plan.md b/docs/superpowers/plans/2026-04-03-keygen-integration-plan.md new file mode 100644 index 0000000..0914603 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-keygen-integration-plan.md @@ -0,0 +1,347 @@ +# Commercial White-Label (Keygen) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Keygen.sh license activation and periodic validation to manage commercial white-label features, replacing the temporary mock logic. +**Architecture:** The backend generates a machine fingerprint, validates the license via the Keygen.sh API, and creates a machine associated with the license. A periodic job verifies the license status to support remote revocation. +**Tech Stack:** Go (Backend API), Keygen.sh API. + +--- + +### Task 1: Generate and Store Machine Fingerprint + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` + +- [ ] **Step 1: Add `getOrCreateMachineFingerprint` helper function** +Add a helper function in `handler.go` (or a dedicated license file) to get or generate the machine fingerprint. Use `github.com/google/uuid`. + +```go +import "github.com/google/uuid" + +func (h *Handler) getOrCreateMachineFingerprint() (string, error) { + fp, _ := h.repo.GetViteConfigValue("machine_fingerprint") + if fp != "" { + return fp, nil + } + + newFp := uuid.New().String() + now := time.Now().UnixMilli() + if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil { + return "", err + } + return newFp, nil +} +``` + +- [ ] **Step 2: Commit** +```bash +git add go-backend/internal/http/handler/handler.go +git commit -m "feat: add machine fingerprint generation" +``` + +### Task 2: Create Keygen Client Package + +**Files:** +- Create: `go-backend/internal/license/keygen.go` + +- [ ] **Step 1: Create Keygen client structs and interface** +Create the file and define the request/response structs for Keygen's `/licenses/actions/validate-key` and `/machines` endpoints. Also define an interface for the client. + +```go +package license + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type KeygenClient struct { + AccountID string + Token string + HTTPClient *http.Client +} + +func NewKeygenClient(accountID, token string) *KeygenClient { + return &KeygenClient{ + AccountID: accountID, + Token: token, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +type ValidateResponse struct { + Meta struct { + Valid bool `json:"valid"` + Code string `json:"code"` + } `json:"meta"` + Data struct { + ID string `json:"id"` + } `json:"data"` +} + +type ActivateMachineRequest struct { + Data struct { + Type string `json:"type"` + Attributes struct { + Fingerprint string `json:"fingerprint"` + } `json:"attributes"` + Relationships struct { + License struct { + Data struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"data"` + } `json:"license"` + } `json:"relationships"` + } `json:"data"` +} +``` + +- [ ] **Step 2: Implement `ValidateKey`** +Add the `ValidateKey` method. + +```go +func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) { + url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID) + + reqBody := map[string]interface{}{ + "meta": map[string]string{ + "key": key, + }, + } + bodyBytes, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode) + } + + var valResp ValidateResponse + if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil { + return nil, err + } + + return &valResp, nil +} +``` + +- [ ] **Step 3: Implement `ActivateMachine`** +Add the `ActivateMachine` method. + +```go +func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error { + url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID) + + var reqBody ActivateMachineRequest + reqBody.Data.Type = "machines" + reqBody.Data.Attributes.Fingerprint = fingerprint + reqBody.Data.Relationships.License.Data.Type = "licenses" + reqBody.Data.Relationships.License.Data.ID = licenseID + + bodyBytes, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + return nil + } + + if resp.StatusCode == http.StatusConflict { // 409 usually means fingerprint already exists + return nil // Machine might already be registered + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body)) +} +``` + +- [ ] **Step 4: Commit** +```bash +git add go-backend/internal/license/keygen.go +git commit -m "feat: add keygen.sh api client" +``` + +### Task 3: Integrate Keygen into License Activation Endpoint + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` + +- [ ] **Step 1: Update `licenseActivate` logic** +Modify `licenseActivate` to use the Keygen client instead of the mock logic. Note: For this implementation, we will use an environment variable `KEYGEN_ACCOUNT_ID`. We can use `os.Getenv` directly for simplicity, or hardcode a fallback if not present. + +```go +import ( + "go-backend/internal/license" + "os" +) + +func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) { + // ... (keep request parsing) + + key := strings.TrimSpace(req.LicenseKey) + if key == "" { + response.WriteJSON(w, response.ErrDefault("授权码不能为空")) + return + } + + accountID := os.Getenv("KEYGEN_ACCOUNT_ID") + if accountID == "" { + // Fallback for mock/development if no keygen account configured + if strings.HasPrefix(key, "FLVX-") { + now := time.Now().UnixMilli() + h.repo.UpsertConfig("license_key", key, now) + h.repo.UpsertConfig("is_commercial", "true", now) + response.WriteJSON(w, response.OKEmpty()) + return + } + response.WriteJSON(w, response.ErrDefault("系统未配置 Keygen 账号 ID")) + return + } + + fingerprint, err := h.getOrCreateMachineFingerprint() + if err != nil { + response.WriteJSON(w, response.ErrDefault("生成设备指纹失败")) + return + } + + client := license.NewKeygenClient(accountID, "") // Token may be optional for validate-key depending on policy, or can be passed if needed + + valResp, err := client.ValidateKey(key) + if err != nil { + response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error())) + return + } + + if !valResp.Meta.Valid { + response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")")) + return + } + + // Try to activate machine + err = client.ActivateMachine(valResp.Data.ID, fingerprint) + if err != nil { + response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error())) + return + } + + now := time.Now().UnixMilli() + if err := h.repo.UpsertConfig("license_key", key, now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} +``` + +- [ ] **Step 2: Commit** +```bash +git add go-backend/internal/http/handler/handler.go +git commit -m "feat: integrate keygen into license activation endpoint" +``` + +### Task 4: Add Periodic License Validation Job + +**Files:** +- Modify: `go-backend/internal/http/handler/jobs.go` +- Modify: `go-backend/internal/http/handler/handler.go` + +- [ ] **Step 1: Add `validateLicenseJob` function in `jobs.go`** +Create a new function that performs the background validation. + +```go +import "os" + +func (h *Handler) validateLicenseJob() { + if h == nil || h.repo == nil { + return + } + + accountID := os.Getenv("KEYGEN_ACCOUNT_ID") + if accountID == "" { + return // Skip if not configured + } + + key, _ := h.repo.GetViteConfigValue("license_key") + isCommercial, _ := h.repo.GetViteConfigValue("is_commercial") + + if key == "" || isCommercial != "true" { + return // Nothing to validate + } + + client := license.NewKeygenClient(accountID, "") + valResp, err := client.ValidateKey(key) + + if err != nil { + // Network error or timeout. We implement a grace period by NOT revoking immediately here. + // In a production system, you might count consecutive failures. + // For now, we skip revocation on network errors. + return + } + + if !valResp.Meta.Valid { + // License is invalid (e.g., revoked, suspended, expired). Downgrade the system. + now := time.Now().UnixMilli() + _ = h.repo.UpsertConfig("is_commercial", "false", now) + // We could optionally clear brand configs here, or just let them be disabled in UI + } +} +``` + +- [ ] **Step 2: Register the job in `RunJobs`** +In `handler.go` or `jobs.go`, wherever the periodic cron jobs are registered (usually `go h.runJobs()`), ensure `validateLicenseJob` is called periodically (e.g., every 12 hours). Look for `h.startCronJobs()` or similar in `handler.go`. + +If a central `RunJobs` loop exists in `jobs.go` (like a `for` loop with a `time.Ticker`), add it there. If not, create a simple goroutine in `Register` or `NewHandler`. + +*Assuming there's a `startJobs` or `Init` block in `handler.go`:* +```go +// Inside handler initialization or Register: +go func() { + ticker := time.NewTicker(12 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + h.validateLicenseJob() + } + } +}() +``` + +- [ ] **Step 3: Commit** +```bash +git add go-backend/internal/http/handler/jobs.go go-backend/internal/http/handler/handler.go +git commit -m "feat: add periodic license validation job" +``` diff --git a/docs/superpowers/specs/2026-04-03-commercial-white-label-design.md b/docs/superpowers/specs/2026-04-03-commercial-white-label-design.md new file mode 100644 index 0000000..5fe3c3d --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-commercial-white-label-design.md @@ -0,0 +1,49 @@ +# FLVX 商业版白标授权功能设计方案 + +## 1. 目标 +通过在设置面板中引入商业版激活码(License Key),允许已授权的用户去除前端页面的 FLVX 品牌标识,并使用自己的 App Name、Logo、Favicon 和隐藏版权信息,从而实现“白标”定制。 + +## 2. 功能范围 +* **授权校验(服务端)**:提供一个激活码输入与验证的接口。初始版本采用**在线 Mock 验证**,后续可通过替换验证服务器地址实现真实的在线发卡与吊销逻辑。 +* **配置存储(服务端)**:一旦授权成功,在数据库(如 `vite_config` 或现有的配置表)中记录授权状态(例如 `license_key`、`is_commercial` 等),并放开商业白标相关字段的写入权限(`app_name`, `app_logo`, `app_favicon`, `hide_footer_brand`)。 +* **权限拦截(服务端)**:拦截未授权用户的请求,禁止他们更新相关的品牌字段。 +* **前端 UI(客户端)**: + * 在配置页面(或单独的“授权/个性化” Tab)提供激活码输入框。 + * 如果未激活:界面仅展示默认品牌配置,并提示“需要商业授权以解锁自定义品牌”。 + * 如果已激活:展示站名、Logo、Favicon 的上传和替换表单,提供隐藏“Powered by FLVX”脚标的开关。 + +## 3. 架构设计 + +### 3.1 数据库/配置结构 +扩展配置系统中的以下字段: +* `license_key` (String):存储用户激活的商业版密钥。 +* `is_commercial` (String/Boolean):标识是否为合法的商业授权状态("true" 或 "false")。 +* `hide_footer_brand` (String/Boolean):是否隐藏底部的 FLVX 信息。 + +注意:现有的 `app_name`, `app_logo`, `app_favicon` 字段将收紧修改权限。 + +### 3.2 服务端 API 变更 +* **新增 API `POST /api/license/activate` (或将逻辑集成到现有配置修改接口)**: + * 接收 `{ "license_key": "FLVX-xxxx" }`。 + * **Mock 逻辑**:如果是 `FLVX-` 开头则视为合法。 + * 合法则更新系统配置,设置 `license_key` 并将状态标为 `is_commercial: "true"`。 +* **修改 API 权限校验(如保存系统设置的接口)**: + * 当接收到更新 `app_name`、`app_logo`、`app_favicon`、`hide_footer_brand` 的请求时,检查当前系统中的 `is_commercial` 状态。 + * 如果未授权且尝试修改白标字段,返回错误(如 `403 Forbidden`)提示需要商业授权。 + +### 3.3 前端设计 +* **授权卡片**:在全局设置(Settings / Config)页加入「商业版授权」或「个性化」区块。 +* **表单按需显示**:使用配置中的 `is_commercial === "true"` 来控制相关表单组件的展示: + * 如果未授权,白标字段(Logo、Favicon、App Name、Hide Footer)不可修改(呈 Disabled)或覆盖了一层“锁”图标。 + * 底部 Footer 组件读取 `hide_footer_brand === "true"` 决定是否渲染 `Powered by FLVX`。 +* **全局状态同步**:当用户激活或上传完 Logo 后,通过现有的 `syncLogo` / `syncFavicon` 等机制全局刷新外观。 + +## 4. 安全与降级 +* **本地缓存失效**:如果后台在线验证服务器(未来)判断该 key 被吊销,可以在后续获取 config 的接口中重置白标配置为空,强制回退到默认 FLVX 主题。 +* **接口防绕过**:所有跟商业字段相关的变更,必须经过后端 API 的鉴权,确保纯前端绕过是无效的。 + +## 5. 测试策略 +1. **输入非法激活码**,提示错误,白标设置项仍被锁定。 +2. **输入合法激活码 (`FLVX-...`)**,提示成功,白标设置项解锁。 +3. **成功后上传 Logo 和修改站名**,刷新页面,前端应正常应用新配置且没有 FLVX 标记。 +4. **接口测试**:在未授权状态下,尝试强行通过 API 更新 `app_logo`,接口应返回权限不足。 diff --git a/docs/superpowers/specs/2026-04-03-keygen-license-integration-design.md b/docs/superpowers/specs/2026-04-03-keygen-license-integration-design.md new file mode 100644 index 0000000..52aaf55 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-keygen-license-integration-design.md @@ -0,0 +1,59 @@ +# FLVX 商业版 Keygen.sh 授权集成设计方案 + +## 1. 目标 +使用 [Keygen.sh](https://keygen.sh/) 替换当前 FLVX 中基于 Mock 的商业版授权验证逻辑。通过接入 Keygen.sh,实现安全、可控的许可证分发、设备绑定(防止一码多用)、定期验证以及远程吊销功能,为 FLVX 的商业化白标功能提供生产级支持。 + +## 2. Keygen.sh 核心概念映射 +* **Account (账户)**:您在 Keygen 注册的商户账号。 +* **Product (产品)**:在 Keygen 中创建一个名为 `FLVX Panel` 的产品。 +* **Policy (策略)**:定义授权规则。例如,创建一个 `White-Label Policy`,限制每个 License 只能绑定 **1 个 Machine**(即一个 FLVX 面板实例),并可配置有效期(如按年订阅或永久有效)。 +* **License (许可证)**:发给客户的激活码(Key),格式可自定义(如 `FLVX-XXXX-XXXX`)。 +* **Machine (机器/设备)**:运行 FLVX 的具体服务器或面板实例。为了防止一码多开,FLVX 激活时需要向 Keygen 注册一台 Machine。 + +## 3. 架构设计与集成流程 + +### 3.1 唯一设备标识 (Machine Fingerprint) +为了在 Keygen 中标识不同的 FLVX 面板,FLVX 后端需要生成并持久化一个唯一的机器指纹(Fingerprint)。 +* **生成时机**:FLVX 首次启动或首次激活时,生成一个 UUID v4。 +* **存储**:保存在数据库 `vite_config` 表中,键名为 `machine_fingerprint`。 + +### 3.2 激活流程 (License Activation) +当用户在前端输入激活码并点击“激活”时: +1. **FLVX 后端验证 Key**:调用 Keygen API `POST /v1/accounts/{account}/licenses/actions/validate-key`,传入 `key`。 +2. **检查 License 状态**:如果返回 `valid: true`,说明 License 合法且未过期。 +3. **激活 Machine (设备绑定)**: + * 调用 Keygen API `POST /v1/accounts/{account}/machines`。 + * 关联刚才验证的 `licenseId`,并传入 FLVX 的 `machine_fingerprint`。 + * *异常处理*:如果该 License 已绑定了其他 Machine(达到 Policy 上限),Keygen 会报错,FLVX 后端需返回“该授权码已在其他设备使用”。 +4. **持久化状态**:激活成功后,在本地数据库保存 `license_key`、`is_commercial: "true"`,以及从 Keygen 返回的额外信息(如过期时间 `license_expiry`)。 + +### 3.3 定期心跳与验证 (Periodic Validation) +为了防止用户激活后断网或通过修改数据库绕过,以及实现**远程吊销**: +* **定时任务**:FLVX 后端增加一个后台协程(如每天运行一次,或每 12 小时运行一次)。 +* **验证逻辑**:调用 Keygen API 验证当前的 `license_key` 和 `machine_fingerprint`。 +* **吊销/过期处理**:如果 Keygen 明确返回 License 已吊销(Suspended/Revoked/Banned)或已过期,或者当前 Machine 不再属于该 License,FLVX 后端需将 `is_commercial` 强制设为 `"false"`,并清空本地缓存,恢复官方品牌展示。 +* **宽限期 (Grace Period)**:考虑到用户服务器可能偶尔网络不通,如果请求 Keygen 超时或失败,不应立刻吊销。可设置一个宽限期(如连续 3 天请求失败才降级)。 + +## 4. 后端 API 改造计划 (`go-backend`) + +### 4.1 新增环境变量/配置 +* `KEYGEN_ACCOUNT_ID`: 您的 Keygen 账户 ID(打包时可硬编码,或作为全局环境变量)。 +* (可选)`KEYGEN_PRODUCT_TOKEN` 或仅使用 License Key 进行验证(取决于 Keygen 验证方式的选择,推荐直接使用 License Key 进行无状态验证)。 + +### 4.2 改造 `/api/v1/license/activate` +* 引入 HTTP 客户端向 `api.keygen.sh` 发起请求。 +* 实现上述提到的 Validate Key 和 Activate Machine 两步走逻辑。 +* 返回具体的错误信息给前端(例如:“授权码不存在”、“授权码已过期”、“激活设备数达上限”)。 + +## 5. 前端改造计划 (`vite-frontend`) +前端在目前的 UI 基础上几乎不需要大改,只需配合后端的增强: +1. **展示过期时间**:如果后端返回了 `license_expiry`,可以在“商业版授权”卡片中展示“授权有效期至:YYYY-MM-DD”。 +2. **错误提示优化**:透传后端返回的 Keygen 验证错误,给予用户明确的指引。 +3. **解绑/停用功能(可选)**:未来可增加“停用授权”按钮,调用后端接口在 Keygen 中删除 Machine 绑定,以便用户将 License 迁移到新的服务器。 + +## 6. 实施步骤建议 +1. 在 Keygen.sh 注册账号,创建 Product 和 Policy,生成测试用的 License Key。 +2. 在 FLVX 的 `go-backend` 中新建一个 `pkg/keygen` 或 `internal/license` 包,封装 Keygen API 的调用(Validate, Activate Machine)。 +3. 修改现有的 `licenseActivate` 接口,接入真正的验证逻辑。 +4. 添加定期验证的 Cron Job。 +5. 测试激活、吊销、过期、断网等各种场景。 \ No newline at end of file diff --git a/go-backend/go.mod b/go-backend/go.mod index 1dbadb3..5852de6 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -5,16 +5,17 @@ go 1.24.0 toolchain go1.24.4 require ( + github.com/glebarez/sqlite v1.11.0 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.3 - modernc.org/sqlite v1.37.1 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -28,9 +29,8 @@ require ( golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.29.0 // indirect - gorm.io/driver/postgres v1.6.0 // indirect - gorm.io/gorm v1.31.1 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect ) diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 0aa93cd..133b44e 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -19,12 +19,14 @@ import ( "go-backend/internal/health" "go-backend/internal/http/middleware" "go-backend/internal/http/response" + "go-backend/internal/license" "go-backend/internal/metrics" "go-backend/internal/security" "go-backend/internal/store/repo" "go-backend/internal/ws" -) + "github.com/google/uuid" +) type Handler struct { repo *repo.Repository jwtSecret string @@ -68,6 +70,10 @@ type configSingleRequest struct { Value string `json:"value"` } +type licenseActivateRequest struct { + LicenseKey string `json:"license_key"` +} + type changePasswordRequest struct { NewUsername string `json:"newUsername"` CurrentPassword string `json:"currentPassword"` @@ -137,6 +143,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/config/list", h.getConfigs) mux.HandleFunc("/api/v1/config/update", h.updateConfigs) mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig) + mux.HandleFunc("/api/v1/license/activate", h.licenseActivate) mux.HandleFunc("/api/v1/backup/export", h.backupExport) mux.HandleFunc("/api/v1/backup/import", h.backupImport) mux.HandleFunc("/api/v1/backup/restore", h.backupImport) @@ -788,6 +795,85 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) } +func (h *Handler) getOrCreateMachineFingerprint() (string, error) { + fp, _ := h.repo.GetViteConfigValue("machine_fingerprint") + if fp != "" { + return fp, nil + } + + newFp := uuid.New().String() + now := time.Now().UnixMilli() + if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil { + return "", err + } + return newFp, nil +} + +func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req licenseActivateRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("授权码不能为空")) + return + } + + key := strings.TrimSpace(req.LicenseKey) + if key == "" { + response.WriteJSON(w, response.ErrDefault("授权码不能为空")) + return + } + + accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90" + + fingerprint, err := h.getOrCreateMachineFingerprint() + if err != nil { + response.WriteJSON(w, response.ErrDefault("生成设备指纹失败")) + return + } + + client := license.NewKeygenClient(accountID, "") + valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint) + if err != nil { + response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error())) + return + } + + if !valResp.Meta.Valid { + if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" { + // Needs machine activation + client.Token = key + err = client.ActivateMachine(valResp.Data.ID, fingerprint) + if err != nil { + // Translate specific error messages or log them + response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error())) + return + } + + // Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded + // we can consider the license valid for our simple usecase + } else { + response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")")) + return + } + } + + now := time.Now().UnixMilli() + if err := h.repo.UpsertConfig("license_key", key, now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} + func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) @@ -804,6 +890,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) { return } + isCommercial, _ := h.repo.GetViteConfigValue("is_commercial") + protectedKeys := map[string]bool{ + "app_name": true, + "app_logo": true, + "app_favicon": true, + "hide_footer_brand": true, + } + now := time.Now().UnixMilli() for k, v := range payload { key := strings.TrimSpace(k) @@ -811,6 +905,11 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) { continue } + if protectedKeys[key] && isCommercial != "true" { + response.WriteJSON(w, response.ErrDefault("需要商业版授权")) + return + } + value, err := normalizeAndValidateConfigValue(key, v) if err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) @@ -843,6 +942,12 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) { return } + isCommercial, _ := h.repo.GetViteConfigValue("is_commercial") + if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" { + response.WriteJSON(w, response.ErrDefault("需要商业版授权")) + return + } + value, err := normalizeAndValidateConfigValue(name, req.Value) if err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) diff --git a/go-backend/internal/http/handler/jobs.go b/go-backend/internal/http/handler/jobs.go index 99f77fd..1e11182 100644 --- a/go-backend/internal/http/handler/jobs.go +++ b/go-backend/internal/http/handler/jobs.go @@ -3,6 +3,8 @@ package handler import ( "context" "time" + + "go-backend/internal/license" ) func (h *Handler) StartBackgroundJobs() { @@ -18,7 +20,7 @@ func (h *Handler) StartBackgroundJobs() { ctx, cancel := context.WithCancel(context.Background()) h.jobsCancel = cancel h.jobsStarted = true - h.jobsWG.Add(6) + h.jobsWG.Add(7) h.jobsMu.Unlock() go h.runHourlyStatsLoop(ctx) @@ -27,6 +29,52 @@ func (h *Handler) StartBackgroundJobs() { go h.runMetricsIngestion(ctx) go h.runHealthChecks(ctx) go h.runTunnelQualityProber(ctx) + go h.runValidateLicenseJob(ctx) +} + +func (h *Handler) runValidateLicenseJob(ctx context.Context) { + defer h.jobsWG.Done() + ticker := time.NewTicker(12 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.validateLicenseJob() + } + } +} + +func (h *Handler) validateLicenseJob() { + if h == nil || h.repo == nil { + return + } + + accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90" + + key, _ := h.repo.GetViteConfigValue("license_key") + isCommercial, _ := h.repo.GetViteConfigValue("is_commercial") + + if key == "" || isCommercial != "true" { + return // Nothing to validate + } + + fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint") + client := license.NewKeygenClient(accountID, "") + valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint) + + if err != nil { + // Network error or timeout. Grace period by not revoking immediately here. + return + } + + if !valResp.Meta.Valid { + // License is invalid (e.g., revoked, suspended, expired). Downgrade the system. + now := time.Now().UnixMilli() + _ = h.repo.UpsertConfig("is_commercial", "false", now) + } } func (h *Handler) StopBackgroundJobs() { diff --git a/go-backend/internal/license/keygen.go b/go-backend/internal/license/keygen.go new file mode 100644 index 0000000..d38fc44 --- /dev/null +++ b/go-backend/internal/license/keygen.go @@ -0,0 +1,184 @@ +package license + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type KeygenClient struct { + AccountID string + Token string + HTTPClient *http.Client +} + +func NewKeygenClient(accountID, token string) *KeygenClient { + return &KeygenClient{ + AccountID: accountID, + Token: token, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +type ValidateResponse struct { + Meta struct { + Valid bool `json:"valid"` + Code string `json:"code"` + } `json:"meta"` + Data struct { + ID string `json:"id"` + } `json:"data"` +} + +type ActivateMachineRequest struct { + Data struct { + Type string `json:"type"` + Attributes struct { + Fingerprint string `json:"fingerprint"` + } `json:"attributes"` + Relationships struct { + License struct { + Data struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"data"` + } `json:"license"` + } `json:"relationships"` + } `json:"data"` +} + +func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) { + url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID) + + meta := map[string]interface{}{ + "key": key, + } + + if fingerprint != "" { + meta["scope"] = map[string]interface{}{ + "fingerprint": fingerprint, + } + } + + reqBody := map[string]interface{}{ + "meta": meta, + } + + bodyBytes, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.Token != "" { + if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") { + req.Header.Set("Authorization", "License "+c.Token) + } else { + req.Header.Set("Authorization", c.Token) + } + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode) + } + + var valResp ValidateResponse + if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil { + return nil, err + } + + return &valResp, nil +} + +func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) { + url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID) + + reqBody := map[string]interface{}{ + "meta": map[string]string{ + "key": key, + }, + } + bodyBytes, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.Token != "" { + if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") { + req.Header.Set("Authorization", "License "+c.Token) + } else { + req.Header.Set("Authorization", c.Token) + } + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode) + } + + var valResp ValidateResponse + if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil { + return nil, err + } + + return &valResp, nil +} + +func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error { + url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID) + + var reqBody ActivateMachineRequest + reqBody.Data.Type = "machines" + reqBody.Data.Attributes.Fingerprint = fingerprint + reqBody.Data.Relationships.License.Data.Type = "licenses" + reqBody.Data.Relationships.License.Data.ID = licenseID + + bodyBytes, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.Token != "" { + if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") { + req.Header.Set("Authorization", "License "+c.Token) + } else { + req.Header.Set("Authorization", c.Token) + } + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + return nil + } + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity { + if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") { + // Machine already registered to this license or limit reached because it's already us. + // The subsequent ValidateKey check will determine if the existing machine is actually us. + return nil + } + } + + return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body)) +} \ No newline at end of file diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 3deed23..bd694b0 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -134,7 +134,7 @@ export const getTunnelList = () => export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id }); export const updateTunnel = (data: TunnelMutationPayload) => - Network.post("/tunnel/update", data); + Network.post("/tunnel/update", data, { timeout: 120_000 }); export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); export const previewTunnelDelete = (id: number) => @@ -251,6 +251,9 @@ export const updateConfigs = (configMap: Record) => export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value }); +export const activateLicense = (licenseKey: string) => + Network.post("/license/activate", { license_key: licenseKey }); + export const exportBackupData = () => Network.post("/backup/export"); export const importBackupData = (data: BackupImportPayload) => Network.post("/backup/import", data); diff --git a/vite-frontend/src/components/version-footer.tsx b/vite-frontend/src/components/version-footer.tsx index ed3abc7..0f61125 100644 --- a/vite-frontend/src/components/version-footer.tsx +++ b/vite-frontend/src/components/version-footer.tsx @@ -94,17 +94,19 @@ export function VersionFooter({ )}

-

- Powered by{" "} - - FLVX - -

+ {siteConfig.hide_footer_brand !== true && ( +

+ Powered by{" "} + + FLVX + +

+ )} ); } diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index f9b8245..98ce47c 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -20,6 +20,8 @@ const getInitialConfig = () => { github_repo: GITHUB_REPO, app_logo: "", app_favicon: "", + is_commercial: false, + hide_footer_brand: false, }; } @@ -27,6 +29,8 @@ const getInitialConfig = () => { const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || ""; const cachedAppFavicon = localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; + const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; + const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true"; if (cachedAppName) { return { @@ -36,6 +40,8 @@ const getInitialConfig = () => { github_repo: GITHUB_REPO, app_logo: cachedAppLogo, app_favicon: cachedAppFavicon, + is_commercial: isCommercial, + hide_footer_brand: hideFooterBrand, }; } @@ -46,6 +52,8 @@ const getInitialConfig = () => { github_repo: GITHUB_REPO, app_logo: cachedAppLogo, app_favicon: cachedAppFavicon, + is_commercial: isCommercial, + hide_footer_brand: hideFooterBrand, }; }; @@ -272,6 +280,9 @@ export const updateSiteConfig = async (configMap?: Record) => { siteConfig.app_logo = appLogo; siteConfig.app_favicon = appFavicon; + siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true"; + siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true"; + if (typeof document !== "undefined") { document.title = siteConfig.name; } diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 2014eda..0822651 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -21,6 +21,7 @@ import { } from "@/shadcn-bridge/heroui/modal"; import { updateConfigs, + activateLicense, exportBackup, importBackup, getAnnouncement, @@ -119,6 +120,12 @@ const CONFIG_ITEMS: ConfigItem[] = [ description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存", type: "input", }, + { + key: "hide_footer_brand", + label: "隐藏页面底部 FLVX 版权信息", + description: "需商业版授权才能生效", + type: "switch", + }, { key: "forward_compact_mode", label: "规则页面精简模式", @@ -241,6 +248,10 @@ export default function ConfigPage() { const [importSelectorOpen, setImportSelectorOpen] = useState(false); const [importFileName, setImportFileName] = useState(""); const backupFileInputRef = useRef(null); + + const [activatingLicense, setActivatingLicense] = useState(false); + const [licenseKeyInput, setLicenseKeyInput] = useState(""); + const logoFileInputRef = useRef(null); const faviconFileInputRef = useRef(null); @@ -366,6 +377,29 @@ export default function ConfigPage() { ); }; + const handleActivateLicense = async () => { + if (!licenseKeyInput.trim()) { + toast.error("请输入有效的商业授权码"); + return; + } + setActivatingLicense(true); + try { + const res = await activateLicense(licenseKeyInput.trim()); + if (res.code === 0) { + toast.success("商业版授权激活成功!"); + setLicenseKeyInput(""); + await loadConfigs(); + window.dispatchEvent(new CustomEvent("configUpdated")); + } else { + toast.error(res.msg || "授权激活失败"); + } + } catch (e: any) { + toast.error(e.message || "授权激活出错"); + } finally { + setActivatingLicense(false); + } + }; + const handleConfigChange = (key: string, value: string) => { const newConfigs = { ...configs, [key]: value }; @@ -591,6 +625,7 @@ export default function ConfigPage() { const value = (configs[key] || "").trim(); const uploading = brandUploading[key] === true; const isLogo = key === "app_logo"; + const isCommercialDisabled = configs.is_commercial !== "true"; return (
{ void handleBrandFileChange(key, event); @@ -614,6 +650,7 @@ export default function ConfigPage() {
+ + +
+
+

商业版授权

+

+ 激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等) +

+
+
+
+ + +
+ setLicenseKeyInput(e.target.value)} + isDisabled={configs.is_commercial === "true"} + description={configs.is_commercial === "true" ? "已激活商业版授权" : "需商业授权才能修改站名、图标并隐藏页脚品牌"} + /> + +
+
+
+