@xcanwin/manyoyo 8.7.2 → 8.7.4
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/README.md +1 -1
- package/bin/manyoyo.js +31 -1
- package/lib/archive/export.js +564 -0
- package/lib/archive/import.js +521 -0
- package/lib/archive/plan.js +197 -0
- package/lib/archive/risks.js +121 -0
- package/lib/archive/runtime.js +72 -0
- package/lib/archive/safe-extract.js +163 -0
- package/lib/archive/tar.js +329 -0
- package/lib/model-services.js +9 -3
- package/lib/setup.js +2 -2
- package/lib/web/AGENTS.md +1 -0
- package/lib/web/container-jobs.js +276 -0
- package/lib/web/index.html +86 -85
- package/lib/web/server.js +233 -7
- package/lib/web/transfer-routes.js +574 -0
- package/package.json +1 -1
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 容器导出 / 导入的 HTTP 接口。全部在全局认证网关之后,非 GET 请求已由 handleWebApi 校验 X-Requested-With。
|
|
4
|
+
// 核心逻辑在 lib/archive/,这里只做:参数校验、任务登记、把请求 / 响应接到流上、把 server.js 里的建容器能力包成 deps。
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { execFile } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const plan = require('../archive/plan');
|
|
12
|
+
const { exportArchive, archiveFileName, zstdAvailable } = require('../archive/export');
|
|
13
|
+
const { importArchive, inspectArchiveHead, ImportError, NAME_RE } = require('../archive/import');
|
|
14
|
+
const { buildImportCreateOptions } = require('../archive/risks');
|
|
15
|
+
const { createCliRuntime } = require('../archive/runtime');
|
|
16
|
+
const { JobCancelled } = require('./container-jobs');
|
|
17
|
+
const { parseEnvEntry } = require('../runtime-normalizers');
|
|
18
|
+
|
|
19
|
+
const FILE_RE = /^manyoyo-export-\d{8}-\d{6}\.tar\.(zst|gz)$/;
|
|
20
|
+
const HEAD_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
const DOWNLOAD_WAIT_MS = 2 * 60 * 1000;
|
|
22
|
+
const MIN_FREE_BYTES = 1024 * 1024 * 1024;
|
|
23
|
+
const MAX_CONTAINERS = 50;
|
|
24
|
+
const MAX_PLAN_BYTES = 64 * 1024;
|
|
25
|
+
|
|
26
|
+
function httpError(status, message, extra = {}) {
|
|
27
|
+
return Object.assign(new Error(message), { statusCode: status, ...extra });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function exportsDirOf(homeDir) {
|
|
31
|
+
return path.join(homeDir, '.manyoyo', 'exports');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function freeBytes(dir, statfs = fs.statfsSync) {
|
|
35
|
+
try {
|
|
36
|
+
let probe = dir;
|
|
37
|
+
while (!fs.existsSync(probe) && path.dirname(probe) !== probe) probe = path.dirname(probe);
|
|
38
|
+
const stat = statfs(probe);
|
|
39
|
+
return Number(stat.bavail) * Number(stat.bsize);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function duBytes(dir, timeoutMs = 5000) {
|
|
46
|
+
return new Promise(resolve => {
|
|
47
|
+
execFile('du', ['-sk', '--', dir], { timeout: timeoutMs }, (error, stdout) => {
|
|
48
|
+
if (error) return resolve(null);
|
|
49
|
+
const kb = Number(String(stdout).split(/\s+/)[0]);
|
|
50
|
+
resolve(Number.isFinite(kb) ? kb * 1024 : null);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readHead(file, bytes) {
|
|
56
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.fstatSync(fd);
|
|
59
|
+
if (!stat.isFile()) throw httpError(400, '不是普通文件');
|
|
60
|
+
const buffer = Buffer.alloc(Math.min(bytes, stat.size));
|
|
61
|
+
let offset = 0;
|
|
62
|
+
while (offset < buffer.length) {
|
|
63
|
+
const n = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
64
|
+
if (n === 0) break;
|
|
65
|
+
offset += n;
|
|
66
|
+
}
|
|
67
|
+
return buffer.subarray(0, offset);
|
|
68
|
+
} finally {
|
|
69
|
+
fs.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readRequestHead(req, limit) {
|
|
74
|
+
const parts = [];
|
|
75
|
+
let size = 0;
|
|
76
|
+
for await (const chunk of req) {
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
if (size > limit) throw httpError(413, '这一段超过了 4MB,不是 manyoyo 导出文件的开头');
|
|
79
|
+
parts.push(chunk);
|
|
80
|
+
}
|
|
81
|
+
return Buffer.concat(parts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createTransferRoutes({ ctx, state, req, res, deps }) {
|
|
85
|
+
const { sendJson, readJsonBody } = deps;
|
|
86
|
+
const homeDir = ctx.homeDir || os.homedir();
|
|
87
|
+
const exportsDir = exportsDirOf(homeDir);
|
|
88
|
+
const registry = state.jobs;
|
|
89
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
90
|
+
const runtime = ctx.transferRuntime || createCliRuntime({ command: ctx.dockerCmd || 'docker', env: ctx.dockerEnv });
|
|
91
|
+
|
|
92
|
+
function fail(error, fallback = 400) {
|
|
93
|
+
if (res.headersSent) {
|
|
94
|
+
res.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
sendJson(res, error.statusCode || fallback, { error: error.message || '操作失败', ...(error.busy ? { busy: error.busy, jobId: error.jobId } : {}) });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateContainerNames(list) {
|
|
101
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > MAX_CONTAINERS) throw httpError(400, `containers 需要 1 到 ${MAX_CONTAINERS} 个容器名`);
|
|
102
|
+
const names = [...new Set(list.map(String))];
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
if (!ctx.isValidContainerName(name)) throw httpError(400, `容器名不合法: ${name}`);
|
|
105
|
+
if (!ctx.containerExists(name) && !deps.historyExists(name)) throw httpError(404, `找不到容器: ${name}`);
|
|
106
|
+
const busy = registry.busyFor(name);
|
|
107
|
+
if (busy) throw httpError(409, busy.type === 'export' ? '这个容器正在导出' : '这个容器正在导入', { busy: busy.type, jobId: busy.id });
|
|
108
|
+
if (state.agentRuns && state.agentRuns.get(name)) throw httpError(409, `容器 ${name} 里有 AGENT 正在执行,请等它结束或先停止再导出`);
|
|
109
|
+
}
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ensureExportsDir() {
|
|
114
|
+
fs.mkdirSync(exportsDir, { recursive: true, mode: 0o700 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function exportDeps() {
|
|
118
|
+
return {
|
|
119
|
+
homeDir,
|
|
120
|
+
webHistoryDir: state.webHistoryDir,
|
|
121
|
+
runtime,
|
|
122
|
+
containerState: deps.containerState,
|
|
123
|
+
version: deps.version,
|
|
124
|
+
loadHistory: name => deps.loadHistory(name)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function exportHooks(job, diskGuard) {
|
|
129
|
+
let sinceCheck = 0;
|
|
130
|
+
return {
|
|
131
|
+
check: () => job.check(),
|
|
132
|
+
onBytes: n => {
|
|
133
|
+
job.addBytes(n);
|
|
134
|
+
sinceCheck += n;
|
|
135
|
+
if (diskGuard && sinceCheck >= 64 * 1024 * 1024) {
|
|
136
|
+
sinceCheck = 0;
|
|
137
|
+
const free = freeBytes(exportsDir, ctx.statfsSync);
|
|
138
|
+
if (free !== null && free < MIN_FREE_BYTES) throw httpError(507, '服务器磁盘空间不足,导出已中止');
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
onStage: info => job.update({ stage: info.stage, container: info.container }),
|
|
142
|
+
onPause: (name, info, how) => job.addPaused(name, info.id, how),
|
|
143
|
+
onUnpause: name => job.removePaused(name)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runExport(job, out, options) {
|
|
148
|
+
try {
|
|
149
|
+
const result = await exportArchive({
|
|
150
|
+
out,
|
|
151
|
+
containers: job.containers.map(name => ({ name })),
|
|
152
|
+
items: options.items,
|
|
153
|
+
includeSecrets: options.includeSecrets,
|
|
154
|
+
compression: options.compression,
|
|
155
|
+
deps: exportDeps(),
|
|
156
|
+
hooks: exportHooks(job, options.diskGuard)
|
|
157
|
+
});
|
|
158
|
+
return result;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const routes = [];
|
|
165
|
+
|
|
166
|
+
// ---- 预览:档位、内容项和各项估算大小 ----
|
|
167
|
+
routes.push({
|
|
168
|
+
method: 'GET',
|
|
169
|
+
match: p => (p === '/api/transfer/preview' ? [] : null),
|
|
170
|
+
handler: async () => {
|
|
171
|
+
const names = String(requestUrl.searchParams.get('containers') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
172
|
+
const estimates = {};
|
|
173
|
+
const containers = [];
|
|
174
|
+
const workKeys = new Set();
|
|
175
|
+
const tasks = [];
|
|
176
|
+
const add = (id, promise) => tasks.push(promise.then(bytes => { estimates[id] = estimates[id] === null || bytes === null ? null : (estimates[id] || 0) + bytes; }));
|
|
177
|
+
for (const name of names.slice(0, MAX_CONTAINERS)) {
|
|
178
|
+
if (!ctx.isValidContainerName(name)) continue;
|
|
179
|
+
const info = ctx.containerExists(name) ? runtime.inspect(name) : null;
|
|
180
|
+
const history = deps.loadHistory(name);
|
|
181
|
+
containers.push({ name, remark: history.remark || '', status: info ? info.status : 'history' });
|
|
182
|
+
const hostPath = (history.runtimeSnapshot && history.runtimeSnapshot.hostPath) || (history.applied && history.applied.hostPath) || '';
|
|
183
|
+
if (hostPath) {
|
|
184
|
+
let real = '';
|
|
185
|
+
try { real = fs.realpathSync(hostPath); } catch (error) { real = ''; }
|
|
186
|
+
if (real && !workKeys.has(real)) { workKeys.add(real); add('workdir', duBytes(real)); }
|
|
187
|
+
}
|
|
188
|
+
if (info && info.id && deps.containerState.isValidId(info.id)) {
|
|
189
|
+
const paths = deps.containerState.paths(homeDir, info.id);
|
|
190
|
+
add('uploads', duBytes(path.join(paths.box, 'uploads')));
|
|
191
|
+
add('snapshots', duBytes(path.join(paths.box, 'snap.git')));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await Promise.all(tasks);
|
|
195
|
+
// 容器内的数据(Agent 记录、系统层)要读 diff 才知道,预览里不算,显示“未知”
|
|
196
|
+
for (const item of plan.ITEMS) if (!(item.id in estimates)) estimates[item.id] = ['agentSessions', 'agentExtras', 'systemLayer'].includes(item.id) ? null : 0;
|
|
197
|
+
ensureExportsDir();
|
|
198
|
+
sendJson(res, 200, {
|
|
199
|
+
tiers: plan.TIERS,
|
|
200
|
+
items: plan.ITEMS,
|
|
201
|
+
estimates,
|
|
202
|
+
containers,
|
|
203
|
+
zstd: zstdAvailable(),
|
|
204
|
+
freeBytes: freeBytes(exportsDir, ctx.statfsSync)
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---- 导出 ----
|
|
210
|
+
routes.push({
|
|
211
|
+
method: 'POST',
|
|
212
|
+
match: p => (p === '/api/exports' ? [] : null),
|
|
213
|
+
handler: async () => {
|
|
214
|
+
try {
|
|
215
|
+
const body = await readJsonBody(req);
|
|
216
|
+
const names = validateContainerNames(body.containers);
|
|
217
|
+
const items = plan.normalizeItems(Array.isArray(body.items) ? body.items : undefined, body.tier || 'standard');
|
|
218
|
+
const output = body.output === 'server' ? 'server' : 'download';
|
|
219
|
+
const options = {
|
|
220
|
+
items,
|
|
221
|
+
includeSecrets: body.includeSecrets !== false,
|
|
222
|
+
compression: body.compression === 'small' ? 'small' : 'fast',
|
|
223
|
+
diskGuard: output === 'server'
|
|
224
|
+
};
|
|
225
|
+
const estimate = Math.max(0, Number(body.estimate) || 0);
|
|
226
|
+
const job = registry.create({ type: 'export', containers: names, meta: { output, options, estimate }, status: output === 'download' ? 'waiting' : 'running' });
|
|
227
|
+
job.update({ estimate });
|
|
228
|
+
if (output === 'download') {
|
|
229
|
+
job.waitTimer = setTimeout(() => {
|
|
230
|
+
if (job.status === 'waiting') job.finish('cancelled', { error: '没有开始下载,已取消' });
|
|
231
|
+
}, DOWNLOAD_WAIT_MS);
|
|
232
|
+
job.waitTimer.unref();
|
|
233
|
+
sendJson(res, 200, { jobId: job.id, output, downloadUrl: `/api/exports/${job.id}/download` });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
ensureExportsDir();
|
|
237
|
+
const free = freeBytes(exportsDir, ctx.statfsSync);
|
|
238
|
+
if (free !== null && free < estimate * 1.1 + MIN_FREE_BYTES) {
|
|
239
|
+
job.finish('failed', { error: '服务器剩余磁盘空间不够,请改用“直接下载”或清理空间' });
|
|
240
|
+
throw httpError(507, '服务器剩余磁盘空间不够,请改用“直接下载”或清理空间');
|
|
241
|
+
}
|
|
242
|
+
const name = archiveFileName();
|
|
243
|
+
const final = path.join(exportsDir, name);
|
|
244
|
+
const part = `${final}.part`;
|
|
245
|
+
job.update({ partFile: part, result: { file: name } });
|
|
246
|
+
const out = fs.createWriteStream(part, { flags: 'wx', mode: 0o600 });
|
|
247
|
+
sendJson(res, 200, { jobId: job.id, output, file: name });
|
|
248
|
+
runExport(job, out, options).then(result => {
|
|
249
|
+
fs.renameSync(part, final);
|
|
250
|
+
job.finish('done', { result: { file: name, bytes: fs.statSync(final).size, entries: result.entries, stats: result.stats } });
|
|
251
|
+
}).catch(error => {
|
|
252
|
+
try { out.destroy(); fs.rmSync(part, { force: true }); } catch (e) { /* ignore */ }
|
|
253
|
+
job.finish(error instanceof JobCancelled ? 'cancelled' : 'failed', { error: error.message || '导出失败' });
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
fail(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
routes.push({
|
|
262
|
+
method: 'GET',
|
|
263
|
+
match: p => p.match(/^\/api\/exports\/([0-9a-f]{16})\/download$/),
|
|
264
|
+
handler: async match => {
|
|
265
|
+
const job = registry.get(match[1]);
|
|
266
|
+
if (!job || job.type !== 'export' || job.status !== 'waiting' || job.meta.output !== 'download') {
|
|
267
|
+
return fail(httpError(404, '这个下载已经用过或已过期,请重新导出'));
|
|
268
|
+
}
|
|
269
|
+
clearTimeout(job.waitTimer);
|
|
270
|
+
// 开始的这一刻才锁容器;有别的任务占着就拒绝
|
|
271
|
+
try {
|
|
272
|
+
for (const name of job.containers) {
|
|
273
|
+
const busy = registry.busyFor(name);
|
|
274
|
+
if (busy) throw httpError(409, '这个容器正在导出或导入', { busy: busy.type, jobId: busy.id });
|
|
275
|
+
if (state.agentRuns && state.agentRuns.get(name)) throw httpError(409, `容器 ${name} 里有 AGENT 正在执行,请等它结束或先停止再导出`);
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
job.finish('failed', { error: error.message });
|
|
279
|
+
return fail(error);
|
|
280
|
+
}
|
|
281
|
+
job.update({ status: 'running' });
|
|
282
|
+
res.writeHead(200, {
|
|
283
|
+
'Content-Type': 'application/octet-stream',
|
|
284
|
+
'Content-Disposition': `attachment; filename="${archiveFileName()}"`,
|
|
285
|
+
'Cache-Control': 'no-store'
|
|
286
|
+
});
|
|
287
|
+
res.on('close', () => {
|
|
288
|
+
if (job.status === 'running') job.cancelRequested = true;
|
|
289
|
+
});
|
|
290
|
+
runExport(job, res, job.meta.options).then(result => {
|
|
291
|
+
job.finish('done', { result: { bytes: job.bytes, entries: result.entries, stats: result.stats } });
|
|
292
|
+
}).catch(error => {
|
|
293
|
+
job.finish(error instanceof JobCancelled ? 'cancelled' : 'failed', { error: error.message || '导出失败' });
|
|
294
|
+
if (!res.writableEnded) res.destroy();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ---- 任务 ----
|
|
300
|
+
routes.push({
|
|
301
|
+
method: 'GET',
|
|
302
|
+
match: p => (p === '/api/jobs' ? [] : null),
|
|
303
|
+
handler: async () => sendJson(res, 200, { jobs: registry.list() })
|
|
304
|
+
});
|
|
305
|
+
routes.push({
|
|
306
|
+
method: 'GET',
|
|
307
|
+
match: p => p.match(/^\/api\/jobs\/([0-9a-f]{16})$/),
|
|
308
|
+
handler: async match => {
|
|
309
|
+
const job = registry.get(match[1]);
|
|
310
|
+
if (!job) return fail(httpError(404, '找不到这个任务'));
|
|
311
|
+
sendJson(res, 200, { job: job.view() });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
routes.push({
|
|
315
|
+
method: 'POST',
|
|
316
|
+
match: p => p.match(/^\/api\/jobs\/([0-9a-f]{16})\/cancel$/),
|
|
317
|
+
handler: async match => {
|
|
318
|
+
const job = registry.get(match[1]);
|
|
319
|
+
if (!job) return fail(httpError(404, '找不到这个任务'));
|
|
320
|
+
if (job.status === 'waiting') {
|
|
321
|
+
clearTimeout(job.waitTimer);
|
|
322
|
+
job.finish('cancelled', { error: '已取消' });
|
|
323
|
+
} else {
|
|
324
|
+
registry.cancel(job.id);
|
|
325
|
+
}
|
|
326
|
+
sendJson(res, 200, { job: job.view() });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---- 服务器上的导出文件 ----
|
|
331
|
+
routes.push({
|
|
332
|
+
method: 'GET',
|
|
333
|
+
match: p => (p === '/api/exports' ? [] : null),
|
|
334
|
+
handler: async () => {
|
|
335
|
+
ensureExportsDir();
|
|
336
|
+
const files = fs.readdirSync(exportsDir).filter(name => FILE_RE.test(name)).map(name => {
|
|
337
|
+
const stat = fs.statSync(path.join(exportsDir, name));
|
|
338
|
+
return { name, size: stat.size, mtime: stat.mtime.toISOString() };
|
|
339
|
+
}).sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
340
|
+
sendJson(res, 200, { dir: exportsDir, files, freeBytes: freeBytes(exportsDir, ctx.statfsSync) });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
routes.push({
|
|
344
|
+
method: 'GET',
|
|
345
|
+
match: p => p.match(/^\/api\/exports\/files\/([^/]+)$/),
|
|
346
|
+
handler: async match => {
|
|
347
|
+
const name = decodeURIComponent(match[1]);
|
|
348
|
+
if (!FILE_RE.test(name)) return fail(httpError(400, '文件名不合法'));
|
|
349
|
+
const file = path.join(exportsDir, name);
|
|
350
|
+
let stat;
|
|
351
|
+
try { stat = fs.lstatSync(file); } catch (error) { return fail(httpError(404, '找不到这个文件')); }
|
|
352
|
+
if (!stat.isFile()) return fail(httpError(404, '找不到这个文件'));
|
|
353
|
+
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': stat.size, 'Content-Disposition': `attachment; filename="${name}"`, 'Cache-Control': 'no-store' });
|
|
354
|
+
const reader = fs.createReadStream(file);
|
|
355
|
+
reader.on('error', () => res.destroy());
|
|
356
|
+
res.on('close', () => reader.destroy());
|
|
357
|
+
reader.pipe(res);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
routes.push({
|
|
361
|
+
method: 'DELETE',
|
|
362
|
+
match: p => p.match(/^\/api\/exports\/files\/([^/]+)$/),
|
|
363
|
+
handler: async match => {
|
|
364
|
+
const name = decodeURIComponent(match[1]);
|
|
365
|
+
if (!FILE_RE.test(name)) return fail(httpError(400, '文件名不合法'));
|
|
366
|
+
const file = path.join(exportsDir, name);
|
|
367
|
+
try {
|
|
368
|
+
if (!fs.lstatSync(file).isFile()) throw new Error('x');
|
|
369
|
+
fs.unlinkSync(file);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
return fail(httpError(404, '找不到这个文件'));
|
|
372
|
+
}
|
|
373
|
+
sendJson(res, 200, { deleted: name });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ---- 导入:先看清单 ----
|
|
378
|
+
routes.push({
|
|
379
|
+
method: 'POST',
|
|
380
|
+
match: p => (p === '/api/imports/inspect' ? [] : null),
|
|
381
|
+
handler: async () => {
|
|
382
|
+
try {
|
|
383
|
+
let head;
|
|
384
|
+
const type = String(req.headers['content-type'] || '');
|
|
385
|
+
if (type.includes('application/json')) {
|
|
386
|
+
const body = await readJsonBody(req);
|
|
387
|
+
const name = String(body.serverFile || '');
|
|
388
|
+
if (!FILE_RE.test(name)) throw httpError(400, '文件名不合法');
|
|
389
|
+
try { head = readHead(path.join(exportsDir, name), HEAD_BYTES); } catch (error) { throw httpError(404, '找不到这个文件'); }
|
|
390
|
+
} else {
|
|
391
|
+
head = await readRequestHead(req, HEAD_BYTES);
|
|
392
|
+
}
|
|
393
|
+
let manifest;
|
|
394
|
+
try { manifest = await inspectArchiveHead(head); } catch (error) { throw httpError(400, error.message); }
|
|
395
|
+
const quickPath = deps.quickChatPath();
|
|
396
|
+
const taken = new Set([...deps.listContainerNames(), ...deps.listHistoryNames()]);
|
|
397
|
+
const containers = manifest.containers.map(item => {
|
|
398
|
+
let suggested = item.name;
|
|
399
|
+
for (let n = 2; taken.has(suggested) && n < 1000; n += 1) suggested = `${item.name}-${n}`;
|
|
400
|
+
taken.add(suggested);
|
|
401
|
+
return {
|
|
402
|
+
idx: item.idx,
|
|
403
|
+
name: item.name,
|
|
404
|
+
remark: typeof item.remark === 'string' ? item.remark.slice(0, 200) : '',
|
|
405
|
+
image: typeof item.image === 'string' ? item.image.slice(0, 200) : '',
|
|
406
|
+
containerPath: typeof item.containerPath === 'string' ? item.containerPath : '',
|
|
407
|
+
hostPath: typeof item.hostPath === 'string' ? item.hostPath : '',
|
|
408
|
+
fidelity: item.fidelity === 'partial' ? 'partial' : 'full',
|
|
409
|
+
work: item.work === undefined ? null : item.work,
|
|
410
|
+
nameTaken: taken.has(item.name) && (ctx.containerExists(item.name) || deps.historyExists(item.name)),
|
|
411
|
+
suggestedName: suggested,
|
|
412
|
+
suggestedHostPath: item.work === null || item.work === undefined ? '' : path.join(quickPath, suggested),
|
|
413
|
+
risks: item.risks && typeof item.risks === 'object' ? item.risks : {}
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
sendJson(res, 200, {
|
|
417
|
+
format: manifest.format,
|
|
418
|
+
manyoyoVersion: manifest.manyoyoVersion || '',
|
|
419
|
+
createdAt: manifest.createdAt || '',
|
|
420
|
+
items: manifest.items,
|
|
421
|
+
tier: manifest.tier || plan.matchTier(manifest.items),
|
|
422
|
+
includeSecrets: manifest.includeSecrets === true,
|
|
423
|
+
containers,
|
|
424
|
+
localImage: `${ctx.imageName}:${ctx.imageVersion}`
|
|
425
|
+
});
|
|
426
|
+
} catch (error) {
|
|
427
|
+
fail(error);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
function validatePlan(rawPlan) {
|
|
433
|
+
if (!rawPlan || typeof rawPlan !== 'object' || typeof rawPlan.containers !== 'object' || !rawPlan.containers) throw httpError(400, '导入设置不对');
|
|
434
|
+
const out = { containers: {} };
|
|
435
|
+
const usedNames = new Set();
|
|
436
|
+
const taken = new Set([...deps.listContainerNames(), ...deps.listHistoryNames()]);
|
|
437
|
+
const workDest = new Map();
|
|
438
|
+
for (const [key, value] of Object.entries(rawPlan.containers)) {
|
|
439
|
+
if (!/^\d{1,4}$/.test(key) || !value || typeof value !== 'object') throw httpError(400, '导入设置不对');
|
|
440
|
+
const name = String(value.name || '');
|
|
441
|
+
if (!NAME_RE.test(name) || name.length > 120 || !ctx.isValidContainerName(name)) throw httpError(400, `容器名不合法: ${name.slice(0, 60)}`);
|
|
442
|
+
if (taken.has(name) || usedNames.has(name)) throw httpError(409, `容器名已存在: ${name}`);
|
|
443
|
+
usedNames.add(name);
|
|
444
|
+
const imageMode = value.imageMode === 'default' ? 'default' : 'same';
|
|
445
|
+
const hostPath = String(value.hostPath || '');
|
|
446
|
+
const remark = typeof value.remark === 'string' ? value.remark.trim().slice(0, 200) : undefined;
|
|
447
|
+
const entry = { name, remark, imageMode, confirm: value.confirm && typeof value.confirm === 'object' ? value.confirm : {}, hostPath };
|
|
448
|
+
if (hostPath) {
|
|
449
|
+
if (!path.isAbsolute(hostPath) || hostPath.includes('\0') || path.normalize(hostPath) !== hostPath.replace(/\/+$/, '')) throw httpError(400, `工作目录必须是绝对路径: ${hostPath}`);
|
|
450
|
+
const manyoyoHome = path.join(homeDir, '.manyoyo');
|
|
451
|
+
// ~/.manyoyo 里是配置、历史和容器状态,不能当工作目录;只有默认的工作目录根 ~/.manyoyo/work 例外
|
|
452
|
+
const workRoot = path.join(manyoyoHome, 'work');
|
|
453
|
+
const inWorkRoot = hostPath.startsWith(`${workRoot}${path.sep}`);
|
|
454
|
+
if ((hostPath === manyoyoHome || hostPath.startsWith(`${manyoyoHome}${path.sep}`)) && !inWorkRoot) throw httpError(400, '工作目录不能放在 ~/.manyoyo 里(~/.manyoyo/work 下的子目录可以)');
|
|
455
|
+
// 目录此刻还不存在(导入时才建),这里只挡明显不能挂的位置;建好后创建容器时还会按挂载规则再校验一遍
|
|
456
|
+
const userHome = process.env.HOME || os.homedir() || '/home';
|
|
457
|
+
if (hostPath === '/' || hostPath === '/home' || hostPath === userHome) throw httpError(400, '不允许把根目录或 home 目录当作工作目录');
|
|
458
|
+
// 上一级不存在时导入会一并建出来(默认的 ~/.manyoyo/work 第一次用就不存在);只要求最近的已有上级是目录
|
|
459
|
+
let probe = path.dirname(hostPath);
|
|
460
|
+
while (!fs.existsSync(probe) && path.dirname(probe) !== probe) probe = path.dirname(probe);
|
|
461
|
+
if (!fs.statSync(probe).isDirectory()) throw httpError(400, `工作目录的上级不是目录: ${probe}`);
|
|
462
|
+
}
|
|
463
|
+
out.containers[Number(key)] = entry;
|
|
464
|
+
workDest.set(hostPath, true);
|
|
465
|
+
}
|
|
466
|
+
if (!Object.keys(out.containers).length) throw httpError(400, '没有选择要导入的容器');
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- 导入:开始 ----
|
|
471
|
+
routes.push({
|
|
472
|
+
method: 'POST',
|
|
473
|
+
match: p => (p === '/api/imports' ? [] : null),
|
|
474
|
+
handler: async () => {
|
|
475
|
+
let job = null;
|
|
476
|
+
let source = null;
|
|
477
|
+
try {
|
|
478
|
+
const type = String(req.headers['content-type'] || '');
|
|
479
|
+
let rawPlan;
|
|
480
|
+
if (type.includes('application/json')) {
|
|
481
|
+
const body = await readJsonBody(req);
|
|
482
|
+
const name = String(body.serverFile || '');
|
|
483
|
+
if (!FILE_RE.test(name)) throw httpError(400, '文件名不合法');
|
|
484
|
+
const file = path.join(exportsDir, name);
|
|
485
|
+
try {
|
|
486
|
+
if (!fs.lstatSync(file).isFile()) throw new Error('x');
|
|
487
|
+
} catch (error) { throw httpError(404, '找不到这个文件'); }
|
|
488
|
+
source = fs.createReadStream(file);
|
|
489
|
+
rawPlan = body.plan;
|
|
490
|
+
} else {
|
|
491
|
+
const planText = requestUrl.searchParams.get('plan') || '';
|
|
492
|
+
if (planText.length > MAX_PLAN_BYTES) throw httpError(400, '导入设置过大');
|
|
493
|
+
try { rawPlan = JSON.parse(planText); } catch (error) { throw httpError(400, '导入设置不是合法的 JSON'); }
|
|
494
|
+
source = req;
|
|
495
|
+
}
|
|
496
|
+
if ([...registry.list()].some(item => item.type === 'import' && item.status === 'running')) throw httpError(409, '已经有一个导入在进行,等它结束再试');
|
|
497
|
+
const importPlan = validatePlan(rawPlan);
|
|
498
|
+
const names = Object.values(importPlan.containers).map(item => item.name);
|
|
499
|
+
job = registry.create({ type: 'import', containers: names, meta: {} });
|
|
500
|
+
const result = await importArchive({
|
|
501
|
+
source,
|
|
502
|
+
plan: importPlan,
|
|
503
|
+
deps: {
|
|
504
|
+
homeDir,
|
|
505
|
+
webHistoryDir: state.webHistoryDir,
|
|
506
|
+
statfs: ctx.statfsSync,
|
|
507
|
+
runtime,
|
|
508
|
+
containerState: deps.containerState,
|
|
509
|
+
createContainer: args => deps.createImportedContainer(args),
|
|
510
|
+
removeContainer: (name, id) => deps.removeCreatedContainer(name, id),
|
|
511
|
+
saveHistory: (name, history, info) => deps.saveImportedHistory(name, history, info)
|
|
512
|
+
},
|
|
513
|
+
job
|
|
514
|
+
});
|
|
515
|
+
job.finish('done', { result });
|
|
516
|
+
sendJson(res, 200, { jobId: job.id, containers: result.containers, stats: result.stats });
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (job) job.finish(error instanceof JobCancelled ? 'cancelled' : 'failed', { error: error.message || '导入失败' });
|
|
519
|
+
const status = error instanceof ImportError ? 400 : (error.statusCode || (error && error.name === 'UnsafeEntryError' ? 400 : 500));
|
|
520
|
+
// 上传没读完就报错时,把剩下的丢掉再回复,浏览器才能收到错误
|
|
521
|
+
if (!req.complete && !res.headersSent) req.resume();
|
|
522
|
+
if (source && source !== req && typeof source.destroy === 'function') source.destroy();
|
|
523
|
+
fail(Object.assign(error, { statusCode: status }));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
return routes;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 导入时建容器(server.js 注入 buildCreateRuntime 等能力):
|
|
532
|
+
// 快照来自别人,先按用户确认过滤,再走与“复制容器”相同的校验和创建路径;first 永远不执行。
|
|
533
|
+
function createImportContainerFactory({ ctx, state, buildCreateRuntime, ensureWebContainer, resolveContainerId, containerState, loadHistory, patchHistory }) {
|
|
534
|
+
return async function createImportedContainer(args) {
|
|
535
|
+
const history = args.history && typeof args.history === 'object' ? args.history : {};
|
|
536
|
+
const snapshot = history.runtimeSnapshot && typeof history.runtimeSnapshot === 'object' ? history.runtimeSnapshot : {};
|
|
537
|
+
const { createOptions, dropped } = buildImportCreateOptions(snapshot, args.confirm, {
|
|
538
|
+
hostPath: args.hostPath,
|
|
539
|
+
imageMode: args.imageMode,
|
|
540
|
+
trustedImages: [ctx.imageName],
|
|
541
|
+
pathExists: p => { try { return fs.existsSync(p); } catch (error) { return false; } }
|
|
542
|
+
});
|
|
543
|
+
createOptions.containerName = args.newName;
|
|
544
|
+
if (!createOptions.containerPath) throw httpError(400, '归档里没有容器内工作目录,无法导入');
|
|
545
|
+
// 校验到现在名字可能被占了(上传期间别处新建了同名容器):不能把导入写进别人的容器
|
|
546
|
+
if (ctx.containerExists(args.newName)) throw httpError(409, `容器名已存在: ${args.newName}`);
|
|
547
|
+
const runtime = buildCreateRuntime(ctx, state, { createOptions, allowReservedName: true });
|
|
548
|
+
runtime.first = null;
|
|
549
|
+
try {
|
|
550
|
+
await ensureWebContainer(ctx, state, runtime);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
// 容器可能已经建出来了:登记下来,撤销时才能删掉
|
|
553
|
+
const leftoverId = resolveContainerId(ctx.dockerExecArgs, args.newName);
|
|
554
|
+
if (leftoverId && args.onCreated) args.onCreated(args.newName, leftoverId);
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
const id = resolveContainerId(ctx.dockerExecArgs, args.newName);
|
|
558
|
+
if (!id) throw httpError(500, '容器创建后读不到 manyoyo.id');
|
|
559
|
+
if (args.onCreated) args.onCreated(args.newName, id);
|
|
560
|
+
patchHistory(args.newName, { applied: runtime.applied, runtimeSnapshot: runtime.runtimeSnapshot });
|
|
561
|
+
const home = ctx.homeDir || os.homedir();
|
|
562
|
+
const paths = containerState.paths(home, id);
|
|
563
|
+
// 归档里的用户环境变量(含面板里后来改过的)覆盖建容器时从快照带来的那份;非法行由 writeEnv 的校验拒绝
|
|
564
|
+
if (typeof args.stateFiles.env === 'string') {
|
|
565
|
+
try { containerState.writeEnv(home, id, args.stateFiles.env, { parseEnvEntry }); } catch (error) { dropped.push(`环境变量未还原: ${error.message}`); }
|
|
566
|
+
}
|
|
567
|
+
if (args.confirm && args.confirm.autostart === true && typeof args.stateFiles['autostart.sh'] === 'string') {
|
|
568
|
+
try { containerState.writeAutostart(home, id, args.stateFiles['autostart.sh']); } catch (error) { dropped.push(`自启动脚本未还原: ${error.message}`); }
|
|
569
|
+
}
|
|
570
|
+
return { containerName: args.newName, id, paths, dropped };
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
module.exports = { createTransferRoutes, createImportContainerFactory, FILE_RE, exportsDirOf, httpError };
|