ai-cli-online 2.9.9 → 3.0.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/README.md +161 -76
- package/README.zh-CN.md +157 -72
- package/package.json +1 -1
- package/server/dist/index.js +27 -552
- package/server/dist/middleware/auth.d.ts +9 -0
- package/server/dist/middleware/auth.js +39 -0
- package/server/dist/routes/editor.d.ts +2 -0
- package/server/dist/routes/editor.js +94 -0
- package/server/dist/routes/files.d.ts +2 -0
- package/server/dist/routes/files.js +312 -0
- package/server/dist/routes/sessions.d.ts +2 -0
- package/server/dist/routes/sessions.js +64 -0
- package/server/dist/routes/settings.d.ts +2 -0
- package/server/dist/routes/settings.js +72 -0
- package/server/package.json +1 -1
- package/shared/package.json +1 -1
- package/web/dist/assets/index-D2zgyu4q.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/package.json +1 -1
- package/web/dist/assets/index-DjhdaelF.js +0 -32
package/server/dist/index.js
CHANGED
|
@@ -5,19 +5,19 @@ import { createServer as createHttpsServer } from 'https';
|
|
|
5
5
|
import { WebSocketServer } from 'ws';
|
|
6
6
|
import helmet from 'helmet';
|
|
7
7
|
import rateLimit from 'express-rate-limit';
|
|
8
|
-
import multer from 'multer';
|
|
9
8
|
import { config } from 'dotenv';
|
|
10
|
-
import { existsSync, readFileSync
|
|
11
|
-
import {
|
|
12
|
-
import { copyFile, unlink, stat, mkdir, readFile, writeFile, rm } from 'fs/promises';
|
|
13
|
-
import { join, dirname, basename, extname } from 'path';
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
14
11
|
import { fileURLToPath } from 'url';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
|
|
19
|
-
import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, getAnnotation, saveAnnotation, cleanupOldAnnotations, closeDb } from './db.js';
|
|
12
|
+
import { setupWebSocket, clearWsIntervals } from './websocket.js';
|
|
13
|
+
import { isTmuxAvailable, cleanupStaleSessions } from './tmux.js';
|
|
14
|
+
import { cleanupOldDrafts, cleanupOldAnnotations, closeDb } from './db.js';
|
|
20
15
|
import { safeTokenCompare } from './auth.js';
|
|
16
|
+
// Route modules
|
|
17
|
+
import sessionsRouter from './routes/sessions.js';
|
|
18
|
+
import filesRouter from './routes/files.js';
|
|
19
|
+
import editorRouter from './routes/editor.js';
|
|
20
|
+
import settingsRouter from './routes/settings.js';
|
|
21
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
config();
|
|
23
23
|
const PORT = process.env.PORT || 3001;
|
|
@@ -25,20 +25,18 @@ const HOST = process.env.HOST || '0.0.0.0';
|
|
|
25
25
|
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
|
|
26
26
|
const DEFAULT_WORKING_DIR = process.env.DEFAULT_WORKING_DIR || process.env.HOME || '/home/ubuntu';
|
|
27
27
|
const HTTPS_ENABLED = process.env.HTTPS_ENABLED !== 'false';
|
|
28
|
-
const CORS_ORIGIN = process.env.CORS_ORIGIN || '';
|
|
29
|
-
const TRUST_PROXY = process.env.TRUST_PROXY || '';
|
|
28
|
+
const CORS_ORIGIN = process.env.CORS_ORIGIN || '';
|
|
29
|
+
const TRUST_PROXY = process.env.TRUST_PROXY || '';
|
|
30
30
|
const MAX_CONNECTIONS = parseInt(process.env.MAX_CONNECTIONS || '10', 10);
|
|
31
31
|
const SESSION_TTL_HOURS = parseInt(process.env.SESSION_TTL_HOURS || '24', 10);
|
|
32
32
|
const RATE_LIMIT_READ = parseInt(process.env.RATE_LIMIT_READ || '180', 10);
|
|
33
33
|
const RATE_LIMIT_WRITE = parseInt(process.env.RATE_LIMIT_WRITE || '60', 10);
|
|
34
34
|
const CERT_PATH = join(__dirname, '../certs/server.crt');
|
|
35
35
|
const KEY_PATH = join(__dirname, '../certs/server.key');
|
|
36
|
-
// Catch unhandled promise rejections to prevent silent crashes
|
|
37
36
|
process.on('unhandledRejection', (reason) => {
|
|
38
37
|
console.error('[FATAL] Unhandled promise rejection:', reason);
|
|
39
38
|
});
|
|
40
39
|
async function main() {
|
|
41
|
-
// Check tmux availability
|
|
42
40
|
if (!isTmuxAvailable()) {
|
|
43
41
|
console.error('ERROR: tmux is not available. Please install it first.');
|
|
44
42
|
console.error('Run: sudo apt install tmux');
|
|
@@ -46,13 +44,11 @@ async function main() {
|
|
|
46
44
|
}
|
|
47
45
|
console.log('tmux is available');
|
|
48
46
|
const app = express();
|
|
49
|
-
// Only trust proxy headers when explicitly configured (prevents IP spoofing without proxy)
|
|
50
47
|
if (TRUST_PROXY) {
|
|
51
48
|
app.set('trust proxy', parseInt(TRUST_PROXY, 10) || TRUST_PROXY);
|
|
52
49
|
}
|
|
53
|
-
//
|
|
50
|
+
// --- Middleware ---
|
|
54
51
|
app.use(compression());
|
|
55
|
-
// Security headers
|
|
56
52
|
app.use(helmet({
|
|
57
53
|
contentSecurityPolicy: {
|
|
58
54
|
directives: {
|
|
@@ -69,7 +65,6 @@ async function main() {
|
|
|
69
65
|
},
|
|
70
66
|
frameguard: { action: 'deny' },
|
|
71
67
|
}));
|
|
72
|
-
// Rate limiting — higher limit for read-only GET, lower for mutations
|
|
73
68
|
app.use('/api/', rateLimit({
|
|
74
69
|
windowMs: 60 * 1000,
|
|
75
70
|
max: (req) => req.method === 'GET' ? RATE_LIMIT_READ : RATE_LIMIT_WRITE,
|
|
@@ -77,9 +72,7 @@ async function main() {
|
|
|
77
72
|
legacyHeaders: false,
|
|
78
73
|
message: { error: 'Too many requests' },
|
|
79
74
|
}));
|
|
80
|
-
// JSON body parser for draft API
|
|
81
75
|
app.use(express.json({ limit: '256kb' }));
|
|
82
|
-
// CORS (only add headers when CORS_ORIGIN is explicitly configured)
|
|
83
76
|
if (CORS_ORIGIN) {
|
|
84
77
|
app.use((_req, res, next) => {
|
|
85
78
|
res.header('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
@@ -91,528 +84,17 @@ async function main() {
|
|
|
91
84
|
next();
|
|
92
85
|
});
|
|
93
86
|
}
|
|
94
|
-
//
|
|
95
|
-
function extractToken(req) {
|
|
96
|
-
const authHeader = req.headers.authorization;
|
|
97
|
-
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
98
|
-
return authHeader.slice(7);
|
|
99
|
-
}
|
|
100
|
-
return undefined;
|
|
101
|
-
}
|
|
102
|
-
function checkAuth(req, res) {
|
|
103
|
-
if (!AUTH_TOKEN)
|
|
104
|
-
return true;
|
|
105
|
-
const token = extractToken(req);
|
|
106
|
-
if (!token || !safeTokenCompare(token, AUTH_TOKEN)) {
|
|
107
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
// Health check
|
|
87
|
+
// --- Routes ---
|
|
113
88
|
app.get('/api/health', (_req, res) => {
|
|
114
89
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
115
90
|
});
|
|
116
|
-
|
|
117
|
-
app.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const sessions = await listSessions(token);
|
|
122
|
-
const activeNames = getActiveSessionNames();
|
|
123
|
-
const result = sessions.map((s) => ({
|
|
124
|
-
sessionId: s.sessionId,
|
|
125
|
-
sessionName: s.sessionName,
|
|
126
|
-
createdAt: s.createdAt,
|
|
127
|
-
active: activeNames.has(s.sessionName),
|
|
128
|
-
}));
|
|
129
|
-
res.json(result);
|
|
130
|
-
});
|
|
131
|
-
// Kill a specific session
|
|
132
|
-
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
|
133
|
-
if (!checkAuth(req, res))
|
|
134
|
-
return;
|
|
135
|
-
if (!isValidSessionId(req.params.sessionId)) {
|
|
136
|
-
res.status(400).json({ error: 'Invalid sessionId' });
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
const token = extractToken(req) || 'default';
|
|
140
|
-
const sessionName = buildSessionName(token, req.params.sessionId);
|
|
141
|
-
await killSession(sessionName);
|
|
142
|
-
deleteDraft(sessionName);
|
|
143
|
-
res.json({ ok: true });
|
|
144
|
-
});
|
|
145
|
-
// --- File transfer APIs ---
|
|
146
|
-
const UPLOAD_TMP_DIR = '/tmp/ai-cli-online-uploads';
|
|
147
|
-
await mkdir(UPLOAD_TMP_DIR, { recursive: true, mode: 0o700 });
|
|
148
|
-
const upload = multer({
|
|
149
|
-
dest: UPLOAD_TMP_DIR,
|
|
150
|
-
limits: { fileSize: MAX_UPLOAD_SIZE, files: 10 },
|
|
151
|
-
});
|
|
152
|
-
/** Helper: resolve session from request params + auth */
|
|
153
|
-
function resolveSession(req, res) {
|
|
154
|
-
if (!checkAuth(req, res))
|
|
155
|
-
return null;
|
|
156
|
-
const sessionId = req.params.sessionId;
|
|
157
|
-
if (!isValidSessionId(sessionId)) {
|
|
158
|
-
res.status(400).json({ error: 'Invalid sessionId' });
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
const token = extractToken(req) || 'default';
|
|
162
|
-
return buildSessionName(token, sessionId);
|
|
163
|
-
}
|
|
164
|
-
// Get current working directory
|
|
165
|
-
app.get('/api/sessions/:sessionId/cwd', async (req, res) => {
|
|
166
|
-
const sessionName = resolveSession(req, res);
|
|
167
|
-
if (!sessionName)
|
|
168
|
-
return;
|
|
169
|
-
try {
|
|
170
|
-
const cwd = await getCwd(sessionName);
|
|
171
|
-
res.json({ cwd });
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
console.error(`[api:cwd] ${sessionName}:`, err);
|
|
175
|
-
res.status(404).json({ error: 'Session not found or not running' });
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
// List files in directory
|
|
179
|
-
app.get('/api/sessions/:sessionId/files', async (req, res) => {
|
|
180
|
-
const sessionName = resolveSession(req, res);
|
|
181
|
-
if (!sessionName)
|
|
182
|
-
return;
|
|
183
|
-
try {
|
|
184
|
-
const cwd = await getCwd(sessionName);
|
|
185
|
-
const subPath = req.query.path || '';
|
|
186
|
-
let targetDir = null;
|
|
187
|
-
if (subPath) {
|
|
188
|
-
targetDir = await validatePath(subPath, cwd);
|
|
189
|
-
// Fallback: allow absolute paths under HOME (e.g., ~/.claude/commands)
|
|
190
|
-
if (!targetDir) {
|
|
191
|
-
const home = process.env.HOME || '/root';
|
|
192
|
-
targetDir = await validatePath(subPath, home);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
targetDir = cwd;
|
|
197
|
-
}
|
|
198
|
-
if (!targetDir) {
|
|
199
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
const { files, truncated } = await listFiles(targetDir);
|
|
203
|
-
res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
|
|
204
|
-
}
|
|
205
|
-
catch (err) {
|
|
206
|
-
console.error(`[api:files] ${sessionName}:`, err);
|
|
207
|
-
res.status(404).json({ error: 'Session not found or directory not accessible' });
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
// Upload files to CWD
|
|
211
|
-
app.post('/api/sessions/:sessionId/upload', upload.array('files', 10), async (req, res) => {
|
|
212
|
-
const sessionName = resolveSession(req, res);
|
|
213
|
-
if (!sessionName)
|
|
214
|
-
return;
|
|
215
|
-
try {
|
|
216
|
-
const cwd = await getCwd(sessionName);
|
|
217
|
-
const uploadedFiles = req.files;
|
|
218
|
-
if (!uploadedFiles || uploadedFiles.length === 0) {
|
|
219
|
-
res.status(400).json({ error: 'No files provided' });
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
const results = [];
|
|
223
|
-
for (const file of uploadedFiles) {
|
|
224
|
-
// Sanitize filename: strip path components to prevent directory traversal
|
|
225
|
-
const safeName = basename(file.originalname);
|
|
226
|
-
if (!safeName || safeName === '.' || safeName === '..') {
|
|
227
|
-
await unlink(file.path).catch(() => { });
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
const destPath = join(cwd, safeName);
|
|
231
|
-
// Use copyFile + unlink instead of rename to handle cross-device moves
|
|
232
|
-
await copyFile(file.path, destPath);
|
|
233
|
-
await unlink(file.path).catch(() => { });
|
|
234
|
-
results.push({ name: safeName, size: file.size });
|
|
235
|
-
}
|
|
236
|
-
res.json({ uploaded: results });
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
239
|
-
console.error('[upload] Failed:', err);
|
|
240
|
-
// Clean up multer temp files on error to prevent disk leak
|
|
241
|
-
const files = req.files;
|
|
242
|
-
if (files) {
|
|
243
|
-
for (const f of files) {
|
|
244
|
-
await unlink(f.path).catch(() => { });
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
res.status(500).json({ error: 'Upload failed' });
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
// Download a file
|
|
251
|
-
app.get('/api/sessions/:sessionId/download', async (req, res) => {
|
|
252
|
-
const sessionName = resolveSession(req, res);
|
|
253
|
-
if (!sessionName)
|
|
254
|
-
return;
|
|
255
|
-
try {
|
|
256
|
-
const cwd = await getCwd(sessionName);
|
|
257
|
-
const filePath = req.query.path;
|
|
258
|
-
if (!filePath) {
|
|
259
|
-
res.status(400).json({ error: 'path query parameter required' });
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
263
|
-
if (!resolved) {
|
|
264
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const fileStat = await stat(resolved);
|
|
268
|
-
if (!fileStat.isFile()) {
|
|
269
|
-
res.status(400).json({ error: 'Not a file' });
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
if (fileStat.size > MAX_DOWNLOAD_SIZE) {
|
|
273
|
-
res.status(413).json({ error: 'File too large (max 100MB)' });
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
|
|
277
|
-
res.setHeader('Content-Length', fileStat.size);
|
|
278
|
-
// A3: Stream with byte counting to guard against TOCTOU size changes
|
|
279
|
-
const stream = createReadStream(resolved);
|
|
280
|
-
let bytesWritten = 0;
|
|
281
|
-
stream.on('data', (chunk) => {
|
|
282
|
-
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
283
|
-
bytesWritten += buf.length;
|
|
284
|
-
if (bytesWritten > MAX_DOWNLOAD_SIZE) {
|
|
285
|
-
stream.destroy();
|
|
286
|
-
if (!res.writableEnded)
|
|
287
|
-
res.end();
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
if (!res.writableEnded)
|
|
291
|
-
res.write(chunk);
|
|
292
|
-
});
|
|
293
|
-
stream.on('end', () => { if (!res.writableEnded)
|
|
294
|
-
res.end(); });
|
|
295
|
-
stream.on('error', () => { if (!res.writableEnded)
|
|
296
|
-
res.end(); });
|
|
297
|
-
}
|
|
298
|
-
catch (err) {
|
|
299
|
-
console.error(`[api:download] ${sessionName}:`, err);
|
|
300
|
-
res.status(404).json({ error: 'File not found' });
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
// Download CWD as tar.gz
|
|
304
|
-
app.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
|
|
305
|
-
const sessionName = resolveSession(req, res);
|
|
306
|
-
if (!sessionName)
|
|
307
|
-
return;
|
|
308
|
-
try {
|
|
309
|
-
const cwd = await getCwd(sessionName);
|
|
310
|
-
// A5: Validate CWD format before passing to tar
|
|
311
|
-
if (!cwd.startsWith('/') || cwd.includes('\0')) {
|
|
312
|
-
res.status(400).json({ error: 'Invalid working directory' });
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
const dirName = basename(cwd);
|
|
316
|
-
res.setHeader('Content-Type', 'application/gzip');
|
|
317
|
-
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
|
|
318
|
-
const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
319
|
-
tar.stdout.pipe(res);
|
|
320
|
-
tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
|
|
321
|
-
tar.on('error', (err) => {
|
|
322
|
-
console.error('[api:download-cwd] tar error:', err);
|
|
323
|
-
if (!res.headersSent)
|
|
324
|
-
res.status(500).json({ error: 'Failed to create archive' });
|
|
325
|
-
});
|
|
326
|
-
tar.on('close', (code) => {
|
|
327
|
-
if (code !== 0 && !res.headersSent) {
|
|
328
|
-
res.status(500).json({ error: 'Archive creation failed' });
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
catch (err) {
|
|
333
|
-
console.error(`[api:download-cwd] ${sessionName}:`, err);
|
|
334
|
-
if (!res.headersSent)
|
|
335
|
-
res.status(500).json({ error: 'Failed to download' });
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
// --- Draft API ---
|
|
339
|
-
// Get draft for a session
|
|
340
|
-
app.get('/api/sessions/:sessionId/draft', (req, res) => {
|
|
341
|
-
const sessionName = resolveSession(req, res);
|
|
342
|
-
if (!sessionName)
|
|
343
|
-
return;
|
|
344
|
-
const content = getDraft(sessionName);
|
|
345
|
-
res.json({ content });
|
|
346
|
-
});
|
|
347
|
-
// Save (upsert) draft for a session
|
|
348
|
-
app.put('/api/sessions/:sessionId/draft', (req, res) => {
|
|
349
|
-
const sessionName = resolveSession(req, res);
|
|
350
|
-
if (!sessionName)
|
|
351
|
-
return;
|
|
352
|
-
const { content } = req.body;
|
|
353
|
-
if (typeof content !== 'string') {
|
|
354
|
-
res.status(400).json({ error: 'content must be a string' });
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
saveDraftDb(sessionName, content);
|
|
358
|
-
res.json({ ok: true });
|
|
359
|
-
});
|
|
360
|
-
// --- Annotations API ---
|
|
361
|
-
// Get annotation for a file
|
|
362
|
-
app.get('/api/sessions/:sessionId/annotations', (req, res) => {
|
|
363
|
-
const sessionName = resolveSession(req, res);
|
|
364
|
-
if (!sessionName)
|
|
365
|
-
return;
|
|
366
|
-
const filePath = req.query.path;
|
|
367
|
-
if (!filePath) {
|
|
368
|
-
res.status(400).json({ error: 'path query parameter required' });
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const result = getAnnotation(sessionName, filePath);
|
|
372
|
-
res.json(result || { content: null, updatedAt: 0 });
|
|
373
|
-
});
|
|
374
|
-
// Save (upsert) annotation for a file
|
|
375
|
-
app.put('/api/sessions/:sessionId/annotations', (req, res) => {
|
|
376
|
-
const sessionName = resolveSession(req, res);
|
|
377
|
-
if (!sessionName)
|
|
378
|
-
return;
|
|
379
|
-
const { path: filePath, content, updatedAt } = req.body;
|
|
380
|
-
if (!filePath || typeof filePath !== 'string') {
|
|
381
|
-
res.status(400).json({ error: 'path must be a string' });
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
if (typeof content !== 'string') {
|
|
385
|
-
res.status(400).json({ error: 'content must be a string' });
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
|
|
389
|
-
res.json({ ok: true });
|
|
390
|
-
});
|
|
391
|
-
// --- Pane command API ---
|
|
392
|
-
// Get current pane command (to detect if claude is running)
|
|
393
|
-
app.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
|
|
394
|
-
const sessionName = resolveSession(req, res);
|
|
395
|
-
if (!sessionName)
|
|
396
|
-
return;
|
|
397
|
-
try {
|
|
398
|
-
const command = await getPaneCommand(sessionName);
|
|
399
|
-
res.json({ command });
|
|
400
|
-
}
|
|
401
|
-
catch {
|
|
402
|
-
res.json({ command: '' });
|
|
403
|
-
}
|
|
404
|
-
});
|
|
405
|
-
// --- Touch (create empty file) API ---
|
|
406
|
-
app.post('/api/sessions/:sessionId/touch', async (req, res) => {
|
|
407
|
-
const sessionName = resolveSession(req, res);
|
|
408
|
-
if (!sessionName)
|
|
409
|
-
return;
|
|
410
|
-
try {
|
|
411
|
-
const { name } = req.body;
|
|
412
|
-
if (!name || typeof name !== 'string' || name.includes('..')) {
|
|
413
|
-
res.status(400).json({ error: 'Invalid filename' });
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
const cwd = await getCwd(sessionName);
|
|
417
|
-
const resolved = await validateNewPath(join(cwd, name), cwd);
|
|
418
|
-
if (!resolved) {
|
|
419
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
// Ensure parent directory exists (supports paths like "PLAN/INDEX.md")
|
|
423
|
-
await mkdir(dirname(resolved), { recursive: true });
|
|
424
|
-
await writeFile(resolved, '', { flag: 'wx' }); // create exclusively
|
|
425
|
-
res.json({ ok: true, path: resolved });
|
|
426
|
-
}
|
|
427
|
-
catch (err) {
|
|
428
|
-
if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
|
|
429
|
-
const cwd = await getCwd(sessionName).catch(() => '');
|
|
430
|
-
res.json({ ok: true, existed: true, path: cwd ? join(cwd, String(req.body.name)) : '' });
|
|
431
|
-
}
|
|
432
|
-
else {
|
|
433
|
-
console.error(`[api:touch] ${sessionName}:`, err);
|
|
434
|
-
res.status(500).json({ error: 'Failed to create file' });
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
});
|
|
438
|
-
// --- Mkdir (create directory) API ---
|
|
439
|
-
app.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
|
|
440
|
-
const sessionName = resolveSession(req, res);
|
|
441
|
-
if (!sessionName)
|
|
442
|
-
return;
|
|
443
|
-
try {
|
|
444
|
-
const { path: dirPath } = req.body;
|
|
445
|
-
if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
|
|
446
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const cwd = await getCwd(sessionName);
|
|
450
|
-
const resolved = await validateNewPath(join(cwd, dirPath), cwd);
|
|
451
|
-
if (!resolved) {
|
|
452
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
await mkdir(resolved, { recursive: true });
|
|
456
|
-
res.json({ ok: true, path: resolved });
|
|
457
|
-
}
|
|
458
|
-
catch (err) {
|
|
459
|
-
console.error(`[api:mkdir] ${sessionName}:`, err);
|
|
460
|
-
res.status(500).json({ error: 'Failed to create directory' });
|
|
461
|
-
}
|
|
462
|
-
});
|
|
463
|
-
// --- Delete file/directory API ---
|
|
464
|
-
app.delete('/api/sessions/:sessionId/rm', async (req, res) => {
|
|
465
|
-
const sessionName = resolveSession(req, res);
|
|
466
|
-
if (!sessionName)
|
|
467
|
-
return;
|
|
468
|
-
try {
|
|
469
|
-
const { path: rmPath } = req.body;
|
|
470
|
-
if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
|
|
471
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
const cwd = await getCwd(sessionName);
|
|
475
|
-
const resolved = await validatePath(rmPath, cwd);
|
|
476
|
-
if (!resolved) {
|
|
477
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
const fileStat = await stat(resolved);
|
|
481
|
-
if (fileStat.isDirectory()) {
|
|
482
|
-
await rm(resolved, { recursive: true });
|
|
483
|
-
}
|
|
484
|
-
else {
|
|
485
|
-
await unlink(resolved);
|
|
486
|
-
}
|
|
487
|
-
res.json({ ok: true });
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
console.error(`[api:rm] ${sessionName}:`, err);
|
|
491
|
-
res.status(500).json({ error: 'Failed to delete' });
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
// --- Settings API ---
|
|
495
|
-
/** Hash token for settings storage (same prefix as tmux session names) */
|
|
496
|
-
function tokenHash(token) {
|
|
497
|
-
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
|
498
|
-
}
|
|
499
|
-
app.get('/api/settings/font-size', (req, res) => {
|
|
500
|
-
if (!checkAuth(req, res))
|
|
501
|
-
return;
|
|
502
|
-
const token = extractToken(req) || 'default';
|
|
503
|
-
const value = getSetting(tokenHash(token), 'font-size');
|
|
504
|
-
const fontSize = value !== null ? parseInt(value, 10) : 14;
|
|
505
|
-
res.json({ fontSize: isNaN(fontSize) ? 14 : fontSize });
|
|
506
|
-
});
|
|
507
|
-
app.put('/api/settings/font-size', (req, res) => {
|
|
508
|
-
if (!checkAuth(req, res))
|
|
509
|
-
return;
|
|
510
|
-
const token = extractToken(req) || 'default';
|
|
511
|
-
const { fontSize } = req.body;
|
|
512
|
-
if (typeof fontSize !== 'number' || fontSize < 10 || fontSize > 24) {
|
|
513
|
-
res.status(400).json({ error: 'fontSize must be a number between 10 and 24' });
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
saveSetting(tokenHash(token), 'font-size', String(fontSize));
|
|
517
|
-
res.json({ ok: true });
|
|
518
|
-
});
|
|
519
|
-
// --- Tabs layout persistence API ---
|
|
520
|
-
app.get('/api/settings/tabs-layout', (req, res) => {
|
|
521
|
-
if (!checkAuth(req, res))
|
|
522
|
-
return;
|
|
523
|
-
const token = extractToken(req) || 'default';
|
|
524
|
-
const value = getSetting(tokenHash(token), 'tabs-layout');
|
|
525
|
-
let layout = null;
|
|
526
|
-
if (value) {
|
|
527
|
-
try {
|
|
528
|
-
layout = JSON.parse(value);
|
|
529
|
-
}
|
|
530
|
-
catch { /* corrupt data */ }
|
|
531
|
-
}
|
|
532
|
-
res.json({ layout });
|
|
533
|
-
});
|
|
534
|
-
app.put('/api/settings/tabs-layout', (req, res) => {
|
|
535
|
-
const { layout, token: bodyToken } = req.body;
|
|
536
|
-
// Support both Authorization header and body token (for sendBeacon)
|
|
537
|
-
let token;
|
|
538
|
-
if (AUTH_TOKEN) {
|
|
539
|
-
token = extractToken(req);
|
|
540
|
-
if (!token && bodyToken) {
|
|
541
|
-
// sendBeacon path: validate token from body
|
|
542
|
-
if (!safeTokenCompare(bodyToken, AUTH_TOKEN)) {
|
|
543
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
token = bodyToken;
|
|
547
|
-
}
|
|
548
|
-
if (!token) {
|
|
549
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
else {
|
|
554
|
-
token = extractToken(req) || bodyToken || 'default';
|
|
555
|
-
}
|
|
556
|
-
if (!layout || typeof layout !== 'object') {
|
|
557
|
-
res.status(400).json({ error: 'layout must be an object' });
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
saveSetting(tokenHash(token), 'tabs-layout', JSON.stringify(layout));
|
|
561
|
-
res.json({ ok: true });
|
|
562
|
-
});
|
|
563
|
-
// --- Document browser: file content API ---
|
|
564
|
-
const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
|
|
565
|
-
const PDF_EXTENSIONS = new Set(['.pdf']);
|
|
566
|
-
app.get('/api/sessions/:sessionId/file-content', async (req, res) => {
|
|
567
|
-
const sessionName = resolveSession(req, res);
|
|
568
|
-
if (!sessionName)
|
|
569
|
-
return;
|
|
570
|
-
const filePath = req.query.path;
|
|
571
|
-
if (!filePath) {
|
|
572
|
-
res.status(400).json({ error: 'path query parameter required' });
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
try {
|
|
576
|
-
const cwd = await getCwd(sessionName);
|
|
577
|
-
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
578
|
-
if (!resolved) {
|
|
579
|
-
res.status(400).json({ error: 'Invalid path' });
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
const fileStat = await stat(resolved);
|
|
583
|
-
if (!fileStat.isFile()) {
|
|
584
|
-
res.status(400).json({ error: 'Not a file' });
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
if (fileStat.size > MAX_DOC_SIZE) {
|
|
588
|
-
res.status(413).json({ error: 'File too large (max 10MB)' });
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
// 304 check: if client sends `since` and file hasn't changed
|
|
592
|
-
const since = parseFloat(req.query.since) || 0;
|
|
593
|
-
if (since > 0 && fileStat.mtimeMs <= since) {
|
|
594
|
-
res.status(304).end();
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
const ext = extname(resolved).toLowerCase();
|
|
598
|
-
const isPdf = PDF_EXTENSIONS.has(ext);
|
|
599
|
-
const content = await readFile(resolved, isPdf ? undefined : 'utf-8');
|
|
600
|
-
res.json({
|
|
601
|
-
content: isPdf ? content.toString('base64') : content,
|
|
602
|
-
mtime: fileStat.mtimeMs,
|
|
603
|
-
size: fileStat.size,
|
|
604
|
-
encoding: isPdf ? 'base64' : 'utf-8',
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
catch (err) {
|
|
608
|
-
console.error(`[api:file-content] ${sessionName}:`, err);
|
|
609
|
-
res.status(404).json({ error: 'File not found' });
|
|
610
|
-
}
|
|
611
|
-
});
|
|
612
|
-
// Serve static files from web/dist in production
|
|
91
|
+
app.use(sessionsRouter);
|
|
92
|
+
app.use(filesRouter);
|
|
93
|
+
app.use(editorRouter);
|
|
94
|
+
app.use(settingsRouter);
|
|
95
|
+
// --- Static files ---
|
|
613
96
|
const webDistPath = join(__dirname, '../../web/dist');
|
|
614
97
|
if (existsSync(webDistPath)) {
|
|
615
|
-
// Vite generates content-hashed filenames — safe to cache indefinitely
|
|
616
98
|
app.use(express.static(webDistPath, {
|
|
617
99
|
maxAge: '1y',
|
|
618
100
|
immutable: true,
|
|
@@ -622,13 +104,12 @@ async function main() {
|
|
|
622
104
|
if (req.path.startsWith('/api') || req.path.startsWith('/ws')) {
|
|
623
105
|
return next();
|
|
624
106
|
}
|
|
625
|
-
// index.html must not be cached (references hashed assets)
|
|
626
107
|
res.setHeader('Cache-Control', 'no-cache');
|
|
627
108
|
res.sendFile(join(webDistPath, 'index.html'));
|
|
628
109
|
});
|
|
629
110
|
console.log('Serving static files from:', webDistPath);
|
|
630
111
|
}
|
|
631
|
-
//
|
|
112
|
+
// --- Server startup ---
|
|
632
113
|
const hasSSL = existsSync(CERT_PATH) && existsSync(KEY_PATH);
|
|
633
114
|
const useHttps = HTTPS_ENABLED && hasSSL;
|
|
634
115
|
let server;
|
|
@@ -642,16 +123,15 @@ async function main() {
|
|
|
642
123
|
console.log('WARNING: HTTPS enabled but certificates not found, falling back to HTTP');
|
|
643
124
|
}
|
|
644
125
|
}
|
|
645
|
-
// WebSocket server with compression and increased payload limit
|
|
646
126
|
const wss = new WebSocketServer({
|
|
647
127
|
server,
|
|
648
128
|
path: '/ws',
|
|
649
|
-
maxPayload: 1024 * 1024,
|
|
129
|
+
maxPayload: 1024 * 1024,
|
|
650
130
|
perMessageDeflate: {
|
|
651
|
-
zlibDeflateOptions: { level: 1 },
|
|
652
|
-
threshold: 128,
|
|
131
|
+
zlibDeflateOptions: { level: 1 },
|
|
132
|
+
threshold: 128,
|
|
653
133
|
concurrencyLimit: 10,
|
|
654
|
-
clientNoContextTakeover: true,
|
|
134
|
+
clientNoContextTakeover: true,
|
|
655
135
|
serverNoContextTakeover: true,
|
|
656
136
|
},
|
|
657
137
|
});
|
|
@@ -671,7 +151,7 @@ async function main() {
|
|
|
671
151
|
console.log('='.repeat(50));
|
|
672
152
|
console.log('');
|
|
673
153
|
});
|
|
674
|
-
//
|
|
154
|
+
// --- Cleanup ---
|
|
675
155
|
try {
|
|
676
156
|
const purged = cleanupOldDrafts(7);
|
|
677
157
|
if (purged > 0)
|
|
@@ -680,10 +160,9 @@ async function main() {
|
|
|
680
160
|
catch (e) {
|
|
681
161
|
console.error('[startup:drafts]', e);
|
|
682
162
|
}
|
|
683
|
-
// Periodic cleanup of stale tmux sessions
|
|
684
163
|
let cleanupTimer = null;
|
|
685
164
|
if (SESSION_TTL_HOURS > 0) {
|
|
686
|
-
const CLEANUP_INTERVAL = 60 * 60 * 1000;
|
|
165
|
+
const CLEANUP_INTERVAL = 60 * 60 * 1000;
|
|
687
166
|
cleanupTimer = setInterval(() => {
|
|
688
167
|
cleanupStaleSessions(SESSION_TTL_HOURS).catch((e) => console.error('[cleanup]', e));
|
|
689
168
|
try {
|
|
@@ -701,18 +180,15 @@ async function main() {
|
|
|
701
180
|
}, CLEANUP_INTERVAL);
|
|
702
181
|
console.log(`Session TTL: ${SESSION_TTL_HOURS}h (cleanup every hour)`);
|
|
703
182
|
}
|
|
704
|
-
// Graceful shutdown
|
|
183
|
+
// --- Graceful shutdown ---
|
|
705
184
|
const shutdown = () => {
|
|
706
185
|
console.log('\n[shutdown] Closing server...');
|
|
707
|
-
// Clear all intervals to allow event loop to drain
|
|
708
186
|
clearWsIntervals();
|
|
709
187
|
if (cleanupTimer)
|
|
710
188
|
clearInterval(cleanupTimer);
|
|
711
|
-
// Close all WebSocket connections (triggers ws 'close' handlers which kill PTYs)
|
|
712
189
|
wss.clients.forEach((client) => {
|
|
713
190
|
client.close(1001, 'Server shutting down');
|
|
714
191
|
});
|
|
715
|
-
// Allow 500ms for WebSocket close handlers to fire and clean up PTYs
|
|
716
192
|
setTimeout(() => {
|
|
717
193
|
server.close(() => {
|
|
718
194
|
try {
|
|
@@ -723,7 +199,6 @@ async function main() {
|
|
|
723
199
|
process.exit(0);
|
|
724
200
|
});
|
|
725
201
|
}, 500);
|
|
726
|
-
// Force exit after 5s if graceful close hangs
|
|
727
202
|
setTimeout(() => {
|
|
728
203
|
console.log('[shutdown] Forced exit');
|
|
729
204
|
process.exit(1);
|