mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-16 01:24:24 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5041c722c9 | |||
| c56798e991 | |||
| 40e96f3592 | |||
| 0e24b53a5b | |||
| a820c49c94 | |||
| 538e64ffc0 |
@@ -229,6 +229,7 @@ jobs:
|
|||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg KEYGEN_ACCOUNT_ID=${{ secrets.KEYGEN_ACCOUNT_ID }} \
|
||||||
--push \
|
--push \
|
||||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
|
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
|
||||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
|
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
|
||||||
@@ -431,4 +432,3 @@ jobs:
|
|||||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||||
|
|
||||||
echo "✅ GOST 二进制文件更新完成"
|
echo "✅ GOST 二进制文件更新完成"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ Alpine Linux 最小化安装若未包含 `curl`,可使用系统自带的 `wget
|
|||||||
wget -O install.sh https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh && chmod +x install.sh && ./install.sh
|
wget -O install.sh https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh && chmod +x install.sh && ./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会在 Alpine 上自动安装 Bash,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
|
脚本会在 Alpine 上自动安装 Bash、`curl` 和 CA 证书,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
|
||||||
|
|
||||||
**安装过程中会提示输入:**
|
**安装过程中会提示输入:**
|
||||||
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
|
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
ARG KEYGEN_ACCOUNT_ID
|
||||||
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -ldflags="-X 'go-backend/internal/license.AccountID=${KEYGEN_ACCOUNT_ID}'" -o /out/paneld ./cmd/paneld
|
||||||
|
|
||||||
FROM docker:27-cli AS dockercli
|
FROM docker:27-cli AS dockercli
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
|
|||||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if value, gated := h.unlicensedPublicBrandValue(configName); gated {
|
||||||
|
response.WriteJSON(w, response.OK(map[string]string{"name": configName, "value": value}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := h.repo.GetConfigByName(configName)
|
cfg, err := h.repo.GetConfigByName(configName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,3 +46,22 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
response.WriteJSON(w, response.OK(cfg))
|
response.WriteJSON(w, response.OK(cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) unlicensedPublicBrandValue(configName string) (string, bool) {
|
||||||
|
switch configName {
|
||||||
|
case "app_name", "app_logo", "app_favicon", "hide_footer_brand":
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||||
|
if isCommercial == "true" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if configName == "app_name" {
|
||||||
|
return "FLVX", true
|
||||||
|
}
|
||||||
|
if configName == "hide_footer_brand" {
|
||||||
|
return "false", true
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,40 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
|
|||||||
assertHandlerCode(t, resp, 0)
|
assertHandlerCode(t, resp, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) {
|
||||||
|
router, r := setupConfigAccessTestRouter(t)
|
||||||
|
seedConfigValue(t, r, "app_name", "Paid Brand")
|
||||||
|
seedConfigValue(t, r, "app_logo", "logo-data")
|
||||||
|
seedConfigValue(t, r, "app_favicon", "favicon-data")
|
||||||
|
seedConfigValue(t, r, "hide_footer_brand", "true")
|
||||||
|
seedConfigValue(t, r, "is_commercial", "false")
|
||||||
|
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"app_name": "FLVX",
|
||||||
|
"app_logo": "",
|
||||||
|
"app_favicon": "",
|
||||||
|
"hide_footer_brand": "false",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
assertHandlerConfigValue(t, resp, name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicBrandConfigUsesSavedValuesWithCommercialLicense(t *testing.T) {
|
||||||
|
router, r := setupConfigAccessTestRouter(t)
|
||||||
|
seedConfigValue(t, r, "app_name", "Paid Brand")
|
||||||
|
seedConfigValue(t, r, "is_commercial", "true")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
assertHandlerConfigValue(t, resp, "app_name", "Paid Brand")
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
|
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
|
||||||
router, _ := setupConfigAccessTestRouter(t)
|
router, _ := setupConfigAccessTestRouter(t)
|
||||||
|
|
||||||
@@ -82,6 +116,56 @@ func TestConfigGetAllowsSensitiveKeysForAdmin(t *testing.T) {
|
|||||||
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
|
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigGetNeverReturnsLicenseCredentials(t *testing.T) {
|
||||||
|
router, r := setupConfigAccessTestRouter(t)
|
||||||
|
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||||
|
seedConfigValue(t, r, "license_key", "license-secret")
|
||||||
|
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
|
||||||
|
|
||||||
|
for _, name := range []string{"license_key", "license_machine_id", "machine_fingerprint"} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
assertHandlerCodeMsg(t, resp, 403, "禁止访问系统授权凭据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigListNeverReturnsLicenseCredentials(t *testing.T) {
|
||||||
|
router, r := setupConfigAccessTestRouter(t)
|
||||||
|
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||||
|
seedConfigValue(t, r, "license_key", "license-secret")
|
||||||
|
seedConfigValue(t, r, "license_machine_id", "machine-id")
|
||||||
|
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
|
||||||
|
seedConfigValue(t, r, "is_commercial", "true")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/list", nil)
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 || out.Data["is_commercial"] != "true" {
|
||||||
|
t.Fatalf("unexpected config response: %+v", out)
|
||||||
|
}
|
||||||
|
if _, ok := out.Data["license_key"]; ok {
|
||||||
|
t.Fatal("license_key must not be returned")
|
||||||
|
}
|
||||||
|
if _, ok := out.Data["license_machine_id"]; ok {
|
||||||
|
t.Fatal("license_machine_id must not be returned")
|
||||||
|
}
|
||||||
|
if _, ok := out.Data["machine_fingerprint"]; ok {
|
||||||
|
t.Fatal("machine_fingerprint must not be returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
|
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||||
router, _ := setupConfigAccessTestRouter(t)
|
router, _ := setupConfigAccessTestRouter(t)
|
||||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||||
@@ -154,7 +238,7 @@ func TestConfigUpdateSingleAllowsCloudflareSecretKeyWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
func TestConfigUpdateRejectsLicenseKeyWrite(t *testing.T) {
|
||||||
router, r := setupConfigAccessTestRouter(t)
|
router, r := setupConfigAccessTestRouter(t)
|
||||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||||
|
|
||||||
@@ -165,18 +249,13 @@ func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
|||||||
|
|
||||||
router.ServeHTTP(resp, req)
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
assertHandlerCode(t, resp, 0)
|
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
|
||||||
|
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
|
||||||
cfg, err := r.GetConfigByName("license_key")
|
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get config: %v", err)
|
|
||||||
}
|
|
||||||
if cfg == nil || cfg.Value != "license-secret" {
|
|
||||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
func TestConfigUpdateSingleRejectsLicenseKeyWrite(t *testing.T) {
|
||||||
router, r := setupConfigAccessTestRouter(t)
|
router, r := setupConfigAccessTestRouter(t)
|
||||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||||
|
|
||||||
@@ -187,14 +266,9 @@ func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
|||||||
|
|
||||||
router.ServeHTTP(resp, req)
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
assertHandlerCode(t, resp, 0)
|
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
|
||||||
|
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
|
||||||
cfg, err := r.GetConfigByName("license_key")
|
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get config: %v", err)
|
|
||||||
}
|
|
||||||
if cfg == nil || cfg.Value != "license-secret" {
|
|
||||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -659,9 +659,21 @@ func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) speedLimiterExists(name string) bool {
|
func (h *Handler) speedLimiterExists(name string) bool {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const forwardRulePrefix = "rule_traffic_limit_"
|
||||||
|
if strings.HasPrefix(name, forwardRulePrefix) {
|
||||||
|
forwardID, err := strconv.ParseInt(strings.TrimPrefix(name, forwardRulePrefix), 10, 64)
|
||||||
|
if err != nil || forwardID <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
forward, err := h.getForwardRecord(forwardID)
|
||||||
|
return err == nil && forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0
|
||||||
|
}
|
||||||
|
|
||||||
id, err := strconv.ParseInt(name, 10, 64)
|
id, err := strconv.ParseInt(name, 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpeedLimiterExistsPreservesForwardRuleLimiter(t *testing.T) {
|
||||||
|
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, ip_speed_id)
|
||||||
|
VALUES(8, 1, 'user', 'forward', 1, '127.0.0.1:80', 'fifo', 0, 0, 1, 1, 1, 0, 3),
|
||||||
|
(9, 1, 'user', 'forward-without-ip-limit', 1, '127.0.0.1:81', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
if !h.speedLimiterExists("rule_traffic_limit_8") {
|
||||||
|
t.Fatal("expected runtime limiter for existing forward to be preserved")
|
||||||
|
}
|
||||||
|
if h.speedLimiterExists("rule_traffic_limit_9") {
|
||||||
|
t.Fatal("expected runtime limiter for forward without per-IP speed limit to be treated as orphaned")
|
||||||
|
}
|
||||||
|
if h.speedLimiterExists("rule_traffic_limit_10") {
|
||||||
|
t.Fatal("expected runtime limiter for missing forward to be treated as orphaned")
|
||||||
|
}
|
||||||
|
if h.speedLimiterExists("rule_traffic_limit_invalid") {
|
||||||
|
t.Fatal("expected malformed runtime limiter name to be treated as orphaned")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -20,7 +21,6 @@ import (
|
|||||||
"go-backend/internal/health"
|
"go-backend/internal/health"
|
||||||
"go-backend/internal/http/middleware"
|
"go-backend/internal/http/middleware"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
"go-backend/internal/license"
|
|
||||||
"go-backend/internal/metrics"
|
"go-backend/internal/metrics"
|
||||||
"go-backend/internal/monitoring"
|
"go-backend/internal/monitoring"
|
||||||
runtimenft "go-backend/internal/runtime/nftables"
|
runtimenft "go-backend/internal/runtime/nftables"
|
||||||
@@ -42,10 +42,12 @@ type Handler struct {
|
|||||||
captchaMu sync.Mutex
|
captchaMu sync.Mutex
|
||||||
captchaTokens map[string]int64
|
captchaTokens map[string]int64
|
||||||
|
|
||||||
jobsMu sync.Mutex
|
jobsMu sync.Mutex
|
||||||
jobsCancel context.CancelFunc
|
jobsCancel context.CancelFunc
|
||||||
jobsStarted bool
|
jobsStarted bool
|
||||||
jobsWG sync.WaitGroup
|
jobsWG sync.WaitGroup
|
||||||
|
fingerprintMu sync.Mutex
|
||||||
|
licenseValidationMu sync.Mutex
|
||||||
|
|
||||||
upgradeMu sync.Mutex
|
upgradeMu sync.Mutex
|
||||||
systemUpgradeMu sync.Mutex
|
systemUpgradeMu sync.Mutex
|
||||||
@@ -400,6 +402,10 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||||
|
if configName == "license_key" || configName == "license_machine_id" || configName == "machine_fingerprint" {
|
||||||
|
response.WriteJSON(w, response.Err(403, "禁止访问系统授权凭据"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
|
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
|
||||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||||
return
|
return
|
||||||
@@ -435,11 +441,13 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
|
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
|
||||||
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
|
claims, isAdmin := ctxClaims.(auth.Claims)
|
||||||
delete(cfgMap, "license_key")
|
if !isAdmin || claims.RoleID != 0 {
|
||||||
delete(cfgMap, "cloudflare_secret_key")
|
cfgMap = repo.FilterSensitiveConfigs(cfgMap)
|
||||||
delete(cfgMap, "jwt_secret")
|
|
||||||
}
|
}
|
||||||
|
delete(cfgMap, "license_key")
|
||||||
|
delete(cfgMap, "license_machine_id")
|
||||||
|
delete(cfgMap, "machine_fingerprint")
|
||||||
response.WriteJSON(w, response.OK(cfgMap))
|
response.WriteJSON(w, response.OK(cfgMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,10 +886,16 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
|
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
|
||||||
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
h.fingerprintMu.Lock()
|
||||||
|
defer h.fingerprintMu.Unlock()
|
||||||
|
|
||||||
|
fp, err := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||||
if fp != "" {
|
if fp != "" {
|
||||||
return fp, nil
|
return fp, nil
|
||||||
}
|
}
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
newFp := uuid.New().String()
|
newFp := uuid.New().String()
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
@@ -908,56 +922,35 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.licenseValidationMu.Lock()
|
||||||
|
defer h.licenseValidationMu.Unlock()
|
||||||
|
|
||||||
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
valResp, err := h.validateLicenseForMachine(key)
|
||||||
|
|
||||||
fingerprint, err := h.getOrCreateMachineFingerprint()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
|
log.Printf("license activation failed: %v", err)
|
||||||
return
|
response.WriteJSON(w, response.ErrDefault(licenseValidationErrorMessage(err)))
|
||||||
}
|
|
||||||
|
|
||||||
client := license.NewKeygenClient(accountID, "")
|
|
||||||
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
|
||||||
if err != nil {
|
|
||||||
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !valResp.Meta.Valid {
|
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" {
|
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
|
||||||
// Needs machine activation
|
return
|
||||||
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()
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
expiry := valResp.Data.Attributes.Expiry
|
expiry := valResp.Data.Attributes.Expiry
|
||||||
if expiry == "" {
|
if expiry == "" {
|
||||||
expiry = "never"
|
expiry = "never"
|
||||||
}
|
}
|
||||||
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
|
licenseState := map[string]string{
|
||||||
|
"license_key": key,
|
||||||
|
"is_commercial": "true",
|
||||||
|
"license_expiry": expiry,
|
||||||
|
}
|
||||||
|
if valResp.MachineID != "" {
|
||||||
|
licenseState["license_machine_id"] = valResp.MachineID
|
||||||
|
}
|
||||||
|
if err := h.repo.UpsertConfigs(licenseState, now); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -999,6 +992,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if repo.IsSystemManagedConfigKey(key) {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if protectedKeys[key] && isCommercial != "true" {
|
if protectedKeys[key] && isCommercial != "true" {
|
||||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||||
@@ -1041,6 +1038,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if repo.IsSystemManagedConfigKey(name) {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/license"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var nftablesTrafficCollectInterval = 30 * time.Second
|
var nftablesTrafficCollectInterval = 30 * time.Second
|
||||||
@@ -38,6 +36,7 @@ func (h *Handler) StartBackgroundJobs() {
|
|||||||
|
|
||||||
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
|
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
|
||||||
defer h.jobsWG.Done()
|
defer h.jobsWG.Done()
|
||||||
|
h.validateLicenseJob()
|
||||||
ticker := time.NewTicker(12 * time.Hour)
|
ticker := time.NewTicker(12 * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -55,22 +54,25 @@ func (h *Handler) validateLicenseJob() {
|
|||||||
if h == nil || h.repo == nil {
|
if h == nil || h.repo == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.licenseValidationMu.Lock()
|
||||||
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
defer h.licenseValidationMu.Unlock()
|
||||||
|
|
||||||
key, _ := h.repo.GetViteConfigValue("license_key")
|
key, _ := h.repo.GetViteConfigValue("license_key")
|
||||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
|
||||||
|
|
||||||
if key == "" || isCommercial != "true" {
|
if key == "" {
|
||||||
return // Nothing to validate
|
return // Nothing to validate
|
||||||
}
|
}
|
||||||
|
|
||||||
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
valResp, err := h.validateLicenseForMachine(key)
|
||||||
client := license.NewKeygenClient(accountID, "")
|
|
||||||
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Network error or timeout. Grace period by not revoking immediately here.
|
// Network and decode failures have no validation response, so retain the
|
||||||
|
// current state as a grace period. A rejected machine binding still has
|
||||||
|
// the original invalid response and must not stay commercially enabled.
|
||||||
|
if licenseValidationErrorIsDefinitive(valResp, err) {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
_ = h.repo.UpsertConfig("is_commercial", "false", now)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,14 @@ func (h *Handler) validateLicenseJob() {
|
|||||||
if expiry == "" {
|
if expiry == "" {
|
||||||
expiry = "never"
|
expiry = "never"
|
||||||
}
|
}
|
||||||
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
|
licenseState := map[string]string{
|
||||||
|
"is_commercial": "true",
|
||||||
|
"license_expiry": expiry,
|
||||||
|
}
|
||||||
|
if valResp.MachineID != "" {
|
||||||
|
licenseState["license_machine_id"] = valResp.MachineID
|
||||||
|
}
|
||||||
|
_ = h.repo.UpsertConfigs(licenseState, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go-backend/internal/license"
|
||||||
|
)
|
||||||
|
|
||||||
|
var newLicenseClient = license.NewKeygenClient
|
||||||
|
|
||||||
|
func keygenAccountID() string {
|
||||||
|
if value := strings.TrimSpace(license.AccountID); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("KEYGEN_ACCOUNT_ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseNeedsMachineActivation(code string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||||
|
case "NO_MACHINES", "NO_MACHINE", "MACHINE_SCOPE_REQUIRED", "FINGERPRINT_SCOPE_MISMATCH":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseValidationErrorIsDefinitive(validation *license.ValidateResponse, err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return validation != nil && !validation.Meta.Valid
|
||||||
|
}
|
||||||
|
var apiErr *license.APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return apiErr.Operation == "activate machine" && validation != nil && !validation.Meta.Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseValidationErrorMessage(err error) string {
|
||||||
|
if strings.Contains(err.Error(), "keygen account id is not configured") {
|
||||||
|
return "授权服务配置错误"
|
||||||
|
}
|
||||||
|
var apiErr *license.APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
if apiErr.HasCode("MACHINE_LIMIT_EXCEEDED") {
|
||||||
|
return "授权设备数量已达上限"
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden {
|
||||||
|
return "授权码无效或无权绑定设备"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "连接授权服务器失败,请稍后重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) validateLicenseForMachine(key string) (*license.ValidateResponse, error) {
|
||||||
|
fingerprint, err := h.getOrCreateMachineFingerprint()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare machine fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
storedMachineID, _ := h.repo.GetViteConfigValue("license_machine_id")
|
||||||
|
|
||||||
|
accountID := keygenAccountID()
|
||||||
|
if accountID == "" {
|
||||||
|
return nil, fmt.Errorf("keygen account id is not configured")
|
||||||
|
}
|
||||||
|
client := newLicenseClient(accountID, "")
|
||||||
|
var validation *license.ValidateResponse
|
||||||
|
if storedMachineID != "" {
|
||||||
|
validation, err = client.ValidateKeyWithMachine(key, fingerprint, storedMachineID)
|
||||||
|
} else {
|
||||||
|
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if validation.Meta.Valid || !licenseNeedsMachineActivation(validation.Meta.Code) {
|
||||||
|
if validation.Meta.Valid {
|
||||||
|
validation.MachineID = storedMachineID
|
||||||
|
}
|
||||||
|
return validation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Token = key
|
||||||
|
machineID, err := client.ActivateMachine(validation.Data.ID, fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return validation, err
|
||||||
|
}
|
||||||
|
if machineID == "" {
|
||||||
|
machineID, err = client.GetMachineID(fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return validation, fmt.Errorf("retrieve activated machine: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validation, err = client.ValidateKeyWithMachine(key, fingerprint, machineID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if validation.Meta.Valid {
|
||||||
|
validation.MachineID = machineID
|
||||||
|
}
|
||||||
|
return validation, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/license"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||||
|
|
||||||
|
var validations atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
if validations.Add(1) == 1 {
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
case strings.Contains(req.URL.Path, "/machines/"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||||
|
fingerprint, err := r.GetViteConfigValue("machine_fingerprint")
|
||||||
|
if err != nil || strings.TrimSpace(fingerprint) == "" {
|
||||||
|
t.Fatalf("expected persisted machine fingerprint, got value=%q err=%v", fingerprint, err)
|
||||||
|
}
|
||||||
|
if got := validations.Load(); got != 2 {
|
||||||
|
t.Fatalf("validation calls = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLicenseJobAcceptsExistingMachineActivation(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||||
|
|
||||||
|
var validations atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
if validations.Add(1) == 1 {
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||||
|
case strings.Contains(req.URL.Path, "/machines/"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
assertLicenseConfig(t, r, "license_expiry", "never")
|
||||||
|
if got := validations.Load(); got != 2 {
|
||||||
|
t.Fatalf("validation calls = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
|
||||||
|
var validations atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
code := "NO_MACHINE"
|
||||||
|
if validations.Add(1) > 1 {
|
||||||
|
code = "FINGERPRINT_SCOPE_MISMATCH"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"meta":{"valid":false,"code":%q},"data":{"id":"license-id","attributes":{}}}`, code)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
case strings.Contains(req.URL.Path, "/machines/"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.licenseActivate(res, req)
|
||||||
|
|
||||||
|
if !strings.Contains(res.Body.String(), "FINGERPRINT_SCOPE_MISMATCH") {
|
||||||
|
t.Fatalf("expected post-activation validation failure, got %s", res.Body.String())
|
||||||
|
}
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "false")
|
||||||
|
for _, name := range []string{"license_key", "license_expiry"} {
|
||||||
|
if value, err := r.GetViteConfigValue(name); err == nil || value != "" {
|
||||||
|
t.Fatalf("%s should not be persisted, got value=%q err=%v", name, value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLicenseActivatePersistsValidatedState(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.licenseActivate(res, req)
|
||||||
|
|
||||||
|
if !strings.Contains(res.Body.String(), `"code":0`) {
|
||||||
|
t.Fatalf("expected activation success, got %s", res.Body.String())
|
||||||
|
}
|
||||||
|
assertLicenseConfig(t, r, "license_key", "license-secret")
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLicenseJobRestoresCommercialStateWhenLicenseRecovers(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "false", now)
|
||||||
|
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
assertLicenseConfig(t, r, "license_expiry", "never")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLicenseJobUsesStoredMachineScope(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||||
|
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||||
|
seedLicenseConfig(t, r, "license_machine_id", "machine-id", now)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if !strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
|
||||||
|
t.Fatalf("unexpected request %s %s", req.Method, req.URL.Path)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Meta struct {
|
||||||
|
Scope map[string]string `json:"scope"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if body.Meta.Scope["machine"] != "machine-id" || body.Meta.Scope["fingerprint"] != "fingerprint" {
|
||||||
|
t.Fatalf("unexpected validation scope: %+v", body.Meta.Scope)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
assertLicenseConfig(t, r, "license_machine_id", "machine-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLicenseJobKeepsStateOnMachineLookupServerFailure(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||||
|
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
|
||||||
|
case strings.Contains(req.URL.Path, "/machines/"):
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"SERVICE_UNAVAILABLE"}]}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLicenseJobKeepsStateOnMachineLookupNotFound(t *testing.T) {
|
||||||
|
r := openLicenseTestRepository(t)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||||
|
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||||
|
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
|
||||||
|
case strings.Contains(req.URL.Path, "/machines/"):
|
||||||
|
http.NotFound(w, req)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
restoreLicenseClientFactory(t, server.URL)
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.validateLicenseJob()
|
||||||
|
|
||||||
|
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLicenseValidationErrorMessageDoesNotExposeKeygenResponse(t *testing.T) {
|
||||||
|
err := &license.APIError{
|
||||||
|
Operation: "activate machine",
|
||||||
|
StatusCode: http.StatusUnprocessableEntity,
|
||||||
|
Body: `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED","detail":"private detail"}]}`,
|
||||||
|
}
|
||||||
|
message := licenseValidationErrorMessage(err)
|
||||||
|
if message != "授权设备数量已达上限" || strings.Contains(message, "private detail") {
|
||||||
|
t.Fatalf("unexpected public error message %q", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLicenseTestRepository(t *testing.T) *repo.Repository {
|
||||||
|
t.Helper()
|
||||||
|
r, err := repo.Open(filepath.Join(t.TempDir(), "license.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo.Open() error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedLicenseConfig(t *testing.T, r *repo.Repository, name, value string, now int64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := r.UpsertConfig(name, value, now); err != nil {
|
||||||
|
t.Fatalf("UpsertConfig(%q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLicenseConfig(t *testing.T, r *repo.Repository, name, want string) {
|
||||||
|
t.Helper()
|
||||||
|
got, err := r.GetViteConfigValue(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetViteConfigValue(%q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("config %q = %q, want %q", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreLicenseClientFactory(t *testing.T, baseURL string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("KEYGEN_ACCOUNT_ID", "account-id")
|
||||||
|
previous := newLicenseClient
|
||||||
|
newLicenseClient = func(accountID, token string) *license.KeygenClient {
|
||||||
|
client := license.NewKeygenClient(accountID, token)
|
||||||
|
client.BaseURL = baseURL
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newLicenseClient = previous })
|
||||||
|
}
|
||||||
@@ -182,6 +182,8 @@ func requiresAdmin(path string) bool {
|
|||||||
return true
|
return true
|
||||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||||
return true
|
return true
|
||||||
|
case "/api/v1/license/activate":
|
||||||
|
return true
|
||||||
case "/api/v1/announcement/update":
|
case "/api/v1/announcement/update":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -197,6 +197,39 @@ func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLicenseActivateRequiresAdmin(t *testing.T) {
|
||||||
|
if !requiresAdmin("/api/v1/license/activate") {
|
||||||
|
t.Fatal("expected license activation to require admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTRejectsNonAdminLicenseActivation(t *testing.T) {
|
||||||
|
secret := "unit-test-secret"
|
||||||
|
token, err := auth.GenerateToken(2, "regular_user", 1, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate token: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := auth.ParseClaims(token, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse claims: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped := JWT(AuthOptions{
|
||||||
|
JWTSecret: secret,
|
||||||
|
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||||
|
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
|
||||||
|
},
|
||||||
|
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
response.WriteJSON(w, response.OK("pass"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", nil)
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(res, req)
|
||||||
|
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
|
||||||
|
}
|
||||||
|
|
||||||
func TestJWTExpiresAfterSevenDays(t *testing.T) {
|
func TestJWTExpiresAfterSevenDays(t *testing.T) {
|
||||||
secret := "unit-test-secret"
|
secret := "unit-test-secret"
|
||||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
|||||||
@@ -6,24 +6,53 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var AccountID string
|
||||||
|
|
||||||
type KeygenClient struct {
|
type KeygenClient struct {
|
||||||
AccountID string
|
AccountID string
|
||||||
Token string
|
Token string
|
||||||
|
BaseURL string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
Operation string
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("keygen %s failed: status %d, response: %s", e.Operation, e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) HasCode(code string) bool {
|
||||||
|
return e != nil && hasKeygenErrorCode([]byte(e.Body), code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
|
||||||
|
|
||||||
func NewKeygenClient(accountID, token string) *KeygenClient {
|
func NewKeygenClient(accountID, token string) *KeygenClient {
|
||||||
return &KeygenClient{
|
return &KeygenClient{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
BaseURL: defaultAPIBaseURL,
|
||||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *KeygenClient) apiURL(path string) string {
|
||||||
|
baseURL := strings.TrimRight(c.BaseURL, "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultAPIBaseURL
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/accounts/%s/%s", baseURL, c.AccountID, strings.TrimLeft(path, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
type ValidateResponse struct {
|
type ValidateResponse struct {
|
||||||
Meta struct {
|
Meta struct {
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
@@ -35,6 +64,7 @@ type ValidateResponse struct {
|
|||||||
Expiry string `json:"expiry"`
|
Expiry string `json:"expiry"`
|
||||||
} `json:"attributes"`
|
} `json:"attributes"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
|
MachineID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivateMachineRequest struct {
|
type ActivateMachineRequest struct {
|
||||||
@@ -54,8 +84,37 @@ type ActivateMachineRequest struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type keygenErrorResponse struct {
|
||||||
|
Errors []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineResponse struct {
|
||||||
|
Data struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasKeygenErrorCode(body []byte, code string) bool {
|
||||||
|
var resp keygenErrorResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range resp.Errors {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(item.Code), code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
|
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)
|
return c.ValidateKeyWithMachine(key, fingerprint, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string) (*ValidateResponse, error) {
|
||||||
|
url := c.apiURL("licenses/actions/validate-key")
|
||||||
|
|
||||||
meta := map[string]interface{}{
|
meta := map[string]interface{}{
|
||||||
"key": key,
|
"key": key,
|
||||||
@@ -66,6 +125,14 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
|||||||
"fingerprint": fingerprint,
|
"fingerprint": fingerprint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if machineID != "" {
|
||||||
|
scope, _ := meta["scope"].(map[string]interface{})
|
||||||
|
if scope == nil {
|
||||||
|
scope = make(map[string]interface{})
|
||||||
|
meta["scope"] = scope
|
||||||
|
}
|
||||||
|
scope["machine"] = machineID
|
||||||
|
}
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]interface{}{
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
@@ -91,7 +158,8 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var valResp ValidateResponse
|
var valResp ValidateResponse
|
||||||
@@ -102,8 +170,44 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
|||||||
return &valResp, nil
|
return &valResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *KeygenClient) GetMachineID(fingerprint string) (string, error) {
|
||||||
|
machineURL := c.apiURL("machines/" + url.PathEscape(fingerprint))
|
||||||
|
req, err := http.NewRequest(http.MethodGet, machineURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.api+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.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", &APIError{Operation: "retrieve machine", StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var machineResp MachineResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&machineResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
machineID := strings.TrimSpace(machineResp.Data.ID)
|
||||||
|
if machineID == "" {
|
||||||
|
return "", fmt.Errorf("failed to retrieve machine: empty machine id")
|
||||||
|
}
|
||||||
|
return machineID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
|
url := c.apiURL("licenses/actions/validate-key")
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]interface{}{
|
||||||
"meta": map[string]string{
|
"meta": map[string]string{
|
||||||
@@ -130,7 +234,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var valResp ValidateResponse
|
var valResp ValidateResponse
|
||||||
@@ -141,8 +246,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
|||||||
return &valResp, nil
|
return &valResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, error) {
|
||||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
|
url := c.apiURL("machines")
|
||||||
|
|
||||||
var reqBody ActivateMachineRequest
|
var reqBody ActivateMachineRequest
|
||||||
reqBody.Data.Type = "machines"
|
reqBody.Data.Type = "machines"
|
||||||
@@ -165,23 +270,24 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
|||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||||
return nil
|
var machineResp MachineResponse
|
||||||
}
|
if json.Unmarshal(body, &machineResp) == nil {
|
||||||
|
return strings.TrimSpace(machineResp.Data.ID), 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 "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
|
if resp.StatusCode == http.StatusUnprocessableEntity && hasKeygenErrorCode(body, "FINGERPRINT_TAKEN") {
|
||||||
}
|
// Machine activation is idempotent. Keygen scopes fingerprint uniqueness
|
||||||
|
// to the target license, so this means the same machine is already bound.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", &APIError{Operation: "activate machine", StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateKeyWithMachineSendsFingerprintAndMachineScopes(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Meta struct {
|
||||||
|
Scope map[string]string `json:"scope"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if body.Meta.Scope["fingerprint"] != "fingerprint" {
|
||||||
|
t.Fatalf("fingerprint scope = %q", body.Meta.Scope["fingerprint"])
|
||||||
|
}
|
||||||
|
if body.Meta.Scope["machine"] != "machine-id" {
|
||||||
|
t.Fatalf("machine scope = %q", body.Meta.Scope["machine"])
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{}}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewKeygenClient("account-id", "")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
validation, err := client.ValidateKeyWithMachine("license-key", "fingerprint", "machine-id")
|
||||||
|
if err != nil || !validation.Meta.Valid {
|
||||||
|
t.Fatalf("ValidateKeyWithMachine() validation=%+v err=%v", validation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMachineIDRetrievesMachineByFingerprint(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/machines/fingerprint") {
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "License license-key" {
|
||||||
|
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewKeygenClient("account-id", "license-key")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
machineID, err := client.GetMachineID("fingerprint")
|
||||||
|
if err != nil || machineID != "machine-id" {
|
||||||
|
t.Fatalf("GetMachineID() = %q, %v", machineID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewKeygenClient("account-id", "license-key")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
|
||||||
|
if machineID, err := client.ActivateMachine("license-id", "fingerprint"); err != nil || machineID != "" {
|
||||||
|
t.Fatalf("ActivateMachine() error = %v, want idempotent success", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivateMachineReturnsCreatedMachineID(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewKeygenClient("account-id", "license-key")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
machineID, err := client.ActivateMachine("license-id", "fingerprint")
|
||||||
|
if err != nil || machineID != "machine-id" {
|
||||||
|
t.Fatalf("ActivateMachine() = %q, %v", machineID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewKeygenClient("account-id", "license-key")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
|
||||||
|
_, err := client.ActivateMachine("license-id", "fingerprint")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "MACHINE_LIMIT_EXCEEDED") {
|
||||||
|
t.Fatalf("ActivateMachine() error = %v, want machine limit failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,27 @@ var publicConfigKeys = map[string]struct{}{
|
|||||||
"app_favicon": {},
|
"app_favicon": {},
|
||||||
"app_bg_image": {},
|
"app_bg_image": {},
|
||||||
"cloudflare_site_key": {},
|
"cloudflare_site_key": {},
|
||||||
|
"is_commercial": {},
|
||||||
|
"hide_footer_brand": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var sensitiveConfigKeys = map[string]struct{}{
|
var sensitiveConfigKeys = map[string]struct{}{
|
||||||
"jwt_secret": {},
|
"jwt_secret": {},
|
||||||
"license_key": {},
|
"license_key": {},
|
||||||
|
"license_expiry": {},
|
||||||
|
"license_machine_id": {},
|
||||||
|
"machine_fingerprint": {},
|
||||||
"cloudflare_secret_key": {},
|
"cloudflare_secret_key": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var systemManagedConfigKeys = map[string]struct{}{
|
||||||
|
"license_key": {},
|
||||||
|
"license_expiry": {},
|
||||||
|
"license_machine_id": {},
|
||||||
|
"is_commercial": {},
|
||||||
|
"machine_fingerprint": {},
|
||||||
|
}
|
||||||
|
|
||||||
func PolicyForConfig(name string) ConfigAccessPolicy {
|
func PolicyForConfig(name string) ConfigAccessPolicy {
|
||||||
if IsPublicConfigKey(name) {
|
if IsPublicConfigKey(name) {
|
||||||
return ConfigAccessPublic
|
return ConfigAccessPublic
|
||||||
@@ -43,6 +56,11 @@ func IsSensitiveConfigKey(name string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsSystemManagedConfigKey(name string) bool {
|
||||||
|
_, ok := systemManagedConfigKeys[normalizeConfigKey(name)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return map[string]string{}
|
return map[string]string{}
|
||||||
@@ -57,6 +75,20 @@ func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FilterBackupConfigs(in map[string]string) map[string]string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(in))
|
||||||
|
for name, value := range in {
|
||||||
|
if IsSensitiveConfigKey(name) || IsSystemManagedConfigKey(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[name] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeConfigKey(name string) string {
|
func normalizeConfigKey(name string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(name))
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ func TestConfigPolicy(t *testing.T) {
|
|||||||
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
|
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
|
||||||
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
|
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
|
||||||
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
|
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
|
||||||
|
{name: "is_commercial is public", key: "is_commercial", want: ConfigAccessPublic},
|
||||||
|
{name: "hide_footer_brand is public", key: "hide_footer_brand", want: ConfigAccessPublic},
|
||||||
{name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive},
|
{name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive},
|
||||||
{name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive},
|
{name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive},
|
||||||
{name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive},
|
{name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive},
|
||||||
@@ -29,14 +31,14 @@ func TestConfigPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigPolicyHelpers(t *testing.T) {
|
func TestConfigPolicyHelpers(t *testing.T) {
|
||||||
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key"}
|
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key", "is_commercial", "hide_footer_brand"}
|
||||||
for _, key := range publicKeys {
|
for _, key := range publicKeys {
|
||||||
if !IsPublicConfigKey(key) {
|
if !IsPublicConfigKey(key) {
|
||||||
t.Fatalf("expected %q to be public", key)
|
t.Fatalf("expected %q to be public", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sensitiveKeys := []string{"jwt_secret", "license_key", "cloudflare_secret_key"}
|
sensitiveKeys := []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "machine_fingerprint", "cloudflare_secret_key"}
|
||||||
for _, key := range sensitiveKeys {
|
for _, key := range sensitiveKeys {
|
||||||
if !IsSensitiveConfigKey(key) {
|
if !IsSensitiveConfigKey(key) {
|
||||||
t.Fatalf("expected %q to be sensitive", key)
|
t.Fatalf("expected %q to be sensitive", key)
|
||||||
@@ -67,3 +69,30 @@ func TestConfigPolicyHelpers(t *testing.T) {
|
|||||||
t.Fatal("expected cloudflare_secret_key to be filtered out")
|
t.Fatal("expected cloudflare_secret_key to be filtered out")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterBackupConfigsOmitsLicenseState(t *testing.T) {
|
||||||
|
filtered := FilterBackupConfigs(map[string]string{
|
||||||
|
"app_name": "Brand",
|
||||||
|
"license_key": "secret-license",
|
||||||
|
"license_expiry": "never",
|
||||||
|
"license_machine_id": "machine-id",
|
||||||
|
"is_commercial": "true",
|
||||||
|
"machine_fingerprint": "fingerprint",
|
||||||
|
})
|
||||||
|
if len(filtered) != 1 || filtered["app_name"] != "Brand" {
|
||||||
|
t.Fatalf("unexpected backup configs: %+v", filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemManagedConfigKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{"license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint"} {
|
||||||
|
if !IsSystemManagedConfigKey(key) {
|
||||||
|
t.Fatalf("expected %s to be system managed", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"jwt_secret", "cloudflare_secret_key", "app_name"} {
|
||||||
|
if IsSystemManagedConfigKey(key) {
|
||||||
|
t.Fatalf("did not expect %s to be system managed", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -473,6 +473,14 @@ func seedData(db *gorm.DB) {
|
|||||||
|
|
||||||
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
||||||
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"is_commercial": "false",
|
||||||
|
"hide_footer_brand": "false",
|
||||||
|
} {
|
||||||
|
cfg := model.ViteConfig{Name: name, Value: value, Time: now}
|
||||||
|
db.Where("name = ?", name).FirstOrCreate(&cfg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User Queries ────────────────────────────────────────────────────
|
// ─── User Queries ────────────────────────────────────────────────────
|
||||||
@@ -600,6 +608,23 @@ func (r *Repository) UpsertConfig(name, value string, now int64) error {
|
|||||||
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error
|
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpsertConfigs(values map[string]string, now int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for name, value := range values {
|
||||||
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "name"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"value", "time"}),
|
||||||
|
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Announcement Queries ────────────────────────────────────────────
|
// ─── Announcement Queries ────────────────────────────────────────────
|
||||||
|
|
||||||
func (r *Repository) GetAnnouncement() (*model.Announcement, error) {
|
func (r *Repository) GetAnnouncement() (*model.Announcement, error) {
|
||||||
@@ -1967,7 +1992,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||||
}
|
}
|
||||||
backup.Configs = FilterSensitiveConfigs(configs)
|
backup.Configs = FilterBackupConfigs(configs)
|
||||||
|
|
||||||
return backup, nil
|
return backup, nil
|
||||||
}
|
}
|
||||||
@@ -2047,7 +2072,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||||
}
|
}
|
||||||
backup.Configs = FilterSensitiveConfigs(v)
|
backup.Configs = FilterBackupConfigs(v)
|
||||||
}
|
}
|
||||||
return backup, nil
|
return backup, nil
|
||||||
}
|
}
|
||||||
@@ -2839,7 +2864,7 @@ func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
|
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
|
||||||
configs = FilterSensitiveConfigs(configs)
|
configs = FilterBackupConfigs(configs)
|
||||||
count := 0
|
count := 0
|
||||||
for name, value := range configs {
|
for name, value := range configs {
|
||||||
err := tx.Clauses(clause.OnConflict{
|
err := tx.Clauses(clause.OnConflict{
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
|||||||
seedConfig(t, r, "cloudflare_site_key", "site-key")
|
seedConfig(t, r, "cloudflare_site_key", "site-key")
|
||||||
seedConfig(t, r, "jwt_secret", "jwt-secret")
|
seedConfig(t, r, "jwt_secret", "jwt-secret")
|
||||||
seedConfig(t, r, "license_key", "license-secret")
|
seedConfig(t, r, "license_key", "license-secret")
|
||||||
|
seedConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||||
|
seedConfig(t, r, "license_machine_id", "machine-id")
|
||||||
|
seedConfig(t, r, "is_commercial", "true")
|
||||||
|
seedConfig(t, r, "machine_fingerprint", "machine-fingerprint")
|
||||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret")
|
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret")
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -117,7 +121,7 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
|||||||
if backup.Configs["cloudflare_site_key"] != "site-key" {
|
if backup.Configs["cloudflare_site_key"] != "site-key" {
|
||||||
t.Fatalf("expected public config in export, got %+v", backup.Configs)
|
t.Fatalf("expected public config in export, got %+v", backup.Configs)
|
||||||
}
|
}
|
||||||
for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} {
|
for _, key := range []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint", "cloudflare_secret_key"} {
|
||||||
if _, ok := backup.Configs[key]; ok {
|
if _, ok := backup.Configs[key]; ok {
|
||||||
t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs)
|
t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs)
|
||||||
}
|
}
|
||||||
@@ -136,12 +140,20 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
|||||||
seedConfig(t, r, "app_name", "before")
|
seedConfig(t, r, "app_name", "before")
|
||||||
seedConfig(t, r, "jwt_secret", "jwt-before")
|
seedConfig(t, r, "jwt_secret", "jwt-before")
|
||||||
seedConfig(t, r, "license_key", "license-before")
|
seedConfig(t, r, "license_key", "license-before")
|
||||||
|
seedConfig(t, r, "license_expiry", "expiry-before")
|
||||||
|
seedConfig(t, r, "license_machine_id", "machine-before")
|
||||||
|
seedConfig(t, r, "is_commercial", "true")
|
||||||
|
seedConfig(t, r, "machine_fingerprint", "fingerprint-before")
|
||||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before")
|
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||||
|
|
||||||
backup := &model.BackupData{Configs: map[string]string{
|
backup := &model.BackupData{Configs: map[string]string{
|
||||||
"app_name": "after",
|
"app_name": "after",
|
||||||
"jwt_secret": "jwt-after",
|
"jwt_secret": "jwt-after",
|
||||||
"license_key": "license-after",
|
"license_key": "license-after",
|
||||||
|
"license_expiry": "expiry-after",
|
||||||
|
"license_machine_id": "machine-after",
|
||||||
|
"is_commercial": "false",
|
||||||
|
"machine_fingerprint": "fingerprint-after",
|
||||||
"cloudflare_secret_key": "cloudflare-after",
|
"cloudflare_secret_key": "cloudflare-after",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -156,6 +168,10 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
|||||||
assertConfigValue(t, r, "app_name", "after")
|
assertConfigValue(t, r, "app_name", "after")
|
||||||
assertConfigValue(t, r, "jwt_secret", "jwt-before")
|
assertConfigValue(t, r, "jwt_secret", "jwt-before")
|
||||||
assertConfigValue(t, r, "license_key", "license-before")
|
assertConfigValue(t, r, "license_key", "license-before")
|
||||||
|
assertConfigValue(t, r, "license_expiry", "expiry-before")
|
||||||
|
assertConfigValue(t, r, "license_machine_id", "machine-before")
|
||||||
|
assertConfigValue(t, r, "is_commercial", "true")
|
||||||
|
assertConfigValue(t, r, "machine_fingerprint", "fingerprint-before")
|
||||||
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-5
@@ -64,6 +64,38 @@ SERVICE_MANAGER="${SERVICE_MANAGER:-}"
|
|||||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||||
PROXY_URL="${PROXY_URL:-}"
|
PROXY_URL="${PROXY_URL:-}"
|
||||||
|
|
||||||
|
ensure_alpine_runtime_dependencies() {
|
||||||
|
[[ -f /etc/alpine-release ]] || return 0
|
||||||
|
|
||||||
|
local missing_packages=()
|
||||||
|
local privileged_command=""
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || missing_packages+=(curl)
|
||||||
|
[[ -f /etc/ssl/certs/ca-certificates.crt ]] || missing_packages+=(ca-certificates)
|
||||||
|
|
||||||
|
if [[ ${#missing_packages[@]} -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
privileged_command="sudo"
|
||||||
|
elif command -v doas >/dev/null 2>&1; then
|
||||||
|
privileged_command="doas"
|
||||||
|
else
|
||||||
|
echo "❌ Alpine 安装需要 root 权限,或已配置 sudo/doas 来安装依赖: ${missing_packages[*]}。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Alpine 缺少运行依赖,正在安装: ${missing_packages[*]}"
|
||||||
|
if [[ -n "$privileged_command" ]]; then
|
||||||
|
"$privileged_command" apk add --no-cache "${missing_packages[@]}"
|
||||||
|
else
|
||||||
|
apk add --no-cache "${missing_packages[@]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# 镜像加速
|
# 镜像加速
|
||||||
maybe_proxy_url() {
|
maybe_proxy_url() {
|
||||||
local url="$1"
|
local url="$1"
|
||||||
@@ -169,6 +201,8 @@ build_download_url() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_download_url_initialized() {
|
ensure_download_url_initialized() {
|
||||||
|
ensure_alpine_runtime_dependencies || return 1
|
||||||
|
|
||||||
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
|
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -299,11 +333,16 @@ ensure_service_manager() {
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
|
# Alpine uses OpenRC even if a systemctl compatibility command happens to be installed.
|
||||||
|
if [[ -f /etc/alpine-release ]]; then
|
||||||
|
if command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
|
||||||
|
SERVICE_MANAGER="openrc"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
elif command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
|
||||||
SERVICE_MANAGER="systemd"
|
SERVICE_MANAGER="systemd"
|
||||||
return 0
|
return 0
|
||||||
fi
|
elif command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
|
||||||
if command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
|
|
||||||
SERVICE_MANAGER="openrc"
|
SERVICE_MANAGER="openrc"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -501,7 +540,8 @@ cleanup_legacy_gost_installation() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
|
if [[ "$SERVICE_MANAGER" == "systemd" ]] && \
|
||||||
|
systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
|
||||||
systemctl stop gost 2>/dev/null || true
|
systemctl stop gost 2>/dev/null || true
|
||||||
systemctl disable gost 2>/dev/null || true
|
systemctl disable gost 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
@@ -520,7 +560,7 @@ cleanup_legacy_gost_installation() {
|
|||||||
rm -f "$LEGACY_GOST_CONFIG_DIR/gost"
|
rm -f "$LEGACY_GOST_CONFIG_DIR/gost"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$removed_service_file" == "1" ]]; then
|
if [[ "$removed_service_file" == "1" && "$SERVICE_MANAGER" == "systemd" ]]; then
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ test_cleanup_legacy_gost_installation_removes_service_and_binary() (
|
|||||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||||
|
SERVICE_MANAGER="systemd"
|
||||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
|
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Gost Proxy Service
|
Description=Gost Proxy Service
|
||||||
@@ -442,6 +443,7 @@ test_cleanup_legacy_gost_installation_preserves_unrelated_gost() (
|
|||||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||||
|
SERVICE_MANAGER="systemd"
|
||||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF'
|
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Unrelated Gost Service
|
Description=Unrelated Gost Service
|
||||||
@@ -469,6 +471,35 @@ EOF
|
|||||||
[[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services"
|
[[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_cleanup_legacy_gost_installation_skips_systemd_on_openrc() (
|
||||||
|
set -euo pipefail
|
||||||
|
load_script_without_main "$ROOT_DIR/install.sh"
|
||||||
|
|
||||||
|
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
|
||||||
|
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||||
|
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||||
|
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||||
|
SERVICE_MANAGER="openrc"
|
||||||
|
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
|
||||||
|
[Unit]
|
||||||
|
WorkingDirectory=$LEGACY_GOST_CONFIG_DIR
|
||||||
|
ExecStart=$LEGACY_GOST_CONFIG_DIR/gost
|
||||||
|
EOF
|
||||||
|
: > "$LEGACY_GOST_CONFIG_DIR/config.json"
|
||||||
|
: > "$LEGACY_GOST_CONFIG_DIR/gost.json"
|
||||||
|
|
||||||
|
local systemctl_calls=""
|
||||||
|
systemctl() {
|
||||||
|
systemctl_calls+=$'\n'"$*"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_legacy_gost_installation >/dev/null
|
||||||
|
|
||||||
|
[[ -z "$systemctl_calls" ]] || fail "OpenRC cleanup should not invoke systemctl"
|
||||||
|
[[ ! -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]] || fail "OpenRC cleanup should still remove the legacy service file"
|
||||||
|
)
|
||||||
|
|
||||||
test_install_script_accepts_proxy_url_env_without_prompt() (
|
test_install_script_accepts_proxy_url_env_without_prompt() (
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
load_script_without_main "$ROOT_DIR/install.sh"
|
load_script_without_main "$ROOT_DIR/install.sh"
|
||||||
@@ -625,6 +656,7 @@ test_install_flux_agent_uses_openrc
|
|||||||
test_remove_flux_agent_service_uses_openrc
|
test_remove_flux_agent_service_uses_openrc
|
||||||
test_cleanup_legacy_gost_installation_removes_service_and_binary
|
test_cleanup_legacy_gost_installation_removes_service_and_binary
|
||||||
test_cleanup_legacy_gost_installation_preserves_unrelated_gost
|
test_cleanup_legacy_gost_installation_preserves_unrelated_gost
|
||||||
|
test_cleanup_legacy_gost_installation_skips_systemd_on_openrc
|
||||||
test_install_script_accepts_proxy_url_env_without_prompt
|
test_install_script_accepts_proxy_url_env_without_prompt
|
||||||
test_panel_install_script_can_disable_proxy
|
test_panel_install_script_can_disable_proxy
|
||||||
test_panel_install_script_recomputes_compose_urls_after_prompt
|
test_panel_install_script_recomputes_compose_urls_after_prompt
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ function DialogClose({
|
|||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
const DialogOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/30 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/30 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className,
|
className,
|
||||||
@@ -42,7 +43,9 @@ function DialogOverlay({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ const PUBLIC_BRAND_CONFIG_KEYS = [
|
|||||||
"app_logo",
|
"app_logo",
|
||||||
"app_favicon",
|
"app_favicon",
|
||||||
"app_bg_image",
|
"app_bg_image",
|
||||||
|
"is_commercial",
|
||||||
|
"hide_footer_brand",
|
||||||
] as const;
|
] as const;
|
||||||
const SENSITIVE_CONFIG_KEYS = new Set([
|
const SENSITIVE_CONFIG_KEYS = new Set([
|
||||||
"jwt_secret",
|
"jwt_secret",
|
||||||
"license_key",
|
"license_key",
|
||||||
|
"license_machine_id",
|
||||||
|
"machine_fingerprint",
|
||||||
"cloudflare_secret_key",
|
"cloudflare_secret_key",
|
||||||
]);
|
]);
|
||||||
const GITHUB_REPO =
|
const GITHUB_REPO =
|
||||||
@@ -49,6 +53,30 @@ const readCachedConfigs = (keys: readonly string[]) => {
|
|||||||
return { cachedConfigs, hasCachedData };
|
return { cachedConfigs, hasCachedData };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readAllCachedSafeConfigs = () => {
|
||||||
|
const cachedConfigs: Record<string, string> = {};
|
||||||
|
|
||||||
|
Object.keys(localStorage).forEach((storageKey) => {
|
||||||
|
if (!storageKey.startsWith(CACHE_PREFIX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = storageKey.slice(CACHE_PREFIX.length).trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!key || SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (value !== null) {
|
||||||
|
cachedConfigs[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedConfigs;
|
||||||
|
};
|
||||||
|
|
||||||
const fetchPublicBrandConfigs = async (): Promise<Record<string, string>> => {
|
const fetchPublicBrandConfigs = async (): Promise<Record<string, string>> => {
|
||||||
const publicConfigMap: Record<string, string> = {};
|
const publicConfigMap: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -106,15 +134,15 @@ const getInitialConfig = () => {
|
|||||||
|
|
||||||
if (cachedAppName) {
|
if (cachedAppName) {
|
||||||
return {
|
return {
|
||||||
name: cachedAppName,
|
name: isCommercial ? cachedAppName : "FLVX",
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
github_repo: GITHUB_REPO,
|
github_repo: GITHUB_REPO,
|
||||||
app_logo: cachedAppLogo,
|
app_logo: isCommercial ? cachedAppLogo : "",
|
||||||
app_favicon: cachedAppFavicon,
|
app_favicon: isCommercial ? cachedAppFavicon : "",
|
||||||
app_bg_image: cachedAppBgImage,
|
app_bg_image: cachedAppBgImage,
|
||||||
is_commercial: isCommercial,
|
is_commercial: isCommercial,
|
||||||
hide_footer_brand: hideFooterBrand,
|
hide_footer_brand: isCommercial && hideFooterBrand,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,11 +151,11 @@ const getInitialConfig = () => {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
github_repo: GITHUB_REPO,
|
github_repo: GITHUB_REPO,
|
||||||
app_logo: cachedAppLogo,
|
app_logo: isCommercial ? cachedAppLogo : "",
|
||||||
app_favicon: cachedAppFavicon,
|
app_favicon: isCommercial ? cachedAppFavicon : "",
|
||||||
app_bg_image: cachedAppBgImage,
|
app_bg_image: cachedAppBgImage,
|
||||||
is_commercial: isCommercial,
|
is_commercial: isCommercial,
|
||||||
hide_footer_brand: hideFooterBrand,
|
hide_footer_brand: isCommercial && hideFooterBrand,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,20 +234,24 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
|||||||
|
|
||||||
// 获取所有配置(优先从缓存)
|
// 获取所有配置(优先从缓存)
|
||||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||||
const { cachedConfigs, hasCachedData } = readCachedConfigs(
|
const {
|
||||||
PUBLIC_BRAND_CONFIG_KEYS,
|
cachedConfigs: publicCachedConfigs,
|
||||||
);
|
hasCachedData: hasPublicCachedData,
|
||||||
|
} = readCachedConfigs(PUBLIC_BRAND_CONFIG_KEYS);
|
||||||
|
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
const publicConfigs = await fetchPublicBrandConfigs();
|
const publicConfigs = await fetchPublicBrandConfigs();
|
||||||
|
|
||||||
if (Object.keys(publicConfigs).length > 0) {
|
if (Object.keys(publicConfigs).length > 0) {
|
||||||
return { ...cachedConfigs, ...publicConfigs };
|
return { ...publicCachedConfigs, ...publicConfigs };
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedConfigs;
|
return publicCachedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedConfigs = readAllCachedSafeConfigs();
|
||||||
|
const hasCachedData = Object.keys(cachedConfigs).length > 0;
|
||||||
|
|
||||||
// 从API获取最新配置
|
// 从API获取最新配置
|
||||||
try {
|
try {
|
||||||
const response = await getConfigs();
|
const response = await getConfigs();
|
||||||
@@ -249,14 +281,20 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
|||||||
return cachedConfigs;
|
return cachedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchPublicBrandConfigs();
|
const publicConfigs = await fetchPublicBrandConfigs();
|
||||||
|
|
||||||
|
return { ...publicCachedConfigs, ...publicConfigs };
|
||||||
} catch {
|
} catch {
|
||||||
// API失败时返回缓存的数据
|
// API失败时返回缓存的数据
|
||||||
if (hasCachedData) {
|
if (hasCachedData) {
|
||||||
return cachedConfigs;
|
return cachedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchPublicBrandConfigs();
|
const publicConfigs = await fetchPublicBrandConfigs();
|
||||||
|
|
||||||
|
return hasPublicCachedData
|
||||||
|
? { ...publicCachedConfigs, ...publicConfigs }
|
||||||
|
: publicConfigs;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,6 +383,12 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
|||||||
"app_bg_image",
|
"app_bg_image",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolvedCommercial = Object.prototype.hasOwnProperty.call(
|
||||||
|
resolvedConfigMap,
|
||||||
|
"is_commercial",
|
||||||
|
)
|
||||||
|
? resolvedConfigMap.is_commercial === "true"
|
||||||
|
: siteConfig.is_commercial;
|
||||||
const appName = hasAppName
|
const appName = hasAppName
|
||||||
? String(resolvedConfigMap.app_name || "").trim()
|
? String(resolvedConfigMap.app_name || "").trim()
|
||||||
: siteConfig.name;
|
: siteConfig.name;
|
||||||
@@ -358,15 +402,23 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
|||||||
? String(resolvedConfigMap.app_bg_image || "").trim()
|
? String(resolvedConfigMap.app_bg_image || "").trim()
|
||||||
: (siteConfig.app_bg_image || "").trim();
|
: (siteConfig.app_bg_image || "").trim();
|
||||||
|
|
||||||
if (appName && appName !== siteConfig.name) {
|
siteConfig.name = resolvedCommercial && appName ? appName : "FLVX";
|
||||||
siteConfig.name = appName;
|
siteConfig.app_logo = resolvedCommercial ? appLogo : "";
|
||||||
}
|
siteConfig.app_favicon = resolvedCommercial ? appFavicon : "";
|
||||||
|
|
||||||
siteConfig.app_logo = appLogo;
|
|
||||||
siteConfig.app_favicon = appFavicon;
|
|
||||||
siteConfig.app_bg_image = appBgImage;
|
siteConfig.app_bg_image = appBgImage;
|
||||||
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
|
if (
|
||||||
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
|
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial")
|
||||||
|
) {
|
||||||
|
siteConfig.is_commercial = resolvedCommercial;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "hide_footer_brand")
|
||||||
|
) {
|
||||||
|
siteConfig.hide_footer_brand =
|
||||||
|
resolvedCommercial && resolvedConfigMap.hide_footer_brand === "true";
|
||||||
|
} else if (!resolvedCommercial) {
|
||||||
|
siteConfig.hide_footer_brand = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.title = siteConfig.name;
|
document.title = siteConfig.name;
|
||||||
|
|||||||
@@ -266,6 +266,9 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
"github_proxy_enabled",
|
"github_proxy_enabled",
|
||||||
"github_proxy_url",
|
"github_proxy_url",
|
||||||
"allow_local_remote_addr",
|
"allow_local_remote_addr",
|
||||||
|
"is_commercial",
|
||||||
|
"license_expiry",
|
||||||
|
"hide_footer_brand",
|
||||||
];
|
];
|
||||||
const initialConfigs: Record<string, string> = {};
|
const initialConfigs: Record<string, string> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,41 @@ function useModalContext() {
|
|||||||
return React.useContext(ModalContext);
|
return React.useContext(ModalContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScrollPosition {
|
||||||
|
element: HTMLElement | null;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureScrollPositions(): ScrollPosition[] {
|
||||||
|
const positions: ScrollPosition[] = [
|
||||||
|
{ element: null, left: window.scrollX, top: window.scrollY },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const element of Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>("main, [data-scroll-container]"),
|
||||||
|
)) {
|
||||||
|
positions.push({
|
||||||
|
element,
|
||||||
|
left: element.scrollLeft,
|
||||||
|
top: element.scrollTop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreScrollPositions(positions: ScrollPosition[]) {
|
||||||
|
for (const position of positions) {
|
||||||
|
if (position.element) {
|
||||||
|
position.element.scrollLeft = position.left;
|
||||||
|
position.element.scrollTop = position.top;
|
||||||
|
} else {
|
||||||
|
window.scrollTo(position.left, position.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
|
type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
|
||||||
|
|
||||||
function mapSize(size: ModalSize | undefined) {
|
function mapSize(size: ModalSize | undefined) {
|
||||||
@@ -97,6 +132,46 @@ export function Modal({
|
|||||||
scrollBehavior,
|
scrollBehavior,
|
||||||
size,
|
size,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
|
const previousScrollPositionsRef = React.useRef<ScrollPosition[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Radix focus management and scroll locking can move an ancestor scroll
|
||||||
|
// container when a modal is opened from a card/grid item. Capture the
|
||||||
|
// current positions before the open render and restore them after focus
|
||||||
|
// settles so opening a modal never changes the page position.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!isOpen) {
|
||||||
|
previousScrollPositionsRef.current = captureScrollPositions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const positions = previousScrollPositionsRef.current;
|
||||||
|
|
||||||
|
if (!isOpen || !positions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreScrollPositions(positions);
|
||||||
|
let nestedFrame = 0;
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
restoreScrollPositions(positions);
|
||||||
|
nestedFrame = window.requestAnimationFrame(() =>
|
||||||
|
restoreScrollPositions(positions),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
previousScrollPositionsRef.current = null;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.cancelAnimationFrame(nestedFrame);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
onOpenChange?.(open);
|
onOpenChange?.(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|||||||
Reference in New Issue
Block a user