webtun 1.4.2 → 1.4.3
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/bin/webtun.js +16 -22
- package/package.json +7 -2
- package/postinstall.js +146 -59
- package/public/index.html +68 -14
- package/server.js +254 -122
package/bin/webtun.js
CHANGED
|
@@ -23,7 +23,7 @@ function printHelp() {
|
|
|
23
23
|
PORT Server port (default: 3000)
|
|
24
24
|
HOST Bind address (default: 0.0.0.0)
|
|
25
25
|
PIN Authentication PIN (empty = no auth)
|
|
26
|
-
SHELL Shell to use (default:
|
|
26
|
+
SHELL Shell to use (default: PowerShell on Windows, bash/sh elsewhere)
|
|
27
27
|
WORKSPACE_ROOT Root directory for file operations (default: ~)
|
|
28
28
|
|
|
29
29
|
Examples:
|
|
@@ -70,25 +70,20 @@ function parseArgs(argv) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function startTunnel(port) {
|
|
73
|
-
const {
|
|
74
|
-
const
|
|
73
|
+
const { spawn } = require('child_process');
|
|
74
|
+
const { findCloudflared } = require('../server');
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (os.platform() === 'win32') {
|
|
79
|
-
execSync('where cloudflared', { stdio: 'ignore' });
|
|
80
|
-
} else {
|
|
81
|
-
execSync('command -v cloudflared', { stdio: 'ignore' });
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
76
|
+
const bin = findCloudflared();
|
|
77
|
+
if (!bin) {
|
|
84
78
|
console.error('\n Error: cloudflared is not installed.');
|
|
85
79
|
console.error(' Install it from: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/');
|
|
80
|
+
console.error(' Or re-run: npm install webtun (postinstall downloads it)');
|
|
86
81
|
return;
|
|
87
82
|
}
|
|
88
83
|
|
|
89
|
-
console.log('
|
|
84
|
+
console.log(' Starting Cloudflare Tunnel...');
|
|
90
85
|
|
|
91
|
-
const proc = spawn(
|
|
86
|
+
const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`], {
|
|
92
87
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
93
88
|
});
|
|
94
89
|
|
|
@@ -121,23 +116,22 @@ function startTunnel(port) {
|
|
|
121
116
|
}
|
|
122
117
|
});
|
|
123
118
|
|
|
124
|
-
|
|
125
|
-
process.on('SIGINT', () => {
|
|
126
|
-
try { proc.kill('SIGTERM'); } catch {}
|
|
127
|
-
process.exit(0);
|
|
128
|
-
});
|
|
129
|
-
process.on('SIGTERM', () => {
|
|
119
|
+
const stop = () => {
|
|
130
120
|
try { proc.kill('SIGTERM'); } catch {}
|
|
131
121
|
process.exit(0);
|
|
132
|
-
}
|
|
122
|
+
};
|
|
123
|
+
process.on('SIGINT', stop);
|
|
124
|
+
process.on('SIGTERM', stop);
|
|
133
125
|
}
|
|
134
126
|
|
|
135
127
|
const opts = parseArgs(args);
|
|
136
|
-
const { startServer } = require('../server');
|
|
128
|
+
const { startServer, PORT } = require('../server');
|
|
129
|
+
|
|
130
|
+
const listenPort = opts.port || PORT;
|
|
137
131
|
|
|
138
132
|
startServer(opts).then(() => {
|
|
139
133
|
if (opts.tunnel) {
|
|
140
|
-
startTunnel(
|
|
134
|
+
startTunnel(listenPort);
|
|
141
135
|
}
|
|
142
136
|
}).catch(err => {
|
|
143
137
|
console.error('Failed to start server:', err.message);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webtun",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Gaurang Patel",
|
|
@@ -93,10 +93,12 @@
|
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
+
"archiver": "^8.0.0",
|
|
96
97
|
"express": "^4.18.2",
|
|
97
98
|
"multer": "^2.0.0",
|
|
98
99
|
"node-pty": "^1.0.0",
|
|
99
|
-
"ws": "^8.21.0"
|
|
100
|
+
"ws": "^8.21.0",
|
|
101
|
+
"yauzl": "^3.4.0"
|
|
100
102
|
},
|
|
101
103
|
"devDependencies": {
|
|
102
104
|
"electron": "^42.2.0",
|
|
@@ -105,5 +107,8 @@
|
|
|
105
107
|
},
|
|
106
108
|
"overrides": {
|
|
107
109
|
"form-data": "4.0.6"
|
|
110
|
+
},
|
|
111
|
+
"allowScripts": {
|
|
112
|
+
"node-pty@1.1.0": true
|
|
108
113
|
}
|
|
109
114
|
}
|
package/postinstall.js
CHANGED
|
@@ -2,6 +2,8 @@ const { execSync, spawnSync } = require('child_process');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
5
7
|
|
|
6
8
|
// ── Rebuild node-pty if native module is missing ─────────────────────
|
|
7
9
|
function rebuildNodePty() {
|
|
@@ -18,16 +20,20 @@ function rebuildNodePty() {
|
|
|
18
20
|
spawnSync('npm', ['rebuild', 'node-pty'], {
|
|
19
21
|
cwd: __dirname,
|
|
20
22
|
stdio: 'inherit',
|
|
21
|
-
timeout: 120000
|
|
23
|
+
timeout: 120000,
|
|
24
|
+
shell: os.platform() === 'win32'
|
|
22
25
|
});
|
|
23
26
|
// Verify it worked
|
|
27
|
+
delete require.cache[require.resolve('node-pty')];
|
|
24
28
|
require('node-pty');
|
|
25
29
|
console.log(' node-pty rebuilt successfully');
|
|
26
30
|
} catch (e) {
|
|
27
31
|
console.log(' node-pty rebuild failed: ' + e.message);
|
|
28
|
-
console.log(' Terminal requires build tools:
|
|
29
|
-
console.log(' Linux:
|
|
30
|
-
console.log(' macOS:
|
|
32
|
+
console.log(' Terminal requires native build tools:');
|
|
33
|
+
console.log(' Linux: sudo apt-get install -y python3 make g++');
|
|
34
|
+
console.log(' macOS: xcode-select --install');
|
|
35
|
+
console.log(' Windows: install Visual Studio Build Tools with "Desktop development with C++"');
|
|
36
|
+
console.log(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -36,47 +42,101 @@ rebuildNodePty();
|
|
|
36
42
|
// ── Install cloudflared ──────────────────────────────────────────────
|
|
37
43
|
const CF_RELEASES = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
function cloudflaredExists() {
|
|
46
|
+
try {
|
|
47
|
+
if (os.platform() === 'win32') {
|
|
48
|
+
execSync('where cloudflared', { stdio: 'ignore' });
|
|
49
|
+
} else {
|
|
50
|
+
execSync('command -v cloudflared', { stdio: 'ignore' });
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
} catch {}
|
|
54
|
+
|
|
55
|
+
const isWin = os.platform() === 'win32';
|
|
56
|
+
const name = isWin ? 'cloudflared.exe' : 'cloudflared';
|
|
57
|
+
const candidates = [
|
|
58
|
+
path.join(__dirname, name),
|
|
59
|
+
path.join(process.cwd(), name),
|
|
60
|
+
];
|
|
61
|
+
if (isWin) {
|
|
62
|
+
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
63
|
+
candidates.push(path.join(localApp, 'cloudflared', name));
|
|
64
|
+
const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
|
|
65
|
+
if (pf) candidates.push(path.join(pf, 'cloudflared', name));
|
|
42
66
|
} else {
|
|
43
|
-
|
|
67
|
+
candidates.push(path.join(os.homedir(), '.local', 'bin', name));
|
|
68
|
+
candidates.push('/usr/local/bin/' + name);
|
|
44
69
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
return candidates.some(c => {
|
|
71
|
+
try { return fs.existsSync(c) && fs.statSync(c).isFile(); } catch { return false; }
|
|
72
|
+
});
|
|
73
|
+
}
|
|
49
74
|
|
|
50
|
-
|
|
51
|
-
linux: { x64: 'cloudflared-linux-amd64', arm64: 'cloudflared-linux-arm64', arm: 'cloudflared-linux-arm' },
|
|
52
|
-
darwin: { x64: 'cloudflared-darwin-amd64.tgz', arm64: 'cloudflared-darwin-arm64.tgz' },
|
|
53
|
-
win32: { x64: 'cloudflared-windows-amd64.exe' }
|
|
54
|
-
};
|
|
75
|
+
if (cloudflaredExists()) process.exit(0);
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
77
|
+
const platform = os.platform();
|
|
78
|
+
const arch = os.arch();
|
|
58
79
|
|
|
59
|
-
|
|
80
|
+
const files = {
|
|
81
|
+
linux: { x64: 'cloudflared-linux-amd64', arm64: 'cloudflared-linux-arm64', arm: 'cloudflared-linux-arm' },
|
|
82
|
+
darwin: { x64: 'cloudflared-darwin-amd64.tgz', arm64: 'cloudflared-darwin-arm64.tgz' },
|
|
83
|
+
win32: { x64: 'cloudflared-windows-amd64.exe', arm64: 'cloudflared-windows-amd64.exe' }
|
|
84
|
+
};
|
|
60
85
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const tmp = path.join(tmpDir, 'cloudflared-' + process.pid + (platform === 'win32' ? '.exe' : ''));
|
|
86
|
+
const file = (files[platform] || {})[arch];
|
|
87
|
+
if (!file) process.exit(0);
|
|
64
88
|
|
|
65
|
-
|
|
66
|
-
try { fs.unlinkSync(tmp); } catch {}
|
|
67
|
-
}
|
|
89
|
+
console.log(' installing cloudflared...');
|
|
68
90
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const r2 = spawnSync('wget', ['-q', url, '-O', tmp], { stdio: 'inherit', timeout: 60000 });
|
|
73
|
-
return r2.status === 0;
|
|
74
|
-
}
|
|
91
|
+
const url = CF_RELEASES + '/' + file;
|
|
92
|
+
const tmpDir = os.tmpdir();
|
|
93
|
+
const tmp = path.join(tmpDir, 'cloudflared-' + process.pid + (platform === 'win32' ? '.exe' : ''));
|
|
75
94
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
function cleanup() {
|
|
96
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function downloadFile(downloadUrl, dest) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const mod = downloadUrl.startsWith('https') ? https : http;
|
|
102
|
+
const req = mod.get(downloadUrl, { headers: { 'User-Agent': 'webtun-postinstall' } }, res => {
|
|
103
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
104
|
+
res.resume();
|
|
105
|
+
return downloadFile(res.headers.location, dest).then(resolve, reject);
|
|
106
|
+
}
|
|
107
|
+
if (res.statusCode !== 200) {
|
|
108
|
+
res.resume();
|
|
109
|
+
return reject(new Error('HTTP ' + res.statusCode));
|
|
110
|
+
}
|
|
111
|
+
const out = fs.createWriteStream(dest);
|
|
112
|
+
res.pipe(out);
|
|
113
|
+
out.on('finish', () => out.close(err => err ? reject(err) : resolve()));
|
|
114
|
+
out.on('error', reject);
|
|
115
|
+
});
|
|
116
|
+
req.on('error', reject);
|
|
117
|
+
req.setTimeout(60000, () => {
|
|
118
|
+
req.destroy();
|
|
119
|
+
reject(new Error('download timeout'));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function downloadWithCurlOrWget() {
|
|
125
|
+
const r = spawnSync('curl', ['-#fL', url, '-o', tmp], { stdio: 'inherit', timeout: 60000 });
|
|
126
|
+
if (r.status === 0) return true;
|
|
127
|
+
const r2 = spawnSync('wget', ['-q', url, '-O', tmp], { stdio: 'inherit', timeout: 60000 });
|
|
128
|
+
return r2.status === 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function main() {
|
|
132
|
+
try {
|
|
133
|
+
await downloadFile(url, tmp);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (!downloadWithCurlOrWget()) {
|
|
136
|
+
console.log(' cloudflared install failed (download error) — skipping');
|
|
137
|
+
cleanup();
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
80
140
|
}
|
|
81
141
|
|
|
82
142
|
// Validate downloaded file is not HTML (e.g. 404 page)
|
|
@@ -98,6 +158,7 @@ try {
|
|
|
98
158
|
}
|
|
99
159
|
|
|
100
160
|
function installBin(src, dest) {
|
|
161
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
101
162
|
try {
|
|
102
163
|
fs.renameSync(src, dest);
|
|
103
164
|
} catch (e) {
|
|
@@ -111,35 +172,61 @@ try {
|
|
|
111
172
|
try { fs.chmodSync(dest, 0o755); } catch {}
|
|
112
173
|
}
|
|
113
174
|
|
|
175
|
+
function preferredDest() {
|
|
176
|
+
const name = platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
|
177
|
+
if (platform === 'win32') {
|
|
178
|
+
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
179
|
+
return path.join(localApp, 'cloudflared', name);
|
|
180
|
+
}
|
|
181
|
+
return path.join(os.homedir(), '.local', 'bin', name);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractDarwinIfNeeded() {
|
|
185
|
+
if (platform !== 'darwin') return tmp;
|
|
186
|
+
const result = spawnSync('tar', ['xzf', tmp, '-C', tmpDir], { stdio: 'inherit' });
|
|
187
|
+
if (result.status !== 0) throw new Error('tar extraction failed');
|
|
188
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
189
|
+
return path.join(tmpDir, 'cloudflared');
|
|
190
|
+
}
|
|
191
|
+
|
|
114
192
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const dest = path.join(progFiles, 'cloudflared', 'cloudflared.exe');
|
|
123
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
124
|
-
installBin(tmp, dest);
|
|
125
|
-
} else {
|
|
126
|
-
installBin(tmp, '/usr/local/bin/cloudflared');
|
|
193
|
+
const src = extractDarwinIfNeeded();
|
|
194
|
+
// Prefer user-local path (no admin), then package dir, then system
|
|
195
|
+
const dests = [preferredDest(), path.join(__dirname, platform === 'win32' ? 'cloudflared.exe' : 'cloudflared')];
|
|
196
|
+
if (platform !== 'win32') dests.push('/usr/local/bin/cloudflared');
|
|
197
|
+
else {
|
|
198
|
+
const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
|
|
199
|
+
if (pf) dests.push(path.join(pf, 'cloudflared', 'cloudflared.exe'));
|
|
127
200
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const localDest = path.join(process.cwd(), 'cloudflared' + (platform === 'win32' ? '.exe' : ''));
|
|
201
|
+
|
|
202
|
+
let installed = null;
|
|
203
|
+
let lastErr = null;
|
|
204
|
+
for (const dest of dests) {
|
|
133
205
|
try {
|
|
134
|
-
installBin(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
} catch (
|
|
138
|
-
|
|
206
|
+
installBin(src, dest);
|
|
207
|
+
installed = dest;
|
|
208
|
+
break;
|
|
209
|
+
} catch (e) {
|
|
210
|
+
lastErr = e;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (installed) {
|
|
215
|
+
console.log(' cloudflared installed → ' + installed);
|
|
216
|
+
if (platform === 'win32' && installed.includes('Local')) {
|
|
217
|
+
console.log(' (not on PATH; WebTun will find it automatically)');
|
|
139
218
|
}
|
|
140
219
|
} else {
|
|
141
|
-
console.log(' cloudflared install failed: ' +
|
|
220
|
+
console.log(' cloudflared install failed: ' + (lastErr && lastErr.message) + ' — skipping');
|
|
142
221
|
}
|
|
143
222
|
cleanup();
|
|
223
|
+
} catch (e) {
|
|
224
|
+
console.log(' cloudflared install failed: ' + e.message + ' — skipping');
|
|
225
|
+
cleanup();
|
|
144
226
|
}
|
|
145
227
|
}
|
|
228
|
+
|
|
229
|
+
main().catch(e => {
|
|
230
|
+
console.log(' cloudflared install failed: ' + e.message + ' — skipping');
|
|
231
|
+
cleanup();
|
|
232
|
+
});
|
package/public/index.html
CHANGED
|
@@ -1152,6 +1152,7 @@ svg { display: block; }
|
|
|
1152
1152
|
<div style="display:flex;align-items:center;gap:8px">
|
|
1153
1153
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
|
|
1154
1154
|
<span>WebTun</span>
|
|
1155
|
+
<span id="about-version" style="color:var(--fg3);font-size:11px"></span>
|
|
1155
1156
|
</div>
|
|
1156
1157
|
<div style="display:flex;align-items:center;gap:8px">
|
|
1157
1158
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--fg2)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
@@ -2437,27 +2438,74 @@ function formatSize(bytes) {
|
|
|
2437
2438
|
return (bytes / 1073741824).toFixed(1) + ' GB';
|
|
2438
2439
|
}
|
|
2439
2440
|
|
|
2440
|
-
function renderBreadcrumb(
|
|
2441
|
+
function renderBreadcrumb(fullPath) {
|
|
2441
2442
|
const el = document.getElementById('file-breadcrumb');
|
|
2442
2443
|
if (!el) return;
|
|
2443
|
-
const
|
|
2444
|
+
const isWin = /\\/.test(fullPath) || /^[A-Za-z]:/.test(fullPath);
|
|
2445
|
+
const sep = isWin ? '\\' : '/';
|
|
2444
2446
|
let html = '';
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2447
|
+
|
|
2448
|
+
if (isWin) {
|
|
2449
|
+
// e.g. C:\Users\name or \\server\share\path
|
|
2450
|
+
const normalized = fullPath.replace(/\//g, '\\');
|
|
2451
|
+
const unc = normalized.startsWith('\\\\');
|
|
2452
|
+
let rest = normalized;
|
|
2453
|
+
let accumulated = '';
|
|
2454
|
+
const segments = [];
|
|
2455
|
+
|
|
2456
|
+
if (unc) {
|
|
2457
|
+
const m = normalized.match(/^\\\\[^\\]+\\[^\\]+/);
|
|
2458
|
+
if (m) {
|
|
2459
|
+
segments.push({ label: m[0], path: m[0] });
|
|
2460
|
+
rest = normalized.slice(m[0].length).replace(/^\\+/, '');
|
|
2461
|
+
accumulated = m[0];
|
|
2462
|
+
}
|
|
2451
2463
|
} else {
|
|
2452
|
-
|
|
2453
|
-
|
|
2464
|
+
const driveMatch = normalized.match(/^([A-Za-z]:)(.*)$/);
|
|
2465
|
+
if (driveMatch) {
|
|
2466
|
+
const root = driveMatch[1] + '\\';
|
|
2467
|
+
segments.push({ label: driveMatch[1], path: root });
|
|
2468
|
+
rest = (driveMatch[2] || '').replace(/^\\+/, '');
|
|
2469
|
+
accumulated = root;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
const parts = rest.split('\\').filter(Boolean);
|
|
2474
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2475
|
+
accumulated = accumulated.endsWith('\\') ? accumulated + parts[i] : accumulated + '\\' + parts[i];
|
|
2476
|
+
segments.push({ label: parts[i], path: accumulated });
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
if (!segments.length) {
|
|
2480
|
+
html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
|
|
2481
|
+
} else {
|
|
2482
|
+
html = segments.map((seg, i) => {
|
|
2483
|
+
const isLast = i === segments.length - 1;
|
|
2484
|
+
if (isLast) return `<span style="color:var(--fg);font-weight:500">${escHtml(seg.label)}</span>`;
|
|
2485
|
+
return `<a href="#" data-path="${escHtml(seg.path)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(seg.label)}</a>` +
|
|
2486
|
+
`<span style="color:var(--fg2);margin:0 2px">\\</span>`;
|
|
2487
|
+
}).join('');
|
|
2488
|
+
}
|
|
2489
|
+
} else {
|
|
2490
|
+
const parts = fullPath.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
2491
|
+
let accumulated = '';
|
|
2492
|
+
for (const part of parts) {
|
|
2493
|
+
accumulated += '/' + part;
|
|
2494
|
+
const isLast = part === parts[parts.length - 1];
|
|
2495
|
+
if (isLast) {
|
|
2496
|
+
html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
|
|
2497
|
+
} else {
|
|
2498
|
+
html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(part)}</a>`;
|
|
2499
|
+
html += `<span style="color:var(--fg2);margin:0 2px">/</span>`;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
if (!parts.length) {
|
|
2503
|
+
html = `<span style="color:var(--fg2)">/</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
|
|
2454
2504
|
}
|
|
2455
2505
|
}
|
|
2456
|
-
|
|
2457
|
-
html = `<span style="color:var(--fg2)">/</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
|
|
2458
|
-
}
|
|
2506
|
+
|
|
2459
2507
|
el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--fg2);margin-right:4px;vertical-align:middle"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' + html;
|
|
2460
|
-
|
|
2508
|
+
|
|
2461
2509
|
if (!el.dataset.delegated) {
|
|
2462
2510
|
el.addEventListener('click', e => {
|
|
2463
2511
|
const a = e.target.closest('a[data-path]');
|
|
@@ -4947,6 +4995,12 @@ function registerSW() {
|
|
|
4947
4995
|
// ═══════════════════════════════════════════════════════
|
|
4948
4996
|
init().catch(e => console.error('Init failed:', e));
|
|
4949
4997
|
|
|
4998
|
+
// Set version in About section
|
|
4999
|
+
fetch('/api/version').then(r => r.json()).then(d => {
|
|
5000
|
+
const el = document.getElementById('about-version');
|
|
5001
|
+
if (el) el.textContent = 'v' + (d.version || '');
|
|
5002
|
+
}).catch(() => {});
|
|
5003
|
+
|
|
4950
5004
|
// Warn before closing if there are unsaved changes or active terminals
|
|
4951
5005
|
window.addEventListener('beforeunload', e => {
|
|
4952
5006
|
const editorOpen = document.getElementById('editor-view').classList.contains('open');
|
package/server.js
CHANGED
|
@@ -14,7 +14,6 @@ try {
|
|
|
14
14
|
} catch {}
|
|
15
15
|
|
|
16
16
|
const express = require('express');
|
|
17
|
-
const http = require('http');
|
|
18
17
|
const WebSocket = require('ws');
|
|
19
18
|
let pty;
|
|
20
19
|
try {
|
|
@@ -33,8 +32,10 @@ try {
|
|
|
33
32
|
console.error(' npm install -g webtun');
|
|
34
33
|
console.error('');
|
|
35
34
|
console.error(' Option 3 — If building from source, install build tools first:');
|
|
36
|
-
console.error(' Linux:
|
|
37
|
-
console.error(' macOS:
|
|
35
|
+
console.error(' Linux: sudo apt-get install -y python3 make g++');
|
|
36
|
+
console.error(' macOS: xcode-select --install');
|
|
37
|
+
console.error(' Windows: install "Desktop development with C++" (Visual Studio Build Tools)');
|
|
38
|
+
console.error(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
|
|
38
39
|
console.error('');
|
|
39
40
|
process.exit(1);
|
|
40
41
|
}
|
|
@@ -44,6 +45,10 @@ const path = require('path');
|
|
|
44
45
|
const os = require('os');
|
|
45
46
|
const crypto = require('crypto');
|
|
46
47
|
const { execSync, execFileSync, spawn } = require('child_process');
|
|
48
|
+
const archiver = require('archiver');
|
|
49
|
+
const yauzl = require('yauzl');
|
|
50
|
+
const https = require('https');
|
|
51
|
+
const http = require('http');
|
|
47
52
|
|
|
48
53
|
// MIME type lookup without mime-types dependency
|
|
49
54
|
const MIME_MAP = {
|
|
@@ -147,6 +152,10 @@ app.get('/api/auth/required', (req, res) => {
|
|
|
147
152
|
res.json({ required: !!PIN });
|
|
148
153
|
});
|
|
149
154
|
|
|
155
|
+
app.get('/api/version', (req, res) => {
|
|
156
|
+
res.json({ version: require('./package.json').version });
|
|
157
|
+
});
|
|
158
|
+
|
|
150
159
|
app.post('/api/auth', authRateLimiter, (req, res) => {
|
|
151
160
|
const { pin } = req.body;
|
|
152
161
|
if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
|
|
@@ -177,6 +186,188 @@ function realPath(targetPath) {
|
|
|
177
186
|
try { return fs.realpathSync(resolved); } catch { return resolved; }
|
|
178
187
|
}
|
|
179
188
|
|
|
189
|
+
// Case-aware path containment (Windows paths are case-insensitive).
|
|
190
|
+
function pathContained(parent, child) {
|
|
191
|
+
let p = path.resolve(parent);
|
|
192
|
+
let c = path.resolve(child);
|
|
193
|
+
if (os.platform() === 'win32') {
|
|
194
|
+
p = p.replace(/\\/g, '/').toLowerCase();
|
|
195
|
+
c = c.replace(/\\/g, '/').toLowerCase();
|
|
196
|
+
if (!p.endsWith('/')) p += '/';
|
|
197
|
+
return c === p.slice(0, -1) || c.startsWith(p);
|
|
198
|
+
}
|
|
199
|
+
return c === p || c.startsWith(p + path.sep);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function renameWithFallback(src, dst) {
|
|
203
|
+
try {
|
|
204
|
+
await fsPromises.rename(src, dst);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
if (e.code === 'EXDEV') {
|
|
207
|
+
await fsPromises.cp(src, dst, { recursive: true, force: true });
|
|
208
|
+
await fsPromises.rm(src, { recursive: true, force: true });
|
|
209
|
+
} else {
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function dirSize(dir) {
|
|
216
|
+
let total = 0;
|
|
217
|
+
async function walk(d) {
|
|
218
|
+
let entries;
|
|
219
|
+
try { entries = await fsPromises.readdir(d, { withFileTypes: true }); } catch { return; }
|
|
220
|
+
await Promise.all(entries.map(async e => {
|
|
221
|
+
const full = path.join(d, e.name);
|
|
222
|
+
try {
|
|
223
|
+
if (e.isDirectory()) await walk(full);
|
|
224
|
+
else if (e.isFile()) {
|
|
225
|
+
const st = await fsPromises.stat(full);
|
|
226
|
+
total += st.size;
|
|
227
|
+
}
|
|
228
|
+
} catch {}
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
await walk(dir);
|
|
232
|
+
return total;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createZipArchive(entries, zipPath) {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const output = fs.createWriteStream(zipPath);
|
|
238
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
239
|
+
output.on('close', () => resolve());
|
|
240
|
+
output.on('error', reject);
|
|
241
|
+
archive.on('error', reject);
|
|
242
|
+
archive.pipe(output);
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
try {
|
|
245
|
+
const st = fs.statSync(entry.fullPath);
|
|
246
|
+
if (st.isDirectory()) archive.directory(entry.fullPath, entry.nameInZip);
|
|
247
|
+
else archive.file(entry.fullPath, { name: entry.nameInZip });
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return reject(e);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
archive.finalize();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function streamZipDirectory(dirPath, res) {
|
|
257
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
258
|
+
archive.on('error', err => {
|
|
259
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
260
|
+
else res.end();
|
|
261
|
+
});
|
|
262
|
+
archive.pipe(res);
|
|
263
|
+
archive.directory(dirPath, path.basename(dirPath));
|
|
264
|
+
archive.finalize();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function extractZip(zipPath, destDir) {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
|
270
|
+
if (err) return reject(err);
|
|
271
|
+
zipfile.readEntry();
|
|
272
|
+
zipfile.on('entry', entry => {
|
|
273
|
+
const entryName = entry.fileName.replace(/\\/g, '/');
|
|
274
|
+
const entryPath = path.normalize(entryName);
|
|
275
|
+
if (entryPath.startsWith('..') || path.isAbsolute(entryPath) || entryName.includes('\0')) {
|
|
276
|
+
return reject(new Error('Invalid zip entry: ' + entry.fileName));
|
|
277
|
+
}
|
|
278
|
+
const target = path.join(destDir, entryPath);
|
|
279
|
+
if (!pathContained(destDir, target)) {
|
|
280
|
+
return reject(new Error('Zip entry escapes destination directory'));
|
|
281
|
+
}
|
|
282
|
+
if (/\/$/.test(entryName)) {
|
|
283
|
+
fs.mkdirSync(target, { recursive: true });
|
|
284
|
+
zipfile.readEntry();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
288
|
+
zipfile.openReadStream(entry, (err2, readStream) => {
|
|
289
|
+
if (err2) return reject(err2);
|
|
290
|
+
const writeStream = fs.createWriteStream(target);
|
|
291
|
+
readStream.on('error', reject);
|
|
292
|
+
writeStream.on('error', reject);
|
|
293
|
+
writeStream.on('close', () => zipfile.readEntry());
|
|
294
|
+
readStream.pipe(writeStream);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
zipfile.on('end', () => resolve());
|
|
298
|
+
zipfile.on('error', reject);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function findCloudflared() {
|
|
304
|
+
const isWin = os.platform() === 'win32';
|
|
305
|
+
const name = isWin ? 'cloudflared.exe' : 'cloudflared';
|
|
306
|
+
const candidates = [
|
|
307
|
+
path.join(__dirname, name),
|
|
308
|
+
path.join(process.cwd(), name),
|
|
309
|
+
];
|
|
310
|
+
if (isWin) {
|
|
311
|
+
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
312
|
+
candidates.push(path.join(localApp, 'cloudflared', name));
|
|
313
|
+
const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
|
|
314
|
+
if (pf) candidates.push(path.join(pf, 'cloudflared', name));
|
|
315
|
+
} else {
|
|
316
|
+
candidates.push(path.join(os.homedir(), '.local', 'bin', name));
|
|
317
|
+
candidates.push('/usr/local/bin/' + name);
|
|
318
|
+
candidates.push('/usr/bin/' + name);
|
|
319
|
+
}
|
|
320
|
+
for (const c of candidates) {
|
|
321
|
+
try { if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; } catch {}
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const cmd = isWin ? 'where cloudflared' : 'command -v cloudflared';
|
|
325
|
+
const out = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\r?\n/)[0];
|
|
326
|
+
if (out && fs.existsSync(out)) return out;
|
|
327
|
+
} catch {}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function killPid(pid, signal = 'SIGTERM') {
|
|
332
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return;
|
|
333
|
+
try {
|
|
334
|
+
if (os.platform() === 'win32') {
|
|
335
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
|
|
336
|
+
} else {
|
|
337
|
+
process.kill(pid, signal);
|
|
338
|
+
}
|
|
339
|
+
} catch {}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function buildSessionEnv() {
|
|
343
|
+
if (os.platform() === 'win32') {
|
|
344
|
+
const env = { ...process.env };
|
|
345
|
+
env.TERM = env.TERM || 'xterm-256color';
|
|
346
|
+
env.COLORTERM = env.COLORTERM || 'truecolor';
|
|
347
|
+
if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE;
|
|
348
|
+
if (!env.USER && env.USERNAME) env.USER = env.USERNAME;
|
|
349
|
+
env.SHELL = SHELL;
|
|
350
|
+
// Prefer Path (Windows) over PATH if both set
|
|
351
|
+
if (env.Path && !env.PATH) env.PATH = env.Path;
|
|
352
|
+
return env;
|
|
353
|
+
}
|
|
354
|
+
const safe = {
|
|
355
|
+
TERM: 'xterm-256color',
|
|
356
|
+
COLORTERM: 'truecolor',
|
|
357
|
+
HOME: process.env.HOME || '',
|
|
358
|
+
USER: process.env.USER || '',
|
|
359
|
+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
360
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
361
|
+
SHELL
|
|
362
|
+
};
|
|
363
|
+
if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
|
|
364
|
+
// Preserve common terminal/locale vars when present
|
|
365
|
+
for (const k of ['LC_ALL', 'LC_CTYPE', 'TERM_PROGRAM', 'COLORFGBG']) {
|
|
366
|
+
if (process.env[k]) safe[k] = process.env[k];
|
|
367
|
+
}
|
|
368
|
+
return safe;
|
|
369
|
+
}
|
|
370
|
+
|
|
180
371
|
async function safeStat(p) {
|
|
181
372
|
try { return await fsPromises.stat(p); } catch { return null; }
|
|
182
373
|
}
|
|
@@ -284,7 +475,7 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
|
|
|
284
475
|
}
|
|
285
476
|
const oldPath = realPath(req.body.oldPath);
|
|
286
477
|
const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
|
|
287
|
-
await
|
|
478
|
+
await renameWithFallback(oldPath, newPath);
|
|
288
479
|
res.json({ success: true, newPath });
|
|
289
480
|
} catch (e) {
|
|
290
481
|
res.status(500).json({ error: e.message });
|
|
@@ -333,7 +524,7 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
|
|
|
333
524
|
}
|
|
334
525
|
await fsPromises.rm(src, { recursive: true, force: true });
|
|
335
526
|
} else {
|
|
336
|
-
await
|
|
527
|
+
await renameWithFallback(src, dst);
|
|
337
528
|
}
|
|
338
529
|
} else {
|
|
339
530
|
if (isDir) {
|
|
@@ -421,7 +612,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
|
|
|
421
612
|
return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
|
|
422
613
|
}
|
|
423
614
|
const p = realPath(req.body.path);
|
|
424
|
-
|
|
615
|
+
await fsPromises.stat(p);
|
|
425
616
|
const baseName = path.basename(p);
|
|
426
617
|
let zipName = baseName + '.zip';
|
|
427
618
|
let zipPath = path.join(path.dirname(p), zipName);
|
|
@@ -432,13 +623,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
|
|
|
432
623
|
zipPath = path.join(path.dirname(p), zipName);
|
|
433
624
|
counter++;
|
|
434
625
|
}
|
|
435
|
-
|
|
436
|
-
const zipTarget = path.basename(zipPath);
|
|
437
|
-
if (st.isDirectory()) {
|
|
438
|
-
execSync(`zip -r -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
|
|
439
|
-
} else {
|
|
440
|
-
execSync(`zip -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
|
|
441
|
-
}
|
|
626
|
+
await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
|
|
442
627
|
res.json({ success: true, name: zipName });
|
|
443
628
|
} catch (e) {
|
|
444
629
|
res.status(500).json({ error: e.message });
|
|
@@ -456,23 +641,8 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
|
|
|
456
641
|
if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
|
|
457
642
|
const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
|
|
458
643
|
await fsPromises.mkdir(destDir, { recursive: true });
|
|
459
|
-
// Security: scan zip entries before extraction
|
|
460
|
-
const unzipList = execSync(`unzip -l "${p}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
461
|
-
for (const line of unzipList.split('\n')) {
|
|
462
|
-
const match = line.match(/^\s*\S+\s+\S+\s+(.+)$/);
|
|
463
|
-
if (match) {
|
|
464
|
-
const entryPath = path.normalize(match[1]);
|
|
465
|
-
if (entryPath.startsWith('..') || path.isAbsolute(entryPath)) {
|
|
466
|
-
return res.status(400).json({ error: 'Invalid zip entry: ' + match[1] });
|
|
467
|
-
}
|
|
468
|
-
const target = path.join(destDir, entryPath);
|
|
469
|
-
if (!target.startsWith(destDir + path.sep) && target !== destDir) {
|
|
470
|
-
return res.status(400).json({ error: 'Zip entry escapes destination directory' });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
644
|
try {
|
|
475
|
-
|
|
645
|
+
await extractZip(p, destDir);
|
|
476
646
|
} catch (e) {
|
|
477
647
|
return res.status(500).json({ error: e.message });
|
|
478
648
|
}
|
|
@@ -537,13 +707,7 @@ app.get('/api/files/download', checkPin, async (req, res) => {
|
|
|
537
707
|
if (st.isDirectory()) {
|
|
538
708
|
res.setHeader('Content-Type', 'application/zip');
|
|
539
709
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
|
|
540
|
-
|
|
541
|
-
const zipProc = spawn('zip', ['-r', '-6', '-', path.basename(p)], { cwd: path.dirname(p), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
542
|
-
zipProc.stdout.pipe(res);
|
|
543
|
-
zipProc.on('error', err => {
|
|
544
|
-
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
545
|
-
});
|
|
546
|
-
zipProc.on('close', code => { if (code !== 0 && !res.headersSent) res.status(500).json({ error: 'zip failed' }); });
|
|
710
|
+
streamZipDirectory(p, res);
|
|
547
711
|
return;
|
|
548
712
|
} else {
|
|
549
713
|
const mimeType = mimeLookup(p);
|
|
@@ -573,7 +737,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
|
|
|
573
737
|
try {
|
|
574
738
|
const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
|
|
575
739
|
const finalDest = path.join(destDir, safeName);
|
|
576
|
-
if (!
|
|
740
|
+
if (!pathContained(destDir, finalDest)) {
|
|
577
741
|
return cb(new Error('Invalid upload destination'));
|
|
578
742
|
}
|
|
579
743
|
const subPath = path.dirname(finalDest);
|
|
@@ -641,8 +805,7 @@ app.get('/api/files/size', checkPin, async (req, res) => {
|
|
|
641
805
|
if (!st.isDirectory()) {
|
|
642
806
|
return res.json({ path: p, size: st.size, isDir: false });
|
|
643
807
|
}
|
|
644
|
-
const
|
|
645
|
-
const size = parseInt(out.split('\t')[0], 10);
|
|
808
|
+
const size = await dirSize(p);
|
|
646
809
|
res.json({ path: p, size, isDir: true });
|
|
647
810
|
} catch (e) {
|
|
648
811
|
res.status(500).json({ error: e.message });
|
|
@@ -812,23 +975,18 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
|
|
|
812
975
|
}
|
|
813
976
|
let dest = realPath(req.body.destination);
|
|
814
977
|
const resolved = req.body.sources.map(s => realPath(s));
|
|
815
|
-
// Prevent zipping workspace root
|
|
816
978
|
// Auto-rename if destination exists
|
|
817
979
|
let counter = 1;
|
|
818
980
|
const ext = '.zip';
|
|
819
981
|
const origDest = dest;
|
|
820
982
|
while (true) {
|
|
821
983
|
try { await fsPromises.access(dest); } catch { break; }
|
|
822
|
-
dest = origDest.replace(/(\.zip)
|
|
984
|
+
dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
|
|
823
985
|
counter++;
|
|
824
986
|
}
|
|
825
|
-
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
const rel = path.relative(zipDir, s);
|
|
829
|
-
return `"${rel}"`;
|
|
830
|
-
});
|
|
831
|
-
execSync(`zip -r -6 "${zipTarget}" ${zipArgs.join(' ')}`, { cwd: zipDir, stdio: 'pipe' });
|
|
987
|
+
await fsPromises.mkdir(path.dirname(dest), { recursive: true });
|
|
988
|
+
const entries = resolved.map(s => ({ fullPath: s, nameInZip: path.basename(s) }));
|
|
989
|
+
await createZipArchive(entries, dest);
|
|
832
990
|
res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
|
|
833
991
|
} catch (e) {
|
|
834
992
|
res.status(500).json({ error: e.message });
|
|
@@ -1091,11 +1249,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1091
1249
|
}
|
|
1092
1250
|
const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
1093
1251
|
|
|
1094
|
-
const sessionEnv = (
|
|
1095
|
-
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 };
|
|
1096
|
-
if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
|
|
1097
|
-
return safe;
|
|
1098
|
-
})();
|
|
1252
|
+
const sessionEnv = buildSessionEnv();
|
|
1099
1253
|
|
|
1100
1254
|
const send = (type, payload) => {
|
|
1101
1255
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -1381,16 +1535,31 @@ async function verifyTunnelUrl(url, retries = 3) {
|
|
|
1381
1535
|
return false;
|
|
1382
1536
|
}
|
|
1383
1537
|
|
|
1538
|
+
function spawnCloudflared(args, opts = {}) {
|
|
1539
|
+
const bin = findCloudflared();
|
|
1540
|
+
if (!bin) {
|
|
1541
|
+
const err = new Error('cloudflared not installed');
|
|
1542
|
+
err.code = 'ENOENT';
|
|
1543
|
+
throw err;
|
|
1544
|
+
}
|
|
1545
|
+
return spawn(bin, args, opts);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1384
1548
|
function restartTunnel(id, entry) {
|
|
1385
1549
|
if (!entry.localUrl) return;
|
|
1386
1550
|
try { if (entry.proc) entry.proc.kill('SIGTERM'); } catch {}
|
|
1387
|
-
try { if (entry.pid && isCloudflaredProcess(entry.pid))
|
|
1551
|
+
try { if (entry.pid && isCloudflaredProcess(entry.pid)) killPid(entry.pid); } catch {}
|
|
1388
1552
|
tunnels.delete(id);
|
|
1389
1553
|
|
|
1390
1554
|
const url = entry.localUrl;
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1555
|
+
let proc;
|
|
1556
|
+
try {
|
|
1557
|
+
proc = spawnCloudflared(['tunnel', '--url', url], {
|
|
1558
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
1559
|
+
});
|
|
1560
|
+
} catch {
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1394
1563
|
proc.unref();
|
|
1395
1564
|
|
|
1396
1565
|
const handler = data => {
|
|
@@ -1464,12 +1633,18 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1464
1633
|
const { url } = req.body;
|
|
1465
1634
|
if (!url) return res.status(400).json({ error: 'url required' });
|
|
1466
1635
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1636
|
+
if (!findCloudflared()) {
|
|
1637
|
+
return res.status(500).json({ error: 'cloudflared not installed' });
|
|
1638
|
+
}
|
|
1469
1639
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1640
|
+
let proc;
|
|
1641
|
+
try {
|
|
1642
|
+
proc = spawnCloudflared(['tunnel', '--url', url], {
|
|
1643
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
1644
|
+
});
|
|
1645
|
+
} catch (e) {
|
|
1646
|
+
return res.status(500).json({ error: e.message });
|
|
1647
|
+
}
|
|
1473
1648
|
proc.unref();
|
|
1474
1649
|
let tunnelUrl = null;
|
|
1475
1650
|
const timeout = 15000;
|
|
@@ -1493,7 +1668,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1493
1668
|
});
|
|
1494
1669
|
|
|
1495
1670
|
try {
|
|
1496
|
-
|
|
1671
|
+
await Promise.race([
|
|
1497
1672
|
urlPromise,
|
|
1498
1673
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
|
|
1499
1674
|
]);
|
|
@@ -1504,7 +1679,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1504
1679
|
saveTunnels();
|
|
1505
1680
|
res.json({ success: true, id, url: tunnelUrl, verified: urlOk });
|
|
1506
1681
|
} catch (e) {
|
|
1507
|
-
try { proc.kill(); } catch {}
|
|
1682
|
+
try { if (proc.pid) killPid(proc.pid); else proc.kill(); } catch {}
|
|
1508
1683
|
res.status(500).json({ error: e.message === 'timeout' ? 'Timed out waiting for tunnel URL' : e.message });
|
|
1509
1684
|
}
|
|
1510
1685
|
});
|
|
@@ -1515,9 +1690,10 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
|
1515
1690
|
const entry = tunnels.get(id);
|
|
1516
1691
|
try {
|
|
1517
1692
|
if (entry.proc) {
|
|
1518
|
-
entry.proc.kill('SIGTERM');
|
|
1693
|
+
try { entry.proc.kill('SIGTERM'); } catch {}
|
|
1694
|
+
if (entry.proc.pid) killPid(entry.proc.pid);
|
|
1519
1695
|
} else if (entry.pid && isCloudflaredProcess(entry.pid)) {
|
|
1520
|
-
|
|
1696
|
+
killPid(entry.pid);
|
|
1521
1697
|
}
|
|
1522
1698
|
} catch {}
|
|
1523
1699
|
tunnels.delete(id);
|
|
@@ -1530,8 +1706,12 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
|
1530
1706
|
function cleanup() {
|
|
1531
1707
|
for (const [id, entry] of tunnels) {
|
|
1532
1708
|
try {
|
|
1533
|
-
if (entry.proc)
|
|
1534
|
-
|
|
1709
|
+
if (entry.proc) {
|
|
1710
|
+
try { entry.proc.kill('SIGTERM'); } catch {}
|
|
1711
|
+
if (entry.proc.pid) killPid(entry.proc.pid);
|
|
1712
|
+
} else if (entry.pid && isCloudflaredProcess(entry.pid)) {
|
|
1713
|
+
killPid(entry.pid);
|
|
1714
|
+
}
|
|
1535
1715
|
} catch {}
|
|
1536
1716
|
}
|
|
1537
1717
|
if (TMUX) {
|
|
@@ -1552,60 +1732,12 @@ function startServer(opts = {}) {
|
|
|
1552
1732
|
loadTunnels();
|
|
1553
1733
|
cleanupOrphanTmuxSessions();
|
|
1554
1734
|
|
|
1555
|
-
return new Promise((resolve) => {
|
|
1735
|
+
return new Promise((resolve, reject) => {
|
|
1736
|
+
server.once('error', reject);
|
|
1556
1737
|
server.listen(port, host, () => {
|
|
1557
|
-
console.log(`\n WebTun running → http://localhost:${port}
|
|
1558
|
-
if (PIN) console.log(` PIN protection enabled
|
|
1559
|
-
console.log(
|
|
1560
|
-
console.log(` GET /api/files?path=<dir> — list directory`);
|
|
1561
|
-
console.log(` GET /api/files/read?path=<file> — read file content`);
|
|
1562
|
-
console.log(` POST /api/files/write — write file { path, content }`);
|
|
1563
|
-
console.log(` POST /api/files/upload?path=<dir> — upload files (multipart)`);
|
|
1564
|
-
console.log(` GET /api/files/download?path=<path> — download file/dir`);
|
|
1565
|
-
console.log(` GET /api/files/image?path=<file> — view image inline`);
|
|
1566
|
-
console.log(` POST /api/files/rename — rename { oldPath, newName }`);
|
|
1567
|
-
console.log(` POST /api/files/copy — copy { source, destination, conflict? }`);
|
|
1568
|
-
console.log(` POST /api/files/move — move { source, destination, conflict? }`);
|
|
1569
|
-
console.log(` DELETE /api/files?path=<path> — delete file/dir`);
|
|
1570
|
-
console.log(` POST /api/files/mkdir — create dir { path }`);
|
|
1571
|
-
console.log(` POST /api/files/touch — create file { path }`);
|
|
1572
|
-
console.log(` POST /api/files/zip — create zip { path }`);
|
|
1573
|
-
console.log(` POST /api/files/unzip — extract zip { path }`);
|
|
1574
|
-
console.log(` GET /api/search?q=<query>&path=<dir> — search files`);
|
|
1575
|
-
console.log(` GET /api/files/stat?path=<path> — file metadata`);
|
|
1576
|
-
console.log(` POST /api/files/batch-delete — bulk delete { paths: [...] }`);
|
|
1577
|
-
console.log(` POST /api/files/batch-copy — bulk copy { sources: [...], destination, conflict? }`);
|
|
1578
|
-
console.log(` POST /api/files/batch-move — bulk move { sources: [...], destination, conflict? }`);
|
|
1579
|
-
console.log(` POST /api/files/chmod — change perms { path, mode }`);
|
|
1580
|
-
console.log(` POST /api/files/symlink — create symlink { target, linkPath }`);
|
|
1581
|
-
console.log(` POST /api/files/search-content — full-text search { query, path, pattern?, maxResults? }`);
|
|
1582
|
-
console.log(` POST /api/files/batch-zip — multi-source zip { sources: [...], destination }`);
|
|
1583
|
-
console.log(` POST /api/files/trash — trash files { paths: [...] }`);
|
|
1584
|
-
console.log(` GET /api/files/trash — list trash`);
|
|
1585
|
-
console.log(` POST /api/files/trash/restore — restore trash { path }`);
|
|
1586
|
-
console.log(` DELETE /api/files/trash?path=<path> — delete trash item permanently`);
|
|
1587
|
-
console.log(` DELETE /api/files/trash/all — empty entire trash`);
|
|
1588
|
-
console.log(` GET /api/files/preview?path=<file> — file preview (md→html, code)`);
|
|
1589
|
-
console.log(` GET /api/files/tail?path=<file>&lines=N — tail log file (SSE)`);
|
|
1590
|
-
console.log(` Git API:`);
|
|
1591
|
-
console.log(` GET /api/git/status?path=<dir> — git status`);
|
|
1592
|
-
console.log(` POST /api/git/diff — git diff { path, file? }`);
|
|
1593
|
-
console.log(` POST /api/git/add — git add { path, files? }`);
|
|
1594
|
-
console.log(` POST /api/git/commit — git commit { path, message }`);
|
|
1595
|
-
console.log(` GET /api/git/log?path=<dir>&maxCount=N — git log`);
|
|
1596
|
-
console.log(` POST /api/git/push — git push { path, remote?, branch? }`);
|
|
1597
|
-
console.log(` POST /api/git/pull — git pull { path, remote?, branch? }`);
|
|
1598
|
-
console.log(` GET /api/git/branches?path=<dir> — list branches`);
|
|
1599
|
-
console.log(` POST /api/git/branch — create branch { path, name, switch? }`);
|
|
1600
|
-
console.log(` GET /api/git/remote?path=<dir> — list remotes`);
|
|
1601
|
-
console.log(` System:`);
|
|
1602
|
-
console.log(` GET /api/system/network — network interfaces, ports`);
|
|
1603
|
-
console.log(` GET /api/env — environment variables`);
|
|
1604
|
-
console.log(` Clipboard:`);
|
|
1605
|
-
console.log(` GET /api/clipboard — clipboard contents`);
|
|
1606
|
-
console.log(` POST /api/clipboard — set clipboard { sources, action }`);
|
|
1607
|
-
console.log(` POST /api/clipboard/paste — paste { destination, conflict? }`);
|
|
1608
|
-
console.log(` DELETE /api/clipboard — clear clipboard`);
|
|
1738
|
+
console.log(`\n WebTun running → http://localhost:${port}`);
|
|
1739
|
+
if (PIN) console.log(` PIN protection enabled`);
|
|
1740
|
+
console.log('');
|
|
1609
1741
|
resolve(server);
|
|
1610
1742
|
});
|
|
1611
1743
|
});
|
|
@@ -1624,7 +1756,7 @@ process.on('SIGTERM', () => { try { cleanup(); } catch {}; process.exit(0); });
|
|
|
1624
1756
|
process.on('SIGINT', () => { try { cleanup(); } catch {}; process.exit(0); });
|
|
1625
1757
|
process.on('exit', () => { try { cleanup(); } catch {} });
|
|
1626
1758
|
|
|
1627
|
-
module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT };
|
|
1759
|
+
module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT, findCloudflared };
|
|
1628
1760
|
|
|
1629
1761
|
if (require.main === module) {
|
|
1630
1762
|
startServer();
|