fix: hide admin authorization in setup mode
This commit is contained in:
parent
8a24ec7e9e
commit
9064fc5f1d
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
|
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
|
||||||
|
|
||||||
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。
|
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
|
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
|
||||||
|
|
||||||
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
|
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
|
||||||
|
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
|
||||||
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。
|
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。
|
||||||
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
|
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
|
||||||
- method 不符 → **405**。
|
- method 不符 → **405**。
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const SAFE_ROUTES = Object.freeze([
|
||||||
"/",
|
"/",
|
||||||
"/assets/config.js",
|
"/assets/config.js",
|
||||||
"/assets/styles.css",
|
"/assets/styles.css",
|
||||||
|
"/api/status",
|
||||||
"/api/config",
|
"/api/config",
|
||||||
"/api/business-config",
|
"/api/business-config",
|
||||||
"/api/admin/session",
|
"/api/admin/session",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
*
|
*
|
||||||
* 路由模型:
|
* 路由模型:
|
||||||
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
|
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
|
||||||
|
* - GET /api/status → 后端配置引导状态;只返回缺失变量名称
|
||||||
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
|
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
|
||||||
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
|
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
|
||||||
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
|
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
|
||||||
|
|
@ -40,6 +41,7 @@ const ROUTE_METHODS = Object.freeze({
|
||||||
"/": ["GET"],
|
"/": ["GET"],
|
||||||
"/assets/config.js": ["GET"],
|
"/assets/config.js": ["GET"],
|
||||||
"/assets/styles.css": ["GET"],
|
"/assets/styles.css": ["GET"],
|
||||||
|
"/api/status": ["GET"],
|
||||||
"/api/config": ["GET", "PUT"],
|
"/api/config": ["GET", "PUT"],
|
||||||
"/api/business-config": ["PUT"],
|
"/api/business-config": ["PUT"],
|
||||||
"/api/admin/session": ["POST"],
|
"/api/admin/session": ["POST"],
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
|
||||||
|
|
||||||
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
|
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
|
||||||
for (const r of [
|
for (const r of [
|
||||||
"/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
|
"/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
|
||||||
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
|
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
|
||||||
]) {
|
]) {
|
||||||
assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`);
|
assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`);
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,30 @@ test("Worker: business config PUT is proxied with the session bearer and empty 2
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Worker: public setup status is proxied without an authorization header", async () => {
|
||||||
|
const restore = stubFetch(async (url, init) => {
|
||||||
|
assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/);
|
||||||
|
assert.equal(init.method, "GET");
|
||||||
|
assert.equal(new Headers(init.headers).has("authorization"), false);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
ready: false,
|
||||||
|
mode: "configuration-setup",
|
||||||
|
missing: ["REDIS_URL"],
|
||||||
|
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await handleFetch(new Request("https://lb.example/api/status"), makeEnv());
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), {
|
||||||
|
ready: false,
|
||||||
|
mode: "configuration-setup",
|
||||||
|
missing: ["REDIS_URL"],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => {
|
test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => {
|
||||||
const res = await handleFetch(
|
const res = await handleFetch(
|
||||||
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),
|
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链
|
||||||
# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080
|
# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080
|
||||||
GET / 业务配置与运行参数管理 SPA
|
GET / 业务配置与运行参数管理 SPA
|
||||||
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
|
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
|
||||||
|
GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名
|
||||||
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session
|
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session
|
||||||
POST /api/admin/session/revoke Bearer admin session;成功返回 204
|
POST /api/admin/session/revoke Bearer admin session;成功返回 204
|
||||||
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
|
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
|
||||||
|
|
@ -338,7 +339,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
|
||||||
|
|
||||||
### 10.4 路由与故障转移
|
### 10.4 路由与故障转移
|
||||||
|
|
||||||
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
|
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
|
||||||
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
|
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
|
||||||
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
|
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
|
||||||
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
|
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,7 @@ stalwart-bot/
|
||||||
|
|
||||||
### 7.3.1 配置管理 API
|
### 7.3.1 配置管理 API
|
||||||
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
||||||
|
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。
|
||||||
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
|
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
|
||||||
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
|
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
|
||||||
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
|
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,13 @@
|
||||||
|
|
||||||
const bootstrapInput = document.querySelector("#bootstrap-input");
|
const bootstrapInput = document.querySelector("#bootstrap-input");
|
||||||
const authForm = document.querySelector("#auth-form");
|
const authForm = document.querySelector("#auth-form");
|
||||||
|
const intro = document.querySelector("#intro");
|
||||||
const authCard = document.querySelector("#auth-card");
|
const authCard = document.querySelector("#auth-card");
|
||||||
|
const setupCard = document.querySelector("#setup-card");
|
||||||
|
const setupMessage = document.querySelector("#setup-message");
|
||||||
|
const setupMissing = document.querySelector("#setup-missing");
|
||||||
|
const statusRetryButton = document.querySelector("#status-retry-button");
|
||||||
|
const privacyNote = document.querySelector("#privacy-note");
|
||||||
const configPanel = document.querySelector("#config-panel");
|
const configPanel = document.querySelector("#config-panel");
|
||||||
const businessForm = document.querySelector("#business-form");
|
const businessForm = document.querySelector("#business-form");
|
||||||
const businessFields = document.querySelector("#business-fields");
|
const businessFields = document.querySelector("#business-fields");
|
||||||
|
|
@ -50,6 +56,7 @@
|
||||||
let expiryTimer = 0;
|
let expiryTimer = 0;
|
||||||
let busyAction = "";
|
let busyAction = "";
|
||||||
let runtimeLoaded = false;
|
let runtimeLoaded = false;
|
||||||
|
let setupMode = true;
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(status, message) {
|
constructor(status, message) {
|
||||||
|
|
@ -59,6 +66,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNotice(kind, message) {
|
function showNotice(kind, message) {
|
||||||
|
notice.hidden = false;
|
||||||
notice.className = `notice notice-${kind}`;
|
notice.className = `notice notice-${kind}`;
|
||||||
notice.textContent = message;
|
notice.textContent = message;
|
||||||
notice.setAttribute("role", kind === "error" ? "alert" : "status");
|
notice.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||||
|
|
@ -122,27 +130,71 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSetupStatus() {
|
async function showSetupStatus() {
|
||||||
|
showSetupView("正在检查服务启动配置…");
|
||||||
|
statusRetryButton.disabled = true;
|
||||||
|
statusRetryButton.textContent = "正在检查…";
|
||||||
try {
|
try {
|
||||||
const status = await request("/api/status", "GET", undefined, "");
|
const status = await request("/api/status", "GET", undefined, "");
|
||||||
if (status && status.ready === false) {
|
if (status && status.ready === true) {
|
||||||
const missing = Array.isArray(status.missing) ? status.missing.join("、") : "启动配置";
|
setupMode = false;
|
||||||
showNotice("error", `服务处于配置引导模式。请在部署环境设置:${missing};密钥不会在页面保存或回显。`);
|
setupCard.hidden = true;
|
||||||
|
notice.hidden = false;
|
||||||
|
intro.hidden = false;
|
||||||
|
authCard.hidden = false;
|
||||||
|
configPanel.hidden = true;
|
||||||
|
privacyNote.hidden = false;
|
||||||
|
showNotice("info", "使用 Redis 管理凭据创建短期管理会话。");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const isSetup = status && status.ready === false && status.mode === "configuration-setup";
|
||||||
|
if (isSetup) {
|
||||||
|
const knownMissing = Array.isArray(status.missing)
|
||||||
|
? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY")
|
||||||
|
: [];
|
||||||
|
const message = knownMissing.length
|
||||||
|
? "服务处于配置引导模式。请在部署环境补齐以下启动变量:"
|
||||||
|
: "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。";
|
||||||
|
showSetupView(message, knownMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showSetupView("无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。");
|
||||||
} catch {
|
} catch {
|
||||||
// The regular connection flow reports unavailable services without exposing details.
|
showSetupView("暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。");
|
||||||
|
} finally {
|
||||||
|
statusRetryButton.disabled = false;
|
||||||
|
statusRetryButton.textContent = "重新检查";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showSetupView(message, missing = []) {
|
||||||
|
setupMode = true;
|
||||||
|
setupMessage.textContent = message;
|
||||||
|
setupMissing.textContent = missing.join("、");
|
||||||
|
setupMissing.hidden = missing.length === 0;
|
||||||
|
setupCard.hidden = false;
|
||||||
|
intro.hidden = true;
|
||||||
|
notice.hidden = true;
|
||||||
|
authCard.hidden = true;
|
||||||
|
configPanel.hidden = true;
|
||||||
|
privacyNote.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
function showConfigView() {
|
function showConfigView() {
|
||||||
|
setupCard.hidden = true;
|
||||||
|
intro.hidden = false;
|
||||||
|
privacyNote.hidden = false;
|
||||||
authCard.hidden = true;
|
authCard.hidden = true;
|
||||||
configPanel.hidden = false;
|
configPanel.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAuthView() {
|
function showAuthView() {
|
||||||
|
setupCard.hidden = true;
|
||||||
|
intro.hidden = false;
|
||||||
|
privacyNote.hidden = false;
|
||||||
configPanel.hidden = true;
|
configPanel.hidden = true;
|
||||||
authCard.hidden = false;
|
authCard.hidden = setupMode;
|
||||||
bootstrapInput.value = "";
|
bootstrapInput.value = "";
|
||||||
bootstrapInput.focus();
|
if (!setupMode) bootstrapInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSecretFields() {
|
function clearSecretFields() {
|
||||||
|
|
@ -490,6 +542,8 @@
|
||||||
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
|
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
statusRetryButton.addEventListener("click", showSetupStatus);
|
||||||
|
|
||||||
defaultsButton.addEventListener("click", () => {
|
defaultsButton.addEventListener("click", () => {
|
||||||
if (!runtimeLoaded || busyAction) return;
|
if (!runtimeLoaded || busyAction) return;
|
||||||
fillRuntimeFields(RUNTIME_DEFAULTS);
|
fillRuntimeFields(RUNTIME_DEFAULTS);
|
||||||
|
|
@ -574,7 +628,7 @@
|
||||||
clearForms();
|
clearForms();
|
||||||
clearSecretFields();
|
clearSecretFields();
|
||||||
configPanel.hidden = true;
|
configPanel.hidden = true;
|
||||||
authCard.hidden = false;
|
authCard.hidden = setupMode;
|
||||||
bootstrapInput.value = "";
|
bootstrapInput.value = "";
|
||||||
setBusy("");
|
setBusy("");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
|
||||||
|
const source = readFileSync(new URL("./config.js", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(hidden = false) {
|
||||||
|
this.hidden = hidden;
|
||||||
|
this.disabled = false;
|
||||||
|
this.required = false;
|
||||||
|
this.checked = false;
|
||||||
|
this.value = "";
|
||||||
|
this.textContent = "";
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.attributes = new Map();
|
||||||
|
this.classList = {
|
||||||
|
toggle() {},
|
||||||
|
add() {},
|
||||||
|
remove() {},
|
||||||
|
};
|
||||||
|
this.children = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type, callback) {
|
||||||
|
this.listeners.set(type, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector) {
|
||||||
|
if (!this.children.has(selector)) this.children.set(selector, new FakeElement());
|
||||||
|
return this.children.get(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
focus() {}
|
||||||
|
reset() {}
|
||||||
|
setCustomValidity() {}
|
||||||
|
reportValidity() { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPage(statusResponse) {
|
||||||
|
const elements = new Map();
|
||||||
|
for (const match of html.matchAll(/<[^>]*\bid="([^"]+)"[^>]*>/gs)) {
|
||||||
|
const [, id] = match;
|
||||||
|
const hidden = /\shidden(?:\s|=|>)/.test(match[0]);
|
||||||
|
elements.set(id, new FakeElement(hidden));
|
||||||
|
}
|
||||||
|
const document = {
|
||||||
|
querySelector(selector) {
|
||||||
|
const match = selector.match(/^#(.+)$/);
|
||||||
|
if (!match || !elements.has(match[1])) throw new Error(`Missing selector: ${selector}`);
|
||||||
|
return elements.get(match[1]);
|
||||||
|
},
|
||||||
|
getElementById(id) {
|
||||||
|
if (!elements.has(id)) throw new Error(`Missing element: ${id}`);
|
||||||
|
return elements.get(id);
|
||||||
|
},
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
};
|
||||||
|
const window = {
|
||||||
|
addEventListener() {},
|
||||||
|
setTimeout() { return 1; },
|
||||||
|
clearTimeout() {},
|
||||||
|
};
|
||||||
|
const fetchCalls = [];
|
||||||
|
const fetch = async (path, options) => {
|
||||||
|
fetchCalls.push({ path, options });
|
||||||
|
if (path !== "/api/status") throw new Error(`Unexpected request: ${path}`);
|
||||||
|
if (statusResponse instanceof Error) throw statusResponse;
|
||||||
|
const httpStatus = statusResponse.httpStatus ?? 200;
|
||||||
|
return {
|
||||||
|
ok: httpStatus >= 200 && httpStatus < 300,
|
||||||
|
status: httpStatus,
|
||||||
|
text: async () => JSON.stringify(statusResponse.body),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.runInNewContext(source, { document, window, fetch, Headers });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await Promise.resolve();
|
||||||
|
return { elements, fetchCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("configuration-setup hides all authorization UI and lists only missing variable names", async () => {
|
||||||
|
const { elements, fetchCalls } = await startPage({
|
||||||
|
body: {
|
||||||
|
ready: false,
|
||||||
|
mode: "configuration-setup",
|
||||||
|
missing: ["REDIS_URL", "CONFIG_ENCRYPTION_KEY", "secret-value-must-not-display"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(elements.get("setup-card").hidden, false);
|
||||||
|
assert.equal(elements.get("intro").hidden, true);
|
||||||
|
assert.equal(elements.get("auth-card").hidden, true);
|
||||||
|
assert.equal(elements.get("config-panel").hidden, true);
|
||||||
|
assert.equal(elements.get("notice").hidden, true);
|
||||||
|
assert.equal(elements.get("privacy-note").hidden, true);
|
||||||
|
assert.equal(elements.get("setup-missing").textContent, "REDIS_URL、CONFIG_ENCRYPTION_KEY");
|
||||||
|
assert.equal(elements.get("setup-message").textContent.includes("配置引导模式"), true);
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(fetchCalls[0].path, "/api/status");
|
||||||
|
assert.equal(fetchCalls[0].options.credentials, "omit");
|
||||||
|
assert.equal(fetchCalls[0].options.cache, "no-store");
|
||||||
|
assert.equal(fetchCalls[0].options.headers.has("authorization"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ready status preserves the existing admin-session entry and configuration page", async () => {
|
||||||
|
const { elements } = await startPage({
|
||||||
|
body: { ready: true, mode: "configured", missing: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(elements.get("setup-card").hidden, true);
|
||||||
|
assert.equal(elements.get("intro").hidden, false);
|
||||||
|
assert.equal(elements.get("auth-card").hidden, false);
|
||||||
|
assert.equal(elements.get("config-panel").hidden, true);
|
||||||
|
assert.equal(elements.get("privacy-note").hidden, false);
|
||||||
|
assert.equal(elements.get("notice").hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status errors keep the authorization form hidden until readiness is confirmed", async () => {
|
||||||
|
const { elements } = await startPage({
|
||||||
|
httpStatus: 503,
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(elements.get("setup-card").hidden, false);
|
||||||
|
assert.equal(elements.get("auth-card").hidden, true);
|
||||||
|
assert.equal(elements.get("config-panel").hidden, true);
|
||||||
|
assert.match(elements.get("setup-message").textContent, /授权入口保持隐藏/);
|
||||||
|
});
|
||||||
|
|
@ -22,17 +22,30 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<section class="intro" aria-labelledby="page-title">
|
<section id="intro" class="intro" aria-labelledby="page-title" hidden>
|
||||||
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
|
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
|
||||||
<h1 id="page-title">管理你的服务连接</h1>
|
<h1 id="page-title">管理你的服务连接</h1>
|
||||||
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
|
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="notice" class="notice notice-info" role="status" aria-live="polite">
|
<div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden>
|
||||||
使用 Redis 管理凭据创建短期管理会话。
|
使用 Redis 管理凭据创建短期管理会话。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title">
|
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 4.4 3.1 7.6 8 9 4.9-1.4 8-4.6 8-9V7l-8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
|
||||||
|
</div>
|
||||||
|
<div><p class="eyebrow">配置引导</p><h2 id="setup-title">服务启动配置</h2></div>
|
||||||
|
</div>
|
||||||
|
<p id="setup-message" class="panel-copy">正在检查服务启动配置…</p>
|
||||||
|
<p id="setup-missing" class="setup-missing" hidden></p>
|
||||||
|
<p class="setup-privacy">启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。</p>
|
||||||
|
<button id="status-retry-button" class="button button-quiet" type="button">重新检查</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title" hidden>
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<div class="icon-tile icon-violet" aria-hidden="true">
|
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
|
||||||
|
|
@ -140,7 +153,7 @@
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="privacy-note"><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
|
<aside id="privacy-note" class="privacy-note" hidden><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer"><span>STALWART BOT</span><span>配置由你的服务端管理</span></footer>
|
<footer class="footer"><span>STALWART BOT</span><span>配置由你的服务端管理</span></footer>
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16;
|
||||||
h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
|
h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
|
||||||
h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; }
|
h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; }
|
||||||
.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
|
.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
|
||||||
|
.setup-panel { max-width: 720px; }
|
||||||
|
.setup-panel .panel-copy { margin-bottom: 14px; }
|
||||||
|
.setup-missing { margin: 0 0 17px; padding: 12px 14px; border: 1px solid #f2e9c8; border-radius: 8px; background: #fffbef; color: #796132; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.setup-privacy { margin: 0 0 17px; color: #858697; font-size: 12px; line-height: 1.65; }
|
||||||
code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; }
|
.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; }
|
||||||
.token-row { display: flex; gap: 10px; }
|
.token-row { display: flex; gap: 10px; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue