## 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
490 lines
14 KiB
JavaScript
490 lines
14 KiB
JavaScript
(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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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);
|