webtun 1.4.1 → 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/README.md +11 -1
- 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 +268 -125
package/README.md
CHANGED
|
@@ -106,7 +106,17 @@ npx webtun --tunnel # With Cloudflare Tunnel
|
|
|
106
106
|
npx webtun -p 4000 -t # Port 4000 + tunnel
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
**Note:** `node-pty`
|
|
109
|
+
**Note:** npm 12+ blocks install scripts by default. Allow them for `node-pty` (native module):
|
|
110
|
+
```bash
|
|
111
|
+
# Allow scripts once during install
|
|
112
|
+
npm install -g --allow-scripts=webtun,node-pty webtun
|
|
113
|
+
|
|
114
|
+
# Or allow globally (one-time setup)
|
|
115
|
+
npm config set allow-scripts=webtun,node-pty --location=user
|
|
116
|
+
npm install -g webtun
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Build tools are also required (for compiling native modules):
|
|
110
120
|
```bash
|
|
111
121
|
# Debian/Ubuntu
|
|
112
122
|
sudo apt-get install -y python3 make g++
|
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,17 +14,29 @@ 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 {
|
|
21
20
|
pty = require('node-pty');
|
|
22
21
|
} catch (e) {
|
|
23
|
-
console.error('
|
|
24
|
-
console.error('
|
|
25
|
-
console.error('
|
|
26
|
-
console.error('
|
|
27
|
-
console.error('
|
|
22
|
+
console.error('');
|
|
23
|
+
console.error(' Error: node-pty native module not found.');
|
|
24
|
+
console.error('');
|
|
25
|
+
console.error(' npm 12+ blocks install scripts by default. To fix:');
|
|
26
|
+
console.error('');
|
|
27
|
+
console.error(' Option 1 — Allow scripts once:');
|
|
28
|
+
console.error(' npm install -g --allow-scripts=webtun,node-pty webtun');
|
|
29
|
+
console.error('');
|
|
30
|
+
console.error(' Option 2 — Allow scripts globally (one-time):');
|
|
31
|
+
console.error(' npm config set allow-scripts=webtun,node-pty --location=user');
|
|
32
|
+
console.error(' npm install -g webtun');
|
|
33
|
+
console.error('');
|
|
34
|
+
console.error(' Option 3 — If building from source, install build tools first:');
|
|
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/');
|
|
39
|
+
console.error('');
|
|
28
40
|
process.exit(1);
|
|
29
41
|
}
|
|
30
42
|
const multer = require('multer');
|
|
@@ -33,6 +45,10 @@ const path = require('path');
|
|
|
33
45
|
const os = require('os');
|
|
34
46
|
const crypto = require('crypto');
|
|
35
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');
|
|
36
52
|
|
|
37
53
|
// MIME type lookup without mime-types dependency
|
|
38
54
|
const MIME_MAP = {
|
|
@@ -136,6 +152,10 @@ app.get('/api/auth/required', (req, res) => {
|
|
|
136
152
|
res.json({ required: !!PIN });
|
|
137
153
|
});
|
|
138
154
|
|
|
155
|
+
app.get('/api/version', (req, res) => {
|
|
156
|
+
res.json({ version: require('./package.json').version });
|
|
157
|
+
});
|
|
158
|
+
|
|
139
159
|
app.post('/api/auth', authRateLimiter, (req, res) => {
|
|
140
160
|
const { pin } = req.body;
|
|
141
161
|
if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
|
|
@@ -166,6 +186,188 @@ function realPath(targetPath) {
|
|
|
166
186
|
try { return fs.realpathSync(resolved); } catch { return resolved; }
|
|
167
187
|
}
|
|
168
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
|
+
|
|
169
371
|
async function safeStat(p) {
|
|
170
372
|
try { return await fsPromises.stat(p); } catch { return null; }
|
|
171
373
|
}
|
|
@@ -273,7 +475,7 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
|
|
|
273
475
|
}
|
|
274
476
|
const oldPath = realPath(req.body.oldPath);
|
|
275
477
|
const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
|
|
276
|
-
await
|
|
478
|
+
await renameWithFallback(oldPath, newPath);
|
|
277
479
|
res.json({ success: true, newPath });
|
|
278
480
|
} catch (e) {
|
|
279
481
|
res.status(500).json({ error: e.message });
|
|
@@ -322,7 +524,7 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
|
|
|
322
524
|
}
|
|
323
525
|
await fsPromises.rm(src, { recursive: true, force: true });
|
|
324
526
|
} else {
|
|
325
|
-
await
|
|
527
|
+
await renameWithFallback(src, dst);
|
|
326
528
|
}
|
|
327
529
|
} else {
|
|
328
530
|
if (isDir) {
|
|
@@ -410,7 +612,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
|
|
|
410
612
|
return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
|
|
411
613
|
}
|
|
412
614
|
const p = realPath(req.body.path);
|
|
413
|
-
|
|
615
|
+
await fsPromises.stat(p);
|
|
414
616
|
const baseName = path.basename(p);
|
|
415
617
|
let zipName = baseName + '.zip';
|
|
416
618
|
let zipPath = path.join(path.dirname(p), zipName);
|
|
@@ -421,13 +623,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
|
|
|
421
623
|
zipPath = path.join(path.dirname(p), zipName);
|
|
422
624
|
counter++;
|
|
423
625
|
}
|
|
424
|
-
|
|
425
|
-
const zipTarget = path.basename(zipPath);
|
|
426
|
-
if (st.isDirectory()) {
|
|
427
|
-
execSync(`zip -r -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
|
|
428
|
-
} else {
|
|
429
|
-
execSync(`zip -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
|
|
430
|
-
}
|
|
626
|
+
await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
|
|
431
627
|
res.json({ success: true, name: zipName });
|
|
432
628
|
} catch (e) {
|
|
433
629
|
res.status(500).json({ error: e.message });
|
|
@@ -445,23 +641,8 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
|
|
|
445
641
|
if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
|
|
446
642
|
const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
|
|
447
643
|
await fsPromises.mkdir(destDir, { recursive: true });
|
|
448
|
-
// Security: scan zip entries before extraction
|
|
449
|
-
const unzipList = execSync(`unzip -l "${p}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
450
|
-
for (const line of unzipList.split('\n')) {
|
|
451
|
-
const match = line.match(/^\s*\S+\s+\S+\s+(.+)$/);
|
|
452
|
-
if (match) {
|
|
453
|
-
const entryPath = path.normalize(match[1]);
|
|
454
|
-
if (entryPath.startsWith('..') || path.isAbsolute(entryPath)) {
|
|
455
|
-
return res.status(400).json({ error: 'Invalid zip entry: ' + match[1] });
|
|
456
|
-
}
|
|
457
|
-
const target = path.join(destDir, entryPath);
|
|
458
|
-
if (!target.startsWith(destDir + path.sep) && target !== destDir) {
|
|
459
|
-
return res.status(400).json({ error: 'Zip entry escapes destination directory' });
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
644
|
try {
|
|
464
|
-
|
|
645
|
+
await extractZip(p, destDir);
|
|
465
646
|
} catch (e) {
|
|
466
647
|
return res.status(500).json({ error: e.message });
|
|
467
648
|
}
|
|
@@ -526,13 +707,7 @@ app.get('/api/files/download', checkPin, async (req, res) => {
|
|
|
526
707
|
if (st.isDirectory()) {
|
|
527
708
|
res.setHeader('Content-Type', 'application/zip');
|
|
528
709
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
|
|
529
|
-
|
|
530
|
-
const zipProc = spawn('zip', ['-r', '-6', '-', path.basename(p)], { cwd: path.dirname(p), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
531
|
-
zipProc.stdout.pipe(res);
|
|
532
|
-
zipProc.on('error', err => {
|
|
533
|
-
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
534
|
-
});
|
|
535
|
-
zipProc.on('close', code => { if (code !== 0 && !res.headersSent) res.status(500).json({ error: 'zip failed' }); });
|
|
710
|
+
streamZipDirectory(p, res);
|
|
536
711
|
return;
|
|
537
712
|
} else {
|
|
538
713
|
const mimeType = mimeLookup(p);
|
|
@@ -562,7 +737,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
|
|
|
562
737
|
try {
|
|
563
738
|
const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
|
|
564
739
|
const finalDest = path.join(destDir, safeName);
|
|
565
|
-
if (!
|
|
740
|
+
if (!pathContained(destDir, finalDest)) {
|
|
566
741
|
return cb(new Error('Invalid upload destination'));
|
|
567
742
|
}
|
|
568
743
|
const subPath = path.dirname(finalDest);
|
|
@@ -630,8 +805,7 @@ app.get('/api/files/size', checkPin, async (req, res) => {
|
|
|
630
805
|
if (!st.isDirectory()) {
|
|
631
806
|
return res.json({ path: p, size: st.size, isDir: false });
|
|
632
807
|
}
|
|
633
|
-
const
|
|
634
|
-
const size = parseInt(out.split('\t')[0], 10);
|
|
808
|
+
const size = await dirSize(p);
|
|
635
809
|
res.json({ path: p, size, isDir: true });
|
|
636
810
|
} catch (e) {
|
|
637
811
|
res.status(500).json({ error: e.message });
|
|
@@ -801,23 +975,18 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
|
|
|
801
975
|
}
|
|
802
976
|
let dest = realPath(req.body.destination);
|
|
803
977
|
const resolved = req.body.sources.map(s => realPath(s));
|
|
804
|
-
// Prevent zipping workspace root
|
|
805
978
|
// Auto-rename if destination exists
|
|
806
979
|
let counter = 1;
|
|
807
980
|
const ext = '.zip';
|
|
808
981
|
const origDest = dest;
|
|
809
982
|
while (true) {
|
|
810
983
|
try { await fsPromises.access(dest); } catch { break; }
|
|
811
|
-
dest = origDest.replace(/(\.zip)
|
|
984
|
+
dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
|
|
812
985
|
counter++;
|
|
813
986
|
}
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
const rel = path.relative(zipDir, s);
|
|
818
|
-
return `"${rel}"`;
|
|
819
|
-
});
|
|
820
|
-
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);
|
|
821
990
|
res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
|
|
822
991
|
} catch (e) {
|
|
823
992
|
res.status(500).json({ error: e.message });
|
|
@@ -1080,11 +1249,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1080
1249
|
}
|
|
1081
1250
|
const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
1082
1251
|
|
|
1083
|
-
const sessionEnv = (
|
|
1084
|
-
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 };
|
|
1085
|
-
if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
|
|
1086
|
-
return safe;
|
|
1087
|
-
})();
|
|
1252
|
+
const sessionEnv = buildSessionEnv();
|
|
1088
1253
|
|
|
1089
1254
|
const send = (type, payload) => {
|
|
1090
1255
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -1370,16 +1535,31 @@ async function verifyTunnelUrl(url, retries = 3) {
|
|
|
1370
1535
|
return false;
|
|
1371
1536
|
}
|
|
1372
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
|
+
|
|
1373
1548
|
function restartTunnel(id, entry) {
|
|
1374
1549
|
if (!entry.localUrl) return;
|
|
1375
1550
|
try { if (entry.proc) entry.proc.kill('SIGTERM'); } catch {}
|
|
1376
|
-
try { if (entry.pid && isCloudflaredProcess(entry.pid))
|
|
1551
|
+
try { if (entry.pid && isCloudflaredProcess(entry.pid)) killPid(entry.pid); } catch {}
|
|
1377
1552
|
tunnels.delete(id);
|
|
1378
1553
|
|
|
1379
1554
|
const url = entry.localUrl;
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1555
|
+
let proc;
|
|
1556
|
+
try {
|
|
1557
|
+
proc = spawnCloudflared(['tunnel', '--url', url], {
|
|
1558
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
1559
|
+
});
|
|
1560
|
+
} catch {
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1383
1563
|
proc.unref();
|
|
1384
1564
|
|
|
1385
1565
|
const handler = data => {
|
|
@@ -1453,12 +1633,18 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1453
1633
|
const { url } = req.body;
|
|
1454
1634
|
if (!url) return res.status(400).json({ error: 'url required' });
|
|
1455
1635
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1636
|
+
if (!findCloudflared()) {
|
|
1637
|
+
return res.status(500).json({ error: 'cloudflared not installed' });
|
|
1638
|
+
}
|
|
1458
1639
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
+
}
|
|
1462
1648
|
proc.unref();
|
|
1463
1649
|
let tunnelUrl = null;
|
|
1464
1650
|
const timeout = 15000;
|
|
@@ -1482,7 +1668,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1482
1668
|
});
|
|
1483
1669
|
|
|
1484
1670
|
try {
|
|
1485
|
-
|
|
1671
|
+
await Promise.race([
|
|
1486
1672
|
urlPromise,
|
|
1487
1673
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
|
|
1488
1674
|
]);
|
|
@@ -1493,7 +1679,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1493
1679
|
saveTunnels();
|
|
1494
1680
|
res.json({ success: true, id, url: tunnelUrl, verified: urlOk });
|
|
1495
1681
|
} catch (e) {
|
|
1496
|
-
try { proc.kill(); } catch {}
|
|
1682
|
+
try { if (proc.pid) killPid(proc.pid); else proc.kill(); } catch {}
|
|
1497
1683
|
res.status(500).json({ error: e.message === 'timeout' ? 'Timed out waiting for tunnel URL' : e.message });
|
|
1498
1684
|
}
|
|
1499
1685
|
});
|
|
@@ -1504,9 +1690,10 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
|
1504
1690
|
const entry = tunnels.get(id);
|
|
1505
1691
|
try {
|
|
1506
1692
|
if (entry.proc) {
|
|
1507
|
-
entry.proc.kill('SIGTERM');
|
|
1693
|
+
try { entry.proc.kill('SIGTERM'); } catch {}
|
|
1694
|
+
if (entry.proc.pid) killPid(entry.proc.pid);
|
|
1508
1695
|
} else if (entry.pid && isCloudflaredProcess(entry.pid)) {
|
|
1509
|
-
|
|
1696
|
+
killPid(entry.pid);
|
|
1510
1697
|
}
|
|
1511
1698
|
} catch {}
|
|
1512
1699
|
tunnels.delete(id);
|
|
@@ -1519,8 +1706,12 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
|
1519
1706
|
function cleanup() {
|
|
1520
1707
|
for (const [id, entry] of tunnels) {
|
|
1521
1708
|
try {
|
|
1522
|
-
if (entry.proc)
|
|
1523
|
-
|
|
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
|
+
}
|
|
1524
1715
|
} catch {}
|
|
1525
1716
|
}
|
|
1526
1717
|
if (TMUX) {
|
|
@@ -1541,60 +1732,12 @@ function startServer(opts = {}) {
|
|
|
1541
1732
|
loadTunnels();
|
|
1542
1733
|
cleanupOrphanTmuxSessions();
|
|
1543
1734
|
|
|
1544
|
-
return new Promise((resolve) => {
|
|
1735
|
+
return new Promise((resolve, reject) => {
|
|
1736
|
+
server.once('error', reject);
|
|
1545
1737
|
server.listen(port, host, () => {
|
|
1546
|
-
console.log(`\n WebTun running → http://localhost:${port}
|
|
1547
|
-
if (PIN) console.log(` PIN protection enabled
|
|
1548
|
-
console.log(
|
|
1549
|
-
console.log(` GET /api/files?path=<dir> — list directory`);
|
|
1550
|
-
console.log(` GET /api/files/read?path=<file> — read file content`);
|
|
1551
|
-
console.log(` POST /api/files/write — write file { path, content }`);
|
|
1552
|
-
console.log(` POST /api/files/upload?path=<dir> — upload files (multipart)`);
|
|
1553
|
-
console.log(` GET /api/files/download?path=<path> — download file/dir`);
|
|
1554
|
-
console.log(` GET /api/files/image?path=<file> — view image inline`);
|
|
1555
|
-
console.log(` POST /api/files/rename — rename { oldPath, newName }`);
|
|
1556
|
-
console.log(` POST /api/files/copy — copy { source, destination, conflict? }`);
|
|
1557
|
-
console.log(` POST /api/files/move — move { source, destination, conflict? }`);
|
|
1558
|
-
console.log(` DELETE /api/files?path=<path> — delete file/dir`);
|
|
1559
|
-
console.log(` POST /api/files/mkdir — create dir { path }`);
|
|
1560
|
-
console.log(` POST /api/files/touch — create file { path }`);
|
|
1561
|
-
console.log(` POST /api/files/zip — create zip { path }`);
|
|
1562
|
-
console.log(` POST /api/files/unzip — extract zip { path }`);
|
|
1563
|
-
console.log(` GET /api/search?q=<query>&path=<dir> — search files`);
|
|
1564
|
-
console.log(` GET /api/files/stat?path=<path> — file metadata`);
|
|
1565
|
-
console.log(` POST /api/files/batch-delete — bulk delete { paths: [...] }`);
|
|
1566
|
-
console.log(` POST /api/files/batch-copy — bulk copy { sources: [...], destination, conflict? }`);
|
|
1567
|
-
console.log(` POST /api/files/batch-move — bulk move { sources: [...], destination, conflict? }`);
|
|
1568
|
-
console.log(` POST /api/files/chmod — change perms { path, mode }`);
|
|
1569
|
-
console.log(` POST /api/files/symlink — create symlink { target, linkPath }`);
|
|
1570
|
-
console.log(` POST /api/files/search-content — full-text search { query, path, pattern?, maxResults? }`);
|
|
1571
|
-
console.log(` POST /api/files/batch-zip — multi-source zip { sources: [...], destination }`);
|
|
1572
|
-
console.log(` POST /api/files/trash — trash files { paths: [...] }`);
|
|
1573
|
-
console.log(` GET /api/files/trash — list trash`);
|
|
1574
|
-
console.log(` POST /api/files/trash/restore — restore trash { path }`);
|
|
1575
|
-
console.log(` DELETE /api/files/trash?path=<path> — delete trash item permanently`);
|
|
1576
|
-
console.log(` DELETE /api/files/trash/all — empty entire trash`);
|
|
1577
|
-
console.log(` GET /api/files/preview?path=<file> — file preview (md→html, code)`);
|
|
1578
|
-
console.log(` GET /api/files/tail?path=<file>&lines=N — tail log file (SSE)`);
|
|
1579
|
-
console.log(` Git API:`);
|
|
1580
|
-
console.log(` GET /api/git/status?path=<dir> — git status`);
|
|
1581
|
-
console.log(` POST /api/git/diff — git diff { path, file? }`);
|
|
1582
|
-
console.log(` POST /api/git/add — git add { path, files? }`);
|
|
1583
|
-
console.log(` POST /api/git/commit — git commit { path, message }`);
|
|
1584
|
-
console.log(` GET /api/git/log?path=<dir>&maxCount=N — git log`);
|
|
1585
|
-
console.log(` POST /api/git/push — git push { path, remote?, branch? }`);
|
|
1586
|
-
console.log(` POST /api/git/pull — git pull { path, remote?, branch? }`);
|
|
1587
|
-
console.log(` GET /api/git/branches?path=<dir> — list branches`);
|
|
1588
|
-
console.log(` POST /api/git/branch — create branch { path, name, switch? }`);
|
|
1589
|
-
console.log(` GET /api/git/remote?path=<dir> — list remotes`);
|
|
1590
|
-
console.log(` System:`);
|
|
1591
|
-
console.log(` GET /api/system/network — network interfaces, ports`);
|
|
1592
|
-
console.log(` GET /api/env — environment variables`);
|
|
1593
|
-
console.log(` Clipboard:`);
|
|
1594
|
-
console.log(` GET /api/clipboard — clipboard contents`);
|
|
1595
|
-
console.log(` POST /api/clipboard — set clipboard { sources, action }`);
|
|
1596
|
-
console.log(` POST /api/clipboard/paste — paste { destination, conflict? }`);
|
|
1597
|
-
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('');
|
|
1598
1741
|
resolve(server);
|
|
1599
1742
|
});
|
|
1600
1743
|
});
|
|
@@ -1613,7 +1756,7 @@ process.on('SIGTERM', () => { try { cleanup(); } catch {}; process.exit(0); });
|
|
|
1613
1756
|
process.on('SIGINT', () => { try { cleanup(); } catch {}; process.exit(0); });
|
|
1614
1757
|
process.on('exit', () => { try { cleanup(); } catch {} });
|
|
1615
1758
|
|
|
1616
|
-
module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT };
|
|
1759
|
+
module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT, findCloudflared };
|
|
1617
1760
|
|
|
1618
1761
|
if (require.main === module) {
|
|
1619
1762
|
startServer();
|