webtun 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,1610 @@
1
+ // Load .env without dotenv dependency
2
+ try {
3
+ const envPath = require('path').join(__dirname, '.env');
4
+ const envContent = require('fs').readFileSync(envPath, 'utf8');
5
+ envContent.split('\n').forEach(line => {
6
+ const trimmed = line.trim();
7
+ if (!trimmed || trimmed.startsWith('#')) return;
8
+ const idx = trimmed.indexOf('=');
9
+ if (idx === -1) return;
10
+ const key = trimmed.slice(0, idx).trim();
11
+ const val = trimmed.slice(idx + 1).trim();
12
+ if (!(key in process.env)) process.env[key] = val;
13
+ });
14
+ } catch {}
15
+
16
+ const express = require('express');
17
+ const http = require('http');
18
+ const WebSocket = require('ws');
19
+ const pty = require('node-pty');
20
+ const multer = require('multer');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const crypto = require('crypto');
25
+ const { execSync, execFileSync, spawn } = require('child_process');
26
+
27
+ // MIME type lookup without mime-types dependency
28
+ const MIME_MAP = {
29
+ '.html':'text/html','.htm':'text/html','.css':'text/css','.js':'application/javascript',
30
+ '.mjs':'application/javascript','.json':'application/json','.xml':'application/xml',
31
+ '.txt':'text/plain','.csv':'text/csv','.tsv':'text/tab-separated-values',
32
+ '.md':'text/markdown','.rtf':'text/rtf',
33
+ '.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.gif':'image/gif',
34
+ '.bmp':'image/bmp','.ico':'image/x-icon','.svg':'image/svg+xml','.webp':'image/webp',
35
+ '.avif':'image/avif','.tif':'image/tiff','.tiff':'image/tiff',
36
+ '.mp3':'audio/mpeg','.mp4':'video/mp4','.webm':'video/webm','.ogg':'audio/ogg',
37
+ '.wav':'audio/wav','.flac':'audio/flac','.aac':'audio/aac','.m4a':'audio/mp4',
38
+ '.pdf':'application/pdf','.zip':'application/zip','.gz':'application/gzip',
39
+ '.tar':'application/x-tar','.7z':'application/x-7z-compressed',
40
+ '.rar':'application/vnd.rar','.bz2':'application/x-bzip2','.xz':'application/x-xz',
41
+ '.doc':'application/msword','.docx':'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
42
+ '.xls':'application/vnd.ms-excel','.xlsx':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
43
+ '.ppt':'application/vnd.ms-powerpoint','.pptx':'application/vnd.openxmlformats-officedocument.presentationml.presentation',
44
+ '.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.otf':'font/otf','.eot':'application/vnd.ms-fontobject',
45
+ '.wasm':'application/wasm','.map':'application/json','.tgz':'application/gzip',
46
+ '.sh':'text/x-shellscript','.py':'text/x-python','.rb':'text/x-ruby',
47
+ '.java':'text/x-java','.c':'text/x-c','.h':'text/x-c','.cpp':'text/x-c++',
48
+ '.go':'text/x-go','.rs':'text/x-rust','.php':'text/x-php','.pl':'text/x-perl',
49
+ '.sql':'application/sql','.graphql':'application/graphql',
50
+ '.yaml':'text/yaml','.yml':'text/yaml','.toml':'application/toml','.ini':'text/plain',
51
+ '.env':'text/plain','.lock':'text/plain',
52
+ };
53
+ function mimeLookup(filePath) {
54
+ const ext = path.extname(filePath).toLowerCase();
55
+ return MIME_MAP[ext] || 'application/octet-stream';
56
+ }
57
+
58
+ // In-memory rate limiter factory
59
+ const rateLimitWindows = new Map();
60
+ function createRateLimiter(opts) {
61
+ return (req, res, next) => {
62
+ if (opts.skipWhenNoPin && !PIN) return next();
63
+ const now = Date.now();
64
+ const key = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'default';
65
+ let win = rateLimitWindows.get(key);
66
+ if (!win || now > win.resetAt) {
67
+ win = { count: 0, resetAt: now + (opts.windowMs || 10000) };
68
+ rateLimitWindows.set(key, win);
69
+ }
70
+ win.count++;
71
+ if (win.count > (opts.limit || 10)) return res.status(429).json({ error: opts.errorMsg || 'Too many requests' });
72
+ next();
73
+ };
74
+ }
75
+
76
+ const authRateLimiter = createRateLimiter({ limit: 5, windowMs: 10000, errorMsg: 'Too many attempts', skipWhenNoPin: true });
77
+ const rateLimiter = createRateLimiter({ limit: 20, windowMs: 10000 });
78
+
79
+ // Periodic cleanup of rate limiter
80
+ setInterval(() => {
81
+ const now = Date.now();
82
+ for (const [key, win] of rateLimitWindows) { if (now > win.resetAt) rateLimitWindows.delete(key); }
83
+ }, 60000);
84
+
85
+ const app = express();
86
+ const server = http.createServer(app);
87
+ const wss = new WebSocket.Server({ server, path: '/ws' });
88
+
89
+ const PORT = process.env.PORT || 3000;
90
+ const PIN = process.env.PIN || '';
91
+ const SHELL = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh'));
92
+ const HOST = process.env.HOST || '0.0.0.0';
93
+ const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
94
+
95
+ app.use(express.json({ limit: '50mb' }));
96
+ app.use(express.static(path.join(__dirname, 'public')));
97
+
98
+ // Security headers
99
+ app.use((req, res, next) => {
100
+ res.setHeader('X-Content-Type-Options', 'nosniff');
101
+ res.setHeader('X-Frame-Options', 'DENY');
102
+ res.setHeader('Referrer-Policy', 'no-referrer');
103
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
104
+ res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' https://*.trycloudflare.com wss:; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:;");
105
+ next();
106
+ });
107
+
108
+ // Trust proxy for proper IP detection behind reverse proxy
109
+ app.set('trust proxy', 1);
110
+
111
+ function constantTimeEqual(a, b) {
112
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
113
+ if (a.length !== b.length) return false;
114
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
115
+ }
116
+
117
+ // ── Auth ──────────────────────────────────────────────────────────────
118
+ function checkPin(req, res, next) {
119
+ if (!PIN) return next();
120
+ const token = (req.headers['x-pin-token'] || req.query.token || '').trim();
121
+ if (token && constantTimeEqual(token, PIN)) return next();
122
+ res.status(401).json({ error: 'Unauthorized' });
123
+ }
124
+
125
+ app.get('/api/auth/required', (req, res) => {
126
+ res.json({ required: !!PIN });
127
+ });
128
+
129
+ app.post('/api/auth', authRateLimiter, (req, res) => {
130
+ const { pin } = req.body;
131
+ if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
132
+ res.json({ success: true, token: PIN || 'open' });
133
+ } else {
134
+ res.status(401).json({ error: 'Invalid PIN' });
135
+ }
136
+ });
137
+
138
+ // ── System info ───────────────────────────────────────────────────────
139
+ app.get('/api/home', checkPin, (req, res) => {
140
+ res.json({ home: os.homedir(), hostname: os.hostname(), platform: os.platform() });
141
+ });
142
+
143
+ // ── File API ──────────────────────────────────────────────────────────
144
+ const fsPromises = fs.promises;
145
+
146
+ function resolvePath(targetPath) {
147
+ if (!targetPath) return WORKSPACE_ROOT;
148
+ return path.resolve(targetPath);
149
+ }
150
+
151
+ // Resolve path and follow symlinks to their real location.
152
+ // Used for write operations so files end up at the intended real path.
153
+ function realPath(targetPath) {
154
+ if (!targetPath) return WORKSPACE_ROOT;
155
+ const resolved = path.resolve(targetPath);
156
+ try { return fs.realpathSync(resolved); } catch { return resolved; }
157
+ }
158
+
159
+ async function safeStat(p) {
160
+ try { return await fsPromises.stat(p); } catch { return null; }
161
+ }
162
+
163
+ async function asyncSafeWalk(currentDir, depth, maxDepth, q, results, maxResults) {
164
+ if (depth > maxDepth || results.length >= maxResults) return;
165
+ let entries;
166
+ try { entries = await fsPromises.readdir(currentDir, { withFileTypes: true }); } catch { return; }
167
+
168
+ const matching = entries.filter(e => e.name.toLowerCase().includes(q));
169
+ const dirs = entries.filter(e => e.isDirectory());
170
+
171
+ await Promise.all(matching.map(async e => {
172
+ if (results.length >= maxResults) return;
173
+ const full = path.join(currentDir, e.name);
174
+ try {
175
+ const st = await fsPromises.stat(full);
176
+ if (results.length < maxResults) results.push({ path: full, name: e.name, isDir: st.isDirectory(), dir: currentDir });
177
+ } catch {}
178
+ }));
179
+
180
+ await Promise.all(dirs.map(async e => {
181
+ if (results.length >= maxResults) return;
182
+ const full = path.join(currentDir, e.name);
183
+ try {
184
+ const st = await fsPromises.lstat(full);
185
+ if (st.isSymbolicLink()) return;
186
+ } catch {}
187
+ await asyncSafeWalk(full, depth + 1, maxDepth, q, results, maxResults);
188
+ }));
189
+ }
190
+
191
+ app.get('/api/files', checkPin, async (req, res) => {
192
+ try {
193
+ const dir = resolvePath(req.query.path || WORKSPACE_ROOT);
194
+
195
+ // Windows: at a drive root (e.g. C:\), list all available drives
196
+ if (os.platform() === 'win32') {
197
+ const parsed = path.parse(dir);
198
+ if (dir === parsed.root || dir === '\\') {
199
+ const files = [];
200
+ for (let i = 65; i <= 90; i++) {
201
+ const letter = String.fromCharCode(i);
202
+ const drive = letter + ':\\';
203
+ try { await fsPromises.access(drive); } catch { continue; }
204
+ files.push({
205
+ name: letter + ':', path: drive, isDir: true,
206
+ isSymlink: false, size: 0, modified: null, ext: ''
207
+ });
208
+ }
209
+ if (dir !== '\\') {
210
+ try {
211
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
212
+ for (const e of entries) {
213
+ const full = path.join(dir, e.name);
214
+ const st = await safeStat(full);
215
+ files.push({
216
+ name: e.name, path: full, isDir: e.isDirectory(),
217
+ isSymlink: e.isSymbolicLink(), size: st ? st.size : 0,
218
+ modified: st ? st.mtime : null, ext: path.extname(e.name).toLowerCase()
219
+ });
220
+ }
221
+ } catch {}
222
+ }
223
+ files.sort((a, b) => {
224
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
225
+ return a.name.localeCompare(b.name);
226
+ });
227
+ return res.json({ path: dir, parent: null, files });
228
+ }
229
+ }
230
+
231
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
232
+ const files = await Promise.all(
233
+ entries.map(async e => {
234
+ const full = path.join(dir, e.name);
235
+ const st = await safeStat(full);
236
+ return {
237
+ name: e.name,
238
+ path: full,
239
+ isDir: e.isDirectory(),
240
+ isSymlink: e.isSymbolicLink(),
241
+ size: st ? st.size : 0,
242
+ modified: st ? st.mtime : null,
243
+ ext: path.extname(e.name).toLowerCase()
244
+ };
245
+ })
246
+ );
247
+ files.sort((a, b) => {
248
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
249
+ return a.name.localeCompare(b.name);
250
+ });
251
+ const parent = path.dirname(dir);
252
+ res.json({ path: dir, parent: parent !== dir ? parent : null, files });
253
+ } catch (e) {
254
+ res.status(500).json({ error: e.message });
255
+ }
256
+ });
257
+
258
+ app.post('/api/files/rename', checkPin, async (req, res) => {
259
+ try {
260
+ if (!req.body.oldPath || !req.body.newName) {
261
+ console.warn('POST /api/files/rename 400 — body requires { oldPath, newName }. Example: { "oldPath": "/home/user/file.txt", "newName": "renamed.txt" }');
262
+ return res.status(400).json({ error: 'oldPath and newName are required', usage: 'POST JSON { "oldPath": "<path>", "newName": "<name>" }' });
263
+ }
264
+ const oldPath = realPath(req.body.oldPath);
265
+ const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
266
+ await fsPromises.rename(oldPath, newPath);
267
+ res.json({ success: true, newPath });
268
+ } catch (e) {
269
+ res.status(500).json({ error: e.message });
270
+ }
271
+ });
272
+
273
+ async function resolveCopyMove(src, dst, conflict, isMove) {
274
+ const VALID_CONFLICTS = ['replace', 'skip', 'keep_both', 'merge', 'cancel'];
275
+ let dstExists = false, dstIsDir = false;
276
+ try { const s = await fsPromises.stat(dst); dstExists = true; dstIsDir = s.isDirectory(); } catch {}
277
+
278
+ if (dstExists && !VALID_CONFLICTS.includes(conflict)) {
279
+ return { conflict: true, isDir: dstIsDir, name: path.basename(dst) };
280
+ }
281
+
282
+ if (conflict === 'cancel') return { success: false, error: 'Cancelled' };
283
+ if (conflict === 'skip') return { success: true, skipped: true };
284
+
285
+ if (conflict === 'keep_both' && dstExists) {
286
+ const ext = path.extname(dst);
287
+ const base = path.basename(dst, ext);
288
+ const dir = path.dirname(dst);
289
+ let counter = 1;
290
+ while (true) {
291
+ const suffix = counter === 1 ? ' (copy)' : ` (copy ${counter})`;
292
+ dst = path.join(dir, base + suffix + ext);
293
+ try { await fsPromises.access(dst); counter++; } catch { break; }
294
+ }
295
+ }
296
+
297
+ const st = await fsPromises.stat(src);
298
+ const isDir = st.isDirectory();
299
+
300
+ if (dstExists && conflict === 'replace') {
301
+ if (isDir) await fsPromises.rm(dst, { recursive: true, force: true });
302
+ else await fsPromises.unlink(dst);
303
+ }
304
+
305
+ if (isMove) {
306
+ if (dstExists && conflict === 'merge' && isDir && dstIsDir) {
307
+ const entries = await fsPromises.readdir(src);
308
+ for (const entry of entries) {
309
+ const srcEntry = path.join(src, entry);
310
+ const dstEntry = path.join(dst, entry);
311
+ await fsPromises.cp(srcEntry, dstEntry, { recursive: true, force: true });
312
+ }
313
+ await fsPromises.rm(src, { recursive: true, force: true });
314
+ } else {
315
+ await fsPromises.rename(src, dst);
316
+ }
317
+ } else {
318
+ if (isDir) {
319
+ if (dstExists && conflict === 'merge' && dstIsDir) {
320
+ const entries = await fsPromises.readdir(src);
321
+ for (const entry of entries) {
322
+ const srcEntry = path.join(src, entry);
323
+ const dstEntry = path.join(dst, entry);
324
+ await fsPromises.cp(srcEntry, dstEntry, { recursive: true, force: true });
325
+ }
326
+ } else {
327
+ await fsPromises.cp(src, dst, { recursive: true, force: true });
328
+ }
329
+ } else {
330
+ await fsPromises.copyFile(src, dst);
331
+ }
332
+ }
333
+ return { success: true };
334
+ }
335
+
336
+ async function handleCopyMove(req, res, isMove) {
337
+ if (!req.body.source || !req.body.destination) {
338
+ return res.status(400).json({ error: 'source and destination are required', usage: 'POST JSON { "source": "<src>", "destination": "<dst>", "conflict": "replace|skip|keep_both|merge|cancel" }' });
339
+ }
340
+ const src = realPath(req.body.source);
341
+ const dst = resolvePath(req.body.destination);
342
+ const result = await resolveCopyMove(src, dst, req.body.conflict || '', isMove);
343
+ res.json(result);
344
+ }
345
+
346
+ app.post('/api/files/copy', checkPin, (req, res) => handleCopyMove(req, res, false));
347
+ app.post('/api/files/move', checkPin, (req, res) => handleCopyMove(req, res, true));
348
+
349
+ app.delete('/api/files', checkPin, async (req, res) => {
350
+ try {
351
+ if (!req.query.path) {
352
+ console.warn('DELETE /api/files 400 — query param ?path= is required. Example: DELETE /api/files?path=/home/user/file.txt');
353
+ return res.status(400).json({ error: 'path is required', usage: 'DELETE /api/files?path=<path>' });
354
+ }
355
+ const p = realPath(req.query.path);
356
+ const st = await fsPromises.stat(p);
357
+ if (st.isDirectory()) {
358
+ await fsPromises.rm(p, { recursive: true, force: true });
359
+ } else {
360
+ await fsPromises.unlink(p);
361
+ }
362
+ res.json({ success: true });
363
+ } catch (e) {
364
+ res.status(500).json({ error: e.message });
365
+ }
366
+ });
367
+
368
+ app.post('/api/files/mkdir', checkPin, async (req, res) => {
369
+ try {
370
+ if (!req.body.path) {
371
+ console.warn('POST /api/files/mkdir 400 — body requires { path }. Example: { "path": "/home/user/newfolder" }');
372
+ return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<dir>" }' });
373
+ }
374
+ const p = realPath(req.body.path);
375
+ await fsPromises.mkdir(p, { recursive: true });
376
+ res.json({ success: true });
377
+ } catch (e) {
378
+ res.status(500).json({ error: e.message });
379
+ }
380
+ });
381
+
382
+ app.post('/api/files/touch', checkPin, async (req, res) => {
383
+ try {
384
+ if (!req.body.path) {
385
+ console.warn('POST /api/files/touch 400 — body requires { path }. Example: { "path": "/home/user/newfile.txt" }');
386
+ return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file>" }' });
387
+ }
388
+ const p = realPath(req.body.path);
389
+ await fsPromises.writeFile(p, '', { flag: 'a' });
390
+ res.json({ success: true });
391
+ } catch (e) {
392
+ res.status(500).json({ error: e.message });
393
+ }
394
+ });
395
+
396
+ app.post('/api/files/zip', checkPin, async (req, res) => {
397
+ try {
398
+ if (!req.body.path) {
399
+ console.warn('POST /api/files/zip 400 — body requires { path }. Example: { "path": "/home/user/mydir" }');
400
+ return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
401
+ }
402
+ const p = realPath(req.body.path);
403
+ const st = await fsPromises.stat(p);
404
+ const baseName = path.basename(p);
405
+ let zipName = baseName + '.zip';
406
+ let zipPath = path.join(path.dirname(p), zipName);
407
+ let counter = 1;
408
+ while (true) {
409
+ try { await fsPromises.access(zipPath); } catch { break; }
410
+ zipName = baseName + ' (' + counter + ').zip';
411
+ zipPath = path.join(path.dirname(p), zipName);
412
+ counter++;
413
+ }
414
+ const zipDir = path.dirname(zipPath);
415
+ const zipTarget = path.basename(zipPath);
416
+ if (st.isDirectory()) {
417
+ execSync(`zip -r -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
418
+ } else {
419
+ execSync(`zip -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
420
+ }
421
+ res.json({ success: true, name: zipName });
422
+ } catch (e) {
423
+ res.status(500).json({ error: e.message });
424
+ }
425
+ });
426
+
427
+ app.post('/api/files/unzip', checkPin, async (req, res) => {
428
+ try {
429
+ if (!req.body.path) {
430
+ console.warn('POST /api/files/unzip 400 — body requires { path }. Example: { "path": "/home/user/archive.zip" }');
431
+ return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<zip_file>" }' });
432
+ }
433
+ const p = realPath(req.body.path);
434
+ const ext = path.extname(p).toLowerCase();
435
+ if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
436
+ const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
437
+ await fsPromises.mkdir(destDir, { recursive: true });
438
+ // Security: scan zip entries before extraction
439
+ const unzipList = execSync(`unzip -l "${p}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
440
+ for (const line of unzipList.split('\n')) {
441
+ const match = line.match(/^\s*\S+\s+\S+\s+(.+)$/);
442
+ if (match) {
443
+ const entryPath = path.normalize(match[1]);
444
+ if (entryPath.startsWith('..') || path.isAbsolute(entryPath)) {
445
+ return res.status(400).json({ error: 'Invalid zip entry: ' + match[1] });
446
+ }
447
+ const target = path.join(destDir, entryPath);
448
+ if (!target.startsWith(destDir + path.sep) && target !== destDir) {
449
+ return res.status(400).json({ error: 'Zip entry escapes destination directory' });
450
+ }
451
+ }
452
+ }
453
+ try {
454
+ execSync(`unzip -o "${p}" -d "${destDir}"`, { stdio: 'pipe' });
455
+ } catch (e) {
456
+ return res.status(500).json({ error: e.message });
457
+ }
458
+ res.json({ success: true, dir: destDir });
459
+ } catch (e) {
460
+ res.status(500).json({ error: e.message });
461
+ }
462
+ });
463
+
464
+ app.get('/api/files/read', checkPin, async (req, res) => {
465
+ try {
466
+ const p = resolvePath(req.query.path);
467
+ const [content, st] = await Promise.all([
468
+ fsPromises.readFile(p, 'utf8'),
469
+ fsPromises.stat(p)
470
+ ]);
471
+ res.json({ content, length: st.size });
472
+ } catch (e) {
473
+ res.status(500).json({ error: e.message });
474
+ }
475
+ });
476
+
477
+ app.post('/api/files/write', checkPin, async (req, res) => {
478
+ try {
479
+ if (!req.body.path) {
480
+ console.warn('POST /api/files/write 400 — body requires { path, content }. Example: { "path": "/home/user/file.txt", "content": "hello world" }');
481
+ return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file>", "content": "<string>" }' });
482
+ }
483
+ const p = realPath(req.body.path);
484
+ await fsPromises.writeFile(p, req.body.content, 'utf8');
485
+ res.json({ success: true });
486
+ } catch (e) {
487
+ res.status(500).json({ error: e.message });
488
+ }
489
+ });
490
+
491
+ // Serve image files for inline viewing (not as download)
492
+ app.get('/api/files/image', checkPin, async (req, res) => {
493
+ try {
494
+ const p = resolvePath(req.query.path);
495
+ const mimeType = mimeLookup(p);
496
+ res.setHeader('Content-Type', mimeType);
497
+ res.setHeader('Cache-Control', 'private, max-age=3600');
498
+ const stream = fs.createReadStream(p);
499
+ stream.on('error', err => {
500
+ if (!res.headersSent) res.status(500).json({ error: err.message });
501
+ });
502
+ stream.pipe(res);
503
+ } catch (e) {
504
+ if (!res.headersSent) res.status(500).json({ error: e.message });
505
+ }
506
+ });
507
+
508
+ app.get('/api/files/download', checkPin, async (req, res) => {
509
+ try {
510
+ if (!req.query.path) {
511
+ console.warn('GET /api/files/download 400 — query param ?path= is required. Example: GET /api/files/download?path=/home/user/file.txt');
512
+ return res.status(400).json({ error: 'path is required', usage: 'GET /api/files/download?path=<path>' });
513
+ }
514
+ const p = realPath(req.query.path);
515
+ const st = await fsPromises.stat(p);
516
+ if (st.isDirectory()) {
517
+ res.setHeader('Content-Type', 'application/zip');
518
+ res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
519
+ // Stream zip to response via system zip command
520
+ const zipProc = spawn('zip', ['-r', '-6', '-', path.basename(p)], { cwd: path.dirname(p), stdio: ['ignore', 'pipe', 'pipe'] });
521
+ zipProc.stdout.pipe(res);
522
+ zipProc.on('error', err => {
523
+ if (!res.headersSent) res.status(500).json({ error: err.message });
524
+ });
525
+ zipProc.on('close', code => { if (code !== 0 && !res.headersSent) res.status(500).json({ error: 'zip failed' }); });
526
+ return;
527
+ } else {
528
+ const mimeType = mimeLookup(p);
529
+ res.setHeader('Content-Type', mimeType);
530
+ res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}"`);
531
+ const stream = fs.createReadStream(p);
532
+ stream.on('error', err => {
533
+ if (!res.headersSent) res.status(500).json({ error: err.message });
534
+ });
535
+ stream.pipe(res);
536
+ }
537
+ } catch (e) {
538
+ if (!res.headersSent) res.status(500).json({ error: e.message });
539
+ }
540
+ });
541
+
542
+ // Upload with multer disk storage – destination resolved per-request
543
+ app.post('/api/files/upload', checkPin, (req, res) => {
544
+ let destDir;
545
+ try {
546
+ destDir = realPath(req.query.path || WORKSPACE_ROOT);
547
+ } catch (e) {
548
+ return res.status(403).json({ error: e.message });
549
+ }
550
+ const storage = multer.diskStorage({
551
+ destination: (req, file, cb) => {
552
+ try {
553
+ const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
554
+ const finalDest = path.join(destDir, safeName);
555
+ if (!finalDest.startsWith(destDir + path.sep) && finalDest !== destDir) {
556
+ return cb(new Error('Invalid upload destination'));
557
+ }
558
+ const subPath = path.dirname(finalDest);
559
+ fs.mkdirSync(subPath, { recursive: true });
560
+ cb(null, subPath);
561
+ } catch (err) {
562
+ cb(err);
563
+ }
564
+ },
565
+ filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_'))
566
+ });
567
+ const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024, files: 100 } }).array('files');
568
+ upload(req, res, err => {
569
+ if (err) return res.status(500).json({ error: err.message });
570
+ res.json({ success: true, count: Array.isArray(req.files) ? req.files.length : 0 });
571
+ });
572
+ });
573
+
574
+ // ── File stat / metadata ──────────────────────────────────────────────
575
+ app.get('/api/files/stat', checkPin, async (req, res) => {
576
+ try {
577
+ if (!req.query.path) {
578
+ console.warn('GET /api/files/stat 400 — query param ?path= is required');
579
+ return res.status(400).json({ error: 'path is required', usage: 'GET /api/files/stat?path=<path>' });
580
+ }
581
+ const p = realPath(req.query.path);
582
+ const st = await fsPromises.stat(p);
583
+ let lst = null;
584
+ try { lst = await fsPromises.lstat(p); } catch {}
585
+ const stat = {
586
+ path: p, name: path.basename(p),
587
+ size: st.size, blocks: st.blocks,
588
+ mode: st.mode.toString(8).slice(-3),
589
+ permissions: (lst || st).mode.toString(8).slice(-3),
590
+ uid: st.uid, gid: st.gid,
591
+ atime: st.atime, mtime: st.mtime, ctime: st.ctime, birthtime: st.birthtime,
592
+ isFile: st.isFile(), isDirectory: st.isDirectory(),
593
+ isSymlink: lst ? lst.isSymbolicLink() : false,
594
+ isSocket: st.isSocket(), isFIFO: st.isFIFO(),
595
+ };
596
+ try {
597
+ stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
598
+ } catch { stat.owner = String(st.uid); }
599
+ try {
600
+ stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
601
+ } catch { stat.group = String(st.gid); }
602
+ try {
603
+ const symlink = lst && lst.isSymbolicLink() ? await fsPromises.readlink(p) : null;
604
+ if (symlink) stat.linkTarget = symlink;
605
+ } catch {}
606
+ res.json(stat);
607
+ } catch (e) {
608
+ res.status(500).json({ error: e.message });
609
+ }
610
+ });
611
+
612
+ // ── Folder size ──────────────────────────────────────────────────────
613
+ app.get('/api/files/size', checkPin, async (req, res) => {
614
+ try {
615
+ if (!req.query.path) {
616
+ return res.status(400).json({ error: 'path is required', usage: 'GET /api/files/size?path=<dir>' });
617
+ }
618
+ const p = realPath(req.query.path);
619
+ const st = await fsPromises.stat(p);
620
+ if (!st.isDirectory()) {
621
+ return res.json({ path: p, size: st.size, isDir: false });
622
+ }
623
+ const out = execFileSync('du', ['-sb', p], { encoding: 'utf8', stdio: 'pipe', timeout: 30000 });
624
+ const size = parseInt(out.split('\t')[0], 10);
625
+ res.json({ path: p, size, isDir: true });
626
+ } catch (e) {
627
+ res.status(500).json({ error: e.message });
628
+ }
629
+ });
630
+
631
+ // ── Batch delete ──────────────────────────────────────────────────────
632
+ app.post('/api/files/batch-delete', checkPin, async (req, res) => {
633
+ try {
634
+ if (!Array.isArray(req.body.paths) || req.body.paths.length === 0) {
635
+ console.warn('POST /api/files/batch-delete 400 — body requires { paths: [...] }');
636
+ return res.status(400).json({ error: 'paths array is required', usage: 'POST JSON { "paths": ["<path1>", "<path2>", ...] }' });
637
+ }
638
+ const results = [];
639
+ for (const raw of req.body.paths) {
640
+ const p = realPath(raw);
641
+ try {
642
+ const st = await fsPromises.stat(p);
643
+ if (st.isDirectory()) await fsPromises.rm(p, { recursive: true, force: true });
644
+ else await fsPromises.unlink(p);
645
+ results.push({ path: raw, success: true });
646
+ } catch (e) {
647
+ results.push({ path: raw, success: false, error: e.message });
648
+ }
649
+ }
650
+ res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
651
+ } catch (e) {
652
+ res.status(500).json({ error: e.message });
653
+ }
654
+ });
655
+
656
+ // ── Batch copy ────────────────────────────────────────────────────────
657
+ async function handleBatchCopyMove(req, res, isMove) {
658
+ if (!Array.isArray(req.body.sources) || req.body.sources.length === 0 || !req.body.destination) {
659
+ return res.status(400).json({ error: 'sources array and destination are required', usage: 'POST JSON { "sources": ["<src1>", ...], "destination": "<dir>", "conflict": "replace|skip|keep_both|merge|cancel" }' });
660
+ }
661
+ const conflict = req.body.conflict || 'replace';
662
+ const destDir = resolvePath(req.body.destination);
663
+ const results = [];
664
+ for (const raw of req.body.sources) {
665
+ const src = realPath(raw);
666
+ try {
667
+ const baseName = path.basename(src);
668
+ const dst = path.join(destDir, baseName);
669
+ const result = await resolveCopyMove(src, dst, conflict, isMove);
670
+ results.push({ path: raw, success: true, ...result });
671
+ } catch (e) {
672
+ results.push({ path: raw, success: false, error: e.message });
673
+ }
674
+ }
675
+ res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
676
+ }
677
+
678
+ app.post('/api/files/batch-copy', checkPin, (req, res) => handleBatchCopyMove(req, res, false));
679
+ app.post('/api/files/batch-move', checkPin, (req, res) => handleBatchCopyMove(req, res, true));
680
+
681
+ // ── Change permissions (chmod) ─────────────────────────────────────────
682
+ app.post('/api/files/chmod', checkPin, async (req, res) => {
683
+ try {
684
+ if (!req.body.path || !req.body.mode) {
685
+ console.warn('POST /api/files/chmod 400 — body requires { path, mode }. Example: { "path": "/home/user/file.sh", "mode": "755" }');
686
+ return res.status(400).json({ error: 'path and mode are required', usage: 'POST JSON { "path": "<path>", "mode": "<octal_perms>" }' });
687
+ }
688
+ const p = realPath(req.body.path);
689
+ if (!/^[0-7]{3,4}$/.test(req.body.mode)) return res.status(400).json({ error: 'mode must be a 3-4 digit octal number (e.g. 755, 644, 1777)' });
690
+ const mode = parseInt(req.body.mode, 8);
691
+ await fsPromises.chmod(p, mode);
692
+ res.json({ success: true, mode: req.body.mode });
693
+ } catch (e) {
694
+ res.status(500).json({ error: e.message });
695
+ }
696
+ });
697
+
698
+ // ── Create symlink ────────────────────────────────────────────────────
699
+ app.post('/api/files/symlink', checkPin, async (req, res) => {
700
+ try {
701
+ if (!req.body.target || !req.body.linkPath) {
702
+ console.warn('POST /api/files/symlink 400 — body requires { target, linkPath }. Example: { "target": "/real/file.txt", "linkPath": "/home/user/link.txt" }');
703
+ return res.status(400).json({ error: 'target and linkPath are required', usage: 'POST JSON { "target": "<existing_path>", "linkPath": "<symlink_path>" }' });
704
+ }
705
+ const target = realPath(req.body.target);
706
+ const linkPath = realPath(req.body.linkPath);
707
+ await fsPromises.mkdir(path.dirname(linkPath), { recursive: true });
708
+ await fsPromises.symlink(target, linkPath);
709
+ res.json({ success: true, target, linkPath });
710
+ } catch (e) {
711
+ res.status(500).json({ error: e.message });
712
+ }
713
+ });
714
+
715
+ // ── Full-text content search ──────────────────────────────────────────
716
+ app.post('/api/files/search-content', rateLimiter, checkPin, async (req, res) => {
717
+ try {
718
+ if (!req.body.query || !req.body.path) {
719
+ console.warn('POST /api/files/search-content 400 — body requires { query, path }. Example: { "query": "TODO", "path": "/home/user/project", "pattern": "string" }');
720
+ return res.status(400).json({ error: 'query and path are required', usage: 'POST JSON { "query": "<text_or_regex>", "path": "<dir>", "pattern": "string|regex", "maxResults": 50, "maxDepth": 4 }' });
721
+ }
722
+ const searchDir = resolvePath(req.body.path);
723
+ const query = req.body.query;
724
+ const isRegex = req.body.pattern === 'regex';
725
+ const maxResults = Math.min(req.body.maxResults || 50, 200);
726
+ const maxDepth = Math.min(req.body.maxDepth || 4, 10);
727
+ const results = [];
728
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // skip files > 10MB
729
+ const BINARY_CHECK_LEN = 4096;
730
+
731
+ let regex;
732
+ if (isRegex) { try { regex = new RegExp(query, 'gi'); } catch { return res.status(400).json({ error: 'invalid regex pattern' }); } }
733
+
734
+ async function walkContentSearch(currentDir, depth) {
735
+ if (depth > maxDepth || results.length >= maxResults) return;
736
+ let entries;
737
+ try { entries = await fsPromises.readdir(currentDir, { withFileTypes: true }); } catch { return; }
738
+ const dirs = [];
739
+ for (const e of entries) {
740
+ if (results.length >= maxResults) break;
741
+ const full = path.join(currentDir, e.name);
742
+ try {
743
+ if (e.isDirectory()) {
744
+ dirs.push(e);
745
+ } else if (e.isFile() || e.isSymbolicLink()) {
746
+ const st = await fsPromises.stat(full);
747
+ if (st.size > MAX_FILE_SIZE) continue;
748
+ if (st.size === 0) continue;
749
+ // Check for binary
750
+ const fd = await fsPromises.open(full, 'r');
751
+ try {
752
+ const buf = Buffer.alloc(BINARY_CHECK_LEN);
753
+ const { bytesRead } = await fd.read(buf, 0, BINARY_CHECK_LEN, 0);
754
+ if (buf.slice(0, bytesRead).includes(0)) continue; // binary
755
+ } finally { await fd.close(); }
756
+ const content = await fsPromises.readFile(full, 'utf8');
757
+ const lines = content.split('\n');
758
+ const lowerQuery = query.toLowerCase();
759
+ for (let i = 0; i < lines.length && results.length < maxResults; i++) {
760
+ let match;
761
+ if (regex) {
762
+ regex.lastIndex = 0;
763
+ match = regex.exec(lines[i]);
764
+ } else {
765
+ const idx = lines[i].toLowerCase().indexOf(lowerQuery);
766
+ match = idx !== -1 ? { index: idx } : null;
767
+ }
768
+ if (match) {
769
+ results.push({ path: full, line: i + 1, column: match.index, content: lines[i].substring(0, 500) });
770
+ }
771
+ }
772
+ }
773
+ } catch {}
774
+ }
775
+ await Promise.all(dirs.map(d => walkContentSearch(path.join(currentDir, d.name), depth + 1)));
776
+ }
777
+
778
+ await walkContentSearch(searchDir, 0);
779
+ res.json({ results, count: results.length, query, path: searchDir });
780
+ } catch (e) {
781
+ res.status(500).json({ error: e.message });
782
+ }
783
+ });
784
+
785
+ // ── Batch zip (multiple sources) ──────────────────────────────────────
786
+ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
787
+ try {
788
+ if (!Array.isArray(req.body.sources) || req.body.sources.length === 0 || !req.body.destination) {
789
+ console.warn('POST /api/files/batch-zip 400 — body requires { sources: [...], destination: "<path>" }. Example: { "sources": ["/a", "/b"], "destination": "/home/user/archive.zip" }');
790
+ return res.status(400).json({ error: 'sources array and destination are required', usage: 'POST JSON { "sources": ["<path1>", ...], "destination": "<zip_path>" }' });
791
+ }
792
+ let dest = realPath(req.body.destination);
793
+ const resolved = req.body.sources.map(s => realPath(s));
794
+ // Prevent zipping workspace root
795
+ // Auto-rename if destination exists
796
+ let counter = 1;
797
+ const ext = '.zip';
798
+ const origDest = dest;
799
+ while (true) {
800
+ try { await fsPromises.access(dest); } catch { break; }
801
+ dest = origDest.replace(/(\.zip)?$/, ` (${counter})${ext}`);
802
+ counter++;
803
+ }
804
+ const zipDir = path.dirname(dest);
805
+ const zipTarget = path.basename(dest);
806
+ const zipArgs = resolved.map(s => {
807
+ const rel = path.relative(zipDir, s);
808
+ return `"${rel}"`;
809
+ });
810
+ execSync(`zip -r -6 "${zipTarget}" ${zipArgs.join(' ')}`, { cwd: zipDir, stdio: 'pipe' });
811
+ res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
812
+ } catch (e) {
813
+ res.status(500).json({ error: e.message });
814
+ }
815
+ });
816
+
817
+
818
+
819
+
820
+
821
+
822
+
823
+ // ── Log tail (SSE) ────────────────────────────────────────────────────
824
+ app.get('/api/files/tail', checkPin, async (req, res) => {
825
+ try {
826
+ if (!req.query.path) {
827
+ res.status(400).json({ error: 'path is required' });
828
+ return;
829
+ }
830
+ const p = realPath(req.query.path);
831
+ const lines = Math.min(parseInt(req.query.lines) || 50, 500);
832
+ const pollInterval = Math.max(500, parseInt(req.query.interval) || 2000);
833
+
834
+ const st = await fsPromises.stat(p);
835
+ if (st.isDirectory()) { res.status(400).json({ error: 'cannot tail a directory' }); return; }
836
+ if (st.size > 100 * 1024 * 1024) { res.status(413).json({ error: 'file too large to tail (max 100MB)' }); return; }
837
+
838
+ res.setHeader('Content-Type', 'text/event-stream');
839
+ res.setHeader('Cache-Control', 'no-cache');
840
+ res.setHeader('Connection', 'keep-alive');
841
+ res.setHeader('X-Accel-Buffering', 'no');
842
+ res.flushHeaders();
843
+
844
+ // Send initial content (last N lines)
845
+ const content = await fsPromises.readFile(p, 'utf8');
846
+ const allLines = content.split('\n');
847
+ const tailLines = allLines.slice(-lines);
848
+ res.write(`data: ${JSON.stringify({ type: 'init', lines: tailLines, total: allLines.length })}\n\n`);
849
+
850
+ // Poll for changes
851
+ let lastSize = content.length;
852
+ const timer = setInterval(async () => {
853
+ if (res.writableEnded) { clearInterval(timer); return; }
854
+ try {
855
+ const newSt = await fsPromises.stat(p);
856
+ if (newSt.size > lastSize) {
857
+ const fd = await fsPromises.open(p, 'r');
858
+ const buf = Buffer.alloc(newSt.size - lastSize);
859
+ await fd.read(buf, 0, buf.length, lastSize);
860
+ await fd.close();
861
+ lastSize = newSt.size;
862
+ const newLines = buf.toString('utf8');
863
+ res.write(`data: ${JSON.stringify({ type: 'data', lines: newLines })}\n\n`);
864
+ } else if (newSt.size < lastSize) {
865
+ // File was truncated — re-read
866
+ lastSize = 0;
867
+ }
868
+ } catch {}
869
+ }, pollInterval);
870
+
871
+ req.on('close', () => { clearInterval(timer); });
872
+ } catch (e) {
873
+ if (!res.headersSent) res.status(500).json({ error: e.message });
874
+ }
875
+ });
876
+
877
+ // ── Network info ──────────────────────────────────────────────────────
878
+ app.get('/api/system/network', checkPin, async (req, res) => {
879
+ try {
880
+ const interfaces = os.networkInterfaces();
881
+ const result = [];
882
+ for (const [name, addrs] of Object.entries(interfaces)) {
883
+ if (!addrs) continue;
884
+ for (const addr of addrs) {
885
+ result.push({ interface: name, family: addr.family, address: addr.address, netmask: addr.netmask, mac: addr.mac, internal: addr.internal, cidr: addr.cidr });
886
+ }
887
+ }
888
+ let gateway = null, dns = null, listenPorts = [];
889
+ try {
890
+ if (os.platform() === 'win32') {
891
+ const route = execFileSync('powershell.exe', ['-Command', '(Get-NetRoute -DestinationPrefix "0.0.0.0/0").NextHop'], { encoding: 'utf8', stdio: 'pipe' }).trim();
892
+ gateway = route.split('\n')[0].trim() || null;
893
+ const dnsOut = execFileSync('powershell.exe', ['-Command', '(Get-DnsClientServerAddress -AddressFamily IPv4).ServerAddresses'], { encoding: 'utf8', stdio: 'pipe' }).trim();
894
+ dns = dnsOut.split('\n').filter(Boolean);
895
+ } else {
896
+ const route = execFileSync('sh', ['-c', "ip route | grep default | head -1 | awk '{print $3}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
897
+ gateway = route || null;
898
+ const resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
899
+ dns = resolv.split('\n').filter(Boolean);
900
+ }
901
+ } catch {}
902
+ try {
903
+ if (os.platform() === 'win32') {
904
+ const out = execFileSync('powershell.exe', ['-Command', 'netstat -ano | findstr LISTEN'], { encoding: 'utf8', stdio: 'pipe' }).trim();
905
+ listenPorts = out.split('\n').filter(Boolean).map(l => {
906
+ const m = l.match(/:(\d+)\s+/);
907
+ return m ? { port: parseInt(m[1]), process: l.split(/\s+/).pop() } : null;
908
+ }).filter(Boolean);
909
+ } else {
910
+ const out = execFileSync('sh', ['-c', "ss -tlnp 2>/dev/null | tail -n+2"], { encoding: 'utf8', stdio: 'pipe' }).trim();
911
+ listenPorts = out.split('\n').filter(Boolean).map(l => {
912
+ const parts = l.split(/\s+/);
913
+ const addr = parts[3] || '';
914
+ const port = parseInt(addr.split(':').pop());
915
+ const proc = parts[5] || '';
916
+ const m = proc.match(/users:\(\("(.+?)"/);
917
+ return { port, address: addr, process: m ? m[1] : '' };
918
+ }).filter(p => !isNaN(p.port));
919
+ }
920
+ } catch {}
921
+ res.json({ interfaces: result, gateway, dns, ports: listenPorts });
922
+ } catch (e) {
923
+ res.status(500).json({ error: e.message });
924
+ }
925
+ });
926
+
927
+
928
+
929
+ // ── Clipboard (server-side staging) ───────────────────────────────────
930
+ let clipboard = { sources: [], action: null, createdAt: null };
931
+
932
+ app.get('/api/clipboard', checkPin, (req, res) => {
933
+ res.json({ clipboard });
934
+ });
935
+
936
+ app.post('/api/clipboard', checkPin, async (req, res) => {
937
+ try {
938
+ if (!Array.isArray(req.body.sources) || req.body.sources.length === 0) {
939
+ return res.status(400).json({ error: 'sources array is required' });
940
+ }
941
+ const action = req.body.action === 'cut' ? 'cut' : 'copy';
942
+ clipboard = {
943
+ sources: req.body.sources.map(s => realPath(s)),
944
+ action,
945
+ createdAt: new Date().toISOString()
946
+ };
947
+ res.json({ clipboard, count: clipboard.sources.length });
948
+ } catch (e) {
949
+ res.status(500).json({ error: e.message });
950
+ }
951
+ });
952
+
953
+ app.post('/api/clipboard/paste', checkPin, async (req, res) => {
954
+ try {
955
+ if (!req.body.destination) return res.status(400).json({ error: 'destination is required' });
956
+ if (!clipboard.sources.length) return res.status(400).json({ error: 'clipboard is empty' });
957
+ const destDir = resolvePath(req.body.destination);
958
+ const conflict = req.body.conflict || 'replace';
959
+ const results = [];
960
+ for (const src of clipboard.sources) {
961
+ try {
962
+ const baseName = path.basename(src);
963
+ const dst = path.join(destDir, baseName);
964
+ const result = await resolveCopyMove(src, dst, conflict, clipboard.action === 'cut');
965
+ results.push({ path: src, success: true, ...result });
966
+ } catch (e) {
967
+ results.push({ path: src, success: false, error: e.message });
968
+ }
969
+ }
970
+ if (clipboard.action === 'cut') clipboard = { sources: [], action: null, createdAt: null };
971
+ res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length, pasteAction: clipboard.action });
972
+ } catch (e) {
973
+ res.status(500).json({ error: e.message });
974
+ }
975
+ });
976
+
977
+ app.delete('/api/clipboard', checkPin, (req, res) => {
978
+ clipboard = { sources: [], action: null, createdAt: null };
979
+ res.json({ success: true });
980
+ });
981
+
982
+ // ── Session persistence via tmux ──────────────────────────────────────
983
+ const TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
984
+
985
+ function isValidPID(pid) {
986
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0;
987
+ }
988
+
989
+ // Clean up dead tmux sessions from previous runs on startup
990
+ function cleanupOrphanTmuxSessions() {
991
+ if (!TMUX) return;
992
+ try {
993
+ const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
994
+ const sessions = out.split('\n').filter(s => s.startsWith('wt-'));
995
+ for (const s of sessions) {
996
+ try {
997
+ const clients = execFileSync(TMUX, ['list-clients', '-t', s], { stdio: 'pipe', encoding: 'utf8' }).trim();
998
+ if (!clients) {
999
+ execFileSync(TMUX, ['kill-session', '-t', s], { stdio: 'ignore' });
1000
+ }
1001
+ } catch {}
1002
+ }
1003
+ } catch {}
1004
+ }
1005
+
1006
+ function tmuxSessionExists(name) {
1007
+ try { execFileSync(TMUX, ['has-session', '-t', name], { stdio: 'ignore' }); return true; } catch { return false; }
1008
+ }
1009
+
1010
+ app.get('/api/sessions', checkPin, (req, res) => {
1011
+ if (!TMUX) return res.json({ tmux: false, sessions: [] });
1012
+ try {
1013
+ const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1014
+ const sessions = out.split('\n')
1015
+ .filter(s => s.startsWith('wt-'))
1016
+ .map(s => ({ id: s.replace(/^wt-/, ''), name: s }));
1017
+ res.json({ tmux: true, sessions });
1018
+ } catch {
1019
+ res.json({ tmux: true, sessions: [] });
1020
+ }
1021
+ });
1022
+
1023
+ app.delete('/api/sessions/:id', checkPin, (req, res) => {
1024
+ if (!TMUX) return res.json({ success: false });
1025
+ const name = 'wt-' + req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
1026
+ try { execFileSync(TMUX, ['kill-session', '-t', name], { stdio: 'ignore' }); } catch {}
1027
+ res.json({ success: true });
1028
+ });
1029
+
1030
+ // ── WebSocket terminal ────────────────────────────────────────────────
1031
+ // Binary protocol (fast, no JSON per keystroke):
1032
+ // Server → Client: [type:1B][payload]
1033
+ // 0x00 = terminal data (UTF-8)
1034
+ // 0x01 = exit (1B exit code)
1035
+ // 0x02 = error (UTF-8 message)
1036
+ // Client → Server:
1037
+ // 0x00 = input (UTF-8) – max 64KB per message
1038
+ // 0x01 = resize (4B: cols uint16LE, rows uint16LE)
1039
+ // 0x02 = ping (no payload)
1040
+
1041
+ const ALLOWED_WS_ORIGINS = new Set();
1042
+ function getWsOrigin(req) {
1043
+ return (req.headers['origin'] || '').replace(/\/$/, '');
1044
+ }
1045
+
1046
+ wss.on('connection', (ws, req) => {
1047
+ // Origin check to prevent Cross-Site WebSocket Hijacking
1048
+ const origin = getWsOrigin(req);
1049
+ if (origin) {
1050
+ const host = req.headers['host'] || '';
1051
+ const allowedLocal = origin === `http://${host}` || origin === `https://${host}` || origin === `http://localhost` || origin === `https://localhost`;
1052
+ if (!allowedLocal && !ALLOWED_WS_ORIGINS.has(origin)) {
1053
+ ws.close(1008, 'Origin not allowed');
1054
+ return;
1055
+ }
1056
+ }
1057
+
1058
+ const url = new URL(req.url, `http://localhost`);
1059
+ const token = url.searchParams.get('token');
1060
+
1061
+ if (PIN && token !== PIN) { ws.close(1008, 'Unauthorized'); return; }
1062
+
1063
+ const cols = parseInt(url.searchParams.get('cols')) || 80;
1064
+ const rows = parseInt(url.searchParams.get('rows')) || 24;
1065
+ let cwd;
1066
+ try {
1067
+ cwd = realPath(url.searchParams.get('cwd') || WORKSPACE_ROOT);
1068
+ } catch {
1069
+ cwd = WORKSPACE_ROOT;
1070
+ }
1071
+ const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
1072
+
1073
+ const sessionEnv = (() => {
1074
+ const safe = { TERM: 'xterm-256color', COLORTERM: 'truecolor', HOME: process.env.HOME || '', USER: process.env.USER || '', PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', LANG: process.env.LANG || 'C.UTF-8', SHELL: SHELL };
1075
+ if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
1076
+ return safe;
1077
+ })();
1078
+
1079
+ const send = (type, payload) => {
1080
+ if (ws.readyState !== WebSocket.OPEN) return;
1081
+ let buf;
1082
+ if (Buffer.isBuffer(payload)) {
1083
+ buf = Buffer.concat([Buffer.from([type]), payload]);
1084
+ } else {
1085
+ buf = Buffer.from([type]);
1086
+ if (payload) buf = Buffer.concat([buf, Buffer.from(payload, 'utf8')]);
1087
+ }
1088
+ ws.send(buf);
1089
+ };
1090
+
1091
+ let proc;
1092
+ try {
1093
+ if (TMUX && sessionId) {
1094
+ const tmuxName = 'wt-' + sessionId;
1095
+ const exists = tmuxSessionExists(tmuxName);
1096
+
1097
+ if (exists) {
1098
+ try { execFileSync(TMUX, ['resize-window', '-t', tmuxName, '-x', String(cols), '-y', String(rows)], { stdio: 'ignore' }); } catch {}
1099
+ proc = pty.spawn(TMUX, ['attach-session', '-t', tmuxName], {
1100
+ name: 'xterm-256color', cols, rows, cwd,
1101
+ env: sessionEnv
1102
+ });
1103
+ } else {
1104
+ proc = pty.spawn(TMUX, ['new-session', '-s', tmuxName], {
1105
+ name: 'xterm-256color', cols, rows, cwd,
1106
+ env: { ...sessionEnv, SHELL }
1107
+ });
1108
+ }
1109
+ } else {
1110
+ proc = pty.spawn(SHELL, [], {
1111
+ name: 'xterm-256color', cols, rows, cwd,
1112
+ env: sessionEnv
1113
+ });
1114
+ }
1115
+ } catch (e) {
1116
+ send(0x02, `Failed to spawn shell: ${e.message}\r\n`);
1117
+ ws.close();
1118
+ return;
1119
+ }
1120
+
1121
+ proc.onData(data => send(0x00, data));
1122
+
1123
+ proc.onExit(() => {
1124
+ if (!TMUX || !sessionId) send(0x01, Buffer.from([0]));
1125
+ ws.close();
1126
+ });
1127
+
1128
+ ws.isAlive = true;
1129
+ const pingInterval = setInterval(() => {
1130
+ if (!ws.isAlive) { clearInterval(pingInterval); ws.terminate(); return; }
1131
+ ws.isAlive = false;
1132
+ ws.ping();
1133
+ }, 30000);
1134
+ ws.on('pong', () => { ws.isAlive = true; });
1135
+
1136
+ ws.on('message', raw => {
1137
+ try {
1138
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
1139
+ if (buf.length < 1) return;
1140
+ const type = buf[0];
1141
+ if (type === 0x00) {
1142
+ // Limit input to 64KB per message
1143
+ const payload = buf.slice(1, Math.min(buf.length, 65537));
1144
+ proc.write(payload.toString('utf8'));
1145
+ } else if (type === 0x01 && buf.length >= 5) {
1146
+ const c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
1147
+ proc.resize(Math.max(2, c), Math.max(2, r));
1148
+ if (TMUX && sessionId) {
1149
+ try { execFileSync(TMUX, ['resize-window', '-t', 'wt-' + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {}
1150
+ }
1151
+ }
1152
+ } catch (e) {
1153
+ console.error('WS message error:', e.message);
1154
+ }
1155
+ });
1156
+
1157
+ const cleanup = () => {
1158
+ clearInterval(pingInterval);
1159
+ try { proc.kill(); } catch {}
1160
+ };
1161
+ ws.on('close', cleanup);
1162
+ ws.on('error', cleanup);
1163
+ });
1164
+
1165
+
1166
+
1167
+ // ── File search (fuzzy finder) ──────────────────────────────────────
1168
+ app.get('/api/search', rateLimiter, checkPin, async (req, res) => {
1169
+ const q = (req.query.q || '').trim().toLowerCase();
1170
+ const dir = req.query.path || WORKSPACE_ROOT;
1171
+ if (!q || q.length < 1) return res.json({ results: [] });
1172
+
1173
+ try {
1174
+ const searchDir = resolvePath(dir);
1175
+ const maxResults = 50;
1176
+ const results = [];
1177
+ const maxDepth = 4;
1178
+
1179
+ await asyncSafeWalk(searchDir, 0, maxDepth, q, results, maxResults);
1180
+ res.json({ results });
1181
+ } catch (e) {
1182
+ res.status(500).json({ error: e.message });
1183
+ }
1184
+ });
1185
+
1186
+ function spawnRead(cmd, args) {
1187
+ return new Promise((resolve, reject) => {
1188
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
1189
+ let stdout = '', stderr = '';
1190
+ child.stdout.on('data', d => stdout += d.toString());
1191
+ child.stderr.on('data', d => stderr += d.toString());
1192
+ child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error(stderr)));
1193
+ child.on('error', reject);
1194
+ });
1195
+ }
1196
+
1197
+ // ── System stats ────────────────────────────────────────────────────
1198
+ app.get('/api/system', checkPin, async (req, res) => {
1199
+ const cpus = os.cpus();
1200
+ const cpuModel = cpus.length > 0 ? cpus[0].model : 'unknown';
1201
+ const cpuCount = cpus.length;
1202
+ const loadAvg = os.loadavg();
1203
+
1204
+ let cpuUsage = 0;
1205
+ try {
1206
+ const getCpuUsageFromCpus = () => {
1207
+ const currentCpus = os.cpus();
1208
+ let totalIdle = 0, totalTick = 0;
1209
+ currentCpus.forEach(cpu => {
1210
+ for (const type in cpu.times) {
1211
+ totalTick += cpu.times[type];
1212
+ }
1213
+ totalIdle += cpu.times.idle;
1214
+ });
1215
+ return { idle: totalIdle / currentCpus.length, total: totalTick / currentCpus.length };
1216
+ };
1217
+ const c1 = getCpuUsageFromCpus();
1218
+ await new Promise(r => setTimeout(r, 100));
1219
+ const c2 = getCpuUsageFromCpus();
1220
+ const idleDiff = c2.idle - c1.idle;
1221
+ const totalDiff = c2.total - c1.total;
1222
+ cpuUsage = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 100) : 0;
1223
+ } catch {}
1224
+
1225
+ const totalMem = os.totalmem();
1226
+ const freeMem = os.freemem();
1227
+ const usedMem = totalMem - freeMem;
1228
+ const memPercent = Math.round((usedMem / totalMem) * 100);
1229
+
1230
+ let disk = [];
1231
+ try {
1232
+ if (os.platform() === 'win32') {
1233
+ const psOut = await spawnRead('powershell.exe', ['-Command', "Get-CimInstance -ClassName Win32_LogicalDisk | Where-Object {$_.DriveType -eq 3} | Select-Object DeviceID, Size, FreeSpace | ConvertTo-Json"]);
1234
+ const data = JSON.parse(psOut);
1235
+ const list = Array.isArray(data) ? data : [data];
1236
+ disk = list.map(d => {
1237
+ const sizeBytes = d.Size || 0;
1238
+ const freeBytes = d.FreeSpace || 0;
1239
+ const usedBytes = sizeBytes - freeBytes;
1240
+ const sizeGB = (sizeBytes / (1024**3)).toFixed(1) + ' GB';
1241
+ const usedGB = (usedBytes / (1024**3)).toFixed(1) + ' GB';
1242
+ const availGB = (freeBytes / (1024**3)).toFixed(1) + ' GB';
1243
+ const usePercent = sizeBytes > 0 ? Math.round((usedBytes / sizeBytes) * 100) + '%' : '0%';
1244
+ return {
1245
+ filesystem: d.DeviceID,
1246
+ size: sizeGB,
1247
+ used: usedGB,
1248
+ avail: availGB,
1249
+ usePercent,
1250
+ mounted: d.DeviceID
1251
+ };
1252
+ });
1253
+ } else {
1254
+ const dfOut = await spawnRead('df', ['-h', '/']);
1255
+ const lines = dfOut.trim().split('\n');
1256
+ if (lines.length > 1) {
1257
+ const parts = lines[1].split(/\s+/);
1258
+ disk = [{ filesystem: parts[0], size: parts[1], used: parts[2], avail: parts[3], usePercent: parts[4], mounted: parts[5] }];
1259
+ }
1260
+ }
1261
+ } catch {}
1262
+
1263
+ let processes = [];
1264
+ try {
1265
+ if (os.platform() === 'win32') {
1266
+ const psOut = await spawnRead('powershell.exe', ['-Command', "Get-Process | Where-Object {$_.CPU -ne $null} | Sort-Object CPU -Descending | Select-Object -First 15 | ForEach-Object { [PSCustomObject]@{ user = 'system'; pid = $_.Id.ToString(); cpu = [Math]::Round($_.CPU, 1).ToString(); mem = [Math]::Round($_.WorkingSet / 1MB, 1).ToString() + 'MB'; cmd = $_.ProcessName } } | ConvertTo-Json"]);
1267
+ const data = JSON.parse(psOut);
1268
+ const list = Array.isArray(data) ? data : [data];
1269
+ processes = list.map(p => ({
1270
+ user: p.user || 'system',
1271
+ pid: p.pid || '',
1272
+ cpu: p.cpu || '',
1273
+ mem: p.mem || '',
1274
+ cmd: p.cmd || ''
1275
+ }));
1276
+ } else {
1277
+ const psOut = await spawnRead('ps', ['-eo', 'pid,user,%cpu,%mem,cmd', '--no-headers', '--sort=-%cpu']);
1278
+ const lines = psOut.trim().split('\n').slice(0, 15);
1279
+ for (const line of lines) {
1280
+ const m = line.match(/^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)/);
1281
+ if (m) {
1282
+ processes.push({ user: m[2], pid: m[1], cpu: m[3], mem: m[4], cmd: m[5] });
1283
+ }
1284
+ }
1285
+ }
1286
+ } catch {}
1287
+
1288
+ res.json({
1289
+ hostname: os.hostname(),
1290
+ platform: os.platform(),
1291
+ uptime: os.uptime(),
1292
+ cpu: { model: cpuModel, count: cpuCount, usage: cpuUsage, loadAvg },
1293
+ memory: { total: totalMem, free: freeMem, used: usedMem, percent: memPercent },
1294
+ disk,
1295
+ processes
1296
+ });
1297
+ });
1298
+
1299
+ // ── Cloudflared tunnel management ──────────────────────────────────
1300
+ const tunnels = new Map();
1301
+ const TUNNEL_FILE = path.join(__dirname, '.tunnels.json');
1302
+ const TUNNEL_URL_FILE = path.join(__dirname, 'tunnel-url.txt');
1303
+
1304
+ function isCloudflaredProcess(pid) {
1305
+ if (!isValidPID(pid)) return false;
1306
+ try {
1307
+ if (os.platform() === 'win32') {
1308
+ const stdout = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1309
+ return stdout.toLowerCase().includes('cloudflared');
1310
+ } else {
1311
+ const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
1312
+ return cmdline.toLowerCase().includes('cloudflared');
1313
+ }
1314
+ } catch {
1315
+ return false;
1316
+ }
1317
+ }
1318
+
1319
+ function saveTunnels() {
1320
+ const arr = Array.from(tunnels.entries()).map(([id, t]) => ({
1321
+ id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, pid: t.pid
1322
+ }));
1323
+ try { fs.writeFileSync(TUNNEL_FILE, JSON.stringify(arr, null, 2)); } catch {}
1324
+ updateTunnelUrlFile();
1325
+ }
1326
+
1327
+ function updateTunnelUrlFile() {
1328
+ const active = Array.from(tunnels.values()).map(t => t.tunnelUrl).filter(Boolean);
1329
+ try {
1330
+ if (active.length > 0) {
1331
+ fs.writeFileSync(TUNNEL_URL_FILE, active.join('\n') + '\n');
1332
+ } else {
1333
+ fs.writeFileSync(TUNNEL_URL_FILE, '');
1334
+ }
1335
+ } catch {}
1336
+ }
1337
+
1338
+ function loadTunnels() {
1339
+ try {
1340
+ const arr = JSON.parse(fs.readFileSync(TUNNEL_FILE, 'utf8'));
1341
+ for (const t of arr) {
1342
+ if (isCloudflaredProcess(t.pid)) {
1343
+ tunnels.set(t.id, { proc: null, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, pid: t.pid });
1344
+ }
1345
+ }
1346
+ } catch {}
1347
+ }
1348
+
1349
+ async function verifyTunnelUrl(url, retries = 3) {
1350
+ for (let i = 0; i < retries; i++) {
1351
+ try {
1352
+ const ac = new AbortController();
1353
+ const timer = setTimeout(() => ac.abort(), 5000);
1354
+ const res = await fetch(url, { method: 'HEAD', signal: ac.signal, redirect: 'follow' });
1355
+ clearTimeout(timer);
1356
+ if (res.ok) return true;
1357
+ } catch {}
1358
+ if (i < retries - 1) await new Promise(r => setTimeout(r, 2000));
1359
+ }
1360
+ return false;
1361
+ }
1362
+
1363
+ function restartTunnel(id, entry) {
1364
+ if (!entry.localUrl) return;
1365
+ try { if (entry.proc) entry.proc.kill('SIGTERM'); } catch {}
1366
+ try { if (entry.pid && isCloudflaredProcess(entry.pid)) process.kill(entry.pid, 'SIGTERM'); } catch {}
1367
+ tunnels.delete(id);
1368
+
1369
+ const url = entry.localUrl;
1370
+ const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1371
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
1372
+ });
1373
+ proc.unref();
1374
+
1375
+ const handler = data => {
1376
+ const text = data.toString();
1377
+ const m = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
1378
+ if (m) {
1379
+ const newUrl = m[0];
1380
+ const newId = newUrl.replace(/^https:\/\//, '').replace(/\.trycloudflare\.com$/, '');
1381
+ proc.stdout.removeAllListeners('data');
1382
+ proc.stderr.removeAllListeners('data');
1383
+ proc.stdout.resume();
1384
+ proc.stderr.resume();
1385
+ tunnels.set(newId, { proc, pid: proc.pid, localUrl: url, tunnelUrl: newUrl, createdAt: Date.now() });
1386
+ saveTunnels();
1387
+ updateTunnelUrlFile();
1388
+ console.log(` Tunnel restarted: ${newUrl} → ${url}`);
1389
+ }
1390
+ };
1391
+ proc.stdout.on('data', handler);
1392
+ proc.stderr.on('data', handler);
1393
+ proc.on('error', () => {});
1394
+ proc.on('exit', () => { proc.stdout.removeAllListeners('data'); proc.stderr.removeAllListeners('data'); });
1395
+ }
1396
+
1397
+ const TUNNEL_CHECK_INTERVAL = 30000;
1398
+ setInterval(async () => {
1399
+ for (const [id, entry] of tunnels) {
1400
+ const alive = entry.proc !== null || (entry.pid && isCloudflaredProcess(entry.pid));
1401
+ if (!alive) {
1402
+ console.log(` Tunnel ${id} dead — restarting…`);
1403
+ restartTunnel(id, entry);
1404
+ }
1405
+ }
1406
+ }, TUNNEL_CHECK_INTERVAL);
1407
+
1408
+ app.get('/api/tunnel', checkPin, async (req, res) => {
1409
+ const entries = Array.from(tunnels.entries());
1410
+ const results = await Promise.allSettled(entries.map(async ([id, t]) => {
1411
+ let alive = t.proc !== null;
1412
+ if (!alive && t.pid) { alive = isCloudflaredProcess(t.pid); }
1413
+ let targetAlive = false;
1414
+ if (alive) {
1415
+ try {
1416
+ const ac = new AbortController();
1417
+ const timer = setTimeout(() => ac.abort(), 2000);
1418
+ const proto = t.localUrl.startsWith('https') ? 'https' : 'http';
1419
+ if (proto === 'http' || proto === 'https') {
1420
+ await fetch(t.localUrl, { method: 'HEAD', signal: ac.signal });
1421
+ clearTimeout(timer);
1422
+ targetAlive = true;
1423
+ }
1424
+ } catch {}
1425
+ }
1426
+ let tunnelAlive = false;
1427
+ if (t.tunnelUrl) {
1428
+ try {
1429
+ const ac = new AbortController();
1430
+ const timer = setTimeout(() => ac.abort(), 3000);
1431
+ await fetch(t.tunnelUrl, { method: 'HEAD', signal: ac.signal });
1432
+ clearTimeout(timer);
1433
+ tunnelAlive = true;
1434
+ } catch {}
1435
+ }
1436
+ return { id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, alive, targetAlive, tunnelAlive };
1437
+ }));
1438
+ const tunnels_list = results.map(r => r.status === 'fulfilled' ? r.value : null).filter(Boolean);
1439
+ res.json({ tunnels: tunnels_list });
1440
+ });
1441
+
1442
+ app.post('/api/tunnel', checkPin, async (req, res) => {
1443
+ const { url } = req.body;
1444
+ if (!url) return res.status(400).json({ error: 'url required' });
1445
+
1446
+ try { execSync(os.platform() === 'win32' ? 'where cloudflared' : 'command -v cloudflared', { stdio: 'ignore' }); }
1447
+ catch { return res.status(500).json({ error: 'cloudflared not installed' }); }
1448
+
1449
+ const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1450
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
1451
+ });
1452
+ proc.unref();
1453
+ let tunnelUrl = null;
1454
+ const timeout = 15000;
1455
+
1456
+ const urlPromise = new Promise((resolve, reject) => {
1457
+ const handler = data => {
1458
+ const text = data.toString();
1459
+ const m = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
1460
+ if (m) {
1461
+ tunnelUrl = m[0];
1462
+ proc.stdout.removeAllListeners('data');
1463
+ proc.stderr.removeAllListeners('data');
1464
+ proc.stdout.resume();
1465
+ proc.stderr.resume();
1466
+ resolve(tunnelUrl);
1467
+ }
1468
+ };
1469
+ proc.stdout.on('data', handler);
1470
+ proc.stderr.on('data', handler);
1471
+ proc.on('error', err => reject(err));
1472
+ });
1473
+
1474
+ try {
1475
+ const result = await Promise.race([
1476
+ urlPromise,
1477
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
1478
+ ]);
1479
+ // Verify tunnel URL is actually reachable
1480
+ const urlOk = await verifyTunnelUrl(tunnelUrl);
1481
+ const id = tunnelUrl.replace(/^https:\/\//, '').replace(/\.trycloudflare\.com$/, '');
1482
+ tunnels.set(id, { proc, pid: proc.pid, localUrl: url, tunnelUrl, createdAt: Date.now() });
1483
+ saveTunnels();
1484
+ res.json({ success: true, id, url: tunnelUrl, verified: urlOk });
1485
+ } catch (e) {
1486
+ try { proc.kill(); } catch {}
1487
+ res.status(500).json({ error: e.message === 'timeout' ? 'Timed out waiting for tunnel URL' : e.message });
1488
+ }
1489
+ });
1490
+
1491
+ app.delete('/api/tunnel', checkPin, (req, res) => {
1492
+ const { id } = req.body;
1493
+ if (!id || !tunnels.has(id)) return res.status(404).json({ error: 'tunnel not found' });
1494
+ const entry = tunnels.get(id);
1495
+ try {
1496
+ if (entry.proc) {
1497
+ entry.proc.kill('SIGTERM');
1498
+ } else if (entry.pid && isCloudflaredProcess(entry.pid)) {
1499
+ process.kill(entry.pid, 'SIGTERM');
1500
+ }
1501
+ } catch {}
1502
+ tunnels.delete(id);
1503
+ saveTunnels();
1504
+ res.json({ success: true });
1505
+ });
1506
+
1507
+ // ─────────────────────────────────────────────────────────────────────
1508
+
1509
+ function cleanup() {
1510
+ for (const [id, entry] of tunnels) {
1511
+ try {
1512
+ if (entry.proc) entry.proc.kill('SIGTERM');
1513
+ else if (entry.pid && isCloudflaredProcess(entry.pid)) process.kill(entry.pid, 'SIGTERM');
1514
+ } catch {}
1515
+ }
1516
+ if (TMUX) {
1517
+ try {
1518
+ const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1519
+ const sessions = out.split('\n').filter(s => s.startsWith('wt-'));
1520
+ for (const s of sessions) {
1521
+ try { execFileSync(TMUX, ['kill-session', '-t', s], { stdio: 'ignore' }); } catch {}
1522
+ }
1523
+ } catch {}
1524
+ }
1525
+ }
1526
+
1527
+ function startServer(opts = {}) {
1528
+ const port = opts.port || PORT;
1529
+ const host = opts.host || HOST;
1530
+
1531
+ loadTunnels();
1532
+ cleanupOrphanTmuxSessions();
1533
+
1534
+ return new Promise((resolve) => {
1535
+ server.listen(port, host, () => {
1536
+ console.log(`\n WebTun running → http://localhost:${port}\n`);
1537
+ if (PIN) console.log(` PIN protection enabled\n`);
1538
+ console.log(` File API examples:`);
1539
+ console.log(` GET /api/files?path=<dir> — list directory`);
1540
+ console.log(` GET /api/files/read?path=<file> — read file content`);
1541
+ console.log(` POST /api/files/write — write file { path, content }`);
1542
+ console.log(` POST /api/files/upload?path=<dir> — upload files (multipart)`);
1543
+ console.log(` GET /api/files/download?path=<path> — download file/dir`);
1544
+ console.log(` GET /api/files/image?path=<file> — view image inline`);
1545
+ console.log(` POST /api/files/rename — rename { oldPath, newName }`);
1546
+ console.log(` POST /api/files/copy — copy { source, destination, conflict? }`);
1547
+ console.log(` POST /api/files/move — move { source, destination, conflict? }`);
1548
+ console.log(` DELETE /api/files?path=<path> — delete file/dir`);
1549
+ console.log(` POST /api/files/mkdir — create dir { path }`);
1550
+ console.log(` POST /api/files/touch — create file { path }`);
1551
+ console.log(` POST /api/files/zip — create zip { path }`);
1552
+ console.log(` POST /api/files/unzip — extract zip { path }`);
1553
+ console.log(` GET /api/search?q=<query>&path=<dir> — search files`);
1554
+ console.log(` GET /api/files/stat?path=<path> — file metadata`);
1555
+ console.log(` POST /api/files/batch-delete — bulk delete { paths: [...] }`);
1556
+ console.log(` POST /api/files/batch-copy — bulk copy { sources: [...], destination, conflict? }`);
1557
+ console.log(` POST /api/files/batch-move — bulk move { sources: [...], destination, conflict? }`);
1558
+ console.log(` POST /api/files/chmod — change perms { path, mode }`);
1559
+ console.log(` POST /api/files/symlink — create symlink { target, linkPath }`);
1560
+ console.log(` POST /api/files/search-content — full-text search { query, path, pattern?, maxResults? }`);
1561
+ console.log(` POST /api/files/batch-zip — multi-source zip { sources: [...], destination }`);
1562
+ console.log(` POST /api/files/trash — trash files { paths: [...] }`);
1563
+ console.log(` GET /api/files/trash — list trash`);
1564
+ console.log(` POST /api/files/trash/restore — restore trash { path }`);
1565
+ console.log(` DELETE /api/files/trash?path=<path> — delete trash item permanently`);
1566
+ console.log(` DELETE /api/files/trash/all — empty entire trash`);
1567
+ console.log(` GET /api/files/preview?path=<file> — file preview (md→html, code)`);
1568
+ console.log(` GET /api/files/tail?path=<file>&lines=N — tail log file (SSE)`);
1569
+ console.log(` Git API:`);
1570
+ console.log(` GET /api/git/status?path=<dir> — git status`);
1571
+ console.log(` POST /api/git/diff — git diff { path, file? }`);
1572
+ console.log(` POST /api/git/add — git add { path, files? }`);
1573
+ console.log(` POST /api/git/commit — git commit { path, message }`);
1574
+ console.log(` GET /api/git/log?path=<dir>&maxCount=N — git log`);
1575
+ console.log(` POST /api/git/push — git push { path, remote?, branch? }`);
1576
+ console.log(` POST /api/git/pull — git pull { path, remote?, branch? }`);
1577
+ console.log(` GET /api/git/branches?path=<dir> — list branches`);
1578
+ console.log(` POST /api/git/branch — create branch { path, name, switch? }`);
1579
+ console.log(` GET /api/git/remote?path=<dir> — list remotes`);
1580
+ console.log(` System:`);
1581
+ console.log(` GET /api/system/network — network interfaces, ports`);
1582
+ console.log(` GET /api/env — environment variables`);
1583
+ console.log(` Clipboard:`);
1584
+ console.log(` GET /api/clipboard — clipboard contents`);
1585
+ console.log(` POST /api/clipboard — set clipboard { sources, action }`);
1586
+ console.log(` POST /api/clipboard/paste — paste { destination, conflict? }`);
1587
+ console.log(` DELETE /api/clipboard — clear clipboard`);
1588
+ resolve(server);
1589
+ });
1590
+ });
1591
+ }
1592
+
1593
+ process.on('uncaughtException', e => {
1594
+ console.error('Uncaught:', e.message);
1595
+ try { cleanup(); } catch {}
1596
+ process.exit(1);
1597
+ });
1598
+ process.on('unhandledRejection', e => {
1599
+ console.error('Unhandled:', e);
1600
+ });
1601
+
1602
+ process.on('SIGTERM', () => { try { cleanup(); } catch {}; process.exit(0); });
1603
+ process.on('SIGINT', () => { try { cleanup(); } catch {}; process.exit(0); });
1604
+ process.on('exit', () => { try { cleanup(); } catch {} });
1605
+
1606
+ module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT };
1607
+
1608
+ if (require.main === module) {
1609
+ startServer();
1610
+ }