dsh-db-tool 1.5.91 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +168 -2
- package/dist/http/index.js +21 -0
- package/dist/manager.js +56 -1
- package/dist/store/connections.js +85 -2
- package/dist/store/export-crypto.js +78 -0
- package/dist/store/index.js +2 -1
- package/docs/connection-export-import.md +172 -0
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -72,6 +72,16 @@ window.__ModuleLoader__.load({
|
|
|
72
72
|
auditDetail: "详情",
|
|
73
73
|
auditDenied: "拒绝",
|
|
74
74
|
auditConfirmed: "已确认",
|
|
75
|
+
// 连接导出/导入(口令加密备份)
|
|
76
|
+
exportConns: "导出",
|
|
77
|
+
importConns: "导入",
|
|
78
|
+
passphrase: "口令",
|
|
79
|
+
passphraseHint: "8 位以上,仅本次导出/导入使用",
|
|
80
|
+
exportDone: "已导出 {n} 条连接",
|
|
81
|
+
importResult: "已导入 {i} 条,跳过 {s} 条",
|
|
82
|
+
importPartialFail: "部分失败:成功 {i} 条,跳过 {s} 条,失败 {f} 条",
|
|
83
|
+
importDecryptFail: "口令错误或文件已损坏",
|
|
84
|
+
pickFile: "选择文件",
|
|
75
85
|
// 授权
|
|
76
86
|
grantsHint: "为当前项目授权连接。只读=仅查询/浏览,读写=允许执行语句与脚本。未授权的连接在业务操作中一律拒绝。",
|
|
77
87
|
grantsEmptyHint: "当前项目尚未授权任何连接,在下方每行选择「只读」或「读写」即可完成授权。",
|
|
@@ -213,6 +223,15 @@ window.__ModuleLoader__.load({
|
|
|
213
223
|
auditDetail: "Detail",
|
|
214
224
|
auditDenied: "denied",
|
|
215
225
|
auditConfirmed: "confirmed",
|
|
226
|
+
exportConns: "Export",
|
|
227
|
+
importConns: "Import",
|
|
228
|
+
passphrase: "Passphrase",
|
|
229
|
+
passphraseHint: "8+ characters, used only for this export/import",
|
|
230
|
+
exportDone: "Exported {n} connection(s)",
|
|
231
|
+
importResult: "Imported {i}, skipped {s}",
|
|
232
|
+
importPartialFail: "Partial failure: {i} imported, {s} skipped, {f} failed",
|
|
233
|
+
importDecryptFail: "Wrong passphrase or corrupted file",
|
|
234
|
+
pickFile: "Pick file",
|
|
216
235
|
grantsHint: "Grant connections to the current project. ro = read-only (query/browse), rw = read-write (execute/script allowed). Unauthorized connections are always rejected.",
|
|
217
236
|
grantsEmptyHint: "No connections granted to this project yet. Pick read-only or read-write on a row below to grant.",
|
|
218
237
|
grantsSummary: "{granted}/{total} connections granted",
|
|
@@ -596,6 +615,74 @@ window.__ModuleLoader__.load({
|
|
|
596
615
|
/* ---------------- 连接管理 ---------------- */
|
|
597
616
|
const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
|
|
598
617
|
|
|
618
|
+
// 口令弹窗(ManageView 局部复用,导出/导入共用):键盘契约对齐 DangerDialog
|
|
619
|
+
// (Escape=取消、Tab 在对话框内循环锁定);差异:初始焦点在口令输入框,确认按钮主色。
|
|
620
|
+
// ponytail: 仅两处使用,故为 ManageView 内部局部组件,不抽通用模块。
|
|
621
|
+
function PassphraseDialog(props) {
|
|
622
|
+
const [value, setValue] = React.useState("");
|
|
623
|
+
const [err, setErr] = React.useState(""); // 就地错误(如口令过短);读屏经 role=alert 播报
|
|
624
|
+
const inputRef = React.useRef(null);
|
|
625
|
+
const dialogRef = React.useRef(null);
|
|
626
|
+
const titleId = "dbt-pass-title";
|
|
627
|
+
function done(v) { props.onClose(v); }
|
|
628
|
+
function submit() {
|
|
629
|
+
const v = value.trim();
|
|
630
|
+
if (v.length < 8) { setErr(t("passphraseHint")); return; } // 与服务端 PASSPHRASE_MIN=8 对齐(服务端为权威边界)
|
|
631
|
+
done(v);
|
|
632
|
+
}
|
|
633
|
+
function onKeyDown(e) {
|
|
634
|
+
if (e.key === "Escape") { e.preventDefault(); done(null); return; }
|
|
635
|
+
if (e.key === "Enter" && e.target === inputRef.current) { e.preventDefault(); submit(); return; }
|
|
636
|
+
if (e.key !== "Tab") return;
|
|
637
|
+
const dialog = dialogRef.current;
|
|
638
|
+
if (!dialog) return;
|
|
639
|
+
const els = dialog.querySelectorAll("input,button");
|
|
640
|
+
if (els.length === 0) return;
|
|
641
|
+
const first = els[0];
|
|
642
|
+
const last = els[els.length - 1];
|
|
643
|
+
const doc = dialog.ownerDocument || document;
|
|
644
|
+
if (!dialog.contains(doc.activeElement)) { e.preventDefault(); (e.shiftKey ? last : first).focus(); return; }
|
|
645
|
+
if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
646
|
+
else if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
647
|
+
}
|
|
648
|
+
// 挂载后焦点落口令输入框(与 DangerDialog 落「取消」不同:本弹窗无危险语义,输入即主路径)
|
|
649
|
+
React.useEffect(() => {
|
|
650
|
+
if (inputRef.current && typeof inputRef.current.focus === "function") inputRef.current.focus();
|
|
651
|
+
}, []);
|
|
652
|
+
return React.createElement(
|
|
653
|
+
"div",
|
|
654
|
+
{ className: "dbt-overlay", onClick: () => done(null), onKeyDown },
|
|
655
|
+
React.createElement(
|
|
656
|
+
"div",
|
|
657
|
+
{
|
|
658
|
+
className: "dbt-dialog",
|
|
659
|
+
onClick: (e) => e.stopPropagation(),
|
|
660
|
+
role: "dialog",
|
|
661
|
+
"aria-modal": "true",
|
|
662
|
+
"aria-labelledby": titleId,
|
|
663
|
+
ref: dialogRef,
|
|
664
|
+
},
|
|
665
|
+
React.createElement("strong", { id: titleId }, props.title),
|
|
666
|
+
React.createElement("input", {
|
|
667
|
+
type: "password",
|
|
668
|
+
"aria-label": t("passphrase"),
|
|
669
|
+
placeholder: t("passphrase"),
|
|
670
|
+
autoComplete: "off",
|
|
671
|
+
value,
|
|
672
|
+
ref: inputRef,
|
|
673
|
+
onChange: (e) => { setValue(e.target.value); if (err) setErr(""); },
|
|
674
|
+
}),
|
|
675
|
+
React.createElement("div", { className: "dbt-muted" }, t("passphraseHint")),
|
|
676
|
+
err ? React.createElement("div", { className: "dbt-err", role: "alert" }, err) : null,
|
|
677
|
+
React.createElement(
|
|
678
|
+
"div",
|
|
679
|
+
{ className: "dbt-row" },
|
|
680
|
+
React.createElement("button", { className: "dbt-btn primary", onClick: submit }, props.confirmLabel),
|
|
681
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => done(null) }, t("cancel")),
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
599
686
|
// 通用表格渲染:columns + rows(审计列表与查询结果共用);wrapClass 可选(浏览弹窗用自适应高度容器)
|
|
600
687
|
function resultTable(columns, rows, cellTitles, wrapClass) {
|
|
601
688
|
return React.createElement(
|
|
@@ -1107,6 +1194,11 @@ window.__ModuleLoader__.load({
|
|
|
1107
1194
|
const [auditOpen, setAuditOpen] = React.useState(false);
|
|
1108
1195
|
const [audit, setAudit] = React.useState(null);
|
|
1109
1196
|
const [testInfo, setTestInfo] = React.useState({}); // connId -> "ok" | "fail: msg"
|
|
1197
|
+
// 导出/导入流程态:passReq 非空即弹口令窗("export" | "import");fileRef 承载隐藏文件选择器
|
|
1198
|
+
const [passReq, setPassReq] = React.useState(null);
|
|
1199
|
+
const [pendingJson, setPendingJson] = React.useState(null); // 待导入的密文文本(选文件后暂存,口令确认后发送)
|
|
1200
|
+
const [ioInfo, setIoInfo] = React.useState(null); // null | {kind: "ok"|"err", text}
|
|
1201
|
+
const fileRef = React.useRef(null);
|
|
1110
1202
|
// 键盘焦点态:统一走模块级 focusRingProps()(描边同 DESIGN.md focus 规范,accent 2px 内缩)
|
|
1111
1203
|
|
|
1112
1204
|
async function saveConn(body, reset) {
|
|
@@ -1156,6 +1248,64 @@ window.__ModuleLoader__.load({
|
|
|
1156
1248
|
}
|
|
1157
1249
|
}
|
|
1158
1250
|
|
|
1251
|
+
// 口令弹窗确认回调:按 passReq 分流导出/导入;value 为 null 即取消
|
|
1252
|
+
async function onPassphrase(value) {
|
|
1253
|
+
const mode = passReq;
|
|
1254
|
+
setPassReq(null);
|
|
1255
|
+
if (value === null || value === undefined) { setPendingJson(null); return; } // 取消即清密文
|
|
1256
|
+
if (mode === "export") await doExport(value);
|
|
1257
|
+
else if (mode === "import") await doImport(value);
|
|
1258
|
+
setPendingJson(null); // 完成即清密文,避免敏感数据滞留组件状态
|
|
1259
|
+
}
|
|
1260
|
+
// 导出:POST /connections/export 拿密文 json → Blob + a[download] 落盘(文件名带日期戳)
|
|
1261
|
+
async function doExport(passphrase) {
|
|
1262
|
+
await props.run("export", async () => {
|
|
1263
|
+
const data = await api("connections/export", { method: "POST", body: { passphrase } });
|
|
1264
|
+
const json = data && data.json;
|
|
1265
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); // YYYYMMDD
|
|
1266
|
+
const blob = new Blob([typeof json === "string" ? json : JSON.stringify(json, null, 2)], { type: "application/json" });
|
|
1267
|
+
const url = URL.createObjectURL(blob);
|
|
1268
|
+
const a = document.createElement("a");
|
|
1269
|
+
a.href = url;
|
|
1270
|
+
a.download = "dsh-db-connections-" + stamp + ".enc.json";
|
|
1271
|
+
document.body.appendChild(a);
|
|
1272
|
+
a.click();
|
|
1273
|
+
a.remove();
|
|
1274
|
+
// 延迟回收:部分浏览器在 click 后才异步 fetch blob,同步 revoke 可能中断下载(与结果导出 download 同模式)
|
|
1275
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1276
|
+
// 服务端回传权威条数 count;缺失时回退本地列表(不声称精确数字)
|
|
1277
|
+
const n = (data && typeof data.count === "number") ? data.count : conns.length;
|
|
1278
|
+
setIoInfo({ kind: "ok", text: t("exportDone", { n }) });
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
// 导入:密文已在 pendingJson;口令错(INVALID_ARGUMENT + 解密失败文案)给专用提示,其余透传后端 message
|
|
1282
|
+
async function doImport(passphrase) {
|
|
1283
|
+
const json = pendingJson;
|
|
1284
|
+
await props.run("import", async () => {
|
|
1285
|
+
const data = await api("connections/import", { method: "POST", body: { json, passphrase } });
|
|
1286
|
+
await reload();
|
|
1287
|
+
const failed = (data && data.errors ? data.errors.length : 0);
|
|
1288
|
+
const i = (data && data.imported) || 0;
|
|
1289
|
+
const s = (data && data.skipped ? data.skipped.length : 0);
|
|
1290
|
+
setIoInfo(failed > 0
|
|
1291
|
+
? { kind: "err", text: t("importPartialFail", { i, s, f: failed }) }
|
|
1292
|
+
: { kind: "ok", text: t("importResult", { i, s }) });
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
// 隐藏文件选择器回调:FileReader 读文本后暂存,再弹口令窗;读失败走通用错误通道
|
|
1296
|
+
function onPickFile(e) {
|
|
1297
|
+
const file = e.target.files && e.target.files[0];
|
|
1298
|
+
e.target.value = ""; // 允许重复选同一文件
|
|
1299
|
+
if (!file) return;
|
|
1300
|
+
const reader = new FileReader();
|
|
1301
|
+
reader.onload = () => {
|
|
1302
|
+
setPendingJson(String(reader.result || ""));
|
|
1303
|
+
setPassReq("import");
|
|
1304
|
+
};
|
|
1305
|
+
reader.onerror = () => props.onError(reader.error || new Error(t("loadFailed")));
|
|
1306
|
+
reader.readAsText(file);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1159
1309
|
if (editing) {
|
|
1160
1310
|
return React.createElement(ConnForm, {
|
|
1161
1311
|
initial: editing === "new" ? null : buildEditForm(editing),
|
|
@@ -1167,13 +1317,23 @@ window.__ModuleLoader__.load({
|
|
|
1167
1317
|
return React.createElement(
|
|
1168
1318
|
"div",
|
|
1169
1319
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
1170
|
-
//
|
|
1320
|
+
// 顶部操作行:审计 + 导出/导入在左,「新建连接」主按钮置右侧
|
|
1171
1321
|
React.createElement(
|
|
1172
1322
|
"div",
|
|
1173
1323
|
{ className: "dbt-row", style: { justifyContent: "space-between" } },
|
|
1174
|
-
React.createElement(
|
|
1324
|
+
React.createElement(
|
|
1325
|
+
"div",
|
|
1326
|
+
{ className: "dbt-row" },
|
|
1327
|
+
React.createElement("button", { className: "dbt-btn", "aria-expanded": auditOpen, ...focusRingProps(), onClick: loadAudit }, t("auditShort")),
|
|
1328
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "export", ...focusRingProps(), onClick: () => setPassReq("export") }, t("exportConns")),
|
|
1329
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "import", ...focusRingProps(), onClick: () => { if (fileRef.current) fileRef.current.click(); } }, t("importConns")),
|
|
1330
|
+
),
|
|
1175
1331
|
React.createElement("button", { className: "dbt-btn primary", ...focusRingProps(), onClick: () => setEditing("new") }, t("newConn")),
|
|
1176
1332
|
),
|
|
1333
|
+
// 隐藏文件选择器:仅导入用,accept 限定 .json
|
|
1334
|
+
React.createElement("input", { type: "file", accept: ".json", "aria-label": t("pickFile"), style: { display: "none" }, ref: fileRef, onChange: onPickFile }),
|
|
1335
|
+
// 导出/导入就地反馈(与测试反馈同语言:dbt-msg 绿 / dbt-err 红)
|
|
1336
|
+
ioInfo ? React.createElement("div", { role: ioInfo.kind === "ok" ? "status" : "alert", className: ioInfo.kind === "ok" ? "dbt-msg" : "dbt-err" }, ioInfo.text) : null,
|
|
1177
1337
|
conns.length === 0
|
|
1178
1338
|
? // 空状态引导:一句引导 + 主按钮直达新增表单(state 切换在 ManageView 内)
|
|
1179
1339
|
React.createElement(
|
|
@@ -1231,6 +1391,12 @@ window.__ModuleLoader__.load({
|
|
|
1231
1391
|
auditTable(audit),
|
|
1232
1392
|
)
|
|
1233
1393
|
: null,
|
|
1394
|
+
// 口令弹窗:导出/导入共用;取消(null)不动作
|
|
1395
|
+
passReq ? React.createElement(PassphraseDialog, {
|
|
1396
|
+
title: passReq === "export" ? t("exportConns") : t("importConns"),
|
|
1397
|
+
confirmLabel: passReq === "export" ? t("exportConns") : t("importConns"),
|
|
1398
|
+
onClose: onPassphrase,
|
|
1399
|
+
}) : null,
|
|
1234
1400
|
);
|
|
1235
1401
|
}
|
|
1236
1402
|
|
package/dist/http/index.js
CHANGED
|
@@ -148,6 +148,27 @@ async function route(req, res, service, path, q, opts) {
|
|
|
148
148
|
}));
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
// 连接导出/导入(口令加密包)。必须置于 connMatch 之前:
|
|
152
|
+
// /api/connections/export 与 /api/connections/import 都是单层路径,
|
|
153
|
+
// 若落到 connMatch 会被当作连接 id 匹配(DELETE 无分支静默 404、PUT 误更新)。
|
|
154
|
+
if (path === '/api/connections/export' && req.method === 'POST') {
|
|
155
|
+
const b = await readBody(req);
|
|
156
|
+
const passphrase = str(b['passphrase']);
|
|
157
|
+
if (!passphrase)
|
|
158
|
+
return send(res, 400, { ok: false, error: '缺少 passphrase', code: 'INVALID_ARGUMENT' });
|
|
159
|
+
const count = service.listConnections().length; // 权威条数,前端不再用可能过期的本地列表
|
|
160
|
+
return sendOk(res, { json: service.exportConnections(passphrase), count });
|
|
161
|
+
}
|
|
162
|
+
if (path === '/api/connections/import' && req.method === 'POST') {
|
|
163
|
+
const b = await readBody(req);
|
|
164
|
+
const json = str(b['json']);
|
|
165
|
+
const passphrase = str(b['passphrase']);
|
|
166
|
+
if (!json)
|
|
167
|
+
return send(res, 400, { ok: false, error: '缺少 json', code: 'INVALID_ARGUMENT' });
|
|
168
|
+
if (!passphrase)
|
|
169
|
+
return send(res, 400, { ok: false, error: '缺少 passphrase', code: 'INVALID_ARGUMENT' });
|
|
170
|
+
return sendOk(res, service.importConnections(json, passphrase));
|
|
171
|
+
}
|
|
151
172
|
// 测试未保存的连接草稿(保存前测试;不落库)
|
|
152
173
|
if (path === '/api/test-draft' && req.method === 'POST') {
|
|
153
174
|
const b = await readBody(req);
|
package/dist/manager.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { getAdapter } from './adapters/index.js';
|
|
3
3
|
import { ChallengeStore, classifyStatement, sqlHead, SQL_READ_HEADS, } from './guard/index.js';
|
|
4
|
-
import { mergeUrlCredentials, normalizeProjectKey, } from './store/index.js';
|
|
4
|
+
import { mergeUrlCredentials, normalizeProjectKey, DECRYPT_FAIL_MESSAGE, } from './store/index.js';
|
|
5
5
|
import { runScriptInChild } from './script/runner.js';
|
|
6
|
+
import { decryptPayload, encryptPayload } from './store/export-crypto.js';
|
|
6
7
|
/* ---------------- 错误与结果类型 ---------------- */
|
|
7
8
|
/** 携带机器可读 code 的业务错误(HTTP 层直接映射 {ok:false,error,code}) */
|
|
8
9
|
export class DbToolError extends Error {
|
|
@@ -144,6 +145,60 @@ export class DbToolService {
|
|
|
144
145
|
throw this.toDriverError(e);
|
|
145
146
|
}
|
|
146
147
|
}
|
|
148
|
+
/* -- 连接导出/导入(口令加密,无需授权;口令不落审计) -- */
|
|
149
|
+
/** 口令下限:导出包含全部连接机密(明文密码/完整 URL),安全边界在服务端而非 UI */
|
|
150
|
+
static PASSPHRASE_MIN = 8;
|
|
151
|
+
/**
|
|
152
|
+
* 导出全部连接(含机密)为口令加密的 JSON 密文。
|
|
153
|
+
* 审计只记连接条数,绝不落口令与密文内容。
|
|
154
|
+
*/
|
|
155
|
+
exportConnections(passphrase) {
|
|
156
|
+
const pp = assertNonEmpty(passphrase, 'passphrase');
|
|
157
|
+
if (pp.length < DbToolService.PASSPHRASE_MIN) {
|
|
158
|
+
throw new DbToolError('INVALID_ARGUMENT', `口令长度至少 ${DbToolService.PASSPHRASE_MIN} 位`);
|
|
159
|
+
}
|
|
160
|
+
const bundle = this.store.connections.exportBundle();
|
|
161
|
+
const json = encryptPayload(bundle, pp);
|
|
162
|
+
this.audit('', '', 'export_connections', `导出 ${bundle.connections.length} 个连接`, 'none', false, true);
|
|
163
|
+
return json;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 从口令加密的 JSON 密文导入连接。口令错误或密文损坏统一报
|
|
167
|
+
* INVALID_ARGUMENT(DECRYPT_FAIL_MESSAGE)(不区分原因,防口令探测)。
|
|
168
|
+
*/
|
|
169
|
+
importConnections(encryptedJson, passphrase) {
|
|
170
|
+
const enc = assertNonEmpty(encryptedJson, 'json');
|
|
171
|
+
const pp = assertNonEmpty(passphrase, 'passphrase');
|
|
172
|
+
if (pp.length < DbToolService.PASSPHRASE_MIN) {
|
|
173
|
+
throw new DbToolError('INVALID_ARGUMENT', `口令长度至少 ${DbToolService.PASSPHRASE_MIN} 位`);
|
|
174
|
+
}
|
|
175
|
+
let bundle;
|
|
176
|
+
try {
|
|
177
|
+
bundle = decryptPayload(enc, pp);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// 口令错误/密文损坏/非合法 JSON 统一归一(decryptPayload 已统一文案,这里兜底)
|
|
181
|
+
throw new DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE);
|
|
182
|
+
}
|
|
183
|
+
// 结构校验:必须是含 connections 数组的对象,且每个元素为带非空 string id/kind 的对象。
|
|
184
|
+
// 畸形元素会让 importBundle 在 try 之外抛 TypeError(绕开统一文案),
|
|
185
|
+
// 或以 undefined id 静默落库成 UI 删不掉的幽灵记录
|
|
186
|
+
const conns = bundle?.connections;
|
|
187
|
+
if (!bundle ||
|
|
188
|
+
typeof bundle !== 'object' ||
|
|
189
|
+
!Array.isArray(conns) ||
|
|
190
|
+
conns.some((c) => c === null ||
|
|
191
|
+
typeof c !== 'object' ||
|
|
192
|
+
typeof c.id !== 'string' ||
|
|
193
|
+
c.id === '' ||
|
|
194
|
+
typeof c.kind !== 'string' ||
|
|
195
|
+
c.kind === '')) {
|
|
196
|
+
throw new DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE);
|
|
197
|
+
}
|
|
198
|
+
const summary = this.store.connections.importBundle(bundle);
|
|
199
|
+
this.audit('', '', 'import_connections', `导入 ${summary.imported} 个连接,跳过 ${summary.skipped.length} 个,失败 ${summary.errors.length} 个`, 'none', false, true);
|
|
200
|
+
return summary;
|
|
201
|
+
}
|
|
147
202
|
/* -- 授权管理 -- */
|
|
148
203
|
/** 项目路径归一化(/api/project-context 无会话解析器时的回退路径) */
|
|
149
204
|
projectKey(projectPath) {
|
|
@@ -10,6 +10,16 @@ import * as path from 'node:path';
|
|
|
10
10
|
import { readJson, writeJsonAtomic } from './io.js';
|
|
11
11
|
/** fields 中被视为机密的键,落盘前拆出到 secrets.json */
|
|
12
12
|
const PASSWORD_KEY = 'password';
|
|
13
|
+
/** id 冲突哨兵错误:importBundle 以此 instanceof 识别跳过项,
|
|
14
|
+
* 替代对 create() 中文错误文案的字符串匹配(文案改动不破坏幂等契约) */
|
|
15
|
+
export class DuplicateConnectionError extends Error {
|
|
16
|
+
connId;
|
|
17
|
+
constructor(connId) {
|
|
18
|
+
super(`连接已存在: ${connId}`);
|
|
19
|
+
this.connId = connId;
|
|
20
|
+
this.name = 'DuplicateConnectionError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
13
23
|
/** authority 段定位:scheme:// 到首个 /?#(兼容可选 jdbc: 前缀,如 jdbc:gaussdb://),
|
|
14
24
|
* 捕获组 1 为 scheme:// 部分、组 2 为 authority(多主机逗号串整体落在该段内)。
|
|
15
25
|
* redactUrl 与 mergeUrlCredentials 的字符串回退注入共用。 */
|
|
@@ -178,11 +188,11 @@ export class ConnectionStore {
|
|
|
178
188
|
const rec = this.findRec(this.load(), id);
|
|
179
189
|
return rec ? toMeta(rec, this.hasPassword(id)) : undefined;
|
|
180
190
|
}
|
|
181
|
-
/** 新建连接;id
|
|
191
|
+
/** 新建连接;id 已存在时抛 DuplicateConnectionError */
|
|
182
192
|
create(input) {
|
|
183
193
|
const data = this.load();
|
|
184
194
|
if (this.findRec(data, input.id)) {
|
|
185
|
-
throw new
|
|
195
|
+
throw new DuplicateConnectionError(input.id);
|
|
186
196
|
}
|
|
187
197
|
const rec = { id: input.id, kind: input.kind };
|
|
188
198
|
if (input.name !== undefined)
|
|
@@ -296,4 +306,77 @@ export class ConnectionStore {
|
|
|
296
306
|
rc.ssl = rec.ssl;
|
|
297
307
|
return rc;
|
|
298
308
|
}
|
|
309
|
+
/** 导出连接迁移包:全部连接记录 + 对应机密(含明文密码)。
|
|
310
|
+
* 调用方必须将整个返回值经 export-crypto 加密后方可落盘/传输,
|
|
311
|
+
* 本方法本身不加密(加密口令属于 HTTP 层入参,不应侵入存储层)。 */
|
|
312
|
+
exportBundle() {
|
|
313
|
+
const connections = this.load().connections;
|
|
314
|
+
const secrets = {};
|
|
315
|
+
for (const rec of connections) {
|
|
316
|
+
const sec = this.secrets.get(rec.id);
|
|
317
|
+
if (sec !== undefined)
|
|
318
|
+
secrets[rec.id] = sec;
|
|
319
|
+
}
|
|
320
|
+
return { connections, secrets };
|
|
321
|
+
}
|
|
322
|
+
/** 导入连接迁移包:逐条走既有 create()(机密拆分/脱敏逻辑全复用)。
|
|
323
|
+
* - id 冲突(「连接已存在」)→ 记入 skipped,不覆盖已有数据;
|
|
324
|
+
* - 其他异常 → 记入 errors 并继续处理后续条目;
|
|
325
|
+
* - 幂等可重入:重复导入同一 bundle,已存在的全部落入 skipped。 */
|
|
326
|
+
importBundle(bundle) {
|
|
327
|
+
const result = { imported: 0, skipped: [], errors: [] };
|
|
328
|
+
for (const rec of bundle.connections) {
|
|
329
|
+
// per-record 校验:解密成功的包也可能内容畸形(schema 漂移/手工构造),
|
|
330
|
+
// 缺 id/kind 的记录不落库,计入 errors
|
|
331
|
+
if (rec === null ||
|
|
332
|
+
typeof rec !== 'object' ||
|
|
333
|
+
typeof rec.id !== 'string' ||
|
|
334
|
+
rec.id === '' ||
|
|
335
|
+
typeof rec.kind !== 'string' ||
|
|
336
|
+
rec.kind === '') {
|
|
337
|
+
result.errors.push({ id: String(rec?.id ?? ''), message: '记录结构非法(缺少 id/kind)' });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const sec = bundle.secrets?.[rec.id];
|
|
341
|
+
try {
|
|
342
|
+
// 重建 create 入参:脱敏 URL 回换完整 URL(机密在 sec.url 中);
|
|
343
|
+
// fields 模式密码经 fields.password 传入由 create 自动拆分到 secrets
|
|
344
|
+
this.create({
|
|
345
|
+
id: rec.id,
|
|
346
|
+
kind: rec.kind,
|
|
347
|
+
name: rec.name,
|
|
348
|
+
url: sec?.url,
|
|
349
|
+
fields: sec?.password !== undefined ? { ...(rec.fields ?? {}), password: sec.password } : rec.fields,
|
|
350
|
+
ssl: rec.ssl,
|
|
351
|
+
});
|
|
352
|
+
// 覆盖式写回:create 成功后该 id 必无冲突,先清掉既有/孤儿机密
|
|
353
|
+
// (create 崩溃窗口可能残留旧条目),再整体重写,确保导入后机密与包一致。
|
|
354
|
+
// 合并写会残留旧键(如旧 url),适配器优先用 rc.url,会被静默错连旧主机。
|
|
355
|
+
this.secrets.delete(rec.id);
|
|
356
|
+
try {
|
|
357
|
+
if (sec !== undefined)
|
|
358
|
+
this.secrets.set(rec.id, sec);
|
|
359
|
+
}
|
|
360
|
+
catch (secErr) {
|
|
361
|
+
// 写回机密失败:连接行已落盘,回滚避免「errors 里却已存在」的半导入门状态
|
|
362
|
+
try {
|
|
363
|
+
this.remove(rec.id);
|
|
364
|
+
}
|
|
365
|
+
catch { /* 尽力而为 */ }
|
|
366
|
+
throw secErr;
|
|
367
|
+
}
|
|
368
|
+
result.imported += 1;
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
372
|
+
if (err instanceof DuplicateConnectionError) {
|
|
373
|
+
result.skipped.push(rec.id);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
result.errors.push({ id: rec.id, message });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
299
382
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连接导出/导入的口令加解密(纯函数,仅用 Node stdlib crypto)。
|
|
3
|
+
*
|
|
4
|
+
* 方案:
|
|
5
|
+
* - KDF:scryptSync(passphrase, salt, 32) 派生 AES-256 密钥;
|
|
6
|
+
* - 算法:AES-256-GCM(机密性 + 完整性一体,tag 防篡改);
|
|
7
|
+
* - iv:12 字节随机(GCM 推荐长度);salt:16 字节随机;
|
|
8
|
+
* - 输出为 JSON 字符串:{ v: 1, kdf: 'scrypt', salt, iv, tag, data },二进制段全部 base64。
|
|
9
|
+
*
|
|
10
|
+
* 失败统一抛 Error('口令错误或文件已损坏'),不向外泄漏是口令错还是文件坏,
|
|
11
|
+
* 避免给攻击者侧信道。口令本身为空/非字符串时单独抛 Error(参数错误,非解密失败)。
|
|
12
|
+
*/
|
|
13
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
14
|
+
/** 解密失败/文件损坏的统一错误信息 */
|
|
15
|
+
export const DECRYPT_FAIL_MESSAGE = '口令错误或文件已损坏';
|
|
16
|
+
/** 口令断言:必须为非空字符串 */
|
|
17
|
+
function assertPassphrase(passphrase) {
|
|
18
|
+
if (typeof passphrase !== 'string' || passphrase.length === 0) {
|
|
19
|
+
throw new Error('口令必须为非空字符串');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** 加密任意可 JSON 序列化的 payload,返回信封 JSON 字符串 */
|
|
23
|
+
export function encryptPayload(plain, passphrase) {
|
|
24
|
+
assertPassphrase(passphrase);
|
|
25
|
+
const salt = randomBytes(16);
|
|
26
|
+
// 显式固定 scrypt 成本参数(与 Node 当前默认一致),加解密两侧同步使用,
|
|
27
|
+
// 避免导出包跨运行时/跨 Node 版本时因默认值漂移而无法解密
|
|
28
|
+
const key = scryptSync(passphrase, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
29
|
+
const iv = randomBytes(12);
|
|
30
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
31
|
+
const data = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
|
|
32
|
+
const box = {
|
|
33
|
+
v: 1,
|
|
34
|
+
kdf: 'scrypt',
|
|
35
|
+
salt: salt.toString('base64'),
|
|
36
|
+
iv: iv.toString('base64'),
|
|
37
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
38
|
+
data: data.toString('base64'),
|
|
39
|
+
};
|
|
40
|
+
return JSON.stringify(box);
|
|
41
|
+
}
|
|
42
|
+
/** 结构校验:缺字段/类型不符即视为文件损坏 */
|
|
43
|
+
function isEncryptedBox(x) {
|
|
44
|
+
if (x === null || typeof x !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
const b = x;
|
|
47
|
+
return (b.v === 1 &&
|
|
48
|
+
b.kdf === 'scrypt' &&
|
|
49
|
+
typeof b.salt === 'string' &&
|
|
50
|
+
typeof b.iv === 'string' &&
|
|
51
|
+
typeof b.tag === 'string' &&
|
|
52
|
+
typeof b.data === 'string');
|
|
53
|
+
}
|
|
54
|
+
/** 解密信封 JSON,返回原始 payload;口令错误或任一环节损坏统一抛 DECRYPT_FAIL_MESSAGE */
|
|
55
|
+
export function decryptPayload(encryptedJson, passphrase) {
|
|
56
|
+
assertPassphrase(passphrase);
|
|
57
|
+
try {
|
|
58
|
+
const box = JSON.parse(encryptedJson);
|
|
59
|
+
if (!isEncryptedBox(box))
|
|
60
|
+
throw new Error('结构非法');
|
|
61
|
+
const salt = Buffer.from(box.salt, 'base64');
|
|
62
|
+
const iv = Buffer.from(box.iv, 'base64');
|
|
63
|
+
const tag = Buffer.from(box.tag, 'base64');
|
|
64
|
+
const data = Buffer.from(box.data, 'base64');
|
|
65
|
+
// base64 解码静默容错,需显式校验解码后长度(iv 12B / tag 16B / salt 非空)
|
|
66
|
+
if (iv.length !== 12 || tag.length !== 16 || salt.length === 0)
|
|
67
|
+
throw new Error('长度非法');
|
|
68
|
+
const key = scryptSync(passphrase, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
decipher.setAuthTag(tag);
|
|
71
|
+
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
72
|
+
return JSON.parse(plain.toString('utf8'));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// 对外统一文案(防侧信道),原始错误挂 cause 供服务端日志区分失败模式
|
|
76
|
+
throw new Error(DECRYPT_FAIL_MESSAGE, { cause: err });
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/store/index.js
CHANGED
|
@@ -18,7 +18,8 @@ import { GrantStore } from './grants.js';
|
|
|
18
18
|
import { chmodBestEffort } from './io.js';
|
|
19
19
|
import { SecretsBox } from './secrets.js';
|
|
20
20
|
export { normalizeProjectKey } from './normalize.js';
|
|
21
|
-
export { mergeUrlCredentials, redactUrl } from './connections.js';
|
|
21
|
+
export { mergeUrlCredentials, redactUrl, DuplicateConnectionError } from './connections.js';
|
|
22
|
+
export { encryptPayload, decryptPayload, DECRYPT_FAIL_MESSAGE } from './export-crypto.js';
|
|
22
23
|
export { AuditLog, ConnectionStore, GrantStore, SecretsBox };
|
|
23
24
|
export class DbToolStore {
|
|
24
25
|
/** 数据目录:$DSH_HOME/db-tool */
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# 连接管理「导出 / 导入」方案
|
|
2
|
+
|
|
3
|
+
> 状态:已实现并验收(全仓 `tsc --noEmit` 0 报错;全量 `npm test` 564/564 通过;`npm run build` 成功)
|
|
4
|
+
> 版本:dsh-db-tool ≥ 1.5.91
|
|
5
|
+
> 范围:仅侧边栏「连接管理」面板;模型工具(LLM)不暴露。
|
|
6
|
+
> 技术依据:Node.js crypto 官方文档 + MDN Web Docs(经 context7 检索),逐条对照见 §3、§4.4。
|
|
7
|
+
|
|
8
|
+
## 1. 背景与目标
|
|
9
|
+
|
|
10
|
+
dsh-db-tool 把连接配置拆两处持久化:
|
|
11
|
+
|
|
12
|
+
- `connections.json` — 连接元数据(绝不含密码;密码段在 URL 中被替换为 `***`)
|
|
13
|
+
- `secrets.json` — 连接级机密(完整 URL / 明文密码,权限 0600)
|
|
14
|
+
|
|
15
|
+
用户需要跨机器迁移连接:一键导出为**单个加密文件**,在另一台机器(或另一个 DSH 实例)用口令解密导入。
|
|
16
|
+
|
|
17
|
+
### 成功标准(验收已满足)
|
|
18
|
+
|
|
19
|
+
- 导出文件用任意文本编辑器打开**无明文密码**(整个 payload 加密)。
|
|
20
|
+
- 导出 → 删除全部连接 → 导入,round-trip 后 `testTarget` 可拼回真实 URL/密码,连接可 test 连通。
|
|
21
|
+
- 重复导入幂等:已存在 id 全部计入 `skipped`,不覆盖。
|
|
22
|
+
- 零新增 npm 依赖(全部走 Node stdlib + 各库官方驱动)。
|
|
23
|
+
|
|
24
|
+
### 明确不做(YAGNI)
|
|
25
|
+
|
|
26
|
+
- 不含 `grants.json` / `audit.jsonl`:授权是「项目路径 ↔ 连接」的机器绑定关系,跨机迁移无意义;审计不可移植。
|
|
27
|
+
- 不做选择性导出(勾选部分连接):需要时后续给 export API 加 `ids?: string[]` 即可,不预留抽象。
|
|
28
|
+
- 不在 `handleToolAction`(模型工具)暴露导出:机密不应进入 LLM 上下文,仅 HTTP 面板可用。
|
|
29
|
+
|
|
30
|
+
## 2. 关键设计决策
|
|
31
|
+
|
|
32
|
+
| 决策点 | 结论 | 依据 |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| 导出内容 | 含密码 | 目标是跨机器迁移,无密码无法做到导入即可用 |
|
|
35
|
+
| 加密算法 | 用户口令 + scrypt 派生密钥 + AES-256-GCM | Node stdlib `crypto` 足够,不引第三方加密库(ponytail 阶梯:stdlib 优先) |
|
|
36
|
+
| 冲突策略 | **skip**(不覆盖) | 用户明确选择;幂等可重入,重复导入安全 |
|
|
37
|
+
| 错误反馈 | 口令错 / 密文篡改 / 格式坏**统一**报「口令错误或文件已损坏」 | 不向外泄露失败原因,防侧信道探测 |
|
|
38
|
+
| AAD | 不使用 | 见 §3「AAD 裁决」 |
|
|
39
|
+
|
|
40
|
+
## 3. 加密格式(按 Node 官方文档校准)
|
|
41
|
+
|
|
42
|
+
信封结构(JSON,二进制段全 base64):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"v": 1,
|
|
47
|
+
"kdf": "scrypt",
|
|
48
|
+
"salt": "<base64, 16 字节>",
|
|
49
|
+
"iv": "<base64, 12 字节>",
|
|
50
|
+
"tag": "<base64, 16 字节>",
|
|
51
|
+
"data": "<base64 密文>"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 官方文档逐条对照
|
|
56
|
+
|
|
57
|
+
| 参数 | 取值 | 官方依据(context7 → nodejs/node) |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| KDF | `scryptSync(passphrase, salt, 32)` | `crypto.scryptSync` 官方定位为 password-based KDF,"designed to be expensive computationally and memory-wise in order to make brute-force attacks unrewarding" |
|
|
60
|
+
| scrypt cost/blockSize/parallelization | **默认值**(N=16384, r=8, p=1),maxmem 默认 32MB | 官方默认值即推荐值,不显式覆盖 |
|
|
61
|
+
| salt | 随机 **16 字节**(`randomBytes(16)`) | 官方:"It is recommended that a salt is random and at least 16 bytes long"(NIST SP 800-132) |
|
|
62
|
+
| 算法 | `aes-256-gcm` | GCM 同时提供机密性与完整性(auth tag 防篡改) |
|
|
63
|
+
| IV | 随机 **12 字节**(`randomBytes(12)`) | OpenSSL 官方变更说明:"The IV length is by default 12 bytes (96 bits)",GCM 推荐 96-bit |
|
|
64
|
+
| auth tag | **默认 16 字节**,不显式传 `authTagLength` | Node DEP0182 已废止短 tag(<16B)以对齐 NIST SP 800-38D;`createCipheriv` 文档明确 GCM 默认 16 字节 |
|
|
65
|
+
| 解密顺序 | `setAuthTag(tag)` 必须在 `final()` 前 | 官方示例代码(`test-crypto-authenticated.js`):`decrypt.setAuthTag(...)` → `update` → `final` |
|
|
66
|
+
|
|
67
|
+
### AAD 裁决(不使用,理由记录)
|
|
68
|
+
|
|
69
|
+
官方示例用 `setAAD` 把密文绑定到外部上下文(用户 id、文件名等),防信封被整体搬到别的上下文冒充。本方案不加,理由:
|
|
70
|
+
|
|
71
|
+
1. 信封自含 `v/kdf/salt/iv/tag`,且**口令本身就是解密前提**——信封整体替换后在任何机器上都需要同一口令才能解,AAD 不增加实际安全边界。
|
|
72
|
+
2. 引入 AAD 需要持久化一个额外的上下文字段并保持两端同步,是真实复杂度(ponytail 阶梯:两个同级方案取边界更简单的)。
|
|
73
|
+
3. 文件本身就是交付物,不存在「信封被混进别的数据流」的攻击面。
|
|
74
|
+
|
|
75
|
+
解密任一环节失败(JSON 解析、结构校验、base64 长度校验、GCM tag 验证、明文 JSON 解析)统一抛 `Error('口令错误或文件已损坏')`(常量 `DECRYPT_FAIL_MESSAGE`)。
|
|
76
|
+
|
|
77
|
+
明文 payload 结构:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"connections": [{ "id", "kind", "name?", "urlSafe?", "fields?", "ssl?" }],
|
|
82
|
+
"secrets": { "<connId>": { "url?", "password?", "...其他机密键" } }
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`secrets` 保留 `SecretEntry` 的扩展键(如 MongoDB 的额外凭证),不只挑 `url/password`。
|
|
87
|
+
|
|
88
|
+
## 4. 实现分层
|
|
89
|
+
|
|
90
|
+
### 4.1 存储层 — `lib/store/`
|
|
91
|
+
|
|
92
|
+
**`export-crypto.ts`(新建,纯函数)**
|
|
93
|
+
|
|
94
|
+
- `encryptPayload(plain: unknown, passphrase: string): string` — 非空口令断言 → scryptSync(pass, salt, 32) → AES-256-GCM → 信封 JSON。
|
|
95
|
+
- `decryptPayload(encryptedJson: string, passphrase: string): unknown` — 结构校验 + iv/tag/salt 长度校验 + tag 验证 → 原始 payload;任一失败统一抛 `DECRYPT_FAIL_MESSAGE`。
|
|
96
|
+
- `DECRYPT_FAIL_MESSAGE` 导出供 service 层复用。
|
|
97
|
+
|
|
98
|
+
**`connections.ts`(修改)**
|
|
99
|
+
|
|
100
|
+
- `exportBundle(): { connections: ConnRecord[]; secrets: Record<string, SecretEntry> }`
|
|
101
|
+
从 `load()` 取全部连接记录,逐条 `this.secrets.get(id)` 合并;无机密的连接不出现在 `secrets` 中。
|
|
102
|
+
- `importBundle(bundle): { imported: number; skipped: string[]; errors: {id, message}[] }`
|
|
103
|
+
逐条走**既有** `this.create()`(机密拆分 / URL 脱敏逻辑全复用,不新造写入路径):`sec.url` 作完整 URL 传入、`sec.password` 经 `fields.password` 自动拆分。冲突(`连接已存在`)→ `skipped`;其他异常 → `errors` 续跑。create 后 `this.secrets.set(rec.id, sec)` 幂等合并写回,保留扩展机密键。
|
|
104
|
+
|
|
105
|
+
**`index.ts`(修改)**:补导出 `encryptPayload / decryptPayload / DECRYPT_FAIL_MESSAGE` 及 `ConnRecord` 类型。
|
|
106
|
+
|
|
107
|
+
### 4.2 服务层 — `lib/manager.ts`(DbToolService)
|
|
108
|
+
|
|
109
|
+
- `exportConnections(passphrase: string): string`
|
|
110
|
+
非空校验 → `store.exportBundle()` → `encryptPayload` → 审计 `export_connections`(statement 仅记「导出 N 个连接」,不落口令/密文)。
|
|
111
|
+
- `importConnections(encryptedJson: string, passphrase: string)`
|
|
112
|
+
解密失败 + 结构校验失败(缺 `connections` 数组)统一转 `DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE)` → `store.importBundle` → 审计 `import_connections`(记 imported/skipped 数)。
|
|
113
|
+
|
|
114
|
+
### 4.3 HTTP 层 — `lib/http/index.ts`
|
|
115
|
+
|
|
116
|
+
两条新路由,**插在 `connMatch` 之前**(`/api/connections/export` 与 `/api/connections/import` 都是单层路径,放后面会被正则 `^/api/connections/([^/]+)` 当作连接 id 静默吞掉):
|
|
117
|
+
|
|
118
|
+
| 路由 | Body | 成功 | 失败 |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| `POST /api/connections/export` | `{passphrase}` | `{ok:true, data:{json}}` | 缺/空 passphrase → 400 INVALID_ARGUMENT |
|
|
121
|
+
| `POST /api/connections/import` | `{json, passphrase}` | `{ok:true, data:{imported, skipped[], errors[]}}` | 缺参 400;口令错走 `sendError` → 200 + `{ok:false, code:'INVALID_ARGUMENT'}`(与既有契约一致) |
|
|
122
|
+
|
|
123
|
+
trust 校验(Host/Origin loopback)、2MB body 上限、`{ok,error,code}` 响应契约全部复用既有链路。
|
|
124
|
+
|
|
125
|
+
### 4.4 客户端 — `client/client.js`(ManageView)
|
|
126
|
+
|
|
127
|
+
**i18n**:zh/en 各加 8 键(exportConns / importConns / passphrase / passphraseHint / exportDone / importResult / importDecryptFail / pickFile)。
|
|
128
|
+
|
|
129
|
+
**`PassphraseDialog`**(局部组件,紧邻 `EMPTY_FORM`):复用 `dbt-overlay/dbt-dialog` 样式;键盘契约对齐 `DangerDialog`(Escape=取消、Tab 循环锁定),差异——初始焦点在口令输入框、确认按钮主色、Enter 提交、前端预校验 ≥6 位。ponytail:仅两处使用,未抽通用模块。
|
|
130
|
+
|
|
131
|
+
**导出 / 导入文件 IO(按 MDN 官方文档校准)**:
|
|
132
|
+
|
|
133
|
+
| 操作 | MDN 最佳实践 | 实现 |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| 下载 | `Blob` → `URL.createObjectURL` → `a.download` → click | `dsh-db-connections-YYYYMMDD.enc.json` |
|
|
136
|
+
| 释放 object URL | 必须 `revokeObjectURL`,但**不能过早**(官方明示:立即 revoke 会让用户无法保存/打开) | `a.click()` 后 `setTimeout(revoke, 1000)`,与仓库内结果导出下载同一模式 |
|
|
137
|
+
| 读取本地文件 | `FileReader.readAsText` + `onload/onerror` 双通道,先校验文件存在 | 读失败走通用错误通道 |
|
|
138
|
+
|
|
139
|
+
**流程**:
|
|
140
|
+
- 导出:点导出 → 口令窗 → `POST connections/export` → Blob 下载 → `dbt-msg` 显示 `exportDone`。
|
|
141
|
+
- 导入:点导入 → 隐藏 `input[type=file accept=".json"]` 选文件 → FileReader 读文本 → 口令窗 → `POST connections/import` → `reload()` → `dbt-msg` 显示 `importResult`;`err.code === 'INVALID_ARGUMENT'` 时 `dbt-err` 显示 `importDecryptFail`。
|
|
142
|
+
|
|
143
|
+
## 5. 边界与失败模式
|
|
144
|
+
|
|
145
|
+
| 场景 | 行为 |
|
|
146
|
+
|---|---|
|
|
147
|
+
| 口令为空 / 非字符串 | store 层抛 `Error('口令必须为非空字符串')`;HTTP 层 400 |
|
|
148
|
+
| 密文被篡改 | GCM tag 校验失败 → 统一「口令错误或文件已损坏」(不泄露是哪一环) |
|
|
149
|
+
| 导入文件非信封结构 / 明文缺 `connections` 数组 | 同上统一错误 |
|
|
150
|
+
| 单条导入失败(kind 非法、secrets 写入 I/O 故障等) | 记入 `errors` 续跑,不整批回滚(与 skip 语义一致,幂等可重入) |
|
|
151
|
+
| 连接无密码 | 不出现在 `secrets` 段;导入后无 `hasPassword` 指示 |
|
|
152
|
+
|
|
153
|
+
## 6. 测试
|
|
154
|
+
|
|
155
|
+
- `tests/store/export-crypto.spec.ts`(5 例):round-trip(密文不含明文)、随机 salt/iv 同文异密、错口令统一错误、篡改 data/tag/结构统一错误、空/非字符串口令拒绝。
|
|
156
|
+
- `tests/store/connections.spec.ts`(追加 4 例):exportBundle 含密码、importBundle skip 冲突不覆盖 + 重复导入幂等、单条异常入 errors 续跑、round-trip 后 testTarget 密码/URL 一致且 connections.json 无明文。
|
|
157
|
+
- `tests/tool/manager.spec.ts`(追加 4 例):round-trip(导出→删除→导入恢复)、错口令 INVALID_ARGUMENT、空参数 INVALID_ARGUMENT、导出/导入审计断言(记条数、不落口令)。
|
|
158
|
+
- `tests/http/api.spec.ts`(追加 3 例):happy path(HTTP 建连接→导出→全删→导入恢复,断言密文不含明文连接串)、缺/空 passphrase 400、坏密文导入 INVALID_ARGUMENT。
|
|
159
|
+
|
|
160
|
+
验收命令:`npm run typecheck && npm test && npm run build`,全部通过。
|
|
161
|
+
|
|
162
|
+
## 7. 实施过程(Agent Teams 并行)
|
|
163
|
+
|
|
164
|
+
任务拆为 3 个 teammate 并行,写范围互斥:
|
|
165
|
+
|
|
166
|
+
| Teammate | 写范围 | 产出 |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| store-crypto | `lib/store/connections.ts`、`lib/store/export-crypto.ts` + 配套测试 | 存储层 + 加解密,21 测试绿 |
|
|
169
|
+
| service-http | `lib/manager.ts`、`lib/http/index.ts` + 配套测试 | service + HTTP 路由,46 测试绿 |
|
|
170
|
+
| client-ui | `client/client.js` | UI + i18n,98 client 测试无回归 |
|
|
171
|
+
|
|
172
|
+
契约先行冻结(store 方法签名、HTTP 端点、错误 code),三路按契约并行开发;store-crypto 完成后 service-http 的真实 store 联跑自然转绿。Lead 负责收尾 integrator(`lib/store/index.ts` 导出面)+ 全仓验收。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|