glad-web 1.0.46 → 2.0.2
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 +4 -192
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/glad.cjs +56 -0
- package/package.json +19 -61
- package/README.zh-CN.md +0 -198
- package/assets/logo.svg +0 -43
- package/bin/cli.js +0 -65
- package/lib/ai-tools/demo/enhanced-demo.js +0 -625
- package/lib/ai-tools/demo/index.js +0 -24
- package/lib/ai-tools/demo/responses.js +0 -88
- package/lib/ai-tools/detector.js +0 -76
- package/lib/ai-tools/registry.js +0 -300
- package/lib/claude/cli-usage.js +0 -95
- package/lib/claude/config.js +0 -82
- package/lib/claude/structured-session.js +0 -884
- package/lib/claude/transcript-repository.js +0 -216
- package/lib/codex/image-store.js +0 -174
- package/lib/codex/structured-session.js +0 -1590
- package/lib/commands/config.js +0 -78
- package/lib/commands/tools.js +0 -128
- package/lib/commands/web.js +0 -605
- package/lib/config/constants.js +0 -17
- package/lib/config/manager.js +0 -108
- package/lib/git/service.js +0 -83
- package/lib/notifications/message-formatter.js +0 -94
- package/lib/notifications/notification-service.js +0 -143
- package/lib/notifications/serverchan-client.js +0 -58
- package/lib/notifications/serverchan-settings-store.js +0 -115
- package/lib/schedule/job-runner.js +0 -162
- package/lib/schedule/job-store.js +0 -167
- package/lib/schedule/key-sequences.js +0 -49
- package/lib/schedule/scheduler-service.js +0 -39
- package/lib/server/routes/notifications.js +0 -52
- package/lib/server/routes/providers.js +0 -114
- package/lib/server/routes/schedules.js +0 -54
- package/lib/server/routes/skillhub.js +0 -104
- package/lib/server/routes/usage.js +0 -23
- package/lib/server/routes/workspace.js +0 -77
- package/lib/session/buffer.js +0 -102
- package/lib/session/file-attachment-store.js +0 -168
- package/lib/session/pty-manager.js +0 -255
- package/lib/session/rendered-history.js +0 -225
- package/lib/session/session-manager.js +0 -1032
- package/lib/session/text-history.js +0 -274
- package/lib/skillhub/client.js +0 -121
- package/lib/skillhub/settings-store.js +0 -168
- package/lib/skillhub/skill-installer.js +0 -320
- package/lib/usage/ccusage-runner.js +0 -128
- package/lib/usage/source-catalog.js +0 -26
- package/lib/usage/usage-service.js +0 -226
- package/lib/utils/logger.js +0 -74
- package/lib/utils/pid.js +0 -67
- package/lib/utils/validation.js +0 -53
- package/lib/web/bootstrap.js +0 -34
- package/lib/web/claude.js +0 -1150
- package/lib/web/codex.js +0 -1045
- package/lib/web/composer.js +0 -493
- package/lib/web/core.js +0 -385
- package/lib/web/git.js +0 -535
- package/lib/web/gitgraph.js +0 -293
- package/lib/web/index.html +0 -547
- package/lib/web/layout.js +0 -69
- package/lib/web/notifications.js +0 -164
- package/lib/web/schedules.js +0 -245
- package/lib/web/session.js +0 -361
- package/lib/web/shell.js +0 -74
- package/lib/web/skillhub.js +0 -197
- package/lib/web/styles.css +0 -932
- package/lib/web/terminal-scroll.js +0 -81
- package/lib/web/theme.js +0 -60
- package/lib/web/timed-inputs.js +0 -216
- package/lib/web/usage.js +0 -323
- package/lib/workspace/service.js +0 -77
- package/scripts/check-syntax.js +0 -26
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
const { v4: uuidv4 } = require('uuid');
|
|
2
|
-
|
|
3
|
-
function statusCode(error) {
|
|
4
|
-
return Number(error?.statusCode) || 500;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function errorBody(error) {
|
|
8
|
-
return {
|
|
9
|
-
error: error?.message || 'SkillHub 操作失败',
|
|
10
|
-
...(error?.code ? { code: error.code } : {})
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function registerSkillHubRoutes(app, {
|
|
15
|
-
settingsStore,
|
|
16
|
-
client,
|
|
17
|
-
installer,
|
|
18
|
-
sessionManager
|
|
19
|
-
}) {
|
|
20
|
-
app.get('/api/skillhub/status', (_req, res) => {
|
|
21
|
-
res.json({ available: installer.available === true });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
app.get('/api/skillhub/settings', (_req, res) => {
|
|
25
|
-
try { res.json(settingsStore.getPublic()); }
|
|
26
|
-
catch (error) { res.status(statusCode(error)).json(errorBody(error)); }
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
app.put('/api/skillhub/settings', async (req, res) => {
|
|
30
|
-
try {
|
|
31
|
-
const resolved = settingsStore.resolve(req.body || {});
|
|
32
|
-
const user = await client.test(resolved);
|
|
33
|
-
const settings = settingsStore.save(resolved);
|
|
34
|
-
res.json({ success: true, settings, user });
|
|
35
|
-
} catch (error) {
|
|
36
|
-
res.status(statusCode(error)).json(errorBody(error));
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
app.delete('/api/skillhub/settings', (_req, res) => {
|
|
41
|
-
try { res.json({ success: true, settings: settingsStore.clear() }); }
|
|
42
|
-
catch (error) { res.status(statusCode(error)).json(errorBody(error)); }
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
app.post('/api/skillhub/settings/test', async (req, res) => {
|
|
46
|
-
try {
|
|
47
|
-
const settings = settingsStore.resolve(req.body || {});
|
|
48
|
-
const user = await client.test(settings);
|
|
49
|
-
res.json({ success: true, user });
|
|
50
|
-
} catch (error) {
|
|
51
|
-
res.status(statusCode(error)).json(errorBody(error));
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
app.get('/api/skillhub/skills', async (_req, res) => {
|
|
56
|
-
try {
|
|
57
|
-
installer.assertAvailable();
|
|
58
|
-
const skills = await client.listSkills();
|
|
59
|
-
res.json({ success: true, skills });
|
|
60
|
-
} catch (error) {
|
|
61
|
-
res.status(statusCode(error)).json(errorBody(error));
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
app.post('/api/skillhub/sessions', async (req, res) => {
|
|
66
|
-
const sessionId = uuidv4();
|
|
67
|
-
let created = false;
|
|
68
|
-
try {
|
|
69
|
-
if (req.body?.toolKey !== 'codex') {
|
|
70
|
-
const error = new Error('SkillHub Session 当前只支持 Codex');
|
|
71
|
-
error.statusCode = 400;
|
|
72
|
-
error.code = 'SKILLHUB_CODEX_ONLY';
|
|
73
|
-
throw error;
|
|
74
|
-
}
|
|
75
|
-
const activeSkill = await installer.prepare(sessionId, req.body?.skill || {});
|
|
76
|
-
const session = sessionManager.create({
|
|
77
|
-
id: sessionId,
|
|
78
|
-
toolKey: 'codex',
|
|
79
|
-
workingDirectory: req.body?.workingDirectory,
|
|
80
|
-
name: activeSkill.name,
|
|
81
|
-
codexOptions: {
|
|
82
|
-
activeSkill,
|
|
83
|
-
extraSkillRoots: [activeSkill.skillsRoot]
|
|
84
|
-
},
|
|
85
|
-
disposeResources: () => installer.cleanupSync(sessionId)
|
|
86
|
-
});
|
|
87
|
-
created = true;
|
|
88
|
-
const defaultPrompt = activeSkill.defaultPrompt
|
|
89
|
-
|| '请先用中文介绍这个 Skill 能完成什么、适合哪些任务,以及用户接下来应该如何使用。这一轮只做使用引导。';
|
|
90
|
-
const intro = `$${activeSkill.name}\n\n${defaultPrompt}`;
|
|
91
|
-
const started = await sessionManager.sendCodexInput(session.id, intro, [], [activeSkill], []);
|
|
92
|
-
if (!started) throw new Error('Codex Skill 引导会话启动失败');
|
|
93
|
-
res.status(201).json({ id: session.id, name: session.name });
|
|
94
|
-
} catch (error) {
|
|
95
|
-
if (created) await sessionManager.kill(sessionId).catch(() => {});
|
|
96
|
-
else {
|
|
97
|
-
try { installer.cleanupSync(sessionId); } catch (_) { /* 目录可能尚未创建 */ }
|
|
98
|
-
}
|
|
99
|
-
res.status(statusCode(error)).json(errorBody(error));
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
module.exports = registerSkillHubRoutes;
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
module.exports = function registerUsageRoutes(app, { usageService, sendJson = (_req, res, payload) => res.json(payload) }) {
|
|
2
|
-
app.get('/api/usage/sources', async (req, res) => {
|
|
3
|
-
try {
|
|
4
|
-
res.json(await usageService.listSources(req.query.refresh === '1'));
|
|
5
|
-
} catch (error) {
|
|
6
|
-
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to load usage sources' });
|
|
7
|
-
}
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
app.get('/api/usage/report', async (req, res) => {
|
|
11
|
-
try {
|
|
12
|
-
const report = await usageService.getDashboard(
|
|
13
|
-
req.query.source,
|
|
14
|
-
req.query.scope || 'weekly',
|
|
15
|
-
req.query.period,
|
|
16
|
-
req.query.refresh === '1'
|
|
17
|
-
);
|
|
18
|
-
sendJson(req, res, report);
|
|
19
|
-
} catch (error) {
|
|
20
|
-
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to load usage report' });
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
};
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
function registerWorkspaceRoutes(app, { sessionManager, gitService, workspaceService, getWorkingDirectory }) {
|
|
2
|
-
function getSession(req, res) {
|
|
3
|
-
const session = sessionManager.get(req.params.id);
|
|
4
|
-
if (!session) res.status(404).json({ error: 'Session not found' });
|
|
5
|
-
return session;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
app.get('/api/sessions/:id/git-show/:hash', async (req, res) => {
|
|
9
|
-
const session = getSession(req, res);
|
|
10
|
-
if (!session) return;
|
|
11
|
-
const result = await gitService.show(getWorkingDirectory(session), req.params.hash);
|
|
12
|
-
res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
app.get('/api/sessions/:id/git-branch/:hash', async (req, res) => {
|
|
16
|
-
const session = getSession(req, res);
|
|
17
|
-
if (!session) return;
|
|
18
|
-
const result = await gitService.nameRev(getWorkingDirectory(session), req.params.hash);
|
|
19
|
-
res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
app.get('/api/sessions/:id/git-log', async (req, res) => {
|
|
23
|
-
const session = getSession(req, res);
|
|
24
|
-
if (!session) return;
|
|
25
|
-
const result = await gitService.log(getWorkingDirectory(session), req.query.maxCount);
|
|
26
|
-
if (!result.success) return res.status(500).json({ error: result.error, stderr: result.stderr });
|
|
27
|
-
res.json({ success: true, commits: result.commits });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
app.get('/api/sessions/:id/git-status', async (req, res) => {
|
|
31
|
-
const session = getSession(req, res);
|
|
32
|
-
if (!session) return;
|
|
33
|
-
const result = await gitService.status(getWorkingDirectory(session));
|
|
34
|
-
if (!result.success) return res.status(500).json({ error: result.error, stderr: result.stderr });
|
|
35
|
-
res.json({ success: true, files: result.files });
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
app.get('/api/sessions/:id/git-diff-numstat', async (req, res) => {
|
|
39
|
-
const session = getSession(req, res);
|
|
40
|
-
if (!session) return;
|
|
41
|
-
const result = await gitService.diffNumstat(getWorkingDirectory(session), req.query.staged === 'true');
|
|
42
|
-
res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
app.get('/api/sessions/:id/git-diff-file', async (req, res) => {
|
|
46
|
-
const session = getSession(req, res);
|
|
47
|
-
if (!session) return;
|
|
48
|
-
if (!req.query.path) return res.status(400).json({ error: 'Missing file path' });
|
|
49
|
-
const result = await gitService.diffFile(getWorkingDirectory(session), req.query.path, req.query.staged === 'true');
|
|
50
|
-
res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
app.get('/api/sessions/:id/file', (req, res) => {
|
|
54
|
-
const session = getSession(req, res);
|
|
55
|
-
if (!session) return;
|
|
56
|
-
if (!req.query.path) return res.status(400).json({ error: 'Missing file path' });
|
|
57
|
-
try {
|
|
58
|
-
const content = workspaceService.readFile(getWorkingDirectory(session), req.query.path);
|
|
59
|
-
res.json({ success: true, content });
|
|
60
|
-
} catch (error) {
|
|
61
|
-
res.status(error.statusCode || 200).json({ success: false, error: error.message });
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
app.get('/api/sessions/:id/fs/dir', async (req, res) => {
|
|
66
|
-
const session = getSession(req, res);
|
|
67
|
-
if (!session) return;
|
|
68
|
-
try {
|
|
69
|
-
const files = await workspaceService.listDirectory(getWorkingDirectory(session), req.query.path || '');
|
|
70
|
-
res.json({ success: true, files });
|
|
71
|
-
} catch (error) {
|
|
72
|
-
res.status(error.statusCode || 200).json({ success: false, error: error.message });
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
module.exports = registerWorkspaceRoutes;
|
package/lib/session/buffer.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
const logger = require('../utils/logger');
|
|
2
|
-
|
|
3
|
-
class CircularBuffer {
|
|
4
|
-
constructor(maxSize = 100000) {
|
|
5
|
-
this.maxSize = maxSize; // 100KB default
|
|
6
|
-
this.buffer = [];
|
|
7
|
-
this.totalSize = 0;
|
|
8
|
-
this.currentSeq = 0;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
previewText(text, maxChars = 240) {
|
|
12
|
-
if (!text) return '';
|
|
13
|
-
const normalized = String(text)
|
|
14
|
-
.replace(/\r/g, '\\r')
|
|
15
|
-
.replace(/\n/g, '\\n')
|
|
16
|
-
.replace(/\t/g, '\\t')
|
|
17
|
-
.replace(/\x1b/g, '\\x1b');
|
|
18
|
-
return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Calculate byte size of data
|
|
22
|
-
getByteSize(data) {
|
|
23
|
-
return Buffer.byteLength(data, 'utf8');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Append data to buffer
|
|
27
|
-
append(data) {
|
|
28
|
-
const timestamp = new Date().toISOString();
|
|
29
|
-
const seq = ++this.currentSeq;
|
|
30
|
-
const byteSize = this.getByteSize(data);
|
|
31
|
-
|
|
32
|
-
const item = {
|
|
33
|
-
seq,
|
|
34
|
-
data,
|
|
35
|
-
timestamp,
|
|
36
|
-
size: byteSize
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
this.buffer.push(item);
|
|
40
|
-
this.totalSize += byteSize;
|
|
41
|
-
|
|
42
|
-
// Remove old items if buffer exceeds max size
|
|
43
|
-
while (this.totalSize > this.maxSize && this.buffer.length > 0) {
|
|
44
|
-
const removed = this.buffer.shift();
|
|
45
|
-
this.totalSize -= removed.size;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return item;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Get all items after a specific sequence number
|
|
52
|
-
getAfter(seq) {
|
|
53
|
-
return this.buffer.filter(item => item.seq > seq);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Get all items
|
|
57
|
-
getAll() {
|
|
58
|
-
return [...this.buffer];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Get current sequence number
|
|
62
|
-
getCurrentSeq() {
|
|
63
|
-
return this.currentSeq;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Clear buffer
|
|
67
|
-
clear() {
|
|
68
|
-
logger.debug('Buffer: cleared');
|
|
69
|
-
this.buffer = [];
|
|
70
|
-
this.totalSize = 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Get buffer stats
|
|
74
|
-
getStats() {
|
|
75
|
-
return {
|
|
76
|
-
items: this.buffer.length,
|
|
77
|
-
totalSize: this.totalSize,
|
|
78
|
-
maxSize: this.maxSize,
|
|
79
|
-
currentSeq: this.currentSeq,
|
|
80
|
-
oldestSeq: this.buffer.length > 0 ? this.buffer[0].seq : null,
|
|
81
|
-
newestSeq: this.buffer.length > 0 ? this.buffer[this.buffer.length - 1].seq : null
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
getDebugSnapshot(maxItems = 5, maxChars = 240) {
|
|
86
|
-
return {
|
|
87
|
-
...this.getStats(),
|
|
88
|
-
tailItems: this.buffer.slice(-maxItems).map(item => ({
|
|
89
|
-
seq: item.seq,
|
|
90
|
-
size: item.size,
|
|
91
|
-
timestamp: item.timestamp,
|
|
92
|
-
preview: this.previewText(item.data, maxChars)
|
|
93
|
-
})),
|
|
94
|
-
combinedTailPreview: this.previewText(
|
|
95
|
-
this.buffer.slice(-maxItems).map(item => item.data).join(''),
|
|
96
|
-
maxChars
|
|
97
|
-
)
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
module.exports = CircularBuffer;
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const os = require('os');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const { v4: uuidv4 } = require('uuid');
|
|
5
|
-
|
|
6
|
-
const MAX_BYTES = 50 * 1024 * 1024;
|
|
7
|
-
const MAX_PER_SESSION = 8;
|
|
8
|
-
const MAX_CHUNKS = 128;
|
|
9
|
-
const CLEANUP_DELAY_MS = 30 * 60 * 1000;
|
|
10
|
-
|
|
11
|
-
function inputError(message, statusCode = 400) {
|
|
12
|
-
const error = new Error(message);
|
|
13
|
-
error.statusCode = statusCode;
|
|
14
|
-
return error;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function safeUploadId(value) {
|
|
18
|
-
return typeof value === 'string' && /^[a-zA-Z0-9-]{8,100}$/.test(value);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function safeFileName(value) {
|
|
22
|
-
let decoded = String(value || 'attachment.bin');
|
|
23
|
-
try { decoded = decodeURIComponent(decoded); } catch (_) {}
|
|
24
|
-
const base = decoded.replace(/\\/g, '/').split('/').pop() || 'attachment.bin';
|
|
25
|
-
const cleaned = base.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_').trim().slice(0, 160);
|
|
26
|
-
return cleaned || 'attachment.bin';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
class FileAttachmentStore {
|
|
30
|
-
constructor({ logger, root, uploadRoot } = {}) {
|
|
31
|
-
this.logger = logger || console;
|
|
32
|
-
this.root = root || path.join(os.tmpdir(), 'glad', 'session-files');
|
|
33
|
-
this.uploadRoot = uploadRoot || path.join(os.tmpdir(), 'glad', 'session-file-uploads');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
assertSession(session) {
|
|
37
|
-
if (!session) throw inputError('Session not found', 404);
|
|
38
|
-
if (!session.fileAttachments || !session.fileUploads) throw inputError('File attachments are unavailable for this session');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async appendChunk(session, input = {}, bytes) {
|
|
42
|
-
this.assertSession(session);
|
|
43
|
-
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw inputError('File chunk is required');
|
|
44
|
-
const uploadId = String(input.uploadId || '');
|
|
45
|
-
const chunkIndex = Number(input.chunkIndex);
|
|
46
|
-
const chunkTotal = Number(input.chunkTotal);
|
|
47
|
-
const name = safeFileName(input.name);
|
|
48
|
-
if (!safeUploadId(uploadId)) throw inputError('Invalid file upload id');
|
|
49
|
-
if (!Number.isInteger(chunkIndex) || !Number.isInteger(chunkTotal) || chunkIndex < 0 || chunkTotal < 1 || chunkTotal > MAX_CHUNKS || chunkIndex >= chunkTotal) {
|
|
50
|
-
throw inputError('Invalid file chunk metadata');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let upload = session.fileUploads.get(uploadId);
|
|
54
|
-
if (!upload) {
|
|
55
|
-
if (chunkIndex !== 0) throw inputError('File upload must start with the first chunk');
|
|
56
|
-
const directory = path.join(this.uploadRoot, session.id);
|
|
57
|
-
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
58
|
-
upload = { id: uploadId, name, path: path.join(directory, `${uploadId}.part`), chunkTotal, nextChunkIndex: 0, bytes: 0 };
|
|
59
|
-
session.fileUploads.set(uploadId, upload);
|
|
60
|
-
}
|
|
61
|
-
if (upload.name !== name || upload.chunkTotal !== chunkTotal || upload.nextChunkIndex !== chunkIndex) {
|
|
62
|
-
throw inputError('File chunks arrived out of order');
|
|
63
|
-
}
|
|
64
|
-
if (upload.bytes + bytes.length > MAX_BYTES) {
|
|
65
|
-
await this.discardUpload(session, uploadId);
|
|
66
|
-
throw inputError('File must be 50 MB or smaller');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (chunkIndex === 0) await fs.promises.writeFile(upload.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
70
|
-
else await fs.promises.appendFile(upload.path, bytes, { mode: 0o600 });
|
|
71
|
-
upload.bytes += bytes.length;
|
|
72
|
-
upload.nextChunkIndex += 1;
|
|
73
|
-
if (upload.nextChunkIndex < upload.chunkTotal) {
|
|
74
|
-
return { complete: false, receivedChunks: upload.nextChunkIndex, size: upload.bytes };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const pendingAttachmentCount = Array.from(session.fileAttachments.values()).filter(item => !item.sent).length;
|
|
78
|
-
if (pendingAttachmentCount >= MAX_PER_SESSION) {
|
|
79
|
-
await this.discardUpload(session, uploadId);
|
|
80
|
-
throw inputError(`You can attach at most ${MAX_PER_SESSION} files at a time`);
|
|
81
|
-
}
|
|
82
|
-
const directory = path.join(this.root, session.id);
|
|
83
|
-
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
84
|
-
const attachment = {
|
|
85
|
-
id: uuidv4(),
|
|
86
|
-
name: upload.name,
|
|
87
|
-
path: path.join(directory, `${uuidv4()}-${upload.name}`),
|
|
88
|
-
size: upload.bytes,
|
|
89
|
-
createdAt: Date.now(),
|
|
90
|
-
cleanupTimer: null
|
|
91
|
-
};
|
|
92
|
-
session.fileUploads.delete(uploadId);
|
|
93
|
-
try {
|
|
94
|
-
await fs.promises.rename(upload.path, attachment.path);
|
|
95
|
-
await fs.promises.chmod(attachment.path, 0o600);
|
|
96
|
-
session.fileAttachments.set(attachment.id, attachment);
|
|
97
|
-
return { complete: true, attachment: { id: attachment.id, name: attachment.name, size: attachment.size, kind: 'file' } };
|
|
98
|
-
} catch (error) {
|
|
99
|
-
await fs.promises.rm(upload.path, { force: true });
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async discardUpload(session, uploadId) {
|
|
105
|
-
if (!session?.fileUploads || !safeUploadId(uploadId)) return false;
|
|
106
|
-
const upload = session.fileUploads.get(uploadId);
|
|
107
|
-
if (!upload) return false;
|
|
108
|
-
session.fileUploads.delete(uploadId);
|
|
109
|
-
await fs.promises.rm(upload.path, { force: true });
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async discardAttachment(session, attachmentId) {
|
|
114
|
-
if (!session?.fileAttachments) return false;
|
|
115
|
-
const attachment = session.fileAttachments.get(attachmentId);
|
|
116
|
-
if (!attachment) return false;
|
|
117
|
-
clearTimeout(attachment.cleanupTimer);
|
|
118
|
-
session.fileAttachments.delete(attachmentId);
|
|
119
|
-
await fs.promises.rm(attachment.path, { force: true });
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
resolve(session, attachmentIds = []) {
|
|
124
|
-
const ids = Array.isArray(attachmentIds) ? attachmentIds : [];
|
|
125
|
-
if (!session) throw inputError('Session not found', 404);
|
|
126
|
-
if (ids.length === 0) return [];
|
|
127
|
-
this.assertSession(session);
|
|
128
|
-
if (ids.length > MAX_PER_SESSION) throw inputError(`You can attach at most ${MAX_PER_SESSION} files at a time`);
|
|
129
|
-
const uniqueIds = [...new Set(ids.map(String))];
|
|
130
|
-
if (uniqueIds.length !== ids.length) throw inputError('Duplicate file attachment');
|
|
131
|
-
return uniqueIds.map(attachmentId => {
|
|
132
|
-
const attachment = session.fileAttachments.get(attachmentId);
|
|
133
|
-
if (!attachment) throw inputError('File attachment is no longer available');
|
|
134
|
-
return attachment;
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
scheduleCleanup(session, attachmentIds) {
|
|
139
|
-
if (!session?.fileAttachments) return;
|
|
140
|
-
for (const attachmentId of attachmentIds) {
|
|
141
|
-
const attachment = session.fileAttachments.get(attachmentId);
|
|
142
|
-
if (!attachment) continue;
|
|
143
|
-
attachment.sent = true;
|
|
144
|
-
clearTimeout(attachment.cleanupTimer);
|
|
145
|
-
attachment.cleanupTimer = setTimeout(() => {
|
|
146
|
-
this.discardAttachment(session, attachmentId).catch(error => {
|
|
147
|
-
this.logger.debugInfo?.(`[file-attachment] cleanup failed: ${error.message}`);
|
|
148
|
-
});
|
|
149
|
-
}, CLEANUP_DELAY_MS);
|
|
150
|
-
attachment.cleanupTimer.unref?.();
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
clear(session) {
|
|
155
|
-
if (!session) return;
|
|
156
|
-
for (const attachment of session.fileAttachments?.values?.() || []) clearTimeout(attachment.cleanupTimer);
|
|
157
|
-
session.fileAttachments?.clear?.();
|
|
158
|
-
session.fileUploads?.clear?.();
|
|
159
|
-
fs.promises.rm(path.join(this.root, session.id), { recursive: true, force: true }).catch(error => {
|
|
160
|
-
this.logger.debugInfo?.(`[file-attachment] cleanup failed: ${error.message}`);
|
|
161
|
-
});
|
|
162
|
-
fs.promises.rm(path.join(this.uploadRoot, session.id), { recursive: true, force: true }).catch(error => {
|
|
163
|
-
this.logger.debugInfo?.(`[file-upload] cleanup failed: ${error.message}`);
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
module.exports = FileAttachmentStore;
|