Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin-common.js
phamnazage-jpg 85954e516a fix(review): address 2026-06-08 review report issues
## Fixed

### High-4: CI 与质量门禁不一致
- Add quality-gates job that runs verify_quality_gates.sh
- Fix Docker job: correct binary paths and remove || true
- Replace fake version/help checks with real health endpoint probe

### High-5: 敏感信息持久化到 localStorage
- Add SENSITIVE_FIELDS list to admin-common.js (adminToken, token, password, key, apiKey, etc.)
- writeStoredConfig now filters sensitive fields by default
- Add allowSensitive option for explicit opt-in (default false)
- Add createSensitiveStorageToggle() UI helper with warning banner
- Update admin/index.html placeholder text to remove misleading 不落盘 claim

### Medium-4: JSON 解码错误静默
- Fix scanUserKeys: return error when allowed_models JSON decode fails
- Fix scanOneUserKey: return error when allowed_models JSON decode fails
- Prevents silent data corruption that would show empty model list

## Quality Gates
 go build ./... - PASS
 go test ./internal/... - PASS (all packages)
 bash ./scripts/test/verify_quality_gates.sh - PASS

## Notes
- High-6 (凭证可预测) requires architecture change to store random credentials in DB
- Medium-3 (部署脚本默认值) considered lower priority for current scope
2026-06-09 09:35:18 +08:00

