feat(web): token 数以 K 输入显示,已存凭证显示掩码占位(FIX-8)
- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens) - 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处 - 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA - contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写 - 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
68f53390f6
commit
fa344a7184
39 files changed
+1175
-232
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
PathMappingCreate,
|
||||
PathMappingUpdate,
|
||||
type SecretPurpose,
|
||||
secretInput,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
@@ -172,6 +173,14 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return await ctx.secrets.create({ purpose, plaintext });
|
||||
};
|
||||
|
||||
/** 审计里的字段名:凭证只记"设置 / 清除",不记值;空串(不变)不记 */
|
||||
const auditFields = (body: Record<string, unknown>) =>
|
||||
Object.keys(body).flatMap((k) => {
|
||||
if (k !== "credential") return [k];
|
||||
const c = secretInput(body.credential as string | null | undefined);
|
||||
return c === undefined ? [] : [c === null ? "credential(cleared)" : "credential(set)"];
|
||||
});
|
||||
|
||||
// ── library_root ──
|
||||
const sentinel = async (localPath: string, file: string | null | undefined) => {
|
||||
const name = file || DEFAULT_ROOT_SENTINEL;
|
||||
@@ -254,7 +263,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return { ...curSettings, ...(next.settings ?? {}), authKind };
|
||||
};
|
||||
endpoint(app, DL.create, async ({ body, actor }) => {
|
||||
const { credential, authKind, ...rest } = body;
|
||||
const { credential: rawCredential, authKind, ...rest } = body;
|
||||
const credential = secretInput(rawCredential) ?? undefined;
|
||||
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
|
||||
const keep = authKind !== "none" && credential;
|
||||
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
|
||||
@@ -277,13 +287,18 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
endpoint(app, DL.update, async ({ params, body, actor }) => {
|
||||
const { credential, authKind, ...rest } = body;
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const { credential: rawCredential, authKind, ...rest } = body;
|
||||
const credential = secretInput(rawCredential);
|
||||
const cur = await getRow("downloader_instance", params.id);
|
||||
const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind;
|
||||
if (credential === null && (authKind ?? curAuth) === "api_key")
|
||||
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
|
||||
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
|
||||
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
|
||||
let dropRef: string | null = null;
|
||||
if (authKind === "none") {
|
||||
// 无认证:不再保留密码 / Key
|
||||
if (authKind === "none" || credential === null) {
|
||||
// 无认证或显式清除:不再保留密码 / Key
|
||||
if (cur.secret_ref) {
|
||||
values.secret_ref = null;
|
||||
dropRef = cur.secret_ref;
|
||||
@@ -299,7 +314,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
"downloader_instance.update",
|
||||
{ type: "downloader_instance", id: row.id },
|
||||
{
|
||||
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
||||
fields: auditFields(body),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
@@ -354,7 +369,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
list("media_server_instance", "media-server-instances", MS.list);
|
||||
get("media_server_instance", "media-server-instances", MS.get);
|
||||
endpoint(app, MS.create, async ({ body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const { credential: rawCredential, ...rest } = body;
|
||||
const credential = secretInput(rawCredential) ?? undefined;
|
||||
const ref = credential
|
||||
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
|
||||
: null;
|
||||
@@ -374,20 +390,27 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
endpoint(app, MS.update, async ({ params, body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const { credential: rawCredential, ...rest } = body;
|
||||
const credential = secretInput(rawCredential);
|
||||
const values = toDb(rest, JSON_COLS);
|
||||
if (credential) {
|
||||
let dropRef: string | null = null;
|
||||
if (credential !== undefined) {
|
||||
const cur = await getRow("media_server_instance", params.id);
|
||||
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
|
||||
if (credential === null) {
|
||||
values.secret_ref = null;
|
||||
dropRef = cur.secret_ref ?? null;
|
||||
} else values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
|
||||
}
|
||||
const row = await update("media_server_instance", params.id, values);
|
||||
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"media_server_instance.update",
|
||||
{ type: "media_server_instance", id: row.id },
|
||||
{
|
||||
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
||||
fields: auditFields(body),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mergeSettings,
|
||||
type SecretPurpose,
|
||||
type SettingsKey,
|
||||
secretInput,
|
||||
} from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
@@ -75,11 +76,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
|
||||
// 审计里不留明文密钥
|
||||
const logged: Record<string, unknown> = { ...patch };
|
||||
const mark = (v: string | null | undefined) => {
|
||||
const x = secretInput(v);
|
||||
return x === undefined ? undefined : x === null ? "(cleared)" : "(set)";
|
||||
};
|
||||
if (patch.metadata && "tmdbApiKey" in patch.metadata)
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" };
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: mark(patch.metadata.tmdbApiKey) };
|
||||
const ws = patch.agent?.webSearch;
|
||||
if (ws && "apiKey" in ws)
|
||||
logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } };
|
||||
if (ws && "apiKey" in ws) logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: mark(ws.apiKey) } };
|
||||
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
|
||||
return next;
|
||||
});
|
||||
@@ -96,7 +100,8 @@ interface SecretSlot {
|
||||
/** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */
|
||||
function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
|
||||
const out: SecretSlot[] = [];
|
||||
const tmdb = patch.metadata?.tmdbApiKey;
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const tmdb = secretInput(patch.metadata?.tmdbApiKey);
|
||||
if (tmdb !== undefined)
|
||||
out.push({
|
||||
plaintext: tmdb,
|
||||
@@ -107,7 +112,7 @@ function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
|
||||
s.metadata.tmdbApiKeySecretRef = ref;
|
||||
},
|
||||
});
|
||||
const ws = patch.agent?.webSearch?.apiKey;
|
||||
const ws = secretInput(patch.agent?.webSearch?.apiKey);
|
||||
if (ws !== undefined)
|
||||
out.push({
|
||||
plaintext: ws,
|
||||
|
||||
Reference in new issue
Block a user