feat: complete unified plugin admin v1.1.0
This commit is contained in:
@@ -14,3 +14,8 @@ PLUGIN_COOKIE_SAMESITE=lax
|
||||
PLUGIN_FRAME_ANCESTORS='self'
|
||||
# Set true only when the immediate reverse proxy is trusted and supplies XFF.
|
||||
PLUGIN_TRUST_PROXY=false
|
||||
# Development-only compatibility endpoints. Keep false in production; the
|
||||
# unified Plugin Admin owns login, sessions and subscription BFF routes.
|
||||
# Compatibility login/API is accepted only with PLUGIN_ENV=development and a
|
||||
# loopback PLUGIN_HOST. Production should leave this false.
|
||||
PLUGIN_STANDALONE_AUTH=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sub2API Subscription Admin Business Plugin V1
|
||||
|
||||
这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。
|
||||
这是一个可独立运行后端的管理员只读订阅业务模块,不是现有 `.s2plugin` transport 插件,也不是插件管理控制面。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。浏览器前端由 `plugin-admin` 统一 TDesign 控制面承载,订阅模块不提供第二个登录页或 Cookie。
|
||||
|
||||
生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
|
||||
|
||||
@@ -13,12 +13,12 @@ PLUGIN_PORT=8091 \
|
||||
go run .
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:8091/admin/`。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。挂载到子路径时同时设置 `PLUGIN_PUBLIC_BASE_PATH` 和 `PLUGIN_COOKIE_PATH`,例如 `/extensions/qiu.subscription-admin`。
|
||||
本地后端可打开 `http://127.0.0.1:8091/healthz` 进行服务契约调试;生产浏览器入口应从 Plugin Admin 的订阅模块路由进入。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。默认情况下本服务只暴露健康检查、就绪检查和交接页,不暴露第二套登录、Cookie 或 Core 数据 API。`PLUGIN_STANDALONE_AUTH=true` 仅在 `PLUGIN_ENV=development` 且监听地址为 loopback 时生效,生产必须保持关闭。
|
||||
|
||||
## V1 范围
|
||||
|
||||
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
|
||||
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。
|
||||
- 由 Plugin Admin 统一完成 Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
|
||||
- 订阅模块复用控制面的 HttpOnly、SameSite 会话和写请求 CSRF 校验,不创建模块级登录会话。
|
||||
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
|
||||
- 只读套餐、订阅列表、订阅详情和插件操作记录。
|
||||
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
|
||||
@@ -28,7 +28,7 @@ go run .
|
||||
|
||||
## Core API allowlist
|
||||
|
||||
插件服务端仅调用这些明确路径:
|
||||
插件服务端在 `PLUGIN_STANDALONE_AUTH=true`、`PLUGIN_ENV=development` 且 loopback 的本地兼容模式下仅调用这些明确路径;生产数据访问由 Plugin Admin 同源 BFF 完成:
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login
|
||||
@@ -54,13 +54,13 @@ GET /api/v1/admin/users/{id}/subscriptions
|
||||
{
|
||||
"id": "qiu.subscription-admin",
|
||||
"label": "订阅管理",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
|
||||
"visibility": "admin",
|
||||
"sort_order": 200
|
||||
}
|
||||
```
|
||||
|
||||
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。
|
||||
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此菜单应指向 Plugin Admin 的统一控制面入口;订阅模块本身不显示登录页、不创建 Cookie。不要把 JWT 放进 URL。
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -78,10 +78,13 @@ node --check ui/app.js
|
||||
```
|
||||
|
||||
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
|
||||
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式:
|
||||
`manifest.json`,并包含清单声明哈希的 UI 文件。该模块采用外部服务模式:
|
||||
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
|
||||
`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行
|
||||
启用、健康检查和菜单应用。生产环境必须把签名文件通过
|
||||
`service_url`(插件 loopback 地址),并为 Plugin Admin 设置
|
||||
`PLUGIN_PUBLIC_URL`(例如 `https://CORE_ORIGIN/extensions/qiu.plugin-admin`)。
|
||||
然后执行启用、健康检查和菜单应用。浏览器前端由 `plugin-admin` 统一挂载并共享
|
||||
控制面会话,菜单固定跳转到 `#/modules/subscription/overview`,不会跳转到本服务
|
||||
的登录页。生产环境必须把签名文件通过
|
||||
`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
|
||||
加入控制面受信发布者配置;未签名包仅限 development + loopback。
|
||||
|
||||
@@ -89,9 +92,23 @@ node --check ui/app.js
|
||||
|
||||
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。
|
||||
|
||||
发布构建必须显式启用签名门禁:
|
||||
|
||||
```sh
|
||||
RELEASE_BUILD=true SIGNATURE_FILE=/secure/signature.json ./package.sh
|
||||
```
|
||||
|
||||
脚本会把 `signature.json` 放入归档并生成同名 `.sha256` 校验文件;未签名包
|
||||
仅用于 development + loopback。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。
|
||||
- 模块后端是常驻服务,不需要每次使用后重启。Core access token 过期时,后端会
|
||||
按需 refresh 并继续当前请求;只有 refresh 失效、Core 撤销管理员、会话
|
||||
空闲超过 30 分钟或达到 8 小时绝对上限时才需要重新登录。
|
||||
- V1 控制面使用内存会话,控制面服务重启会要求重新登录一次;订阅模块不会
|
||||
单独要求登录。多实例或跨重启免登录需将控制面会话存储替换为插件自有的
|
||||
加密 Redis/共享会话服务。
|
||||
- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
|
||||
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
|
||||
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": 1,
|
||||
"plugin_id": "qiu.subscription-admin",
|
||||
"name": "Subscription Admin",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"core_api_baseline": "sub2api-0.1.183",
|
||||
"capabilities": ["subscription.admin.v1"],
|
||||
"tested_core_versions": ["0.1.183"],
|
||||
@@ -37,8 +37,8 @@
|
||||
"GET /api/v1/admin/users/{id}/subscriptions"
|
||||
],
|
||||
"files": {
|
||||
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697",
|
||||
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca",
|
||||
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54"
|
||||
"ui/index.html": "1c863feae45d0c2b17e7c3f267f0360b5998b796961eb191deb783ea5cc5ebf7",
|
||||
"ui/app.js": "c6b5742f7926a89df8ddf7a18b60903f235b80ee39d697192a7b57b145f5c126",
|
||||
"ui/styles.css": "2f0a1848586f66c3df4a54e7b674618788c2309dc8e3068e88a2676a6dcb09e9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "qiu.subscription-admin",
|
||||
"label": "订阅管理",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
|
||||
"visibility": "admin",
|
||||
"sort_order": 200
|
||||
}
|
||||
|
||||
@@ -7,16 +7,29 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=sub2api-plugin
|
||||
Group=sub2api-plugin
|
||||
WorkingDirectory=/opt/sub2api/subscription-admin
|
||||
EnvironmentFile=/etc/sub2api/subscription-admin.env
|
||||
ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin
|
||||
WorkingDirectory=/var/lib/sub2api-add/subscription-admin
|
||||
EnvironmentFile=/etc/sub2api-add/subscription-admin.env
|
||||
ExecStart=/opt/sub2api-add/subscription-admin/bin/subscription-admin
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/sub2api/subscription-admin
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
TasksMax=128
|
||||
MemoryMax=512M
|
||||
CPUQuota=200%
|
||||
ReadWritePaths=/var/lib/sub2api-add/subscription-admin
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
@@ -32,8 +33,9 @@ const (
|
||||
sessionTTL = 30 * time.Minute
|
||||
sessionMaxTTL = 8 * time.Hour
|
||||
pendingTTL = 5 * time.Minute
|
||||
maxPendingLogins = 1024
|
||||
pluginID = "qiu.subscription-admin"
|
||||
pluginVersion = "0.1.1"
|
||||
pluginVersion = "0.2.0"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
maxRequestIDBytes = 64
|
||||
)
|
||||
@@ -436,10 +438,33 @@ func token(n int) string {
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, out any) error {
|
||||
if r == nil || r.Body == nil {
|
||||
return errors.New("request body is required")
|
||||
}
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes))
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(raw)) > maxBodyBytes {
|
||||
return errors.New("request body exceeds size limit")
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return errors.New("request body is required")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(out)
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("request body must contain exactly one JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
@@ -480,6 +505,13 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
|
||||
now := a.clock()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
// Expire buckets opportunistically so a long-lived development compatibility
|
||||
// process cannot retain one map entry for every attempted identity forever.
|
||||
for candidate, attempt := range a.loginAttempts {
|
||||
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
|
||||
delete(a.loginAttempts, candidate)
|
||||
}
|
||||
}
|
||||
attempt := a.loginAttempts[key]
|
||||
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
|
||||
attempt = loginAttempt{started: now}
|
||||
@@ -493,6 +525,32 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// addPendingLogin keeps the optional standalone 2FA compatibility mode
|
||||
// bounded. A client can create a challenge without completing it, so expired
|
||||
// entries are removed before enforcing the hard cap.
|
||||
func (a *app) addPendingLogin(value pendingLogin) (string, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if a.clock != nil {
|
||||
now = a.clock()
|
||||
}
|
||||
for id, pending := range a.pending {
|
||||
if !pending.expires.After(now) {
|
||||
delete(a.pending, id)
|
||||
}
|
||||
}
|
||||
if len(a.pending) >= maxPendingLogins {
|
||||
return "", false
|
||||
}
|
||||
if a.pending == nil {
|
||||
a.pending = make(map[string]pendingLogin)
|
||||
}
|
||||
id := token(24)
|
||||
a.pending[id] = value
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (a *app) clientIP(r *http.Request) string {
|
||||
return trustedClientIPWithConfig(r, a.trustProxy)
|
||||
}
|
||||
@@ -526,10 +584,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"})
|
||||
return
|
||||
}
|
||||
pendingID := token(24)
|
||||
a.mu.Lock()
|
||||
a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)}
|
||||
a.mu.Unlock()
|
||||
pendingID, accepted := a.addPendingLogin(pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)})
|
||||
if !accepted {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many pending login challenges"})
|
||||
return
|
||||
}
|
||||
a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID})
|
||||
return
|
||||
}
|
||||
@@ -760,6 +820,13 @@ func (a *app) userProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized {
|
||||
a.removeSessionIfCurrent(sessionID, s.accessToken)
|
||||
a.core.logout(r.Context(), s.refreshToken, correlationID)
|
||||
a.setSessionCookie(w, "", -1)
|
||||
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
|
||||
return
|
||||
}
|
||||
a.coreError(w, err, "user lookup failed")
|
||||
return
|
||||
}
|
||||
@@ -853,18 +920,25 @@ func (a *app) refreshSession(ctx context.Context, id string, stale session) (ses
|
||||
return session{}, false
|
||||
}
|
||||
data := envelopeData(refreshed)
|
||||
candidateRefresh := current.refreshToken
|
||||
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
|
||||
candidateRefresh = nextRefresh
|
||||
}
|
||||
access, _ := data["access_token"].(string)
|
||||
if access == "" {
|
||||
a.core.logout(ctx, candidateRefresh, correlationID)
|
||||
return session{}, false
|
||||
}
|
||||
// Refresh-token rotation is common. Do not commit the replacement until the
|
||||
// new access token has passed the Core identity and admin-role checks; if a
|
||||
// check fails, revoke the replacement instead of leaving it live.
|
||||
nextMe, err := a.core.me(ctx, access, correlationID)
|
||||
if err != nil || !isAdmin(envelopeData(nextMe)) {
|
||||
a.core.logout(ctx, candidateRefresh, correlationID)
|
||||
return session{}, false
|
||||
}
|
||||
current.accessToken = access
|
||||
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
|
||||
current.refreshToken = nextRefresh
|
||||
}
|
||||
current.refreshToken = candidateRefresh
|
||||
current.user = publicUser(envelopeData(nextMe))
|
||||
current.lastSeen = a.clock()
|
||||
a.mu.Lock()
|
||||
@@ -995,40 +1069,63 @@ func (a *app) static(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// standaloneAuthEnabled is intentionally fail-closed. The module's own
|
||||
// login/API compatibility surface is useful for local contract tests only;
|
||||
// production deployments must expose the shared Plugin Admin Shell instead.
|
||||
func standaloneAuthEnabled() bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_STANDALONE_AUTH")), "true") {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_ENV")), "development") {
|
||||
return false
|
||||
}
|
||||
host := strings.Trim(strings.TrimSpace(os.Getenv("PLUGIN_HOST")), "[]")
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func (a *app) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", a.health)
|
||||
mux.HandleFunc("/readyz", a.ready)
|
||||
mux.HandleFunc("/login", a.login)
|
||||
mux.HandleFunc("/login/2fa", a.login2FA)
|
||||
mux.HandleFunc("/logout", a.logout)
|
||||
mux.HandleFunc("/api/me", a.me)
|
||||
mux.HandleFunc("/api/status", a.status)
|
||||
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
|
||||
mux.HandleFunc("/api/audit", a.auditLog)
|
||||
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
|
||||
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
|
||||
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
|
||||
if !positiveID(id) || strings.Contains(id, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
|
||||
})
|
||||
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 1 {
|
||||
a.userProxy(w, r)
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
|
||||
})
|
||||
// The business module is mounted by Plugin Admin. A standalone compatibility
|
||||
// API is available only when explicitly opted into for local contract tests;
|
||||
// production deployments leave it disabled so there is no second login,
|
||||
// session cookie, or Core-data BFF on the module's own port.
|
||||
if standaloneAuthEnabled() {
|
||||
mux.HandleFunc("/login", a.login)
|
||||
mux.HandleFunc("/login/2fa", a.login2FA)
|
||||
mux.HandleFunc("/logout", a.logout)
|
||||
mux.HandleFunc("/api/me", a.me)
|
||||
mux.HandleFunc("/api/status", a.status)
|
||||
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
|
||||
mux.HandleFunc("/api/audit", a.auditLog)
|
||||
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
|
||||
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
|
||||
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
|
||||
if !positiveID(id) || strings.Contains(id, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
|
||||
})
|
||||
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 1 {
|
||||
a.userProxy(w, r)
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("/", a.static)
|
||||
return requestIDMiddleware(a.securityHeaders(mux))
|
||||
}
|
||||
|
||||
@@ -259,6 +259,97 @@ func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotationRevokesCandidateWhenAdminCheckFails(t *testing.T) {
|
||||
var meCalls int32
|
||||
var logoutTokens []string
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
if atomic.AddInt32(&meCalls, 1) == 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":2,"role":"user"}}`))
|
||||
case "/api/v1/auth/refresh":
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
|
||||
case "/api/v1/auth/logout":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
logoutTokens = append(logoutTokens, body["refresh_token"])
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
||||
default:
|
||||
t.Errorf("unexpected Core path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
a := newApp(c, false)
|
||||
now := time.Now()
|
||||
a.sessions["sid"] = session{accessToken: "OLD", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
|
||||
a.sessionLocks["sid"] = &sync.Mutex{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
||||
rec := httptest.NewRecorder()
|
||||
a.me(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected refreshed session rejection: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, value := range logoutTokens {
|
||||
if value == "NEW-REFRESH" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
|
||||
}
|
||||
|
||||
func TestUserProxyClearsSessionWhenReadTokenIsRevoked(t *testing.T) {
|
||||
var logoutCalls int32
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
|
||||
case "/api/v1/admin/users/1":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
|
||||
case "/api/v1/auth/refresh":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"refresh revoked"}`))
|
||||
case "/api/v1/auth/logout":
|
||||
atomic.AddInt32(&logoutCalls, 1)
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
||||
default:
|
||||
t.Errorf("unexpected Core path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
a := newApp(c, false)
|
||||
now := time.Now()
|
||||
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
|
||||
a.sessionLocks["sid"] = &sync.Mutex{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
||||
rec := httptest.NewRecorder()
|
||||
a.userProxy(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := a.sessions["sid"]; ok {
|
||||
t.Fatal("revoked session remained in plugin store")
|
||||
}
|
||||
if atomic.LoadInt32(&logoutCalls) != 1 {
|
||||
t.Fatalf("logout calls=%d", logoutCalls)
|
||||
}
|
||||
cleared := false
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.Name == sessionCookieName && cookie.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Fatalf("session cookie was not cleared: %#v", rec.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := newApp(nil, false)
|
||||
@@ -285,6 +376,61 @@ func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingLoginCapAndExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := newApp(nil, false)
|
||||
a.clock = func() time.Time { return now }
|
||||
for i := 0; i < maxPendingLogins; i++ {
|
||||
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "TEMP", expires: now.Add(time.Minute)}); !ok {
|
||||
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
|
||||
}
|
||||
}
|
||||
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "OVERFLOW", expires: now.Add(time.Minute)}); ok {
|
||||
t.Fatal("pending challenge cap was not enforced")
|
||||
}
|
||||
if got := len(a.pending); got != maxPendingLogins {
|
||||
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
|
||||
}
|
||||
a.clock = func() time.Time { return now.Add(pendingTTL + time.Second) }
|
||||
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "AFTER-EXPIRY", expires: now.Add(2 * pendingTTL)}); !ok {
|
||||
t.Fatal("expired pending challenges were not evicted")
|
||||
}
|
||||
if got := len(a.pending); got != 1 {
|
||||
t.Fatalf("pending map size after expiry=%d want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneLoginRateLimitBoundsCoreAttempts(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
|
||||
t.Setenv("PLUGIN_ENV", "development")
|
||||
var loginCalls int32
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/api/v1/auth/login" {
|
||||
atomic.AddInt32(&loginCalls, 1)
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"requires_2fa":true,"temp_token":"TEMP"}}`))
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected Core path %s", r.URL.Path)
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
||||
}))
|
||||
a := newAppWithConfig(c, appConfig{LoginLimit: 2, LoginWindow: time.Hour})
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
a.login(rec, req)
|
||||
if attempt < 2 && rec.Code != http.StatusOK {
|
||||
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
if attempt == 2 && (rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "") {
|
||||
t.Fatalf("limit response status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&loginCalls); got != 2 {
|
||||
t.Fatalf("Core received %d login calls want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/v1/admin/subscriptions/1/progress",
|
||||
@@ -299,22 +445,39 @@ func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
|
||||
a := newApp(nil, false)
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "false")
|
||||
t.Setenv("PLUGIN_ENV", "production")
|
||||
var coreCalls int32
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&coreCalls, 1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
a := newApp(c, false)
|
||||
server := httptest.NewServer(a.routes())
|
||||
t.Cleanup(server.Close)
|
||||
response, err := server.Client().Get(server.URL + "/api/plans")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, endpoint := range []string{"/login", "/login/2fa", "/logout", "/api/me", "/api/status", "/api/plans", "/api/subscriptions", "/api/users/1"} {
|
||||
response, err := server.Client().Get(server.URL + endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("endpoint=%s status=%d", endpoint, response.StatusCode)
|
||||
}
|
||||
if len(response.Cookies()) != 0 {
|
||||
t.Fatalf("endpoint=%s unexpectedly set cookies: %#v", endpoint, response.Cookies())
|
||||
}
|
||||
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("endpoint=%s security headers missing: %#v", endpoint, response.Header)
|
||||
}
|
||||
}
|
||||
if response.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", response.StatusCode)
|
||||
}
|
||||
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("security headers missing: %#v", response.Header)
|
||||
if atomic.LoadInt32(&coreCalls) != 0 {
|
||||
t.Fatalf("disabled standalone routes called Core %d times", coreCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
|
||||
t.Setenv("PLUGIN_ENV", "development")
|
||||
var coreRequestID string
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
coreRequestID = r.Header.Get(requestIDHeader)
|
||||
@@ -358,6 +521,26 @@ func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneAuthFailsClosedOutsideDevelopmentLoopback(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
env string
|
||||
host string
|
||||
}{
|
||||
{name: "production", env: "production", host: "127.0.0.1"},
|
||||
{name: "public-development", env: "development", host: "0.0.0.0"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
|
||||
t.Setenv("PLUGIN_ENV", test.env)
|
||||
t.Setenv("PLUGIN_HOST", test.host)
|
||||
if standaloneAuthEnabled() {
|
||||
t.Fatal("standalone auth unexpectedly enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, response *http.Response) string {
|
||||
t.Helper()
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -3,6 +3,7 @@ set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"}
|
||||
RELEASE_BUILD=${RELEASE_BUILD:-false}
|
||||
command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
|
||||
TMP=$(mktemp -d)
|
||||
cleanup() {
|
||||
@@ -24,10 +25,30 @@ jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "
|
||||
"$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
|
||||
|
||||
if [ -n "${SIGNATURE_FILE:-}" ]; then
|
||||
cp "$SIGNATURE_FILE" "$TMP/signature.json"
|
||||
[ -f "$SIGNATURE_FILE" ] || { printf '%s\n' 'SIGNATURE_FILE does not exist' >&2; exit 2; }
|
||||
SIGNATURE_KEY=$(jq -r '.publisher.key_id // empty' "$TMP/manifest.json")
|
||||
SIGNATURE_ALGORITHM=$(jq -r '.algorithm // empty' "$SIGNATURE_FILE")
|
||||
SIGNATURE_FILE_KEY=$(jq -r '.key_id // empty' "$SIGNATURE_FILE")
|
||||
SIGNATURE_VALUE=$(jq -r '.signature // empty' "$SIGNATURE_FILE")
|
||||
[ "$SIGNATURE_ALGORITHM" = "ed25519" ] || { printf '%s\n' 'signature.json must use ed25519' >&2; exit 2; }
|
||||
[ -n "$SIGNATURE_KEY" ] && [ "$SIGNATURE_FILE_KEY" = "$SIGNATURE_KEY" ] || { printf '%s\n' 'signature key_id does not match manifest publisher' >&2; exit 2; }
|
||||
[ -n "$SIGNATURE_VALUE" ] || { printf '%s\n' 'signature value is required' >&2; exit 2; }
|
||||
cp "$SIGNATURE_FILE" "$TMP/signature.json"
|
||||
elif [ "$RELEASE_BUILD" = "true" ]; then
|
||||
printf '%s\n' 'RELEASE_BUILD=true requires SIGNATURE_FILE' >&2
|
||||
exit 2
|
||||
else
|
||||
printf '%s\n' 'warning: building an unsigned development package' >&2
|
||||
fi
|
||||
|
||||
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
|
||||
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")"
|
||||
(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui)
|
||||
find "$TMP" -type f -exec touch -t 198001010000 {} +
|
||||
if [ -f "$TMP/signature.json" ]; then
|
||||
(cd "$TMP" && zip -X -q -r "$OUT_PATH" manifest.json signature.json ui)
|
||||
else
|
||||
(cd "$TMP" && zip -X -q -r "$OUT_PATH" manifest.json ui)
|
||||
fi
|
||||
ARCHIVE_HASH=$(shasum -a 256 "$OUT_PATH" | awk '{print $1}')
|
||||
printf '%s %s\n' "$ARCHIVE_HASH" "$(basename -- "$OUT_PATH")" > "$OUT_PATH.sha256"
|
||||
printf '%s\n' "$OUT_PATH"
|
||||
|
||||
@@ -8,7 +8,9 @@ const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscrip
|
||||
|
||||
await fs.mkdir(path.resolve(outputDir), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const launchOptions = { headless: true }
|
||||
if (process.env.PLAYWRIGHT_EXECUTABLE_PATH) launchOptions.executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH
|
||||
const browser = await chromium.launch(launchOptions)
|
||||
try {
|
||||
for (const width of [425, 900, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
|
||||
@@ -21,18 +23,12 @@ try {
|
||||
} catch (_) {}
|
||||
})
|
||||
await page.goto(entry, { waitUntil: 'domcontentloaded' })
|
||||
await page.getByLabel('管理员邮箱').fill('admin@example.com')
|
||||
await page.getByLabel('密码').fill('password')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await page.locator('#app-view').waitFor({ state: 'visible' })
|
||||
await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
|
||||
if (await page.locator('input[type="password"], form').count()) throw new Error('subscription module exposed an independent login form')
|
||||
await page.waitForTimeout(50)
|
||||
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
|
||||
await page.getByRole('button', { name: '用户订阅' }).click()
|
||||
await page.locator('#subscriptions-list table').waitFor()
|
||||
await page.getByRole('button', { name: '下一页' }).click()
|
||||
await page.getByRole('button', { name: '概览' }).click()
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.locator('#app-view').waitFor({ state: 'visible' })
|
||||
await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
|
||||
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
|
||||
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true })
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http'
|
||||
const port = Number(process.env.MOCK_CORE_PORT || 18080)
|
||||
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
|
||||
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
|
||||
const captchaProvider = String(process.env.MOCK_CAPTCHA_PROVIDER || '').toLowerCase()
|
||||
const users = new Map([
|
||||
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }],
|
||||
['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }]
|
||||
@@ -47,6 +48,9 @@ const server = http.createServer(async (req, res) => {
|
||||
const input = await body(req)
|
||||
const user = users.get(input.email)
|
||||
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' })
|
||||
if (captchaProvider === 'turnstile' && input.turnstile_token !== 'MOCK-TURNSTILE-TOKEN') return json(res, 400, { code: 400, message: 'captcha required' })
|
||||
if (captchaProvider === 'tencent' && (!input.tencent_captcha_ticket || !input.tencent_captcha_randstr)) return json(res, 400, { code: 400, message: 'captcha required' })
|
||||
if (captchaProvider === 'aliyun' && !input.turnstile_token) return json(res, 400, { code: 400, message: 'captcha required' })
|
||||
return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } })
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/refresh') {
|
||||
@@ -55,7 +59,12 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } })
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} })
|
||||
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } })
|
||||
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: {
|
||||
turnstile_enabled: captchaProvider === 'turnstile', turnstile_site_key: captchaProvider === 'turnstile' ? 'mock-site-key' : '',
|
||||
geetest_captcha_enabled: false,
|
||||
tencent_captcha_enabled: captchaProvider === 'tencent', tencent_captcha_app_id: captchaProvider === 'tencent' ? '123456' : '', tencent_captcha_region: 'cn',
|
||||
aliyun_captcha_enabled: captchaProvider === 'aliyun', aliyun_captcha_scene_id: captchaProvider === 'aliyun' ? 'mock-scene' : '', aliyun_captcha_prefix: captchaProvider === 'aliyun' ? 'mock-prefix' : '', aliyun_captcha_region: 'cn'
|
||||
} })
|
||||
if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' })
|
||||
if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') })
|
||||
if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
|
||||
|
||||
@@ -8,14 +8,22 @@ cleanup() {
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$!
|
||||
CORE_BASE_URL=http://127.0.0.1:18080 \
|
||||
PLUGIN_HOST=127.0.0.1 \
|
||||
PLUGIN_PORT=18082 \
|
||||
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \
|
||||
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \
|
||||
PLUGIN_COOKIE_SECURE=false \
|
||||
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
|
||||
(
|
||||
export CORE_BASE_URL=http://127.0.0.1:18080
|
||||
export PLUGIN_HOST=127.0.0.1
|
||||
export PLUGIN_PORT=18082
|
||||
export PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
|
||||
export PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
|
||||
export PLUGIN_COOKIE_SECURE=false
|
||||
cd "$ROOT"
|
||||
go run .
|
||||
) >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
|
||||
PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$!
|
||||
|
||||
sleep 2
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:18081/extensions/qiu.subscription-admin/healthz >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
|
||||
|
||||
@@ -1,170 +1,5 @@
|
||||
// The subscription browser UI is mounted by Plugin Admin. This compatibility
|
||||
// entry intentionally contains no login form, cookie, or token handling.
|
||||
(() => {
|
||||
'use strict'
|
||||
|
||||
const state = {
|
||||
csrf: '',
|
||||
plans: [],
|
||||
subscriptions: null,
|
||||
subscriptionPage: 1,
|
||||
subscriptionPageSize: 50,
|
||||
captcha: { enabled: false, provider: '' }
|
||||
}
|
||||
const $ = (selector) => document.querySelector(selector)
|
||||
const loginView = $('#login-view')
|
||||
const appView = $('#app-view')
|
||||
const loginForm = $('#login-form')
|
||||
const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '')
|
||||
const route = (path) => `${basePath}${path}`
|
||||
|
||||
function showError(message, target = $('#app-error')) {
|
||||
target.textContent = message || '请求失败'
|
||||
target.hidden = !message
|
||||
}
|
||||
function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true }
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
||||
if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf)
|
||||
const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' })
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true }
|
||||
throw new Error(payload.error || '请求失败')
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload }
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value)
|
||||
}
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])) }
|
||||
function statusLabel(value) { const safe = String(value || 'unknown'); return `<span class="pill pill-${safe.toLowerCase()}">${escapeHTML(safe)}</span>` }
|
||||
function setView(name) {
|
||||
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name))
|
||||
document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` })
|
||||
if (name === 'plans' && !state.plans.length) loadPlans()
|
||||
if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions()
|
||||
if (name === 'audit') loadAudit()
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault(); clearError($('#login-error'))
|
||||
const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true
|
||||
try {
|
||||
const captchaToken = String(form.get('captcha_token') || '').trim()
|
||||
if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果')
|
||||
let payload = await request('/login', { method: 'POST', body: JSON.stringify({
|
||||
email: form.get('email'),
|
||||
password: form.get('password'),
|
||||
turnstile_token: captchaToken || undefined,
|
||||
tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined,
|
||||
tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined
|
||||
}) })
|
||||
if (payload.requires_2fa) {
|
||||
const code = window.prompt('请输入管理员 2FA 验证码')
|
||||
if (!code) throw new Error('需要 2FA 验证码')
|
||||
payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) })
|
||||
}
|
||||
if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效')
|
||||
state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false
|
||||
const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
|
||||
await Promise.all([loadStatus(), loadOverview()])
|
||||
} catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false }
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true }
|
||||
catch (error) { showError(error.message); return false }
|
||||
}
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const params = new URLSearchParams(); const form = new FormData($('#subscription-filter'))
|
||||
const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '')
|
||||
if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
|
||||
let endpoint = '/api/subscriptions'
|
||||
if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions`
|
||||
if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) }
|
||||
const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload
|
||||
$('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true
|
||||
} catch (error) { showError(error.message); return false }
|
||||
}
|
||||
async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } }
|
||||
async function loadOverview() {
|
||||
clearError(); $('#sync-time').textContent = '同步中'
|
||||
const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()])
|
||||
const healthy = results.every(Boolean); const degraded = results.some(Boolean)
|
||||
setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad')
|
||||
$('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败'
|
||||
}
|
||||
async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } }
|
||||
async function loadStatus() {
|
||||
try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' }
|
||||
catch { $('#credential-status').textContent = '不可用' }
|
||||
}
|
||||
async function loadCaptchaConfig() {
|
||||
try {
|
||||
const payload = await request('/api/captcha-config'); state.captcha = payload
|
||||
const panel = $('#captcha-panel')
|
||||
if (!payload.enabled) { panel.hidden = true; return }
|
||||
panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证`
|
||||
const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' }
|
||||
$('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。'
|
||||
$('#captcha-randstr-row').hidden = payload.provider !== 'tencent'
|
||||
} catch { $('#captcha-panel').hidden = true }
|
||||
}
|
||||
async function loadBalance(userID) {
|
||||
if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
|
||||
const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance)
|
||||
}
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const payload = await request('/api/me'); state.csrf = payload.csrf_token || ''
|
||||
if (!state.csrf || !payload.user) throw new Error('session unavailable')
|
||||
loginView.hidden = true; appView.hidden = false
|
||||
const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
|
||||
await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()])
|
||||
} catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() }
|
||||
}
|
||||
function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` }
|
||||
function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } }
|
||||
function renderPlans() {
|
||||
const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '<p class="empty">暂无套餐数据</p>'; return }
|
||||
container.innerHTML = state.plans.map((plan) => {
|
||||
const groups = (plan.included_groups || []).map((group) => `<span class="tag">${escapeHTML(group.name || group.id)}</span>`).join('') || '<span class="muted">未返回分组</span>'
|
||||
const features = parseFeatures(plan.features)
|
||||
return `<article class="plan-card"><div class="card-heading"><div><h3>${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}</h3><p class="muted">${escapeHTML(plan.description || '')}</p></div>${plan.for_sale ? '<span class="pill pill-active">可售</span>' : '<span class="pill">下架</span>'}</div><div class="plan-price">${formatNumber(plan.price)} <small>${escapeHTML(plan.currency || '')}</small></div><dl class="facts"><div><dt>有效期</dt><dd>${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}</dd></div><div><dt>周期额度</dt><dd>${formatNumber(plan.cycle_quota_usd)}</dd></div><div><dt>总额度</dt><dd>${formatNumber(plan.total_quota_usd)}</dd></div><div><dt>实例上限</dt><dd>${formatNumber(plan.max_subscriptions_per_user)}</dd></div></dl><div class="tags">${groups}</div>${features.length ? `<ul class="feature-list">${features.map((feature) => `<li>${escapeHTML(feature)}</li>`).join('')}</ul>` : ''}</article>`
|
||||
}).join('')
|
||||
}
|
||||
function renderSubscriptions(payload) {
|
||||
const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination')
|
||||
if (Array.isArray(payload) || !payload.pages) pagination.hidden = true
|
||||
else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages }
|
||||
if (!items.length) { container.innerHTML = '<p class="empty">暂无订阅数据</p>'; return }
|
||||
container.innerHTML = `<table><thead><tr><th>订阅实例</th><th>用户</th><th>套餐</th><th>状态</th><th>有效期</th><th>周期用量</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><code>#${escapeHTML(item.id)}</code></td><td>${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}</td><td>${escapeHTML(item.plan_name || item.plan_id || '-')}</td><td>${statusLabel(item.status)}</td><td>${formatDate(item.starts_at)}<br><span class="muted">至 ${formatDate(item.expires_at)}</span></td><td>${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}</td><td><button class="link-button detail-button" data-id="${escapeHTML(item.id)}" type="button">详情</button></td></tr>`).join('')}</tbody></table>`
|
||||
container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id)))
|
||||
}
|
||||
async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } }
|
||||
function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '<p class="empty">暂无操作记录</p>'; return }; container.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>结果</th><th>用户 ID</th><th>请求 ID</th></tr></thead><tbody>${items.slice().reverse().map((item) => `<tr><td>${formatDate(item.time)}</td><td><code>${escapeHTML(item.action)}</code></td><td>${statusLabel(item.result)}</td><td>${escapeHTML(item.user_id ?? '-')}</td><td><code>${escapeHTML(item.request_id || '-')}</code></td></tr>`).join('')}</tbody></table>` }
|
||||
|
||||
loginForm.addEventListener('submit', login)
|
||||
$('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() })
|
||||
$('#refresh-all').addEventListener('click', loadOverview)
|
||||
$('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() })
|
||||
$('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } })
|
||||
$('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } })
|
||||
$('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } })
|
||||
$('#close-detail').addEventListener('click', () => $('#detail-dialog').close())
|
||||
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view)))
|
||||
document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() }))
|
||||
void bootstrap()
|
||||
})()
|
||||
|
||||
@@ -3,120 +3,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>订阅管理插件</title>
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>订阅管理模块</title>
|
||||
<link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css">
|
||||
</head>
|
||||
<body data-base-path="__PLUGIN_BASE_PATH__">
|
||||
<main class="shell">
|
||||
<section id="login-view" class="auth-panel" aria-labelledby="login-title">
|
||||
<div class="eyebrow">SUB2API EXTENSION</div>
|
||||
<h1 id="login-title">订阅管理</h1>
|
||||
<p class="muted">使用 Sub2API 管理员账号登录。普通用户没有访问权限。</p>
|
||||
<form id="login-form" class="stack" autocomplete="on">
|
||||
<label>
|
||||
<span>管理员邮箱</span>
|
||||
<input name="email" type="email" autocomplete="username" required maxlength="254">
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required maxlength="200">
|
||||
</label>
|
||||
<fieldset id="captcha-panel" class="captcha-panel" hidden>
|
||||
<legend id="captcha-label">安全验证</legend>
|
||||
<p id="captcha-help" class="muted"></p>
|
||||
<label>
|
||||
<span>验证结果</span>
|
||||
<textarea name="captcha_token" rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea>
|
||||
</label>
|
||||
<label id="captcha-randstr-row" hidden>
|
||||
<span>验证随机串</span>
|
||||
<input name="captcha_randstr" maxlength="512" autocomplete="off">
|
||||
</label>
|
||||
</fieldset>
|
||||
<button class="primary" type="submit">登录</button>
|
||||
<p id="login-error" class="error" role="alert" hidden></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="app-view" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">SUB2API EXTENSION</div>
|
||||
<h1>订阅管理</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span id="operator" class="operator"></span>
|
||||
<button id="logout" class="secondary" type="button">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="插件页面">
|
||||
<button class="tab active" data-view="overview" type="button">概览</button>
|
||||
<button class="tab" data-view="plans" type="button">套餐</button>
|
||||
<button class="tab" data-view="subscriptions" type="button">用户订阅</button>
|
||||
<button class="tab" data-view="audit" type="button">操作记录</button>
|
||||
<button class="tab" data-view="settings" type="button">设置</button>
|
||||
</nav>
|
||||
|
||||
<p id="app-error" class="error" role="alert" hidden></p>
|
||||
|
||||
<section id="view-overview" class="view stack">
|
||||
<div class="section-heading"><div><h2>连接概览</h2><p class="muted">Core 是套餐、余额和订阅账本的唯一来源。</p></div><button id="refresh-all" class="secondary" type="button">刷新数据</button></div>
|
||||
<div class="metric-grid">
|
||||
<article class="metric"><span>插件状态</span><strong class="status ok">运行中</strong><small>独立服务 · 只读模式</small></article>
|
||||
<article class="metric"><span>Core 连接</span><strong id="core-status" class="status">检查中</strong><small id="sync-time">尚未同步</small></article>
|
||||
<article class="metric"><span>可售套餐</span><strong id="plan-count">-</strong><small>来自 Core 套餐目录</small></article>
|
||||
<article class="metric"><span>订阅实例</span><strong id="subscription-count">-</strong><small>当前分页结果</small></article>
|
||||
<article class="metric"><span>用户余额</span><strong id="user-balance">-</strong><small>选择用户后显示 Core 余额</small></article>
|
||||
</div>
|
||||
<form id="balance-form" class="filter-row balance-form">
|
||||
<label><span>查询用户余额</span><input name="user_id" inputmode="numeric" pattern="[1-9][0-9]*" placeholder="用户 ID" required></label>
|
||||
<button class="secondary" type="submit">查询余额</button>
|
||||
</form>
|
||||
<div class="notice"><strong>只读试验版</strong><span>余额购买、续费、撤销和外部支付尚未启用。页面不会提交任何写操作。</span></div>
|
||||
</section>
|
||||
|
||||
<section id="view-plans" class="view" hidden>
|
||||
<div class="section-heading"><div><h2>套餐目录</h2><p class="muted">展示 Core 当前返回的价格、额度和覆盖分组。</p></div><button class="secondary reload" data-target="plans" type="button">刷新</button></div>
|
||||
<div id="plans-list" class="card-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-subscriptions" class="view" hidden>
|
||||
<div class="section-heading"><div><h2>用户订阅</h2><p class="muted">每个订阅实例独立展示,支持同档位多实例。</p></div><button class="secondary reload" data-target="subscriptions" type="button">刷新</button></div>
|
||||
<form id="subscription-filter" class="filter-row">
|
||||
<label><span>用户 ID</span><input name="user_id" inputmode="numeric" pattern="[0-9]*"></label>
|
||||
<label><span>状态</span><select name="status"><option value="">全部</option><option value="active">active</option><option value="expired">expired</option><option value="revoked">revoked</option></select></label>
|
||||
<button class="secondary" type="submit">筛选</button>
|
||||
</form>
|
||||
<div id="subscriptions-list" class="table-wrap"></div>
|
||||
<div id="subscription-pagination" class="pagination" hidden>
|
||||
<span id="subscription-page-info" class="muted"></span>
|
||||
<div><button id="subscription-prev" class="secondary" type="button">上一页</button><button id="subscription-next" class="secondary" type="button">下一页</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-audit" class="view" hidden>
|
||||
<div class="section-heading"><div><h2>操作记录</h2><p class="muted">仅记录插件会话和只读查询结果,不记录 token 或密码。</p></div><button class="secondary reload" data-target="audit" type="button">刷新</button></div>
|
||||
<div id="audit-list" class="table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-settings" class="view stack" hidden>
|
||||
<div class="section-heading"><div><h2>插件设置</h2><p class="muted">部署参数由服务端环境变量管理,页面不提供 secret 编辑入口。</p></div></div>
|
||||
<div class="settings-list">
|
||||
<div><span>运行模式</span><strong>只读 V1</strong></div>
|
||||
<div><span>Core API allowlist</span><strong>10 个固定端点</strong></div>
|
||||
<div><span>会话存储</span><strong>进程内存(重启后需重新登录)</strong></div>
|
||||
<div><span>Core 凭据</span><strong id="credential-status">检查中</strong></div>
|
||||
<div><span>余额购买 / 续费 / 撤销</span><strong class="status">未启用</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<main class="handoff-page">
|
||||
<div class="handoff-mark" aria-hidden="true">S</div>
|
||||
<p class="eyebrow">SUB2API BUSINESS MODULE</p>
|
||||
<h1>订阅管理已纳入统一控制面</h1>
|
||||
<p class="muted">此服务只提供订阅模块的后端能力。管理员请从 Plugin Admin 进入,订阅页面会复用同一个会话,不需要再次登录。</p>
|
||||
<a class="primary" href="__PLUGIN_BASE_PATH__/admin/">打开统一控制面</a>
|
||||
<p class="security-note">Core 凭据由服务端托管,浏览器不会接触访问令牌。</p>
|
||||
</main>
|
||||
<dialog id="detail-dialog" class="detail-dialog">
|
||||
<div class="dialog-heading"><h2>订阅详情</h2><button id="close-detail" class="icon-button" type="button" aria-label="关闭">×</button></div>
|
||||
<pre id="detail-content"></pre>
|
||||
</dialog>
|
||||
<script src="__PLUGIN_BASE_PATH__/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,103 +1,12 @@
|
||||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f7fa; font-synthesis: none; }
|
||||
@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } }
|
||||
:root { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; color: #1f2937; background: #f3f6fa; font-synthesis: none; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .6; }
|
||||
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
|
||||
.auth-panel { width: min(440px, 100%); margin: 10vh auto 0; padding: 32px; border: 1px solid #dce4eb; border-radius: 10px; background: #fff; box-shadow: 0 12px 36px rgb(20 38 56 / 8%); }
|
||||
@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; }
|
||||
h2 { font-size: 1.2rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
.eyebrow { color: #3977a9; font-size: .7rem; font-weight: 700; letter-spacing: .08em; }
|
||||
.muted { color: #647384; font-size: .86rem; line-height: 1.5; }
|
||||
@media (prefers-color-scheme: dark) { .muted { color: #a8b5c2; } }
|
||||
.stack { display: grid; gap: 18px; }
|
||||
form.stack { margin-top: 26px; }
|
||||
label { display: grid; gap: 7px; color: #4f6070; font-size: .82rem; font-weight: 600; }
|
||||
input, select, textarea { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #cbd7e1; border-radius: 5px; color: inherit; background: transparent; outline: none; }
|
||||
textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font: .78rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
input:focus, select:focus { border-color: #3977a9; box-shadow: 0 0 0 3px rgb(57 119 169 / 17%); }
|
||||
button { min-height: 36px; border-radius: 5px; border: 1px solid transparent; padding: 0 14px; }
|
||||
.primary { color: white; background: #3977a9; }
|
||||
.primary:hover { background: #2e628e; }
|
||||
.secondary { color: #2d536f; background: transparent; border-color: #b9c9d7; }
|
||||
.secondary:hover { background: rgb(57 119 169 / 8%); }
|
||||
.topbar, .section-heading, .card-heading, .top-actions, .filter-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d8e1e9; }
|
||||
.top-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.operator { max-width: 260px; overflow: hidden; color: #647384; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tabs { display: flex; gap: 6px; overflow-x: auto; padding: 18px 0; border-bottom: 1px solid #d8e1e9; }
|
||||
.tab { color: #647384; background: transparent; border: 0; white-space: nowrap; }
|
||||
.tab.active { color: #3977a9; box-shadow: inset 0 -2px #3977a9; }
|
||||
.view { padding-top: 28px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.metric { min-height: 122px; display: grid; align-content: space-between; gap: 8px; padding: 18px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
|
||||
.metric > span { color: #647384; font-size: .82rem; }
|
||||
.metric strong { font-size: 1.55rem; font-variant-numeric: tabular-nums; }
|
||||
.metric small { color: #7b8996; font-size: .75rem; }
|
||||
.status { color: #647384; }
|
||||
.status.ok { color: #2f8b5c; }
|
||||
.status.bad { color: #c14f4f; }
|
||||
.notice { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border: 1px solid #c6dce9; border-left: 3px solid #3977a9; border-radius: 6px; background: #fff; font-size: .86rem; }
|
||||
.card-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
|
||||
.plan-card { display: grid; gap: 15px; padding: 20px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
|
||||
.plan-price { color: #3977a9; font-size: 1.7rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.plan-price small { color: #647384; font-size: .8rem; font-weight: 500; }
|
||||
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
|
||||
.facts div { padding: 10px; border-radius: 5px; background: rgb(100 115 132 / 8%); }
|
||||
.facts dt { color: #647384; font-size: .72rem; }
|
||||
.facts dd { margin: 4px 0 0; font-size: .86rem; font-variant-numeric: tabular-nums; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag, .pill { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border-radius: 99px; font-size: .72rem; white-space: nowrap; }
|
||||
.tag { color: #3977a9; background: rgb(57 119 169 / 11%); }
|
||||
.pill { color: #647384; background: rgb(100 115 132 / 12%); }
|
||||
.pill-active, .pill-success { color: #2f8b5c; background: rgb(47 139 92 / 13%); }
|
||||
.pill-expired, .pill-revoked, .pill-failed, .pill-bad { color: #bd5050; background: rgb(189 80 80 / 13%); }
|
||||
.feature-list { display: grid; gap: 5px; margin: 0; padding-left: 18px; color: #647384; font-size: .82rem; }
|
||||
.filter-row { justify-content: flex-start; flex-wrap: wrap; margin-top: 18px; }
|
||||
.filter-row label { width: min(220px, 100%); }
|
||||
.balance-form { margin-top: 16px; }
|
||||
.captcha-panel { display: grid; gap: 10px; margin: 2px 0 0; padding: 12px; border: 1px solid #dce4eb; border-radius: 6px; }
|
||||
.captcha-panel legend { padding: 0 4px; color: #4f6070; font-size: .82rem; font-weight: 600; }
|
||||
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
|
||||
.pagination > div { display: flex; gap: 8px; }
|
||||
.settings-list { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dce4eb; border-radius: 8px; background: #dce4eb; }
|
||||
.settings-list > div { display: flex; justify-content: space-between; gap: 18px; padding: 15px 16px; background: #fff; font-size: .84rem; }
|
||||
.settings-list strong { font-weight: 600; text-align: right; }
|
||||
@media (prefers-color-scheme: dark) { .settings-list { border-color: #2b3947; background: #2b3947; } .settings-list > div { background: #18212b; } }
|
||||
.table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 18px; border: 1px solid #dce4eb; border-radius: 8px; }
|
||||
table { width: 100%; min-width: 760px; border-collapse: collapse; background: #fff; }
|
||||
th, td { padding: 13px 14px; border-bottom: 1px solid #e2e8ee; text-align: left; vertical-align: top; font-size: .82rem; white-space: nowrap; }
|
||||
th { color: #647384; font-size: .74rem; font-weight: 600; background: rgb(100 115 132 / 6%); }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
code { color: #3977a9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
|
||||
.link-button { min-height: 28px; padding: 0; color: #3977a9; background: transparent; border: 0; }
|
||||
.empty { padding: 32px; color: #647384; text-align: center; }
|
||||
.error { color: #b84d4d; font-size: .84rem; }
|
||||
.detail-dialog { width: min(720px, calc(100% - 28px)); max-height: min(700px, calc(100dvh - 28px)); padding: 0; border: 1px solid #dce4eb; border-radius: 8px; color: inherit; background: #fff; }
|
||||
.detail-dialog::backdrop { background: rgb(15 27 39 / 42%); }
|
||||
.dialog-heading { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid #dce4eb; }
|
||||
.icon-button { width: 32px; min-height: 32px; padding: 0; color: #647384; background: transparent; border: 0; font-size: 1.3rem; }
|
||||
pre { max-height: 580px; overflow: auto; margin: 0; padding: 18px; font: .76rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 760px) {
|
||||
.shell { width: min(100% - 20px, 600px); padding-top: 18px; }
|
||||
.auth-panel { margin-top: 4vh; padding: 22px; }
|
||||
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
|
||||
.top-actions { width: 100%; justify-content: space-between; }
|
||||
.metric-grid, .card-list { grid-template-columns: 1fr; }
|
||||
.metric { min-height: 104px; }
|
||||
.notice { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.filter-row { align-items: stretch; flex-direction: column; }
|
||||
.filter-row label { width: 100%; }
|
||||
.filter-row button { width: 100%; }
|
||||
.pagination { align-items: stretch; flex-direction: column; }
|
||||
.pagination > div { width: 100%; }
|
||||
.pagination button { flex: 1; }
|
||||
.settings-list > div { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.settings-list strong { text-align: left; }
|
||||
}
|
||||
body { min-width: 320px; min-height: 100vh; margin: 0; }
|
||||
.handoff-page { width: min(560px, calc(100% - 32px)); min-height: 100vh; margin: 0 auto; display: grid; align-content: center; justify-items: start; gap: 16px; }
|
||||
.handoff-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 9px; color: #fff; background: #0052d9; font-size: 22px; font-weight: 700; }
|
||||
.eyebrow { margin: 10px 0 0; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
|
||||
h1 { max-width: 500px; margin: 0; color: #111827; font-size: clamp(26px, 5vw, 38px); line-height: 1.2; }
|
||||
.muted { max-width: 510px; margin: 0; color: #687585; font-size: 14px; line-height: 1.75; }
|
||||
.primary { display: inline-flex; align-items: center; min-height: 40px; padding: 0 18px; border-radius: 5px; color: #fff; background: #0052d9; text-decoration: none; font-size: 13px; }
|
||||
.primary:hover { background: #003cab; }
|
||||
.security-note { margin: 8px 0 0; color: #9aa5b4; font-size: 11px; }
|
||||
@media (max-width: 480px) { .handoff-page { width: calc(100% - 24px); } h1 { font-size: 28px; } }
|
||||
|
||||
Reference in New Issue
Block a user