nx-sk 0.4.0 → 0.6.1
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 +43 -1
- package/README.md +44 -2
- package/assets/nx-sk/SKILL.md +31 -1
- package/assets/nx-sk/references/00-design.md +3 -2
- package/assets/nx-sk/references/10-sections-entries.md +10 -2
- 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/service.js +59 -14
- 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 +2 -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/registry.js +1 -0
- package/src/web/frontend/styles.css +104 -7
- package/src/web/public/assets/files-BNh48qeo.js +1 -0
- package/src/web/public/assets/{index-Bbe3iqam.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-Djo-TKw9.js → view-BIdVqIGe.js} +1 -1
- package/src/web/public/assets/{view-CekTHUPO.js → view-Bu70Q6GO.js} +1 -1
- package/src/web/public/assets/view-D5LfHr7W.js +1 -0
- package/src/web/public/assets/{view-b8TAoyhU.js → view-DXqI-4_F.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-C1bxqdjl.css +0 -1
- package/src/web/public/assets/view-Bnwx5y_O.js +0 -7
- package/src/web/public/assets/view-DuKJm1oq.js +0 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// 附件(二进制原件)的磁盘管理:~/nx-sk/files/ 下存储,元数据随 store.json 落盘。
|
|
2
|
+
//
|
|
3
|
+
// 设计要点:
|
|
4
|
+
// - 本机路径导入**默认移动**(用户明确要求:提交附件 = 把文件搬进 nx-sk 目录),
|
|
5
|
+
// `--copy` 才保留原件;Web 上传只有「复制」语义(浏览器删不掉任意路径的文件)。
|
|
6
|
+
// - 「可逆」:`file restore` 能把附件移回原始位置(或指定目录),并撤销台账记录;
|
|
7
|
+
// `--copy` 则只导出一份副本、库中保留。
|
|
8
|
+
// - 同 sha256 去重:完全相同的文件不重复占空间,直接回已有记录。
|
|
9
|
+
// - 元数据是**台账**:物理文件永远以台账 rel 为准;所有移动先改物理文件、再在事务里改台账。
|
|
10
|
+
// - 链接方式:条目里 `file` 字段的值 = 附件 id(或唯一文件名)。id 全局唯一,
|
|
11
|
+
// 天然实现「各条目各自的附件空间互不串台」;同一份原件也可被多条条目复用(去重后同 id)。
|
|
12
|
+
// 附件**不预定义任何分类/槽位结构**:附件中心按「哪些栏目在用它」动态分组(见 fileReferences)。
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import fsp from 'node:fs/promises';
|
|
15
|
+
import { createReadStream } from 'node:fs';
|
|
16
|
+
import { basename, dirname, extname, join, parse, resolve, sep } from 'node:path';
|
|
17
|
+
import { badInput } from './errors.js';
|
|
18
|
+
import { displayPath, filesDir, isInside, toPosix } from './paths.js';
|
|
19
|
+
import { ensureDir, hashFile } from './fsx.js';
|
|
20
|
+
import { newId, nowIso } from './ids.js';
|
|
21
|
+
|
|
22
|
+
// —— 分类 ——
|
|
23
|
+
export const FILE_CATEGORIES = [
|
|
24
|
+
{ id: 'resume', label: '简历' },
|
|
25
|
+
{ id: 'avatar', label: '头像' },
|
|
26
|
+
{ id: 'transcript', label: '成绩单' },
|
|
27
|
+
{ id: 'certificate', label: '证书' },
|
|
28
|
+
{ id: 'document', label: '文档' },
|
|
29
|
+
{ id: 'other', label: '其他' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const CATEGORY_IDS = FILE_CATEGORIES.map((c) => c.id);
|
|
33
|
+
export const categoryLabel = (id) => FILE_CATEGORIES.find((c) => c.id === id)?.label || '其他';
|
|
34
|
+
|
|
35
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.ico']);
|
|
36
|
+
const DOC_EXT = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv', '.wps', '.ofd']);
|
|
37
|
+
|
|
38
|
+
export function isImageExt(ext) {
|
|
39
|
+
return IMAGE_EXT.has(String(ext || '').toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 按扩展名猜分类;猜不准归「其他」(头像/简历这种语义由调用方显式给)。 */
|
|
43
|
+
export function categoryFromExt(ext) {
|
|
44
|
+
const e = String(ext || '').toLowerCase();
|
|
45
|
+
if (isImageExt(e)) return 'other';
|
|
46
|
+
if (DOC_EXT.has(e)) return 'document';
|
|
47
|
+
return 'other';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// —— 流式输出的 MIME ——
|
|
51
|
+
export const MIME_TYPES = {
|
|
52
|
+
'.html': 'text/html; charset=utf-8',
|
|
53
|
+
'.pdf': 'application/pdf',
|
|
54
|
+
'.doc': 'application/msword',
|
|
55
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
56
|
+
'.xls': 'application/vnd.ms-excel',
|
|
57
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
58
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
59
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
60
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
61
|
+
'.md': 'text/plain; charset=utf-8',
|
|
62
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
63
|
+
'.svg': 'image/svg+xml',
|
|
64
|
+
'.png': 'image/png',
|
|
65
|
+
'.jpg': 'image/jpeg',
|
|
66
|
+
'.jpeg': 'image/jpeg',
|
|
67
|
+
'.webp': 'image/webp',
|
|
68
|
+
'.gif': 'image/gif',
|
|
69
|
+
'.bmp': 'image/bmp',
|
|
70
|
+
'.avif': 'image/avif',
|
|
71
|
+
'.ico': 'image/x-icon',
|
|
72
|
+
'.wps': 'application/octet-stream',
|
|
73
|
+
'.ofd': 'application/octet-stream',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Windows 保留设备名,做文件名会让文件无法正常访问。
|
|
77
|
+
const RESERVED_STEMS = new Set(['con', 'prn', 'aux', 'nul',
|
|
78
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
79
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`)]);
|
|
80
|
+
|
|
81
|
+
/** 把任意来源的名字收成跨平台可用的单段文件名(保留中文)。 */
|
|
82
|
+
export function safeFileName(name) {
|
|
83
|
+
let s = String(name ?? '')
|
|
84
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
85
|
+
.replace(/\s+/g, ' ')
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^\.+/, '');
|
|
88
|
+
if (!s) s = 'file';
|
|
89
|
+
const stem = parse(s).name;
|
|
90
|
+
if (RESERVED_STEMS.has(stem.toLowerCase())) s = `_${s}`;
|
|
91
|
+
return s.slice(0, 140);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 在台账里为「分类 + 文件名」找一个不冲突的 posix 相对路径(`(2)` 递增)。 */
|
|
95
|
+
function uniqueRel(store, category, fileName, excludeId = null) {
|
|
96
|
+
const { name: stem, ext } = parse(fileName);
|
|
97
|
+
let rel = toPosix(join(category, fileName));
|
|
98
|
+
let n = 1;
|
|
99
|
+
while (store.files.some((r) => r.id !== excludeId && r.rel === rel)) {
|
|
100
|
+
n += 1;
|
|
101
|
+
rel = toPosix(join(category, `${stem} (${n})${ext}`));
|
|
102
|
+
}
|
|
103
|
+
return rel;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// —— 台账记录 ——
|
|
107
|
+
export function normalizeFileRecord(raw) {
|
|
108
|
+
if (!raw || typeof raw !== 'object' || !raw.id || !raw.rel) return null;
|
|
109
|
+
const name = String(raw.name || basename(String(raw.rel)));
|
|
110
|
+
return {
|
|
111
|
+
id: String(raw.id),
|
|
112
|
+
// 分类只是**磁盘分目录**用的内部属性(`files/<分类>/`),不是用户要维护的「槽位」。
|
|
113
|
+
category: CATEGORY_IDS.includes(raw.category) ? raw.category : 'other',
|
|
114
|
+
name,
|
|
115
|
+
rel: toPosix(String(raw.rel)),
|
|
116
|
+
originalName: String(raw.originalName || name),
|
|
117
|
+
ext: String(raw.ext || extname(name).toLowerCase()),
|
|
118
|
+
bytes: Number(raw.bytes) || 0,
|
|
119
|
+
sha256: String(raw.sha256 || ''),
|
|
120
|
+
// 原始绝对路径(posix);Web 上传没有,留空——还原时需显式给目标目录
|
|
121
|
+
sourcePath: String(raw.sourcePath || ''),
|
|
122
|
+
mode: ['move', 'copy', 'upload'].includes(raw.mode) ? raw.mode : 'upload',
|
|
123
|
+
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
|
|
124
|
+
note: String(raw.note || ''),
|
|
125
|
+
createdAt: raw.createdAt || null,
|
|
126
|
+
updatedAt: raw.updatedAt || null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 附件定位:id 优先,其次唯一文件名。 */
|
|
131
|
+
export function findFileRecord(store, ref) {
|
|
132
|
+
const key = String(ref ?? '').trim();
|
|
133
|
+
if (!key) return null;
|
|
134
|
+
const byId = store.files.find((r) => r.id === key);
|
|
135
|
+
if (byId) return byId;
|
|
136
|
+
const byName = store.files.filter((r) => r.name === key);
|
|
137
|
+
if (byName.length === 1) return byName[0];
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 反向链接:哪些条目的 `file` 字段引用了这个附件。
|
|
143
|
+
* 条目里 `file` 字段的值 = 附件 id(或唯一文件名),所以按记录反查即可。
|
|
144
|
+
* 附件中心据此显示「被哪些栏目 / 条目 / 字段用到」——**完全由栏目驱动**,不预定义结构。
|
|
145
|
+
*/
|
|
146
|
+
export function fileReferences(store, rec) {
|
|
147
|
+
if (!rec) return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const sec of store.sections || []) {
|
|
150
|
+
const fileFields = (sec.fields || []).filter((f) => f.type === 'file');
|
|
151
|
+
if (!fileFields.length) continue;
|
|
152
|
+
for (const e of store.entries || []) {
|
|
153
|
+
if (e.section !== sec.id) continue;
|
|
154
|
+
for (const f of fileFields) {
|
|
155
|
+
const v = String(e.values?.[f.key] ?? '').trim();
|
|
156
|
+
if (!v) continue;
|
|
157
|
+
if (findFileRecord(store, v) !== rec) continue;
|
|
158
|
+
out.push({
|
|
159
|
+
sectionId: sec.id, sectionTitle: sec.title,
|
|
160
|
+
entryId: e.id, entryTitle: e.title,
|
|
161
|
+
fieldKey: f.key, fieldLabel: f.label,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 附件中心的分组素材:哪些栏目带 `file` 字段。
|
|
171
|
+
* 中心据此按栏目筛选 / 分组 —— 组织方式**完全由栏目驱动,不预定义任何分类或槽位**。
|
|
172
|
+
*/
|
|
173
|
+
export function collectFileSections(store) {
|
|
174
|
+
return (store.sections || [])
|
|
175
|
+
.filter((s) => (s.fields || []).some((f) => f.type === 'file'))
|
|
176
|
+
.map((s) => ({ id: s.id, title: s.title }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function fileRecordAbs(rec) {
|
|
180
|
+
return join(filesDir(), rec.rel.split('/').join(sep));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function fileLinks(rec) {
|
|
184
|
+
const u = `/api/files/${encodeURIComponent(rec.id)}/content`;
|
|
185
|
+
return { url: u, downloadUrl: `${u}?download=1` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 对外(CLI / Web)的完整描述:物理路径 + 展示路径 + 链接(+ 可选的反向引用)。 */
|
|
189
|
+
export function describeFile(rec, store = null) {
|
|
190
|
+
const abs = fileRecordAbs(rec);
|
|
191
|
+
return {
|
|
192
|
+
...rec,
|
|
193
|
+
categoryLabel: categoryLabel(rec.category),
|
|
194
|
+
isImage: isImageExt(rec.ext),
|
|
195
|
+
path: abs,
|
|
196
|
+
displayPath: displayPath(abs),
|
|
197
|
+
sourceDisplay: rec.sourcePath ? displayPath(rec.sourcePath) : '',
|
|
198
|
+
usedBy: store ? fileReferences(store, rec) : [],
|
|
199
|
+
...fileLinks(rec),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildRecord({ id, category, rel, srcAbs, originalName, ext: e, bytes, sha, mode, tags, note }) {
|
|
204
|
+
return normalizeFileRecord({
|
|
205
|
+
id, category, name: basename(rel), rel,
|
|
206
|
+
originalName: originalName || basename(rel),
|
|
207
|
+
ext: e, bytes, sha256: sha,
|
|
208
|
+
sourcePath: srcAbs ? toPosix(srcAbs) : '',
|
|
209
|
+
mode,
|
|
210
|
+
tags: Array.isArray(tags) ? tags.map(String) : [],
|
|
211
|
+
note: String(note || ''),
|
|
212
|
+
createdAt: nowIso(), updatedAt: nowIso(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 从本机路径导入。默认**移动**,copy=true 才复制。
|
|
218
|
+
* dryRun 只做读取与落点计算,不碰目标位置。
|
|
219
|
+
*/
|
|
220
|
+
export async function importLocalFile(store, opts = {}) {
|
|
221
|
+
const { path: src, category, copy, tags, note, name, dryRun } = opts;
|
|
222
|
+
if (!src) throw badInput('缺少文件路径:--path <本机文件路径>');
|
|
223
|
+
const srcAbs = resolve(String(src));
|
|
224
|
+
let st;
|
|
225
|
+
try { st = await fsp.stat(srcAbs); } catch {
|
|
226
|
+
throw badInput(`读不到文件: ${displayPath(srcAbs)}`);
|
|
227
|
+
}
|
|
228
|
+
if (!st.isFile()) throw badInput(`不是文件: ${displayPath(srcAbs)}`);
|
|
229
|
+
if (isInside(filesDir(), srcAbs)) {
|
|
230
|
+
throw badInput('该文件已经在附件库目录里,无需重复导入');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sha = await hashFile(srcAbs);
|
|
234
|
+
const dup = store.files.find((r) => r.sha256 === sha);
|
|
235
|
+
if (dup) return { record: dup, duplicate: true, dryRun: false };
|
|
236
|
+
|
|
237
|
+
const e = extname(srcAbs).toLowerCase();
|
|
238
|
+
const cat = category || categoryFromExt(e);
|
|
239
|
+
const fileName = safeFileName(name || basename(srcAbs));
|
|
240
|
+
const rel = uniqueRel(store, cat, fileName);
|
|
241
|
+
const record = buildRecord({
|
|
242
|
+
id: newId('f'), category: cat, rel, srcAbs, originalName: basename(srcAbs),
|
|
243
|
+
ext: e, bytes: st.size, sha, mode: copy ? 'copy' : 'move', tags, note,
|
|
244
|
+
});
|
|
245
|
+
if (dryRun) return { record, duplicate: false, dryRun: true };
|
|
246
|
+
|
|
247
|
+
const dst = join(filesDir(), rel.split('/').join(sep));
|
|
248
|
+
await ensureDir(dirname(dst));
|
|
249
|
+
if (copy) {
|
|
250
|
+
await fsp.copyFile(srcAbs, dst);
|
|
251
|
+
} else {
|
|
252
|
+
try {
|
|
253
|
+
await fsp.rename(srcAbs, dst);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// EXDEV:跨卷 rename 不被支持,退成「复制 + 删原件」,语义不变
|
|
256
|
+
if (err && err.code === 'EXDEV') {
|
|
257
|
+
await fsp.copyFile(srcAbs, dst);
|
|
258
|
+
await fsp.unlink(srcAbs);
|
|
259
|
+
} else throw err;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { record, duplicate: false, dryRun: false, moved: !copy };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function decodeBase64(content) {
|
|
266
|
+
let b64 = String(content || '');
|
|
267
|
+
const comma = b64.indexOf(',');
|
|
268
|
+
if (b64.startsWith('data:') && comma >= 0) b64 = b64.slice(comma + 1);
|
|
269
|
+
let buf;
|
|
270
|
+
try { buf = Buffer.from(b64, 'base64'); } catch {
|
|
271
|
+
throw badInput('上传内容不是合法的 base64');
|
|
272
|
+
}
|
|
273
|
+
if (!buf.length) throw badInput('上传内容为空');
|
|
274
|
+
return buf;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Web 上传:base64(或 data URL)内容直接写进库。 */
|
|
278
|
+
export async function importContent(store, opts = {}) {
|
|
279
|
+
const { content, name, category, tags, note, dryRun } = opts;
|
|
280
|
+
if (content === undefined || content === null) throw badInput('缺少上传内容');
|
|
281
|
+
if (!name) throw badInput('上传必须带文件名');
|
|
282
|
+
const buf = decodeBase64(content);
|
|
283
|
+
const sha = createHash('sha256').update(buf).digest('hex');
|
|
284
|
+
const dup = store.files.find((r) => r.sha256 === sha);
|
|
285
|
+
if (dup) return { record: dup, duplicate: true, dryRun: false };
|
|
286
|
+
|
|
287
|
+
const originalName = safeFileName(name);
|
|
288
|
+
const e = extname(originalName).toLowerCase();
|
|
289
|
+
const cat = category || categoryFromExt(e);
|
|
290
|
+
const rel = uniqueRel(store, cat, originalName);
|
|
291
|
+
const record = buildRecord({
|
|
292
|
+
id: newId('f'), category: cat, rel, srcAbs: null, originalName,
|
|
293
|
+
ext: e, bytes: buf.length, sha, mode: 'upload', tags, note,
|
|
294
|
+
});
|
|
295
|
+
if (dryRun) return { record, duplicate: false, dryRun: true };
|
|
296
|
+
|
|
297
|
+
const dst = join(filesDir(), rel.split('/').join(sep));
|
|
298
|
+
await ensureDir(dirname(dst));
|
|
299
|
+
await fsp.writeFile(dst, buf);
|
|
300
|
+
return { record, duplicate: false, dryRun: false, moved: false };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 改分类 / 文件名:物理文件先搬到新落点,返回新记录(由 service 在事务里落台账)。
|
|
305
|
+
*/
|
|
306
|
+
export async function relocateFile(store, rec, { category, name }) {
|
|
307
|
+
const cat = category || rec.category;
|
|
308
|
+
const fileName = safeFileName(name || rec.name);
|
|
309
|
+
const same = cat === rec.category && fileName === rec.name;
|
|
310
|
+
const rel = same ? rec.rel : uniqueRel(store, cat, fileName, rec.id);
|
|
311
|
+
if (!same) {
|
|
312
|
+
const oldAbs = fileRecordAbs(rec);
|
|
313
|
+
const newAbs = join(filesDir(), rel.split('/').join(sep));
|
|
314
|
+
await ensureDir(dirname(newAbs));
|
|
315
|
+
await fsp.copyFile(oldAbs, newAbs);
|
|
316
|
+
await fsp.unlink(oldAbs);
|
|
317
|
+
}
|
|
318
|
+
return normalizeFileRecord({ ...rec, category: cat, name: basename(rel), rel, updatedAt: nowIso() });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 删除物理原件;文件本就不存在视为成功(台账是权威)。 */
|
|
322
|
+
export async function deletePhysical(rec) {
|
|
323
|
+
try {
|
|
324
|
+
await fsp.unlink(fileRecordAbs(rec));
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err && err.code === 'ENOENT') return;
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function nonClashPath(dir, fileName) {
|
|
332
|
+
const { name: stem, ext } = parse(fileName);
|
|
333
|
+
let dst = join(dir, fileName);
|
|
334
|
+
let n = 1;
|
|
335
|
+
const exists = async (p) => { try { await fsp.access(p); return true; } catch { return false; } };
|
|
336
|
+
return (async () => {
|
|
337
|
+
while (await exists(dst)) { n += 1; dst = join(dir, `${stem} (${n})${ext}`); }
|
|
338
|
+
return dst;
|
|
339
|
+
})();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 还原 / 导出:把附件复制到目标位置;copy=false(默认)时再删掉库内原件
|
|
344
|
+
* (由 service 负责撤销台账记录)。
|
|
345
|
+
*/
|
|
346
|
+
export async function restoreFile(store, rec, { to, copy } = {}) {
|
|
347
|
+
const targetDir = resolve(String(to || (rec.sourcePath ? dirname(rec.sourcePath) : '')));
|
|
348
|
+
if (!targetDir || targetDir === sep) {
|
|
349
|
+
throw badInput('该附件没有原始位置,需要给目标目录:--to <目录>');
|
|
350
|
+
}
|
|
351
|
+
const dst = await nonClashPath(targetDir, rec.name);
|
|
352
|
+
if (!copy && isInside(filesDir(), dst)) {
|
|
353
|
+
throw badInput('还原落点在附件库目录内,会自己覆盖自己;换一个目录');
|
|
354
|
+
}
|
|
355
|
+
await ensureDir(targetDir);
|
|
356
|
+
await fsp.copyFile(fileRecordAbs(rec), dst);
|
|
357
|
+
if (!copy) await fsp.unlink(fileRecordAbs(rec));
|
|
358
|
+
return { path: dst, displayPath: displayPath(dst), kept: copy === true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 以正确的 Content-Type 把附件流式输出给浏览器;download=true 时强制下载。 */
|
|
362
|
+
export function streamRecord(res, rec, { download = false } = {}) {
|
|
363
|
+
const file = fileRecordAbs(rec);
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
const rs = createReadStream(file);
|
|
366
|
+
rs.once('error', reject);
|
|
367
|
+
rs.once('open', () => {
|
|
368
|
+
res.writeHead(200, {
|
|
369
|
+
'content-type': MIME_TYPES[rec.ext] || 'application/octet-stream',
|
|
370
|
+
'content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(rec.name)}`,
|
|
371
|
+
'cache-control': 'no-cache',
|
|
372
|
+
});
|
|
373
|
+
rs.pipe(res);
|
|
374
|
+
rs.once('end', resolve);
|
|
375
|
+
rs.once('error', reject);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
package/src/core/fields.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 字段字典:栏目的字段模板是**数据**,不是代码分支。
|
|
2
2
|
// agent 与面板都从同一份声明渲染,所以「CLI 能填的字段」与「面板能填的字段」不可能分叉。
|
|
3
3
|
//
|
|
4
|
-
// type 取值:text | textarea | number | date | month | bool | select | tags | secret
|
|
4
|
+
// type 取值:text | textarea | number | date | month | bool | select | tags | secret | file
|
|
5
5
|
|
|
6
|
-
export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret'];
|
|
6
|
+
export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret', 'file'];
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 字段的**填写策略**——回答「这个字段值不值得填」,与 type 正交。
|
|
@@ -128,6 +128,12 @@ const JOB_FIELDS = [
|
|
|
128
128
|
f('acceptAdjustment', '是否接受调剂', 'bool', 'job', { hint: '是否接受岗位或城市调剂' }),
|
|
129
129
|
f('jobNote', '求职备注', 'textarea', 'job', { hint: '补充说明' }),
|
|
130
130
|
|
|
131
|
+
// —— 附件资料(统一附件库;值 = 附件 id,直接在字段里上传或从附件库选一个) ——
|
|
132
|
+
f('resumeFile', '简历附件', 'file', 'files', { kind: 'doc', hint: '简历 PDF/Word 原件;在本字段直接上传,或从附件库选一个' }),
|
|
133
|
+
f('avatar', '头像图片', 'file', 'files', { kind: 'image', hint: '证件照/头像图片原件' }),
|
|
134
|
+
f('transcript', '成绩单', 'file', 'files', { kind: 'doc', hint: '在校成绩单原件' }),
|
|
135
|
+
f('certificateScan', '证书扫描件', 'file', 'files', { kind: 'doc', fill: 'optional', hint: '获奖/资格证书扫描件(不必填)' }),
|
|
136
|
+
|
|
131
137
|
// —— 其他 ——
|
|
132
138
|
f('religion', '宗教信仰', 'text', 'extra', { hint: '没有就写「无」' }),
|
|
133
139
|
f('unionVolunteer', '是否自愿加入公司工会', 'bool', 'extra'),
|
|
@@ -186,6 +192,7 @@ const TEMPLATES = {
|
|
|
186
192
|
{ id: 'emergency', title: '紧急联系' },
|
|
187
193
|
{ id: 'about', title: '自我描述' },
|
|
188
194
|
{ id: 'job', title: '求职意向' },
|
|
195
|
+
{ id: 'files', title: '附件资料' },
|
|
189
196
|
{ id: 'extra', title: '其他' },
|
|
190
197
|
],
|
|
191
198
|
fields: JOB_FIELDS,
|
|
@@ -313,6 +320,55 @@ export function syncSectionGroups(groups, fields) {
|
|
|
313
320
|
return out;
|
|
314
321
|
}
|
|
315
322
|
|
|
323
|
+
/**
|
|
324
|
+
* 模板增量合并:把模板里**尚未出现在栏目**的分组与字段补进去(只增,绝不改/删)。
|
|
325
|
+
*
|
|
326
|
+
* 为什么需要它:栏目实例化时字段字典是**深拷贝**(见 instantiateTemplate),
|
|
327
|
+
* 从此「栏目自己的字段集独立演化」。于是模板后续新增的字段(例如给求职加
|
|
328
|
+
* 「附件资料」组的 4 个 `file` 字段)**不会回溯**到已经存在的栏目——
|
|
329
|
+
* 面板按栏目自己的字典渲染,老栏目里就永远看不到上传入口。
|
|
330
|
+
*
|
|
331
|
+
* 合并规则(尽量少动用户数据):
|
|
332
|
+
* - 分组:按模板顺序排列;栏目已有的分组**沿用栏目自己的定义**(标题可能被改过),
|
|
333
|
+
* 模板没有的自定义分组原顺序附在后面。
|
|
334
|
+
* - 字段:栏目已有的字段原样保留(含用户改过的 label/hint/fill);缺的模板字段
|
|
335
|
+
* 插到「模板顺序里它前一个字段」之后,未在模板中的自定义字段保持原位。
|
|
336
|
+
*/
|
|
337
|
+
export function mergeTemplate(section, template) {
|
|
338
|
+
const existingGroups = section?.groups || [];
|
|
339
|
+
const existingFields = section?.fields || [];
|
|
340
|
+
const groupById = new Map(existingGroups.map((g) => [g.id, g]));
|
|
341
|
+
const fieldKeys = new Set(existingFields.map((f) => f.key));
|
|
342
|
+
|
|
343
|
+
const groups = [];
|
|
344
|
+
const seenGroup = new Set();
|
|
345
|
+
for (const g of template?.groups || []) {
|
|
346
|
+
groups.push(groupById.get(g.id) || { ...g });
|
|
347
|
+
seenGroup.add(g.id);
|
|
348
|
+
}
|
|
349
|
+
for (const g of existingGroups) if (!seenGroup.has(g.id)) groups.push(g);
|
|
350
|
+
const addedGroups = (template?.groups || []).filter((g) => !existingGroups.some((x) => x.id === g.id)).map((g) => g.id);
|
|
351
|
+
|
|
352
|
+
const tplOrder = new Map((template?.fields || []).map((f, i) => [f.key, i]));
|
|
353
|
+
const fields = [...existingFields];
|
|
354
|
+
const addedFields = [];
|
|
355
|
+
for (const tf of template?.fields || []) {
|
|
356
|
+
if (fieldKeys.has(tf.key)) continue;
|
|
357
|
+
// 插到第一个「模板序更靠后」的既有字段之前;找不到就追加到末尾
|
|
358
|
+
const ti = tplOrder.get(tf.key);
|
|
359
|
+
let insertAt = fields.length;
|
|
360
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
361
|
+
const oi = tplOrder.get(fields[i].key);
|
|
362
|
+
if (oi !== undefined && oi > ti) { insertAt = i; break; }
|
|
363
|
+
}
|
|
364
|
+
fields.splice(insertAt, 0, { ...tf });
|
|
365
|
+
fieldKeys.add(tf.key);
|
|
366
|
+
addedFields.push(tf.key);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { groups, fields, addedGroups, addedFields };
|
|
370
|
+
}
|
|
371
|
+
|
|
316
372
|
export function fieldByKey(section, key) {
|
|
317
373
|
return (section?.fields || []).find((x) => x.key === key) || null;
|
|
318
374
|
}
|
package/src/core/paths.js
CHANGED
|
@@ -50,6 +50,11 @@ export function exportDir() {
|
|
|
50
50
|
return join(appHome(), 'export');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** 附件统一存储目录:`~/nx-sk/files/`(简历、头像、成绩单等二进制原件都落在这里)。 */
|
|
54
|
+
export function filesDir() {
|
|
55
|
+
return join(appHome(), 'files');
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
export function snapshotDir() {
|
|
54
59
|
return join(appHome(), 'backup');
|
|
55
60
|
}
|
package/src/core/render.js
CHANGED
|
@@ -133,6 +133,7 @@ export function dumpSection(section, entries, { mask = false, decrypt } = {}) {
|
|
|
133
133
|
group: x.group,
|
|
134
134
|
hint: x.hint || '',
|
|
135
135
|
options: x.options || null,
|
|
136
|
+
kind: x.kind || '',
|
|
136
137
|
sensitive: isSensitiveField(section, x.key),
|
|
137
138
|
// 填写策略要跟着字段字典出去,否则面板与 agent 不知道哪个格子「不用填」
|
|
138
139
|
fill: fillPolicy(x),
|
package/src/core/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { displayPath, snapshotDir, storeFile } from './paths.js';
|
|
6
6
|
import { ensureDir, pathExists, writeAtomic, writeJsonAtomic } from './fsx.js';
|
|
7
7
|
import { instantiateTemplate, seedSections, syncSectionGroups } from './fields.js';
|
|
8
|
+
import { normalizeFileRecord } from './attachments.js';
|
|
8
9
|
import { nowIso, stamp } from './ids.js';
|
|
9
10
|
|
|
10
11
|
/** v2:密钥栏目从 8 字段模板改成单字段 KV(名称 → 值)。
|
|
@@ -32,6 +33,7 @@ function emptyStore(seed) {
|
|
|
32
33
|
// 说明用户自己删光了栏目——这时候再播种会把它变回来,等于删不掉。
|
|
33
34
|
sections: seed ? seedSections(at) : [],
|
|
34
35
|
entries: [],
|
|
36
|
+
files: [],
|
|
35
37
|
exports: [],
|
|
36
38
|
};
|
|
37
39
|
}
|
|
@@ -176,6 +178,7 @@ export function normalize(data) {
|
|
|
176
178
|
delete base.settings.kvSection;
|
|
177
179
|
base.sections = (Array.isArray(data.sections) ? data.sections : []).map(normalizeSection).filter(Boolean);
|
|
178
180
|
base.entries = (Array.isArray(data.entries) ? data.entries : []).map(normalizeEntry).filter(Boolean);
|
|
181
|
+
base.files = (Array.isArray(data.files) ? data.files : []).map(normalizeFileRecord).filter(Boolean);
|
|
179
182
|
base.exports = Array.isArray(data.exports) ? data.exports.slice(0, MAX_EXPORT_HISTORY) : [];
|
|
180
183
|
if (oldVersion < 2) base._migration = migrateSecretToKv(base);
|
|
181
184
|
if (oldVersion < 3) {
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
// 加密备份 / 恢复:整份数据用「口令派生密钥 + AES-256-GCM」对称加密。
|
|
2
2
|
//
|
|
3
|
-
// 导出:用户设置口令 →
|
|
4
|
-
//
|
|
3
|
+
// 导出:用户设置口令 → 先把 secret 栏目的密文字段用**本机 vaultKey 解开**(明文),
|
|
4
|
+
// 再把整份 payload 用口令派生 key 加密落到备份文件;口令不落盘。
|
|
5
|
+
// 导入:用户输入口令 → 解密后 payload 里的 secret 字段就是明文;直接写回 store.json。
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
7
|
+
// 这样跨机器恢复:B 机拿到口令后 secret 字段就是明文,**不再依赖 A 机的 vaultKey**。
|
|
8
|
+
// 这是 store.json 内的密文被解开的瞬态——落回磁盘后字段仍是 vaultKey 加密形态(每次
|
|
9
|
+
// `key set` 会重新用 B 机 vaultKey 加密),所以单机的"落盘密文"防护没退化。
|
|
10
|
+
//
|
|
11
|
+
// 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue)。
|
|
8
12
|
import fsp from 'node:fs/promises';
|
|
9
13
|
import { randomBytes } from 'node:crypto';
|
|
10
14
|
import { resolve } from 'node:path';
|
|
11
15
|
import { displayPath, snapshotDir } from '../../core/paths.js';
|
|
12
16
|
import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
|
|
13
17
|
import { loadStore, saveStore, snapshotStore } from '../../core/store.js';
|
|
14
|
-
import { deriveKey, encryptValue,
|
|
18
|
+
import { decryptValue, deriveKey, encryptValue, isCipherBlob } from '../../core/crypto.js';
|
|
15
19
|
import { badInput, blocked } from '../../core/errors.js';
|
|
16
20
|
import { nowIso, stamp } from '../../core/ids.js';
|
|
21
|
+
import { isSensitiveField } from '../../core/fields.js';
|
|
22
|
+
import { resolveVaultKey } from '../../core/vault.js';
|
|
17
23
|
|
|
18
24
|
export const BACKUP_KIND = 'encrypted-backup';
|
|
19
|
-
export const BACKUP_VERSION =
|
|
25
|
+
export const BACKUP_VERSION = 2;
|
|
20
26
|
export const ENV_BACKUP_PASSWORD = 'NX_SK_BACKUP_PASSWORD';
|
|
21
27
|
const EXT = '.nxskbak';
|
|
22
28
|
|
|
@@ -32,11 +38,39 @@ function passwordFrom(ctx) {
|
|
|
32
38
|
return s;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 把 payload 里 secret 栏目的密文字段解开成明文(不修改其他字段)。
|
|
43
|
+
* 用本机 vaultKey 解:A 机本地能解;导出文件不依赖 A 机后续状态。
|
|
44
|
+
*/
|
|
45
|
+
async function unsealSecretsForExport(payload) {
|
|
46
|
+
const sections = payload.sections || [];
|
|
47
|
+
const entries = payload.entries || [];
|
|
48
|
+
const { key: vaultKey } = await resolveVaultKey();
|
|
49
|
+
|
|
50
|
+
const newEntries = [];
|
|
51
|
+
let unsealed = 0;
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
const sec = sections.find((s) => s.id === e.section);
|
|
54
|
+
if (!sec || !e.values) { newEntries.push(e); continue; }
|
|
55
|
+
const nextValues = { ...e.values };
|
|
56
|
+
let touched = false;
|
|
57
|
+
for (const def of sec.fields || []) {
|
|
58
|
+
if (!isSensitiveField(sec, def.key)) continue;
|
|
59
|
+
const v = nextValues[def.key];
|
|
60
|
+
if (!isCipherBlob(v)) continue;
|
|
61
|
+
nextValues[def.key] = decryptValue(vaultKey, v);
|
|
62
|
+
touched = true; unsealed++;
|
|
63
|
+
}
|
|
64
|
+
newEntries.push(touched ? { ...e, values: nextValues } : e);
|
|
65
|
+
}
|
|
66
|
+
return { ...payload, entries: newEntries, _unsealed: unsealed };
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
/** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
|
|
36
70
|
export async function buildBackup(ctx) {
|
|
37
71
|
const password = passwordFrom(ctx);
|
|
38
72
|
const store = await loadStore();
|
|
39
|
-
const
|
|
73
|
+
const raw = {
|
|
40
74
|
version: store.version,
|
|
41
75
|
settings: store.settings,
|
|
42
76
|
sections: store.sections,
|
|
@@ -45,8 +79,9 @@ export async function buildBackup(ctx) {
|
|
|
45
79
|
};
|
|
46
80
|
|
|
47
81
|
const salt = randomBytes(16);
|
|
48
|
-
const
|
|
49
|
-
const
|
|
82
|
+
const wrapKey = deriveKey(password, salt);
|
|
83
|
+
const payload = await unsealSecretsForExport(raw);
|
|
84
|
+
const data = encryptValue(wrapKey, JSON.stringify(payload));
|
|
50
85
|
const archive = {
|
|
51
86
|
app: 'nx-sk',
|
|
52
87
|
kind: BACKUP_KIND,
|
|
@@ -56,7 +91,11 @@ export async function buildBackup(ctx) {
|
|
|
56
91
|
salt: salt.toString('base64'),
|
|
57
92
|
data,
|
|
58
93
|
};
|
|
59
|
-
const counts = {
|
|
94
|
+
const counts = {
|
|
95
|
+
sections: store.sections.length,
|
|
96
|
+
entries: store.entries.length,
|
|
97
|
+
unsealed: payload._unsealed || 0,
|
|
98
|
+
};
|
|
60
99
|
if (ctx['dry-run']) return { dryRun: true, counts };
|
|
61
100
|
return { archive, counts };
|
|
62
101
|
}
|
|
@@ -117,7 +156,7 @@ export function decryptBackup(text, password) {
|
|
|
117
156
|
return { payload, createdAt: archive.createdAt };
|
|
118
157
|
}
|
|
119
158
|
|
|
120
|
-
/** 恢复:解密 →
|
|
159
|
+
/** 恢复:解密 → 留安全快照 → 整份替换(secret 字段已是明文,落盘后下次写入时由 B 机 vaultKey 重新加密)。 */
|
|
121
160
|
export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
122
161
|
const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
|
|
123
162
|
|
|
@@ -127,6 +166,7 @@ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
|
127
166
|
backupCreatedAt: createdAt || '',
|
|
128
167
|
sections: payload.sections.length,
|
|
129
168
|
entries: payload.entries.length,
|
|
169
|
+
unsealed: payload._unsealed || 0,
|
|
130
170
|
};
|
|
131
171
|
}
|
|
132
172
|
|
|
@@ -146,6 +186,7 @@ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
|
146
186
|
backupCreatedAt: createdAt || '',
|
|
147
187
|
sections: payload.sections.length,
|
|
148
188
|
entries: payload.entries.length,
|
|
189
|
+
unsealed: payload._unsealed || 0,
|
|
149
190
|
safetySnapshot: displayPath(safety),
|
|
150
191
|
};
|
|
151
192
|
}
|
|
@@ -166,23 +207,27 @@ export async function importBackup(ctx) {
|
|
|
166
207
|
|
|
167
208
|
export function renderBackup(d) {
|
|
168
209
|
if (d.status === 'skipped') {
|
|
169
|
-
return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM
|
|
210
|
+
return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`
|
|
211
|
+
+ (d.unsealed ? `\n含明文保存的密钥字段: ${d.unsealed} 个(其他机器用本次口令可解开)` : '');
|
|
170
212
|
}
|
|
171
213
|
return [
|
|
172
214
|
`已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
|
|
173
215
|
`文件: ${d.file} ${d.bytes} 字节`,
|
|
174
216
|
`内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
217
|
+
`本次解开为明文的密钥字段: ${d.unsealed || 0} 个(其他机器用本次口令可解开)`,
|
|
175
218
|
`恢复时需要输入本次设置的口令。`,
|
|
176
219
|
].join('\n');
|
|
177
220
|
}
|
|
178
221
|
|
|
179
222
|
export function renderRestore(d) {
|
|
180
223
|
if (d.status === 'dry-run') {
|
|
181
|
-
return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries}
|
|
224
|
+
return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目`
|
|
225
|
+
+ `,含明文密钥字段 ${d.unsealed || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
|
|
182
226
|
}
|
|
183
227
|
return [
|
|
184
228
|
`已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
229
|
+
`从备份中拿到的明文密钥字段: ${d.unsealed || 0} 个`,
|
|
185
230
|
`备份生成于: ${d.backupCreatedAt || '未知'}`,
|
|
186
231
|
`恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
|
|
187
232
|
].join('\n');
|
|
188
|
-
}
|
|
233
|
+
}
|