nx-sk 0.3.4 → 0.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/CHANGELOG.md +47 -1
- package/README.md +62 -4
- package/assets/nx-sk/SKILL.md +31 -2
- package/assets/nx-sk/references/00-design.md +4 -3
- package/assets/nx-sk/references/10-sections-entries.md +10 -2
- package/assets/nx-sk/references/30-export-backup.md +16 -0
- package/assets/nx-sk/references/40-files.md +104 -0
- package/package.json +1 -1
- package/src/core/attachments.js +378 -0
- package/src/core/fields.js +58 -2
- package/src/core/paths.js +5 -0
- package/src/core/render.js +1 -0
- package/src/core/store.js +3 -0
- package/src/modules/backup/index.js +41 -0
- package/src/modules/backup/service.js +283 -0
- package/src/modules/backup/view.jsx +145 -0
- package/src/modules/entries/service.js +35 -2
- package/src/modules/files/index.js +114 -0
- package/src/modules/files/service.js +276 -0
- package/src/modules/files/view.jsx +342 -0
- package/src/modules/sections/index.js +10 -0
- package/src/modules/sections/service.js +55 -2
- package/src/modules/sections/view.jsx +30 -2
- package/src/runtime/api.js +10 -1
- package/src/runtime/registry.js +3 -1
- package/src/web/frontend/App.jsx +36 -18
- package/src/web/frontend/api/files.js +28 -0
- package/src/web/frontend/components/fieldEditor.jsx +72 -1
- package/src/web/frontend/index.html +4 -0
- package/src/web/frontend/public/favicon-16.png +0 -0
- package/src/web/frontend/public/favicon-32.png +0 -0
- package/src/web/frontend/public/favicon-48.png +0 -0
- package/src/web/frontend/public/favicon.ico +0 -0
- package/src/web/frontend/public/logo-rounded.png +0 -0
- package/src/web/frontend/public/logo.png +0 -0
- package/src/web/frontend/registry.js +2 -0
- package/src/web/frontend/styles.css +104 -6
- package/src/web/public/assets/files-BNh48qeo.js +1 -0
- package/src/web/public/assets/{index-BnVuH1lC.js → index-B7CtqgoG.js} +10 -9
- package/src/web/public/assets/index-DLtjhflt.css +1 -0
- package/src/web/public/assets/view-1kwWQYbz.js +7 -0
- package/src/web/public/assets/view-BAYyPWdU.js +1 -0
- package/src/web/public/assets/{view-BvyRxTgX.js → view-BIdVqIGe.js} +1 -1
- package/src/web/public/assets/{view-rvADq3vX.js → view-Bu70Q6GO.js} +1 -1
- package/src/web/public/assets/view-D5LfHr7W.js +1 -0
- package/src/web/public/assets/view-DXqI-4_F.js +3 -0
- package/src/web/public/favicon-16.png +0 -0
- package/src/web/public/favicon-32.png +0 -0
- package/src/web/public/favicon-48.png +0 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/index.html +6 -2
- package/src/web/public/logo-rounded.png +0 -0
- package/src/web/public/logo.png +0 -0
- package/assets/nx-sk/references/40-extend.md +0 -75
- package/src/web/public/assets/index-CwF5hM2v.css +0 -1
- package/src/web/public/assets/view-CDje4hqZ.js +0 -1
- package/src/web/public/assets/view-DsyplQBh.js +0 -7
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// 加密备份 / 恢复:整份数据用「口令派生密钥 + AES-256-GCM」对称加密。
|
|
2
|
+
//
|
|
3
|
+
// 导出:用户设置口令 → 随机盐写进备份文件,密钥由 scrypt(口令, 盐) 派生(口令不落盘)。
|
|
4
|
+
// 导入:用户输入口令 → 用文件里的盐派生同一把密钥解密;口令错误 GCM 校验失败、明确报错。
|
|
5
|
+
//
|
|
6
|
+
// 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue),
|
|
7
|
+
// 与字段级密钥库(core/vault.js)相互独立:备份口令是一次性的,不写进 vault。
|
|
8
|
+
//
|
|
9
|
+
// 跨机器恢复:store.json 里凭据原本是「本机 vault key(AES-256-GCM + kid)」加密的,
|
|
10
|
+
// 备份打包会把这份密文**再裹一层**——用备份口令派生 key 重加密一份副本,写进
|
|
11
|
+
// `reWrapped`。导入时先解开 reWrapped 拿明文,再用**目标机器**的 vault key 重新加密,
|
|
12
|
+
// 写回主字段、丢掉 reWrapped。这样:
|
|
13
|
+
// - 备份包在 A 机器上能独立解开(不依赖 A 的 ~/.vaultkey / NX_SK_PASSPHRASE)
|
|
14
|
+
// - 导入 B 机器后,凭据绑的是 B 的本地密钥,跟其他密文一样走 decryptValue
|
|
15
|
+
// - 没有「reWrapped 的密文只能输口令解」的尾巴——一次导入就兑回本机
|
|
16
|
+
// 没有 reWrapped 的密文(早期版本/未带口令的导出)依然走 kid 校验;换机会得到明确 BLOCKED。
|
|
17
|
+
import fsp from 'node:fs/promises';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { displayPath, snapshotDir } from '../../core/paths.js';
|
|
21
|
+
import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
|
|
22
|
+
import { loadStore, saveStore, snapshotStore } from '../../core/store.js';
|
|
23
|
+
import { decryptValue, deriveKey, encryptValue, isCipherBlob } from '../../core/crypto.js';
|
|
24
|
+
import { badInput, blocked } from '../../core/errors.js';
|
|
25
|
+
import { nowIso, stamp } from '../../core/ids.js';
|
|
26
|
+
import { isSensitiveField } from '../../core/fields.js';
|
|
27
|
+
import { resolveVaultKey } from '../../core/vault.js';
|
|
28
|
+
|
|
29
|
+
export const BACKUP_KIND = 'encrypted-backup';
|
|
30
|
+
export const BACKUP_VERSION = 1;
|
|
31
|
+
export const ENV_BACKUP_PASSWORD = 'NX_SK_BACKUP_PASSWORD';
|
|
32
|
+
const EXT = '.nxskbak';
|
|
33
|
+
|
|
34
|
+
function passwordFrom(ctx) {
|
|
35
|
+
const pw = ctx?.password ?? process.env[ENV_BACKUP_PASSWORD];
|
|
36
|
+
const s = String(pw ?? '');
|
|
37
|
+
if (!s) {
|
|
38
|
+
throw badInput(
|
|
39
|
+
`备份口令不能为空(用 --password,或设置环境变量 ${ENV_BACKUP_PASSWORD})。` +
|
|
40
|
+
'口令用于加密备份文件,导入时需要再次输入。',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 给 payload 里的每条密文字段加一层「口令派生的重加密」。
|
|
48
|
+
*
|
|
49
|
+
* 输入是 buildBackup 准备好的 store payload;输出是**新对象**,原对象不变。
|
|
50
|
+
* 已带 reWrapped 的不再二次包——避免循环。
|
|
51
|
+
*/
|
|
52
|
+
async function reWrapSecretsInPlace(payload, password, salt) {
|
|
53
|
+
const sections = payload.sections || [];
|
|
54
|
+
const entries = payload.entries || [];
|
|
55
|
+
const transportKey = deriveKey(password, salt);
|
|
56
|
+
const { key: vaultKey } = await resolveVaultKey();
|
|
57
|
+
|
|
58
|
+
const newEntries = [];
|
|
59
|
+
let reWrapped = 0;
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
const sec = sections.find((s) => s.id === e.section);
|
|
62
|
+
if (!sec || !e.values) { newEntries.push(e); continue; }
|
|
63
|
+
const nextValues = { ...e.values };
|
|
64
|
+
let touched = false;
|
|
65
|
+
for (const def of sec.fields || []) {
|
|
66
|
+
if (!isSensitiveField(sec, def.key)) continue;
|
|
67
|
+
const v = nextValues[def.key];
|
|
68
|
+
if (!isCipherBlob(v) || v.reWrapped) continue;
|
|
69
|
+
// 用本机 vault key 解开 → 用口令派生 key 重加密 → 挂到 reWrapped 上。
|
|
70
|
+
// 原来的密文保留——A 机用原 vaultKey 仍能解(不动既有逻辑)。
|
|
71
|
+
const plain = decryptValue(vaultKey, v);
|
|
72
|
+
nextValues[def.key] = { ...v, reWrapped: encryptValue(transportKey, plain) };
|
|
73
|
+
touched = true; reWrapped++;
|
|
74
|
+
}
|
|
75
|
+
newEntries.push(touched ? { ...e, values: nextValues } : e);
|
|
76
|
+
}
|
|
77
|
+
return { ...payload, entries: newEntries, _reWrapped: reWrapped };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 反向:把 reWrapped 副本兑回本机 vault key。
|
|
82
|
+
* 拿不到口令(密码错)会抛 BLOCKED;拿到口令则静默完成,用户不感知。
|
|
83
|
+
*/
|
|
84
|
+
async function unwrapSecretsToLocal(payload, password, salt) {
|
|
85
|
+
const sections = payload.sections || [];
|
|
86
|
+
const entries = payload.entries || [];
|
|
87
|
+
const transportKey = deriveKey(password, salt);
|
|
88
|
+
const { key: vaultKey } = await resolveVaultKey();
|
|
89
|
+
|
|
90
|
+
const newEntries = [];
|
|
91
|
+
let unwrapped = 0;
|
|
92
|
+
for (const e of entries) {
|
|
93
|
+
const sec = sections.find((s) => s.id === e.section);
|
|
94
|
+
if (!sec || !e.values) { newEntries.push(e); continue; }
|
|
95
|
+
const nextValues = { ...e.values };
|
|
96
|
+
let touched = false;
|
|
97
|
+
for (const def of sec.fields || []) {
|
|
98
|
+
if (!isSensitiveField(sec, def.key)) continue;
|
|
99
|
+
const v = nextValues[def.key];
|
|
100
|
+
if (!isCipherBlob(v) || !v.reWrapped) continue;
|
|
101
|
+
const plain = decryptValue(transportKey, v.reWrapped);
|
|
102
|
+
// tenant 用来保留 A 机原 blob 的非密文字段(如 v/alg)但**不能**包含 iv/ct/tag/kid——
|
|
103
|
+
// 这些必须来自新的 encryptValue(用本机 vaultKey 生成的)。否则 spread 后会把
|
|
104
|
+
// 新的 iv/ct/tag 覆盖回 A 机的旧值,写进 store.json 的密文就解不开了。
|
|
105
|
+
const { reWrapped: _rw, iv: _oldIv, ct: _oldCt, tag: _oldTag, kid: _oldKid, ...tenant } = v;
|
|
106
|
+
void _rw; void _oldIv; void _oldCt; void _oldTag; void _oldKid;
|
|
107
|
+
nextValues[def.key] = { ...encryptValue(vaultKey, plain), ...tenant };
|
|
108
|
+
touched = true; unwrapped++;
|
|
109
|
+
}
|
|
110
|
+
newEntries.push(touched ? { ...e, values: nextValues } : e);
|
|
111
|
+
}
|
|
112
|
+
return { ...payload, entries: newEntries, _reWrapped: unwrapped };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
|
|
116
|
+
export async function buildBackup(ctx) {
|
|
117
|
+
const password = passwordFrom(ctx);
|
|
118
|
+
const store = await loadStore();
|
|
119
|
+
const raw = {
|
|
120
|
+
version: store.version,
|
|
121
|
+
settings: store.settings,
|
|
122
|
+
sections: store.sections,
|
|
123
|
+
entries: store.entries,
|
|
124
|
+
exports: store.exports,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const salt = randomBytes(16);
|
|
128
|
+
const wrapKey = deriveKey(password, salt);
|
|
129
|
+
const payload = await reWrapSecretsInPlace(raw, password, salt);
|
|
130
|
+
const data = encryptValue(wrapKey, JSON.stringify(payload));
|
|
131
|
+
const archive = {
|
|
132
|
+
app: 'nx-sk',
|
|
133
|
+
kind: BACKUP_KIND,
|
|
134
|
+
v: BACKUP_VERSION,
|
|
135
|
+
createdAt: nowIso(),
|
|
136
|
+
kdf: 'scrypt',
|
|
137
|
+
salt: salt.toString('base64'),
|
|
138
|
+
data,
|
|
139
|
+
};
|
|
140
|
+
const counts = {
|
|
141
|
+
sections: store.sections.length,
|
|
142
|
+
entries: store.entries.length,
|
|
143
|
+
reWrapped: payload._reWrapped || 0,
|
|
144
|
+
};
|
|
145
|
+
if (ctx['dry-run']) return { dryRun: true, counts };
|
|
146
|
+
return { archive, counts };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** CLI 导出:把加密备份写成文件(缺省放到 ~/nx-sk/backup)。 */
|
|
150
|
+
export async function exportBackup(ctx) {
|
|
151
|
+
const built = await buildBackup(ctx);
|
|
152
|
+
if (built.dryRun) return { status: 'skipped', dryRun: true, ...built.counts };
|
|
153
|
+
const { archive, counts } = built;
|
|
154
|
+
const dir = ctx.out ? resolve(ctx.out) : snapshotDir();
|
|
155
|
+
const name = `nx-sk-backup-${stamp()}${EXT}`;
|
|
156
|
+
await ensureDir(dir);
|
|
157
|
+
const file = resolve(dir, name);
|
|
158
|
+
await writeAtomic(file, JSON.stringify(archive, null, 2) + '\n');
|
|
159
|
+
return {
|
|
160
|
+
status: 'ok',
|
|
161
|
+
file: displayPath(file),
|
|
162
|
+
fileRaw: file,
|
|
163
|
+
bytes: await fileSize(file),
|
|
164
|
+
encrypted: true,
|
|
165
|
+
...counts,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseArchive(text) {
|
|
170
|
+
let archive;
|
|
171
|
+
try {
|
|
172
|
+
archive = JSON.parse(text);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
throw badInput(`备份文件不是合法 JSON(${e.message})——请确认选择的是 nx-sk 导出的 ${EXT} 文件`);
|
|
175
|
+
}
|
|
176
|
+
if (!archive || archive.app !== 'nx-sk' || archive.kind !== BACKUP_KIND || !archive.data) {
|
|
177
|
+
throw badInput(`不是 nx-sk 的加密备份文件(缺少 app/kind 标记)`);
|
|
178
|
+
}
|
|
179
|
+
if (archive.v !== BACKUP_VERSION) {
|
|
180
|
+
throw badInput(`备份文件版本不受支持: v${archive.v}`);
|
|
181
|
+
}
|
|
182
|
+
return archive;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 用口令解密归档文本 → 备份时的 store 负载 + archive(带 salt)。口令错误在此抛 BLOCKED。 */
|
|
186
|
+
export function decryptBackup(text, password) {
|
|
187
|
+
const archive = parseArchive(text);
|
|
188
|
+
if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
|
|
189
|
+
let payload;
|
|
190
|
+
try {
|
|
191
|
+
const key = deriveKey(password, archive.salt);
|
|
192
|
+
payload = JSON.parse(decryptValue(key, archive.data));
|
|
193
|
+
} catch (e) {
|
|
194
|
+
if (e && (e.code === 'BLOCKED' || /解密失败/.test(String(e.message)))) {
|
|
195
|
+
throw blocked('解密失败:口令不正确,或备份文件已损坏。请确认输入的是导出时设置的口令。');
|
|
196
|
+
}
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
|
|
200
|
+
throw badInput('备份内容缺少 sections / entries,无法恢复。');
|
|
201
|
+
}
|
|
202
|
+
return { payload, createdAt: archive.createdAt, salt: archive.salt };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 恢复:解密 → 把 reWrapped 兑回本机 vault key → 留安全快照 → 整份替换。 */
|
|
206
|
+
export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
207
|
+
const { payload, createdAt, salt } = decryptBackup(String(content ?? ''), password);
|
|
208
|
+
|
|
209
|
+
if (dryRun) {
|
|
210
|
+
return {
|
|
211
|
+
status: 'dry-run', dryRun: true,
|
|
212
|
+
backupCreatedAt: createdAt || '',
|
|
213
|
+
sections: payload.sections.length,
|
|
214
|
+
entries: payload.entries.length,
|
|
215
|
+
reWrapped: payload._reWrapped || 0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 把 reWrapped 兑回本机 vault key:解口令派生密文 → 用本机 vaultKey 重加密。
|
|
220
|
+
// 失败(口令对但 reWrapped 损坏)会被 unwrapSecretsToLocal 抛 BLOCKED。
|
|
221
|
+
const unwrapped = await unwrapSecretsToLocal(payload, password, salt);
|
|
222
|
+
|
|
223
|
+
// 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
|
|
224
|
+
const safety = await snapshotStore('before-restore');
|
|
225
|
+
|
|
226
|
+
await saveStore({
|
|
227
|
+
version: unwrapped.version,
|
|
228
|
+
settings: unwrapped.settings,
|
|
229
|
+
sections: unwrapped.sections,
|
|
230
|
+
entries: unwrapped.entries,
|
|
231
|
+
exports: Array.isArray(unwrapped.exports) ? unwrapped.exports : [],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
status: 'restored',
|
|
236
|
+
backupCreatedAt: createdAt || '',
|
|
237
|
+
sections: unwrapped.sections.length,
|
|
238
|
+
entries: unwrapped.entries.length,
|
|
239
|
+
reWrapped: unwrapped._reWrapped || 0,
|
|
240
|
+
safetySnapshot: displayPath(safety),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** CLI 导入:从文件读取归档文本后恢复。 */
|
|
245
|
+
export async function importBackup(ctx) {
|
|
246
|
+
const file = resolve(String(ctx.file ?? ''));
|
|
247
|
+
if (!ctx.file || !(await pathExists(file))) {
|
|
248
|
+
throw badInput(`找不到备份文件: ${ctx.file || '(未用 --file 指定)'}`);
|
|
249
|
+
}
|
|
250
|
+
const text = await fsp.readFile(file, 'utf8');
|
|
251
|
+
return restoreBackup({
|
|
252
|
+
content: text,
|
|
253
|
+
password: ctx.password ?? process.env[ENV_BACKUP_PASSWORD],
|
|
254
|
+
'dry-run': ctx['dry-run'],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function renderBackup(d) {
|
|
259
|
+
if (d.status === 'skipped') {
|
|
260
|
+
return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`
|
|
261
|
+
+ (d.reWrapped ? `\n携带口令派生重加密的密文字段: ${d.reWrapped} 个(其他机器用本次口令可解开)` : '');
|
|
262
|
+
}
|
|
263
|
+
return [
|
|
264
|
+
`已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
|
|
265
|
+
`文件: ${d.file} ${d.bytes} 字节`,
|
|
266
|
+
`内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
267
|
+
`携带口令派生重加密的密文字段: ${d.reWrapped || 0} 个(其他机器用本次口令可解开)`,
|
|
268
|
+
`恢复时需要输入本次设置的口令。`,
|
|
269
|
+
].join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function renderRestore(d) {
|
|
273
|
+
if (d.status === 'dry-run') {
|
|
274
|
+
return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目`
|
|
275
|
+
+ `,含口令派生重加密的密文字段 ${d.reWrapped || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
|
|
276
|
+
}
|
|
277
|
+
return [
|
|
278
|
+
`已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
279
|
+
`兑回本机密钥库的密文字段: ${d.reWrapped || 0} 个`,
|
|
280
|
+
`备份生成于: ${d.backupCreatedAt || '未知'}`,
|
|
281
|
+
`恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
|
|
282
|
+
].join('\n');
|
|
283
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { api } from '../../web/frontend/api/client.js';
|
|
3
|
+
import { useStore } from '../../web/frontend/store.jsx';
|
|
4
|
+
import { CliHints, Empty, Modal } from '../../web/frontend/components/ui.jsx';
|
|
5
|
+
|
|
6
|
+
function fileStamp() {
|
|
7
|
+
const d = new Date();
|
|
8
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
9
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function downloadText(filename, text) {
|
|
13
|
+
const blob = new Blob([text], { type: 'application/json;charset=utf-8' });
|
|
14
|
+
const url = URL.createObjectURL(blob);
|
|
15
|
+
const a = document.createElement('a');
|
|
16
|
+
a.href = url;
|
|
17
|
+
a.download = filename;
|
|
18
|
+
document.body.appendChild(a);
|
|
19
|
+
a.click();
|
|
20
|
+
a.remove();
|
|
21
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function BackupView() {
|
|
25
|
+
const { toast, guard, dialog, reload } = useStore();
|
|
26
|
+
const [pw, setPw] = useState('');
|
|
27
|
+
const [pw2, setPw2] = useState('');
|
|
28
|
+
const [restorePw, setRestorePw] = useState('');
|
|
29
|
+
const [picked, setPicked] = useState(null);
|
|
30
|
+
const [result, setResult] = useState(null);
|
|
31
|
+
const fileRef = useRef(null);
|
|
32
|
+
|
|
33
|
+
const doExport = guard(async () => {
|
|
34
|
+
if (!pw) { toast('请先设置备份口令', 'bad'); return; }
|
|
35
|
+
if (pw !== pw2) { toast('两次输入的口令不一致', 'bad'); return; }
|
|
36
|
+
const r = await api('/api/backup', { method: 'POST', body: { password: pw } });
|
|
37
|
+
const filename = `nx-sk-backup-${fileStamp()}.nxskbak`;
|
|
38
|
+
downloadText(filename, JSON.stringify(r.archive, null, 2) + '\n');
|
|
39
|
+
toast(`已生成加密备份:${r.counts.sections} 栏目 / ${r.counts.entries} 条目`);
|
|
40
|
+
setPw(''); setPw2('');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const onPick = async (e) => {
|
|
44
|
+
const file = e.target.files?.[0];
|
|
45
|
+
if (!file) return;
|
|
46
|
+
const text = await file.text();
|
|
47
|
+
setPicked({ name: file.name, bytes: file.size, text });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const doRestore = guard(async () => {
|
|
51
|
+
if (!picked) { toast('请先选择备份文件', 'bad'); return; }
|
|
52
|
+
if (!restorePw) { toast('请输入备份口令', 'bad'); return; }
|
|
53
|
+
const ok = await dialog({
|
|
54
|
+
danger: true,
|
|
55
|
+
confirmText: '解密并整份恢复',
|
|
56
|
+
message: '恢复会用备份内容整份替换当前数据。',
|
|
57
|
+
detail: `备份文件:${picked.name}\n恢复前会自动给当前数据留一份安全快照(可回滚)。`,
|
|
58
|
+
});
|
|
59
|
+
if (!ok) return;
|
|
60
|
+
const r = await api('/api/backup/restore', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
body: { password: restorePw, content: picked.text },
|
|
63
|
+
});
|
|
64
|
+
setResult(r);
|
|
65
|
+
setRestorePw('');
|
|
66
|
+
setPicked(null);
|
|
67
|
+
if (fileRef.current) fileRef.current.value = '';
|
|
68
|
+
await reload();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<div className="toolbar rowgap pad">
|
|
74
|
+
<strong>加密备份</strong>
|
|
75
|
+
<span className="muted">整份数据用口令对称加密(AES-256-GCM);导出设口令、恢复输口令,口令不落盘</span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div className="card">
|
|
79
|
+
<div className="colhead">加密导出</div>
|
|
80
|
+
<div className="pad stack">
|
|
81
|
+
<div className="grid">
|
|
82
|
+
<div className="field">
|
|
83
|
+
<div className="flabel"><span>设置备份口令</span><span className="fkey">建议至少 6 位</span></div>
|
|
84
|
+
<div className="fctrl">
|
|
85
|
+
<input type="password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="备份口令" />
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="field">
|
|
89
|
+
<div className="flabel"><span>再输入一次</span></div>
|
|
90
|
+
<div className="fctrl">
|
|
91
|
+
<input type="password" autoComplete="new-password" value={pw2} onChange={(e) => setPw2(e.target.value)} placeholder="确认口令" />
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="hint">导出为单个 .nxskbak 文件并在浏览器下载;文件内含随机盐,密钥由口令经 scrypt 派生。恢复时需要输入同一口令。</div>
|
|
96
|
+
<div className="rowgap">
|
|
97
|
+
<button className="btn" onClick={doExport}>加密导出并下载</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="card">
|
|
103
|
+
<div className="colhead">解密恢复(导入)</div>
|
|
104
|
+
<div className="pad stack">
|
|
105
|
+
<div className="grid">
|
|
106
|
+
<div className="field">
|
|
107
|
+
<div className="flabel"><span>备份文件</span><span className="fkey">.nxskbak</span></div>
|
|
108
|
+
<div className="fctrl">
|
|
109
|
+
<input ref={fileRef} type="file" accept=".nxskbak,application/json" onChange={onPick} />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="field">
|
|
113
|
+
<div className="flabel"><span>输入备份口令</span></div>
|
|
114
|
+
<div className="fctrl">
|
|
115
|
+
<input type="password" autoComplete="off" value={restorePw} onChange={(e) => setRestorePw(e.target.value)} placeholder="导出时设置的口令" />
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
{picked ? (
|
|
120
|
+
<div className="row">
|
|
121
|
+
<span className="mono name">{picked.name}</span>
|
|
122
|
+
<span className="acts"><span className="tag">{picked.bytes} 字节</span></span>
|
|
123
|
+
</div>
|
|
124
|
+
) : <Empty>还没有选择备份文件。</Empty>}
|
|
125
|
+
<div className="hint">口令错误或文件损坏会明确报错、不会改动当前数据;确认恢复前会先留安全快照。</div>
|
|
126
|
+
<div className="rowgap">
|
|
127
|
+
<button className="btn danger" onClick={doRestore}>解密并恢复</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<CliHints module="backup" />
|
|
133
|
+
|
|
134
|
+
{result ? (
|
|
135
|
+
<Modal title="恢复完成" onClose={() => setResult(null)}>
|
|
136
|
+
<div className="stack">
|
|
137
|
+
<div>已从加密备份恢复:{result.sections} 个栏目 / {result.entries} 条条目</div>
|
|
138
|
+
<div className="muted">备份生成于 {result.backupCreatedAt || '未知'}</div>
|
|
139
|
+
<pre className="block">恢复前的安全快照:{result.safetySnapshot}</pre>
|
|
140
|
+
</div>
|
|
141
|
+
</Modal>
|
|
142
|
+
) : null}
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -13,6 +13,8 @@ import { completeness, displaySensitive, serializeEntry } from '../../core/rende
|
|
|
13
13
|
import { newId, nowIso } from '../../core/ids.js';
|
|
14
14
|
import { decryptValue, encryptValue, isCipherBlob, maskValue } from '../../core/crypto.js';
|
|
15
15
|
import { resolveVaultKey, sensitiveViewer } from '../../core/vault.js';
|
|
16
|
+
import * as att from '../../core/attachments.js';
|
|
17
|
+
import { displayPath } from '../../core/paths.js';
|
|
16
18
|
|
|
17
19
|
export function mustFindSection(store, ref) {
|
|
18
20
|
const s = findSection(store, ref);
|
|
@@ -100,6 +102,13 @@ function normalizeValue(def, raw) {
|
|
|
100
102
|
}
|
|
101
103
|
return list;
|
|
102
104
|
}
|
|
105
|
+
if (type === 'file') {
|
|
106
|
+
// 值 = 附件 id(或唯一文件名)。id 全局唯一,天然做到「各条目附件互不串台」;
|
|
107
|
+
// 同一份原件被多条条目引用也没问题(sha256 去重后是同一个 id)。
|
|
108
|
+
// 不校验存在性——附件可能被还原/删掉,前端按「引用的附件不存在」展示。
|
|
109
|
+
const s = String(raw).trim();
|
|
110
|
+
return s === '' ? null : s;
|
|
111
|
+
}
|
|
103
112
|
const s = String(raw);
|
|
104
113
|
return s.trim() === '' ? null : s;
|
|
105
114
|
}
|
|
@@ -174,7 +183,12 @@ export async function listEntries({ section, q } = {}) {
|
|
|
174
183
|
if (!viewers.has(sec.id)) viewers.set(sec.id, await sensitiveViewer(sec, list.filter((x) => x.section === sec.id)));
|
|
175
184
|
const item = serializeEntry(sec, e, { decrypt: viewers.get(sec.id) });
|
|
176
185
|
if (q) {
|
|
177
|
-
|
|
186
|
+
// file 字段值是附件 id,把解析出的附件名也加进搜索文本,才能按「简历/成绩单」搜到条目
|
|
187
|
+
const fileNames = sec.fields
|
|
188
|
+
.filter((f) => f.type === 'file')
|
|
189
|
+
.map((f) => att.findFileRecord(store, e.values?.[f.key])?.name || '');
|
|
190
|
+
const hay = [item.title, sec.title, ...fileNames,
|
|
191
|
+
...Object.values(item.values).map((v) => (v === null ? '' : Array.isArray(v) ? v.join(',') : String(v)))]
|
|
178
192
|
.join('\n').toLowerCase();
|
|
179
193
|
if (!hay.includes(String(q).toLowerCase())) continue;
|
|
180
194
|
}
|
|
@@ -189,7 +203,21 @@ export async function getEntry(ref, { mask, section } = {}) {
|
|
|
189
203
|
const sec = store.sections.find((s) => s.id === entry.section);
|
|
190
204
|
if (!sec) throw notFound(`条目 ${entry.id} 指向的栏目已不存在: ${entry.section}`);
|
|
191
205
|
const decrypt = await sensitiveViewer(sec, [entry]);
|
|
192
|
-
|
|
206
|
+
const serialized = serializeEntry(sec, entry, { mask: !!mask, decrypt });
|
|
207
|
+
// 附带 file 字段引用的附件信息:键 = 字段值(即附件 id / 文件名),值为名称 / 路径 / 链接
|
|
208
|
+
const files = {};
|
|
209
|
+
for (const f of sec.fields.filter((x) => x.type === 'file')) {
|
|
210
|
+
const ref = serialized.values[f.key];
|
|
211
|
+
if (!ref) continue;
|
|
212
|
+
const rec = att.findFileRecord(store, ref);
|
|
213
|
+
if (!rec) continue;
|
|
214
|
+
const links = att.fileLinks(rec);
|
|
215
|
+
files[ref] = {
|
|
216
|
+
id: rec.id, name: rec.name, ext: rec.ext, isImage: att.isImageExt(rec.ext),
|
|
217
|
+
path: displayPath(att.fileRecordAbs(rec)), url: links.url, downloadUrl: links.downloadUrl,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { ...serialized, sectionTitle: sec.title, mask: !!mask, files };
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
export async function addEntry({ section, title, set, data, tags, 'dry-run': dryRun } = {}) {
|
|
@@ -312,6 +340,7 @@ export async function fieldDictionary({ section } = {}) {
|
|
|
312
340
|
groups: sec.groups,
|
|
313
341
|
fields: sec.fields.map((x) => ({
|
|
314
342
|
key: x.key, label: x.label, type: x.type, group: x.group,
|
|
343
|
+
kind: x.kind || '',
|
|
315
344
|
hint: x.hint || '', options: x.options || null, sensitive: isSensitiveField(sec, x.key),
|
|
316
345
|
})),
|
|
317
346
|
});
|
|
@@ -498,6 +527,10 @@ export function renderEntryGet(d) {
|
|
|
498
527
|
];
|
|
499
528
|
for (const [k, v] of Object.entries(d.values)) {
|
|
500
529
|
if (v === null) continue;
|
|
530
|
+
if (d.files && d.files[v]) {
|
|
531
|
+
lines.push(` ${k.padEnd(24)} ${d.files[v].name} ${d.files[v].path}`);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
501
534
|
lines.push(` ${k.padEnd(24)} ${Array.isArray(v) ? v.join('、') : (typeof v === 'boolean' ? (v ? '是' : '否') : String(v))}`);
|
|
502
535
|
}
|
|
503
536
|
if (d.missing.length) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as service from './service.js';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: 'files',
|
|
5
|
+
title: '附件',
|
|
6
|
+
order: 650,
|
|
7
|
+
view: () => import('./view.jsx'),
|
|
8
|
+
// 一组同级实体,每个附件有标识,用户会想替换/还原/删除其中一个 → 集合资源
|
|
9
|
+
resource: 'file',
|
|
10
|
+
actions: [
|
|
11
|
+
{
|
|
12
|
+
id: 'file.list',
|
|
13
|
+
cli: ['file', 'list'],
|
|
14
|
+
http: ['GET', '/api/files'],
|
|
15
|
+
summary: '列附件(可按栏目/关键字过滤);返回「哪些栏目在引用附件」与存储目录。组织方式由栏目驱动,无预定义分类',
|
|
16
|
+
flags: {
|
|
17
|
+
section: { type: 'string', hint: '只看某栏目引用的附件(none = 只列没有任何条目引用的)' },
|
|
18
|
+
category: { type: 'string', enum: ['resume', 'avatar', 'transcript', 'certificate', 'document', 'other'], hint: '按磁盘分类过滤(内部属性,一般不用)' },
|
|
19
|
+
q: { type: 'string', hint: '关键字(名称 / 备注 / 标签 / 引用它的栏目条目)' },
|
|
20
|
+
},
|
|
21
|
+
run: (ctx) => service.listFiles(ctx),
|
|
22
|
+
render: (d) => service.renderFileList(d),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'file.get',
|
|
26
|
+
cli: ['file', 'get'],
|
|
27
|
+
http: ['GET', '/api/files/:ref'],
|
|
28
|
+
summary: '读单个附件的台账:物理路径、链接、被哪些栏目/条目/字段引用、原始位置、标签、备注',
|
|
29
|
+
args: ['ref'],
|
|
30
|
+
run: (ctx) => service.getFile(ctx.ref),
|
|
31
|
+
render: (d) => service.renderFileGet(d),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'file.add',
|
|
35
|
+
cli: ['file', 'add'],
|
|
36
|
+
http: ['POST', '/api/files'],
|
|
37
|
+
summary: '导入附件:--path 本机路径默认移动(--copy 仅复制);Web 上传走 content(base64)+name。同内容自动去重',
|
|
38
|
+
flags: {
|
|
39
|
+
path: { type: 'string', hint: '本机文件路径(默认把原件移动进库)' },
|
|
40
|
+
content: { type: 'string', hint: 'base64 内容(Web 上传自动带,可为 data URL)' },
|
|
41
|
+
name: { type: 'string', hint: '文件名(content 上传时必填)' },
|
|
42
|
+
category: { type: 'string', enum: ['resume', 'avatar', 'transcript', 'certificate', 'document', 'other'] },
|
|
43
|
+
copy: { type: 'boolean', hint: '只复制、保留原件(默认移动)' },
|
|
44
|
+
tags: { type: 'array' },
|
|
45
|
+
note: { type: 'string' },
|
|
46
|
+
'dry-run': { type: 'boolean', hint: '只校验与计算落点,不移动、不写入' },
|
|
47
|
+
},
|
|
48
|
+
run: (ctx) => service.addFile(ctx),
|
|
49
|
+
render: (d) => service.renderFileChange(d),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'file.update',
|
|
53
|
+
cli: ['file', 'update'],
|
|
54
|
+
http: ['PATCH', '/api/files/:ref'],
|
|
55
|
+
summary: '改附件:名称、分类(物理文件随之移动)、标签、备注',
|
|
56
|
+
args: ['ref'],
|
|
57
|
+
flags: {
|
|
58
|
+
name: { type: 'string' },
|
|
59
|
+
category: { type: 'string', enum: ['resume', 'avatar', 'transcript', 'certificate', 'document', 'other'] },
|
|
60
|
+
tags: { type: 'array' },
|
|
61
|
+
note: { type: 'string' },
|
|
62
|
+
'dry-run': { type: 'boolean' },
|
|
63
|
+
},
|
|
64
|
+
run: (ctx) => service.updateFile(ctx.ref, ctx),
|
|
65
|
+
render: (d) => service.renderFileChange(d),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'file.remove',
|
|
69
|
+
cli: ['file', 'remove'],
|
|
70
|
+
http: ['DELETE', '/api/files/:ref'],
|
|
71
|
+
summary: '删附件(连物理原件一起删);写前自动留台账快照。要保留原件请用 file restore',
|
|
72
|
+
args: ['ref'],
|
|
73
|
+
flags: { 'dry-run': { type: 'boolean' } },
|
|
74
|
+
run: (ctx) => service.removeFile(ctx.ref, ctx),
|
|
75
|
+
render: (d) => service.renderFileChange(d),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'file.content',
|
|
79
|
+
cli: ['file', 'content'],
|
|
80
|
+
http: ['GET', '/api/files/:ref/content'],
|
|
81
|
+
// raw:HTTP 不走 JSON 包装,直接流式输出文件本体(浏览器内预览/下载)
|
|
82
|
+
raw: true,
|
|
83
|
+
summary: '取附件本体:浏览器内直接预览(?download=1 强制下载);CLI 打印物理路径与链接',
|
|
84
|
+
args: ['ref'],
|
|
85
|
+
flags: { download: { type: 'boolean', hint: '强制下载而非内联预览' } },
|
|
86
|
+
run: (ctx) => service.contentInfo(ctx.ref, ctx),
|
|
87
|
+
serveRaw: (ctx, frame) => service.serveRaw(ctx, frame),
|
|
88
|
+
render: (d) => service.renderContent(d),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'file.restore',
|
|
92
|
+
cli: ['file', 'restore'],
|
|
93
|
+
http: ['POST', '/api/files/:ref/restore'],
|
|
94
|
+
summary: '可逆操作:把附件移回原始位置并撤销台账(--copy 只导出副本、库中保留;--to 指定目录)',
|
|
95
|
+
args: ['ref'],
|
|
96
|
+
flags: {
|
|
97
|
+
to: { type: 'string', hint: '目标目录(Web 上传件无原始位置时必填)' },
|
|
98
|
+
copy: { type: 'boolean', hint: '只导出副本,库中保留原件与台账' },
|
|
99
|
+
'dry-run': { type: 'boolean' },
|
|
100
|
+
},
|
|
101
|
+
run: (ctx) => service.restoreFileAction(ctx.ref, ctx),
|
|
102
|
+
render: (d) => service.renderFileChange(d),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'file.open',
|
|
106
|
+
cli: ['file', 'open'],
|
|
107
|
+
http: null, // 本机动作,不通过 HTTP 暴露
|
|
108
|
+
summary: '用系统默认程序打开附件原件',
|
|
109
|
+
args: ['ref'],
|
|
110
|
+
run: (ctx) => service.openFileAction(ctx.ref),
|
|
111
|
+
render: (d) => service.renderOpen(d),
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|