490 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function initSub2ApiAdminCommon(global) {
const ADMIN_LINKS = [
{ key: "home", href: "/portal/admin/", label: "管理首页" },
{
key: "logical-groups",
href: "/portal/admin/logical-groups.html",
label: "逻辑分组 / 路由",
},
{
key: "route-health",
href: "/portal/admin/route-health.html",
label: "Route 健康视图",
},
{ key: "accounts", href: "/portal/admin/accounts.html", label: "帐号资产" },
{
key: "providers",
href: "/portal/admin/providers.html",
label: "新增模型 / 供应商目录",
},
{
key: "batch-import",
href: "/portal/admin/batch-import.html",
label: "导入供应商帐号",
},
{
key: "portal",
href: "/portal/",
label: "用户 Portal",
target: "_blank",
rel: "noreferrer",
},
];
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function defaultApiBase() {
const origin =
global.location && typeof global.location.origin === "string"
? global.location.origin
: "";
if (!origin || origin === "null") {
return "/portal-admin-api";
}
return `${origin}/portal-admin-api`;
}
function normalizeApiBase(value) {
return (String(value || "").trim() || defaultApiBase()).replace(/\/+$/, "");
}
function authHeaders(tokenValue) {
const token = String(tokenValue || "").trim();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function normalizeTone(tone) {
const value = String(tone || "")
.trim()
.toLowerCase();
if (!value || value === "note" || value === "info") {
return "note";
}
if (value === "warn") {
return "warning";
}
if (value === "error") {
return "danger";
}
return value;
}
function setStatus(element, message, tone = "note") {
if (!element) {
return;
}
element.textContent = message;
const normalizedTone = normalizeTone(tone);
if (normalizedTone !== "note") {
element.setAttribute("data-tone", normalizedTone);
} else {
element.removeAttribute("data-tone");
}
}
function describeSessionPayload(payload, options = {}) {
const fallbackUsername =
String(options.usernameFallback || "admin").trim() || "admin";
const currentUsername = String(payload?.username || "").trim();
const effectiveUsername = currentUsername || fallbackUsername;
if (payload?.authenticated) {
const suffix = options.includeSessionSuffix ? "session" : "";
return {
tone: "success",
message: `管理员会话已建立:${currentUsername || "unknown"}${suffix}`,
};
}
if (payload?.login_enabled) {
if (options.allowBearerFallback) {
return {
tone: "warning",
message: `当前未登录。可用管理员用户名 ${effectiveUsername} 建立 session或继续使用 Bearer token。`,
};
}
return {
tone: "warning",
message: "当前未登录。可直接使用管理员用户名密码建立会话。",
};
}
return {
tone: "warning",
message: "当前实例未启用管理员登录,只能使用 Bearer token。",
};
}
function sessionStateSpec(kind, context = {}, options = {}) {
const username =
String(
context.username || options.usernameFallback || "unknown",
).trim() || "unknown";
const message =
context.error instanceof Error
? context.error.message
: String(context.error || "").trim();
switch (kind) {
case "missing_credentials":
return {
tone: "warning",
message: "请先输入管理员用户名和密码。",
};
case "login_success":
return {
tone: "success",
message: `管理员会话已建立:${username}`,
};
case "login_failed":
return {
tone: "danger",
message: `管理员登录失败:${message || "未知错误"}`,
};
case "logout_success":
return {
tone: "warning",
message: "管理员会话已退出。",
};
case "logout_failed":
return {
tone: "danger",
message: `退出会话失败:${message || "未知错误"}`,
};
case "check_failed":
return {
tone: "danger",
message: `检查管理员会话失败:${message || "未知错误"}`,
};
default:
return {
tone: "note",
message: String(context.message || ""),
};
}
}
function applySessionPayload(element, payload, options = {}) {
const status = describeSessionPayload(payload, options);
setStatus(element, status.message, status.tone);
return status;
}
function setSessionState(element, kind, context = {}, options = {}) {
const status = sessionStateSpec(kind, context, options);
setStatus(element, status.message, status.tone);
return status;
}
async function requestJSON(client, path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { Accept: "application/json", ...headers };
if (!skipAuth) {
Object.assign(
finalHeaders,
authHeaders(client.adminTokenInput && client.adminTokenInput.value),
);
}
const response = await fetch(
`${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`,
{
...rest,
credentials: "include",
headers: finalHeaders,
},
);
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = { raw: text };
}
if (!response.ok) {
const message =
payload?.error?.message ||
payload?.message ||
payload?.error ||
payload?.raw ||
`HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
// 敏感字段列表:这些字段默认不会被持久化到 localStorage
const SENSITIVE_FIELDS = [
"adminToken",
"token",
"password",
"secret",
"key",
"apiKey",
"probeAPIKey",
"accessAPIKey",
"providerKeys",
"entries",
];
// 检查对象是否包含敏感字段
function containsSensitiveData(payload) {
if (!payload || typeof payload !== "object") return false;
return SENSITIVE_FIELDS.some((field) => field in payload);
}
// 过滤敏感字段,返回安全的数据副本
function filterSensitiveData(payload) {
if (!payload || typeof payload !== "object") return payload;
const filtered = {};
for (const [key, value] of Object.entries(payload)) {
if (!SENSITIVE_FIELDS.includes(key)) {
filtered[key] = value;
}
}
return filtered;
}
function readStoredConfig(storageKey) {
try {
return JSON.parse(global.localStorage.getItem(storageKey) || "{}");
} catch (error) {
console.warn(`failed to parse ${storageKey}`, error);
return {};
}
}
/**
* 写入配置到 localStorage
* @param {string} storageKey - 存储键名
* @param {object} payload - 要存储的数据
* @param {object} options - 选项
* @param {boolean} options.allowSensitive - 是否允许存储敏感数据(默认 false
*/
function writeStoredConfig(storageKey, payload, options = {}) {
const { allowSensitive = false } = options;
// 如果包含敏感数据且未明确允许,则过滤敏感字段
if (!allowSensitive && containsSensitiveData(payload)) {
console.warn(
`[Security] Sensitive data detected in ${storageKey}. Storing only non-sensitive fields. ` +
`To allow sensitive data storage, pass { allowSensitive: true } with explicit user consent.`,
);
const filtered = filterSensitiveData(payload);
global.localStorage.setItem(storageKey, JSON.stringify(filtered));
return;
}
global.localStorage.setItem(storageKey, JSON.stringify(payload));
}
/**
* 创建敏感数据存储开关的 UI 控件
* @param {string} id - 控件 ID
* @returns {HTMLLabelElement} 开关标签元素
*/
function createSensitiveStorageToggle(id = "allow-sensitive-storage") {
const label = document.createElement("label");
label.className = "sensitive-storage-toggle";
label.style.cssText =
"display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 8px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; font-size: 13px;";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = id;
checkbox.style.cssText = "margin: 0;";
const text = document.createElement("span");
text.innerHTML =
'<strong style="color: #856404;">⚠️ 安全风险</strong>记住敏感信息Token/Key到浏览器本地存储';
label.appendChild(checkbox);
label.appendChild(text);
return label;
}
function renderAdminNav(container, currentKey) {
if (!container) {
return;
}
container.innerHTML = ADMIN_LINKS.map((link) => {
const currentClass = currentKey === link.key ? " is-current" : "";
const target = link.target ? ` target="${link.target}"` : "";
const rel = link.rel ? ` rel="${link.rel}"` : "";
return `<a href="${escapeHTML(link.href)}"${target}${rel} class="${currentClass.trim()}">${escapeHTML(link.label)}</a>`;
}).join("");
}
function createAdminPageRuntime(options) {
const client = {
apiBaseInput: options.apiBaseInput,
adminTokenInput: options.adminTokenInput,
};
const sessionPresentation = options.sessionPresentation || {};
async function refreshAdminSession() {
try {
const payload = await requestJSON(client, "/api/admin/session", {
skipAuth: !options.includeAuthOnSessionCheck,
});
if (
payload.username &&
options.adminUsernameInput &&
!options.adminUsernameInput.value.trim()
) {
options.adminUsernameInput.value = payload.username;
}
applySessionPayload(
options.adminSessionStatus,
payload,
sessionPresentation,
);
return payload;
} catch (error) {
setSessionState(
options.adminSessionStatus,
"check_failed",
{ error },
sessionPresentation,
);
throw error;
}
}
async function loginAdminSession() {
const username = options.adminUsernameInput
? options.adminUsernameInput.value.trim()
: "";
const password = options.adminPasswordInput
? options.adminPasswordInput.value
: "";
if (!username || !password) {
const error = new Error("管理员用户名和密码不能为空");
setSessionState(
options.adminSessionStatus,
"missing_credentials",
{},
sessionPresentation,
);
throw error;
}
try {
const payload = await requestJSON(client, "/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (options.adminPasswordInput) {
options.adminPasswordInput.value = "";
}
if (typeof options.onSessionPersist === "function") {
options.onSessionPersist();
}
setSessionState(
options.adminSessionStatus,
"login_success",
{ username: payload.username || username },
sessionPresentation,
);
return payload;
} catch (error) {
setSessionState(
options.adminSessionStatus,
"login_failed",
{ error },
sessionPresentation,
);
throw error;
}
}
async function logoutAdminSession() {
try {
const response = await fetch(
`${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`,
{
method: "POST",
credentials: "include",
headers: authHeaders(
options.adminTokenInput && options.adminTokenInput.value,
),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
if (options.adminPasswordInput) {
options.adminPasswordInput.value = "";
}
setSessionState(
options.adminSessionStatus,
"logout_success",
{},
sessionPresentation,
);
} catch (error) {
setSessionState(
options.adminSessionStatus,
"logout_failed",
{ error },
sessionPresentation,
);
throw error;
}
}
return {
defaultApiBase,
normalizeApiBase() {
return normalizeApiBase(
options.apiBaseInput && options.apiBaseInput.value,
);
},
authHeaders() {
return authHeaders(
options.adminTokenInput && options.adminTokenInput.value,
);
},
requestJSON(path, requestOptions = {}) {
return requestJSON(client, path, requestOptions);
},
readStoredConfig,
writeStoredConfig,
renderAdminNav,
setStatus,
applySessionPayload(element, payload) {
return applySessionPayload(element, payload, sessionPresentation);
},
setSessionState(element, kind, context = {}) {
return setSessionState(element, kind, context, sessionPresentation);
},
refreshAdminSession,
loginAdminSession,
logoutAdminSession,
};
}
global.Sub2ApiAdminCommon = {
ADMIN_LINKS,
createAdminPageRuntime,
defaultApiBase,
normalizeApiBase,
authHeaders,
normalizeTone,
setStatus,
describeSessionPayload,
applySessionPayload,
setSessionState,
readStoredConfig,
writeStoredConfig,
renderAdminNav,
createSensitiveStorageToggle,
SENSITIVE_FIELDS,
};
})(window);