chore: initialize standalone business plugin repository
Business Plugins CI / check (plugin-admin) (push) Successful in 3m13s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m41s

This commit is contained in:
Qiufeng
2026-08-27 23:36:08 +08:00
commit 5feae3ad41
59 changed files with 8950 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
CORE_BASE_URL=http://127.0.0.1:8080
PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8091
# Optional public path when mounted behind a reverse proxy, e.g.
# /extensions/qiu.subscription-admin
PLUGIN_PUBLIC_BASE_PATH=
# Limit the session cookie to the plugin mount path in production.
PLUGIN_COOKIE_PATH=
# Set true only behind HTTPS. Non-loopback listeners fail closed when false.
PLUGIN_COOKIE_SECURE=false
# lax (same-site proxy), strict, or none (cross-site iframe; requires Secure).
PLUGIN_COOKIE_SAMESITE=lax
# Space-separated frame ancestors. Keep 'self' for same-origin proxying.
PLUGIN_FRAME_ANCESTORS='self'
# Set true only when the immediate reverse proxy is trusted and supplies XFF.
PLUGIN_TRUST_PROXY=false
+14
View File
@@ -0,0 +1,14 @@
.PHONY: test build package check
test:
go test ./... -count=1
build:
./build.sh
package:
./package.sh
check: test
node --check ui/app.js
sh -n package.sh
+97
View File
@@ -0,0 +1,97 @@
# Sub2API Subscription Admin Business Plugin V1
这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。
生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
## 本地启动
```sh
CORE_BASE_URL=http://127.0.0.1:8080 \
PLUGIN_HOST=127.0.0.1 \
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`。
## V1 范围
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
- 只读套餐、订阅列表、订阅详情和插件操作记录。
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
- 页面刷新会从插件会话恢复,并重新取得短期 CSRF token;服务端不会把 Core token 返回浏览器。
- 登录会读取 Core 公开验证码配置并透传 Turnstile、腾讯、阿里云或 GeeTest 的验证结果;验证码本身仍由 Core 校验。
- 余额购买、续费、撤销、退款和外部支付不在 V1,页面不渲染提交按钮。
## Core API allowlist
插件服务端仅调用这些明确路径:
```text
POST /api/v1/auth/login
POST /api/v1/auth/login/2fa
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
GET /api/v1/settings/public
GET /api/v1/admin/payment/plans
GET /api/v1/admin/subscriptions
GET /api/v1/admin/subscriptions/{id}
GET /api/v1/admin/users/{id}
GET /api/v1/admin/users/{id}/subscriptions
```
列表请求只接受 `page`、`page_size`、`limit`、`user_id`、`group_id`、`status`、`platform`、`sort_by`、`sort_order` 参数。插件不会代理任意 URL,也不会调用尚不存在的 `/api/v1/plugin-host/*`。
## Core 菜单与反向代理
控制面会依据清单中的菜单声明生成下面的 `custom_menu_items` 项;部署时无需手工写入订阅菜单:
```json
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"visibility": "admin",
"sort_order": 200
}
```
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。
## 测试
```sh
go test ./... -count=1
node --check ui/app.js
```
`main_test.go` 覆盖 Core 路径 allowlist、查询参数过滤、管理员角色拒绝、插件 Cookie、Core token 不泄露、会话过期、请求 ID、登录限流和 2FA pending 一次性消费。浏览器验收脚本位于 `test/browser-check.mjs`,可使用本地 Mock Core 验证直连、子路径反代和三种视口。
## 生成可安装包
```sh
./package.sh
```
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式:
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行
启用、健康检查和菜单应用。生产环境必须把签名文件通过
`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
加入控制面受信发布者配置;未签名包仅限 development + loopback。
## 清单和发布
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。
## 已知限制
- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。
- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mkdir -p "$ROOT/bin"
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/subscription-admin" "$ROOT"
@@ -0,0 +1,44 @@
{
"schema_version": 1,
"plugin_id": "qiu.subscription-admin",
"name": "Subscription Admin",
"version": "0.1.1",
"core_api_baseline": "sub2api-0.1.183",
"capabilities": ["subscription.admin.v1"],
"tested_core_versions": ["0.1.183"],
"backend": {
"health_path": "/healthz",
"readiness_path": "/readyz",
"listen_env": "PLUGIN_PORT"
},
"ui": {
"entrypoint": "ui/index.html",
"menu": {
"id": "qiu.subscription-admin",
"label": "订阅管理",
"visibility": "admin",
"sort_order": 200
}
},
"publisher": {
"key_id": "qiu-subscription-admin-dev"
},
"core_api_allowlist": [
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/payment/plans",
"GET /api/v1/admin/subscriptions",
"GET /api/v1/admin/subscriptions/{id}",
"GET /api/v1/admin/users/{id}",
"GET /api/v1/admin/users/{id}/subscriptions"
],
"files": {
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697",
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca",
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54"
}
}
@@ -0,0 +1,12 @@
# Keep the plugin service private on loopback; expose it behind HTTPS.
CORE_ORIGIN {
handle_path /extensions/qiu.subscription-admin/* {
reverse_proxy 127.0.0.1:8091
}
}
# Start the plugin with:
# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
# PLUGIN_COOKIE_SECURE=true
# PLUGIN_FRAME_ANCESTORS='self'
@@ -0,0 +1,7 @@
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"visibility": "admin",
"sort_order": 200
}
@@ -0,0 +1,17 @@
# Deploy beside the Core reverse proxy. Keep the plugin bound to loopback.
location /extensions/qiu.subscription-admin/ {
proxy_pass http://127.0.0.1:8091/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Start the plugin with:
# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
# PLUGIN_COOKIE_SECURE=true
# PLUGIN_FRAME_ANCESTORS='self'
@@ -0,0 +1,22 @@
[Unit]
Description=Sub2API Subscription Admin business plugin
After=network-online.target
Wants=network-online.target
[Service]
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
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/subscription-admin
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
module git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin
go 1.23
@@ -0,0 +1,204 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
)
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)+$`)
var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
var requiredAllowlist = []string{
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/payment/plans",
"GET /api/v1/admin/subscriptions",
"GET /api/v1/admin/subscriptions/{id}",
"GET /api/v1/admin/users/{id}",
"GET /api/v1/admin/users/{id}/subscriptions",
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
CoreAPIBaseline string `json:"core_api_baseline"`
Capabilities []string `json:"capabilities"`
TestedCoreVersions []string `json:"tested_core_versions"`
Backend struct {
HealthPath string `json:"health_path"`
ReadinessPath string `json:"readiness_path"`
ListenEnv string `json:"listen_env"`
} `json:"backend"`
UI struct {
Entrypoint string `json:"entrypoint"`
Menu struct {
ID string `json:"id"`
Label string `json:"label"`
Visibility string `json:"visibility"`
SortOrder int `json:"sort_order"`
} `json:"menu"`
} `json:"ui"`
Publisher struct {
KeyID string `json:"key_id"`
} `json:"publisher"`
CoreAPIAllowlist []string `json:"core_api_allowlist"`
Files map[string]string `json:"files,omitempty"`
}
type Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
func Load(path string) (Manifest, []byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, nil, err
}
var m Manifest
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return Manifest{}, nil, errors.New("manifest contains trailing JSON")
}
return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err)
}
if err := Validate(m); err != nil {
return Manifest{}, nil, err
}
return m, raw, nil
}
func Validate(m Manifest) error {
if m.SchemaVersion != 1 {
return fmt.Errorf("schema_version must be 1")
}
if !pluginIDPattern.MatchString(m.PluginID) {
return fmt.Errorf("invalid plugin_id")
}
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
return fmt.Errorf("name is required and must be at most 160 characters")
}
if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) {
return fmt.Errorf("invalid plugin version")
}
baseline := strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-")
if !versionPattern.MatchString(strings.TrimPrefix(baseline, "v")) {
return fmt.Errorf("invalid core_api_baseline")
}
if len(m.Capabilities) != 1 || m.Capabilities[0] != "subscription.admin.v1" {
return fmt.Errorf("capabilities must contain subscription.admin.v1 only")
}
for _, version := range m.TestedCoreVersions {
if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) {
return fmt.Errorf("invalid tested_core_versions entry")
}
}
if m.Backend.HealthPath != "/healthz" || m.Backend.ReadinessPath != "/readyz" || m.Backend.ListenEnv != "PLUGIN_PORT" {
return fmt.Errorf("backend health_path/listen_env do not match V1 contract")
}
if (m.UI.Entrypoint != "/admin" && m.UI.Entrypoint != "/admin/" && m.UI.Entrypoint != "ui/index.html") || m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 || m.Publisher.KeyID == "" {
return fmt.Errorf("ui.entrypoint/menu and publisher.key_id are required")
}
if len(m.CoreAPIAllowlist) != len(requiredAllowlist) {
return fmt.Errorf("core_api_allowlist must contain exactly %d entries", len(requiredAllowlist))
}
seen := make(map[string]struct{}, len(m.CoreAPIAllowlist))
for _, entry := range m.CoreAPIAllowlist {
if _, ok := seen[entry]; ok {
return fmt.Errorf("duplicate allowlist entry: %s", entry)
}
seen[entry] = struct{}{}
}
for _, required := range requiredAllowlist {
if _, ok := seen[required]; !ok {
return fmt.Errorf("missing allowlist entry: %s", required)
}
}
for file, hash := range m.Files {
if strings.TrimSpace(file) == "" || strings.HasPrefix(strings.ReplaceAll(file, "\\", "/"), "/") || strings.Contains(strings.ReplaceAll(file, "\\", "/"), "..") {
return fmt.Errorf("invalid file declaration: %s", file)
}
if _, err := hex.DecodeString(hash); err != nil || len(hash) != 64 {
return fmt.Errorf("invalid file hash declaration: %s", file)
}
}
return nil
}
// DeclaredFiles returns file paths in deterministic order for package tooling.
func DeclaredFiles(m Manifest) []string {
files := make([]string, 0, len(m.Files))
for file := range m.Files {
files = append(files, file)
}
sort.Strings(files)
return files
}
func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error {
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("signature contains trailing JSON")
}
return fmt.Errorf("decode signature trailing data: %w", err)
}
if signature.Algorithm != "ed25519" || signature.KeyID == "" {
return errors.New("signature must use ed25519 and include key_id")
}
publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes)))
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return errors.New("invalid base64 ed25519 public key")
}
sig, err := base64.StdEncoding.DecodeString(signature.Signature)
if err != nil || len(sig) != ed25519.SignatureSize {
return errors.New("invalid base64 ed25519 signature")
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) {
return errors.New("manifest signature verification failed")
}
return nil
}
func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error {
var signature Signature
if err := json.Unmarshal(signatureBytes, &signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID {
return errors.New("signature key_id does not match manifest publisher")
}
return nil
}
func RequiredAllowlist() []string {
return append([]string(nil), requiredAllowlist...)
}
@@ -0,0 +1,62 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"testing"
)
func validManifest() Manifest {
var m Manifest
m.SchemaVersion = 1
m.PluginID = "qiu.subscription-admin"
m.Name = "Subscription Admin"
m.Version = "0.1.0"
m.CoreAPIBaseline = "sub2api-0.1.183"
m.Capabilities = []string{"subscription.admin.v1"}
m.TestedCoreVersions = []string{"0.1.183"}
m.Backend.HealthPath = "/healthz"
m.Backend.ReadinessPath = "/readyz"
m.Backend.ListenEnv = "PLUGIN_PORT"
m.UI.Entrypoint = "ui/index.html"
m.UI.Menu.ID = m.PluginID
m.UI.Menu.Label = "订阅管理"
m.UI.Menu.Visibility = "admin"
m.UI.Menu.SortOrder = 200
m.Publisher.KeyID = "test-key"
m.CoreAPIAllowlist = RequiredAllowlist()
return m
}
func TestValidateManifest(t *testing.T) {
if err := Validate(validManifest()); err != nil {
t.Fatal(err)
}
m := validManifest()
m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/users")
if err := Validate(m); err == nil {
t.Fatal("expected exact allowlist validation failure")
}
}
func TestVerifySignature(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
manifestBytes := []byte(`{"schema_version":1}`)
signature := Signature{Algorithm: "ed25519", KeyID: "test-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
t.Fatal(err)
}
manifestBytes[0] = '{'
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
t.Fatal("expected tamper failure")
}
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func testCoreClient(t *testing.T, handler http.Handler) *coreClient {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
c, err := newCoreClient(ts.URL)
if err != nil {
t.Fatal(err)
}
return c
}
func TestCoreClientAllowlistAndRequestID(t *testing.T) {
var gotPath, gotAuth, gotRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth, gotRequestID = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Request-Id")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"role":"admin"}}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "CORE-TOKEN"); err != nil {
t.Fatal(err)
}
if gotPath != "/api/v1/admin/subscriptions?page=1" {
t.Fatalf("path=%q", gotPath)
}
if gotAuth != "Bearer CORE-TOKEN" || gotRequestID == "" {
t.Fatalf("headers auth=%q request_id=%q", gotAuth, gotRequestID)
}
if _, err := c.read(context.Background(), "/api/v1/admin/payment/plans/1", "TOKEN"); err == nil {
t.Fatal("unexpected allowlist success")
}
}
func TestCoreReadQueryIsSanitized(t *testing.T) {
var gotPath string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.RequestURI()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":[]}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "TOKEN"); err != nil {
t.Fatal(err)
}
if gotPath != "/api/v1/admin/subscriptions?page=1" {
t.Fatalf("sanitized path=%q", gotPath)
}
}
func TestNewCoreClientRejectsNonAbsoluteOrQueryURL(t *testing.T) {
for _, base := range []string{"", "/api", "ftp://core", "https://core.test/?token=secret", "http://core.test", "https://user:pass@core.test"} {
if _, err := newCoreClient(base); err == nil {
t.Fatalf("expected invalid Core URL: %q", base)
}
}
for _, base := range []string{"http://127.0.0.1:8080", "http://[::1]:8080", "http://localhost:8080"} {
if _, err := newCoreClient(base); err != nil {
t.Fatalf("expected loopback URL to be accepted: %q: %v", base, err)
}
}
}
func TestCoreClientRejectsNonzeroEnvelopeCode(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":422,"message":"bad","data":{}}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions", "TOKEN"); err == nil {
t.Fatal("expected nonzero Core envelope to fail")
}
}
func TestLoginRequiresAdminAndDoesNotReturnCoreToken(t *testing.T) {
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/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":7,"role":"user","email":"user@example.com"}}`))
case "/api/v1/auth/logout":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "CORE-TOKEN") || strings.Contains(rec.Body.String(), "CORE-REFRESH") {
t.Fatalf("core token leaked: %s", rec.Body.String())
}
}
func TestLoginAndReadProxyUsePluginCookie(t *testing.T) {
var readAuth 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/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"admin@example.com"}}`))
case "/api/v1/admin/subscriptions":
readAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[],"total":0,"page":1,"page_size":20,"pages":1}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
loginRec := httptest.NewRecorder()
a.login(loginRec, loginReq)
if loginRec.Code != http.StatusOK || strings.Contains(loginRec.Body.String(), "CORE-TOKEN") {
t.Fatalf("login status/body: %d %s", loginRec.Code, loginRec.Body.String())
}
cookie := loginRec.Result().Cookies()[0]
readReq := httptest.NewRequest(http.MethodGet, "/api/subscriptions?page=1", nil)
readReq.AddCookie(cookie)
readRec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(readRec, readReq)
if readRec.Code != http.StatusOK || readAuth != "Bearer CORE-TOKEN" {
t.Fatalf("read status=%d auth=%q body=%s", readRec.Code, readAuth, readRec.Body.String())
}
}
func TestReadProxyStripsSensitiveCoreFields(t *testing.T) {
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/subscriptions":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"LEAK","items":[{"refresh_token":"LEAK2","id":1}]}}`))
default:
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}
}))
a := newApp(c, false)
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
if strings.Contains(rec.Body.String(), "LEAK") || strings.Contains(rec.Body.String(), "refresh_token") {
t.Fatalf("sensitive field leaked: %s", rec.Body.String())
}
}
func TestCoreRevocationDestroysPluginSession(t *testing.T) {
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/me" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}}
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("status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.sessions["sid"]; ok {
t.Fatal("revoked Core session remained in plugin store")
}
}
func TestLogoutRevokesCoreRefreshToken(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")
if r.URL.Path == "/api/v1/auth/logout" {
atomic.AddInt32(&logoutCalls, 1)
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["refresh_token"] != "REFRESH" {
t.Errorf("refresh token=%q", body["refresh_token"])
}
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newApp(c, false)
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
rec := httptest.NewRecorder()
a.logout(rec, req)
if rec.Code != http.StatusOK || atomic.LoadInt32(&logoutCalls) != 1 {
t.Fatalf("status=%d logout_calls=%d body=%s", rec.Code, logoutCalls, rec.Body.String())
}
}
func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
var refreshCalls int32
var meCalls int32
var readCalls 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":
call := atomic.AddInt32(&meCalls, 1)
if call == 1 {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
} else {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
}
case "/api/v1/admin/subscriptions":
call := atomic.AddInt32(&readCalls, 1)
if call == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
} else {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[]}}`))
}
case "/api/v1/auth/refresh":
atomic.AddInt32(&refreshCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
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}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
if rec.Code != http.StatusOK || atomic.LoadInt32(&refreshCalls) != 1 {
t.Fatalf("status=%d refresh_calls=%d body=%s", rec.Code, refreshCalls, rec.Body.String())
}
}
func TestSessionExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now.Add(-sessionTTL - time.Second), user: map[string]any{"id": 1}}
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("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
a.pending["pending"] = pendingLogin{tempToken: "CORE-TEMP", expires: now.Add(time.Minute)}
first := a.pending["pending"]
delete(a.pending, "pending")
if _, ok := a.pending["pending"]; ok || first.tempToken != "CORE-TEMP" {
t.Fatal("pending token was not consumed")
}
}
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
for _, path := range []string{
"/api/v1/admin/subscriptions/1/progress",
"/api/v1/admin/users/1/subscriptions/extra",
"/api/v1/admin/payment/plans/1",
"/api/v1/admin/../users",
} {
if allowedReadPath(path) {
t.Fatalf("unexpected allowlist match: %s", path)
}
}
}
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
a := newApp(nil, 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)
}
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)
}
}
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
var coreRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
coreRequestID = r.Header.Get(requestIDHeader)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"A","refresh_token":"R"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"a@example.com"}}`))
case "/api/v1/auth/logout":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}
}))
a := newApp(c, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
request, _ := http.NewRequest(http.MethodPost, server.URL+"/login", strings.NewReader(`{"email":"a@example.com","password":"password"}`))
request.Header.Set(requestIDHeader, "client-request-123")
request.Header.Set("Content-Type", "application/json")
response, err := server.Client().Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || response.Header.Get(requestIDHeader) != "client-request-123" || coreRequestID != "client-request-123" {
t.Fatalf("status=%d response_id=%q core_id=%q", response.StatusCode, response.Header.Get(requestIDHeader), coreRequestID)
}
cookies := response.Cookies()
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode {
t.Fatalf("cookie=%#v", cookies)
}
bootstrap, _ := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
bootstrap.AddCookie(cookies[0])
bootstrapResponse, err := server.Client().Do(bootstrap)
if err != nil {
t.Fatal(err)
}
if bootstrapResponse.StatusCode != http.StatusOK || !strings.Contains(readBody(t, bootstrapResponse), "csrf_token") {
t.Fatalf("bootstrap status=%d", bootstrapResponse.StatusCode)
}
}
func readBody(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"}
command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
TMP=$(mktemp -d)
cleanup() {
rm -rf "$TMP"
}
trap cleanup EXIT INT TERM
mkdir -p "$TMP/ui" "$(dirname -- "$OUT")"
cp "$ROOT/ui/index.html" "$ROOT/ui/app.js" "$ROOT/ui/styles.css" "$TMP/ui/"
# Recompute declared hashes at package time so a UI change cannot produce an
# archive that the control plane rejects. The repository descriptor remains
# the human-readable source contract.
INDEX_HASH=$(shasum -a 256 "$ROOT/ui/index.html" | awk '{print $1}')
APP_HASH=$(shasum -a 256 "$ROOT/ui/app.js" | awk '{print $1}')
STYLES_HASH=$(shasum -a 256 "$ROOT/ui/styles.css" | awk '{print $1}')
jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "$STYLES_HASH" \
'.files["ui/index.html"]=$index_hash | .files["ui/app.js"]=$app_hash | .files["ui/styles.css"]=$styles_hash' \
"$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
if [ -n "${SIGNATURE_FILE:-}" ]; then
cp "$SIGNATURE_FILE" "$TMP/signature.json"
fi
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")"
(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui)
printf '%s\n' "$OUT_PATH"
@@ -0,0 +1,43 @@
import { chromium } from 'playwright'
import fs from 'node:fs/promises'
import path from 'node:path'
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18081'
const entry = `${origin}/extensions/qiu.subscription-admin/admin/`
const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscription-admin'
await fs.mkdir(path.resolve(outputDir), { recursive: true })
const browser = await chromium.launch({ headless: true })
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
const responseLeaks = []
page.on('response', async (response) => {
if (!response.headers()['content-type']?.includes('application/json')) return
try {
const body = await response.text()
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} 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.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' })
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 })
await page.close()
}
} finally {
await browser.close()
}
@@ -0,0 +1,80 @@
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 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' }]
])
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(body))
}
async function body(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
}
function authorized(req) {
return req.headers.authorization === `Bearer ${token}` || req.headers.authorization === `Bearer NEW-MOCK-ACCESS`
}
function subscriptions(page = 1, pageSize = 50) {
const all = Array.from({ length: 57 }, (_, index) => ({
id: index + 1,
user_id: 1,
plan_id: 10 + (index % 2),
plan_name: index % 2 ? '专业版' : '基础版',
status: 'active',
starts_at: '2026-08-01T00:00:00Z',
expires_at: '2026-09-01T00:00:00Z',
cycle_usage_usd: index / 100,
cycle_quota_usd: 100,
user: { id: 1, username: 'admin', email: 'admin@example.com', balance: 123.45 }
}))
const start = (page - 1) * pageSize
return { items: all.slice(start, start + pageSize), total: all.length, page, page_size: pageSize, pages: Math.ceil(all.length / pageSize) }
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
const path = url.pathname
if (req.method === 'POST' && path === '/api/v1/auth/login') {
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' })
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') {
const input = await body(req)
if (input.refresh_token !== refresh && input.refresh_token !== 'NEW-MOCK-REFRESH') return json(res, 401, { code: 401, message: 'expired' })
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 (!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') {
return json(res, 200, { code: 0, message: 'success', data: [{ id: 10, name: '基础版', price: 9.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }, { id: 11, name: '专业版', price: 19.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }] })
}
if (req.method === 'GET' && path === '/api/v1/admin/subscriptions') {
const page = Number(url.searchParams.get('page') || 1)
const pageSize = Number(url.searchParams.get('page_size') || 50)
return json(res, 200, { code: 0, message: 'success', data: subscriptions(page, pageSize) })
}
const subscriptionMatch = path.match(/^\/api\/v1\/admin\/subscriptions\/(\d+)$/)
if (req.method === 'GET' && subscriptionMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 1).items[0] })
const userSubscriptionsMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)\/subscriptions$/)
if (req.method === 'GET' && userSubscriptionsMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 2).items })
const userMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)$/)
if (req.method === 'GET' && userMatch) return json(res, 200, { code: 0, message: 'success', data: { id: Number(userMatch[1]), username: 'admin', email: 'admin@example.com', balance: 123.45, frozen_balance: 0 } })
return json(res, 404, { code: 404, message: 'not found' })
})
server.listen(port, '127.0.0.1', () => {
process.stdout.write(`mock core listening on 127.0.0.1:${port}\n`)
})
@@ -0,0 +1,27 @@
import http from 'node:http'
const prefix = '/extensions/qiu.subscription-admin'
const targetPort = Number(process.env.PLUGIN_TARGET_PORT || 18082)
const port = Number(process.env.MOCK_PROXY_PORT || 18081)
const server = http.createServer((req, res) => {
if (!req.url.startsWith(prefix)) {
res.writeHead(404)
res.end('not found')
return
}
const forwardedPath = req.url.slice(prefix.length) || '/'
const proxy = http.request({ hostname: '127.0.0.1', port: targetPort, method: req.method, path: forwardedPath, headers: { ...req.headers, host: `127.0.0.1:${targetPort}` } }, (upstream) => {
const headers = { ...upstream.headers }
if (headers.location && headers.location.startsWith('/admin/')) headers.location = `${prefix}${headers.location}`
res.writeHead(upstream.statusCode || 502, headers)
upstream.pipe(res)
})
proxy.on('error', () => {
if (!res.headersSent) res.writeHead(502)
res.end('proxy error')
})
req.pipe(proxy)
})
server.listen(port, '127.0.0.1', () => process.stdout.write(`mock proxy listening on 127.0.0.1:${port}\n`))
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cleanup() {
kill "${PROXY_PID:-}" "${PLUGIN_PID:-}" "${CORE_PID:-}" 2>/dev/null || true
}
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=$!
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
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
@@ -0,0 +1,45 @@
package main
import (
"flag"
"fmt"
"os"
"git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin/internal/manifest"
)
func main() {
manifestPath := flag.String("manifest", "business-plugin-manifest.v1.json", "manifest path")
signaturePath := flag.String("signature", "", "optional detached signature path")
publicKeyPath := flag.String("public-key", "", "base64 Ed25519 public key file")
flag.Parse()
m, raw, err := manifest.Load(*manifestPath)
if err != nil {
fatal(err)
}
if (*signaturePath == "") != (*publicKeyPath == "") {
fatal(fmt.Errorf("-signature and -public-key must be provided together"))
}
if *signaturePath != "" {
signature, err := os.ReadFile(*signaturePath)
if err != nil {
fatal(err)
}
publicKey, err := os.ReadFile(*publicKeyPath)
if err != nil {
fatal(err)
}
if err := manifest.VerifyKeyID(signature, m.Publisher.KeyID); err != nil {
fatal(err)
}
if err := manifest.VerifySignature(raw, signature, publicKey); err != nil {
fatal(err)
}
}
fmt.Printf("manifest valid: %s\n", *manifestPath)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+170
View File
@@ -0,0 +1,170 @@
(() => {
'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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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()
})()
+9
View File
@@ -0,0 +1,9 @@
package ui
import "embed"
// FS contains the static administrator UI shipped with the business plugin.
// It is embedded so the service does not depend on a writable host directory.
//
//go:embed index.html app.js styles.css
var FS embed.FS
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<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>
<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>
+103
View File
@@ -0,0 +1,103 @@
: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; } }
* { 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; }
}