termbeam 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/public/index.html +56 -32
- package/public/terminal.html +258 -7
- package/src/auth.js +2 -1
- package/src/cli.js +14 -0
- package/src/client.js +1 -1
- package/src/preview.js +1 -1
- package/src/routes.js +111 -4
- package/src/server.js +4 -0
- package/src/sessions.js +2 -21
- package/src/tunnel.js +0 -15
- package/src/websocket.js +19 -0
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ termbeam -i # interactive setup wizard
|
|
|
72
72
|
|
|
73
73
|
- **Terminal search** with regex, match count, and prev/next navigation
|
|
74
74
|
- **Command palette** (Ctrl+K / Cmd+K) for quick access to all actions
|
|
75
|
+
- **File upload** — send files from your phone to the session's working directory
|
|
75
76
|
- **Completion notifications** — browser alerts when background commands finish
|
|
76
77
|
- **12 color themes** with adjustable font size
|
|
77
78
|
- **Port preview** — reverse-proxy a local web server through TermBeam
|
|
@@ -108,6 +109,7 @@ flowchart LR
|
|
|
108
109
|
| `--port <port>` | Server port | `3456` |
|
|
109
110
|
| `--host <addr>` | Bind address | `127.0.0.1` |
|
|
110
111
|
| `--lan` | Bind to all interfaces (LAN access) | Off |
|
|
112
|
+
| `--public` | Allow public tunnel access (no Microsoft login) | Off |
|
|
111
113
|
| `-i, --interactive` | Interactive setup wizard | Off |
|
|
112
114
|
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
|
|
113
115
|
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -991,17 +991,22 @@
|
|
|
991
991
|
}
|
|
992
992
|
|
|
993
993
|
async function loadSessions() {
|
|
994
|
-
|
|
995
|
-
|
|
994
|
+
try {
|
|
995
|
+
const res = await fetch('/api/sessions');
|
|
996
|
+
if (!res.ok) {
|
|
997
|
+
console.error(`Failed to load sessions: ${res.status}`);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const sessions = await res.json();
|
|
996
1001
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1002
|
+
if (sessions.length === 0) {
|
|
1003
|
+
listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1001
1006
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1007
|
+
listEl.innerHTML = sessions
|
|
1008
|
+
.map(
|
|
1009
|
+
(s) => `
|
|
1005
1010
|
<div class="swipe-wrap" data-session-id="${esc(s.id)}">
|
|
1006
1011
|
<div class="swipe-delete">
|
|
1007
1012
|
<button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
|
|
@@ -1031,22 +1036,25 @@
|
|
|
1031
1036
|
</div>
|
|
1032
1037
|
</div>
|
|
1033
1038
|
`,
|
|
1034
|
-
|
|
1035
|
-
|
|
1039
|
+
)
|
|
1040
|
+
.join('');
|
|
1036
1041
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
});
|
|
1042
|
-
listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
|
|
1043
|
-
card.addEventListener('click', () => {
|
|
1044
|
-
location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
|
|
1042
|
+
// Attach swipe handlers and click handlers after rendering
|
|
1043
|
+
listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
|
|
1044
|
+
listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
|
|
1045
|
+
btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
|
|
1045
1046
|
});
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1047
|
+
listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
|
|
1048
|
+
card.addEventListener('click', () => {
|
|
1049
|
+
location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
|
|
1053
|
+
dot.style.background = dot.dataset.color || 'var(--success)';
|
|
1054
|
+
});
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
console.error('Failed to load sessions:', err);
|
|
1057
|
+
}
|
|
1050
1058
|
}
|
|
1051
1059
|
|
|
1052
1060
|
document.getElementById('new-session-btn').addEventListener('click', () => {
|
|
@@ -1085,13 +1093,21 @@
|
|
|
1085
1093
|
if (initialCommand) body.initialCommand = initialCommand;
|
|
1086
1094
|
if (color) body.color = color;
|
|
1087
1095
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1096
|
+
try {
|
|
1097
|
+
const res = await fetch('/api/sessions', {
|
|
1098
|
+
method: 'POST',
|
|
1099
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1100
|
+
body: JSON.stringify(body),
|
|
1101
|
+
});
|
|
1102
|
+
if (!res.ok) {
|
|
1103
|
+
console.error(`Failed to create session: ${res.status}`);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const data = await res.json();
|
|
1107
|
+
location.href = data.url;
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
console.error('Failed to create session:', err);
|
|
1110
|
+
}
|
|
1095
1111
|
});
|
|
1096
1112
|
|
|
1097
1113
|
// --- Shell detection ---
|
|
@@ -1101,6 +1117,7 @@
|
|
|
1101
1117
|
const shellSelect = document.getElementById('sess-shell');
|
|
1102
1118
|
try {
|
|
1103
1119
|
const res = await fetch('/api/shells');
|
|
1120
|
+
if (!res.ok) throw new Error(`Failed to load shells: ${res.status}`);
|
|
1104
1121
|
const data = await res.json();
|
|
1105
1122
|
if (data.cwd) {
|
|
1106
1123
|
document.getElementById('sess-cwd').placeholder = data.cwd;
|
|
@@ -1225,7 +1242,10 @@
|
|
|
1225
1242
|
document.getElementById('browse-btn').addEventListener('click', async () => {
|
|
1226
1243
|
if (hubServerCwd === '/') {
|
|
1227
1244
|
try {
|
|
1228
|
-
const data = await fetch('/api/shells').then((r) =>
|
|
1245
|
+
const data = await fetch('/api/shells').then((r) => {
|
|
1246
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
1247
|
+
return r.json();
|
|
1248
|
+
});
|
|
1229
1249
|
if (data.cwd) hubServerCwd = data.cwd;
|
|
1230
1250
|
} catch {}
|
|
1231
1251
|
}
|
|
@@ -1254,6 +1274,7 @@
|
|
|
1254
1274
|
|
|
1255
1275
|
try {
|
|
1256
1276
|
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
|
|
1277
|
+
if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
|
|
1257
1278
|
const data = await res.json();
|
|
1258
1279
|
let items = '';
|
|
1259
1280
|
// Add parent (..) entry unless at root
|
|
@@ -1310,7 +1331,10 @@
|
|
|
1310
1331
|
|
|
1311
1332
|
// Fetch version
|
|
1312
1333
|
fetch('/api/version')
|
|
1313
|
-
.then((r) =>
|
|
1334
|
+
.then((r) => {
|
|
1335
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
1336
|
+
return r.json();
|
|
1337
|
+
})
|
|
1314
1338
|
.then((d) => {
|
|
1315
1339
|
document.getElementById('version').textContent = 'v' + d.version;
|
|
1316
1340
|
})
|
package/public/terminal.html
CHANGED
|
@@ -1165,6 +1165,9 @@
|
|
|
1165
1165
|
max-height: 90vh;
|
|
1166
1166
|
overflow-y: auto;
|
|
1167
1167
|
}
|
|
1168
|
+
#upload-modal .modal {
|
|
1169
|
+
overflow: visible;
|
|
1170
|
+
}
|
|
1168
1171
|
.modal h2 {
|
|
1169
1172
|
font-size: 18px;
|
|
1170
1173
|
margin-bottom: 16px;
|
|
@@ -1982,6 +1985,53 @@
|
|
|
1982
1985
|
<button class="key-btn icon-btn" data-key="[C" title="Right">→</button>
|
|
1983
1986
|
</div>
|
|
1984
1987
|
</div>
|
|
1988
|
+
<input type="file" id="upload-input" multiple hidden />
|
|
1989
|
+
|
|
1990
|
+
<!-- Upload Confirm Modal -->
|
|
1991
|
+
<div class="modal-overlay" id="upload-modal">
|
|
1992
|
+
<div class="modal">
|
|
1993
|
+
<h2>Upload Files</h2>
|
|
1994
|
+
<div
|
|
1995
|
+
id="upload-file-list"
|
|
1996
|
+
style="
|
|
1997
|
+
margin-bottom: 12px;
|
|
1998
|
+
font-size: 13px;
|
|
1999
|
+
color: var(--text-secondary);
|
|
2000
|
+
max-height: 120px;
|
|
2001
|
+
overflow-y: auto;
|
|
2002
|
+
"
|
|
2003
|
+
></div>
|
|
2004
|
+
<label for="upload-dir">Destination directory</label>
|
|
2005
|
+
<div class="cwd-picker">
|
|
2006
|
+
<input type="text" id="upload-dir" placeholder="Session working directory" />
|
|
2007
|
+
<button
|
|
2008
|
+
type="button"
|
|
2009
|
+
class="cwd-browse-btn"
|
|
2010
|
+
id="upload-browse-btn"
|
|
2011
|
+
title="Browse folders"
|
|
2012
|
+
>
|
|
2013
|
+
<svg
|
|
2014
|
+
width="18"
|
|
2015
|
+
height="18"
|
|
2016
|
+
viewBox="0 0 24 24"
|
|
2017
|
+
fill="none"
|
|
2018
|
+
stroke="currentColor"
|
|
2019
|
+
stroke-width="2"
|
|
2020
|
+
stroke-linecap="round"
|
|
2021
|
+
stroke-linejoin="round"
|
|
2022
|
+
>
|
|
2023
|
+
<path
|
|
2024
|
+
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
|
2025
|
+
/>
|
|
2026
|
+
</svg>
|
|
2027
|
+
</button>
|
|
2028
|
+
</div>
|
|
2029
|
+
<div class="modal-actions">
|
|
2030
|
+
<button class="btn-cancel" id="upload-cancel">Cancel</button>
|
|
2031
|
+
<button class="btn-create" id="upload-confirm">Upload</button>
|
|
2032
|
+
</div>
|
|
2033
|
+
</div>
|
|
2034
|
+
</div>
|
|
1985
2035
|
|
|
1986
2036
|
<div id="reconnect-overlay">
|
|
1987
2037
|
<div class="msg">Session disconnected</div>
|
|
@@ -2426,7 +2476,14 @@
|
|
|
2426
2476
|
|
|
2427
2477
|
// ===== Init =====
|
|
2428
2478
|
async function init() {
|
|
2429
|
-
|
|
2479
|
+
let sessionList = [];
|
|
2480
|
+
try {
|
|
2481
|
+
const res = await fetch('/api/sessions');
|
|
2482
|
+
if (!res.ok) throw new Error(`${res.status}`);
|
|
2483
|
+
sessionList = await res.json();
|
|
2484
|
+
} catch (err) {
|
|
2485
|
+
console.error('Failed to load sessions:', err);
|
|
2486
|
+
}
|
|
2430
2487
|
const initialId = new URLSearchParams(location.search).get('id');
|
|
2431
2488
|
|
|
2432
2489
|
for (const s of sessionList) addSession(s);
|
|
@@ -2449,6 +2506,7 @@
|
|
|
2449
2506
|
setupKeyBar();
|
|
2450
2507
|
setupPaste();
|
|
2451
2508
|
setupImagePaste();
|
|
2509
|
+
setupUpload();
|
|
2452
2510
|
setupSelectMode();
|
|
2453
2511
|
setupNewSessionModal();
|
|
2454
2512
|
setupPreviewModal();
|
|
@@ -2627,7 +2685,10 @@
|
|
|
2627
2685
|
|
|
2628
2686
|
// Version
|
|
2629
2687
|
fetch('/api/version')
|
|
2630
|
-
.then((r) =>
|
|
2688
|
+
.then((r) => {
|
|
2689
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
2690
|
+
return r.json();
|
|
2691
|
+
})
|
|
2631
2692
|
.then((d) => {
|
|
2632
2693
|
window._termbeamVersion = 'v' + d.version;
|
|
2633
2694
|
document.getElementById('side-panel-version').textContent = 'v' + d.version;
|
|
@@ -3739,6 +3800,168 @@
|
|
|
3739
3800
|
);
|
|
3740
3801
|
}
|
|
3741
3802
|
|
|
3803
|
+
// ===== File Upload =====
|
|
3804
|
+
function setupUpload() {
|
|
3805
|
+
const uploadInput = document.getElementById('upload-input');
|
|
3806
|
+
const uploadModal = document.getElementById('upload-modal');
|
|
3807
|
+
const uploadDirInput = document.getElementById('upload-dir');
|
|
3808
|
+
const uploadFileList = document.getElementById('upload-file-list');
|
|
3809
|
+
const uploadConfirmBtn = document.getElementById('upload-confirm');
|
|
3810
|
+
const uploadCancelBtn = document.getElementById('upload-cancel');
|
|
3811
|
+
const uploadBrowseBtn = document.getElementById('upload-browse-btn');
|
|
3812
|
+
|
|
3813
|
+
let pendingFiles = null;
|
|
3814
|
+
|
|
3815
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
3816
|
+
|
|
3817
|
+
function openUploadModal(files) {
|
|
3818
|
+
pendingFiles = files;
|
|
3819
|
+
const ms = managed.get(activeId);
|
|
3820
|
+
const cwd = (ms && ms.cwd) || '';
|
|
3821
|
+
uploadDirInput.value = cwd;
|
|
3822
|
+
let hasOversized = false;
|
|
3823
|
+
uploadFileList.innerHTML = '';
|
|
3824
|
+
Array.from(files).forEach((f) => {
|
|
3825
|
+
const oversized = f.size > MAX_FILE_SIZE;
|
|
3826
|
+
if (oversized) hasOversized = true;
|
|
3827
|
+
const row = document.createElement('div');
|
|
3828
|
+
if (oversized) row.style.color = '#f87171';
|
|
3829
|
+
row.textContent =
|
|
3830
|
+
'📄 ' +
|
|
3831
|
+
f.name +
|
|
3832
|
+
' (' +
|
|
3833
|
+
formatSize(f.size) +
|
|
3834
|
+
')' +
|
|
3835
|
+
(oversized ? ' exceeds 10 MB limit' : '');
|
|
3836
|
+
uploadFileList.appendChild(row);
|
|
3837
|
+
});
|
|
3838
|
+
uploadConfirmBtn.disabled = hasOversized;
|
|
3839
|
+
uploadConfirmBtn.style.opacity = hasOversized ? '0.5' : '1';
|
|
3840
|
+
uploadModal.classList.add('visible');
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
function closeUploadModal() {
|
|
3844
|
+
uploadModal.classList.remove('visible');
|
|
3845
|
+
closeBrowseDropdown();
|
|
3846
|
+
pendingFiles = null;
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
function formatSize(bytes) {
|
|
3850
|
+
if (bytes < 1024) return bytes + ' B';
|
|
3851
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
3852
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
uploadInput.addEventListener('change', () => {
|
|
3856
|
+
const files = uploadInput.files;
|
|
3857
|
+
if (!files || !files.length || !activeId) return;
|
|
3858
|
+
openUploadModal(files);
|
|
3859
|
+
});
|
|
3860
|
+
|
|
3861
|
+
uploadCancelBtn.addEventListener('click', closeUploadModal);
|
|
3862
|
+
uploadModal.addEventListener('click', (e) => {
|
|
3863
|
+
if (e.target === uploadModal) closeUploadModal();
|
|
3864
|
+
});
|
|
3865
|
+
|
|
3866
|
+
// Directory browsing (reuse /api/dirs)
|
|
3867
|
+
let browseDropdown = null;
|
|
3868
|
+
|
|
3869
|
+
function closeBrowseDropdown() {
|
|
3870
|
+
if (browseDropdown) {
|
|
3871
|
+
browseDropdown.remove();
|
|
3872
|
+
browseDropdown = null;
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
// Close dropdown when clicking outside
|
|
3877
|
+
document.addEventListener('click', (e) => {
|
|
3878
|
+
if (
|
|
3879
|
+
browseDropdown &&
|
|
3880
|
+
!browseDropdown.contains(e.target) &&
|
|
3881
|
+
e.target !== uploadBrowseBtn
|
|
3882
|
+
) {
|
|
3883
|
+
closeBrowseDropdown();
|
|
3884
|
+
}
|
|
3885
|
+
});
|
|
3886
|
+
|
|
3887
|
+
uploadBrowseBtn.addEventListener('click', async () => {
|
|
3888
|
+
if (browseDropdown) {
|
|
3889
|
+
closeBrowseDropdown();
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
const q = uploadDirInput.value || '';
|
|
3893
|
+
try {
|
|
3894
|
+
const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
|
|
3895
|
+
credentials: 'same-origin',
|
|
3896
|
+
});
|
|
3897
|
+
if (!res.ok) throw new Error(`Failed to browse directories: ${res.status}`);
|
|
3898
|
+
const data = await res.json();
|
|
3899
|
+
if (!data.dirs || !data.dirs.length) return;
|
|
3900
|
+
browseDropdown = document.createElement('div');
|
|
3901
|
+
browseDropdown.style.cssText =
|
|
3902
|
+
'position:absolute;left:0;right:0;bottom:100%;margin-bottom:4px;max-height:150px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:6px;z-index:10;box-shadow:0 -4px 12px rgba(0,0,0,0.2);';
|
|
3903
|
+
data.dirs.forEach((d) => {
|
|
3904
|
+
const opt = document.createElement('div');
|
|
3905
|
+
opt.textContent = d;
|
|
3906
|
+
opt.style.cssText =
|
|
3907
|
+
'padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);';
|
|
3908
|
+
opt.addEventListener('mouseenter', () => (opt.style.background = 'var(--hover)'));
|
|
3909
|
+
opt.addEventListener('mouseleave', () => (opt.style.background = 'none'));
|
|
3910
|
+
opt.addEventListener('click', () => {
|
|
3911
|
+
uploadDirInput.value = d;
|
|
3912
|
+
closeBrowseDropdown();
|
|
3913
|
+
});
|
|
3914
|
+
browseDropdown.appendChild(opt);
|
|
3915
|
+
});
|
|
3916
|
+
uploadBrowseBtn.parentElement.style.position = 'relative';
|
|
3917
|
+
uploadBrowseBtn.parentElement.appendChild(browseDropdown);
|
|
3918
|
+
} catch {}
|
|
3919
|
+
});
|
|
3920
|
+
|
|
3921
|
+
uploadConfirmBtn.addEventListener('click', async () => {
|
|
3922
|
+
if (!pendingFiles || !pendingFiles.length || !activeId) return;
|
|
3923
|
+
const targetDir = uploadDirInput.value.trim();
|
|
3924
|
+
const filesToUpload = Array.from(pendingFiles);
|
|
3925
|
+
closeUploadModal();
|
|
3926
|
+
|
|
3927
|
+
let uploaded = 0;
|
|
3928
|
+
let failed = 0;
|
|
3929
|
+
|
|
3930
|
+
for (const file of filesToUpload) {
|
|
3931
|
+
try {
|
|
3932
|
+
const headers = {
|
|
3933
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
3934
|
+
'X-Filename': file.name,
|
|
3935
|
+
};
|
|
3936
|
+
if (targetDir) headers['X-Target-Dir'] = targetDir;
|
|
3937
|
+
const res = await fetch(`/api/sessions/${activeId}/upload`, {
|
|
3938
|
+
method: 'POST',
|
|
3939
|
+
headers,
|
|
3940
|
+
body: file,
|
|
3941
|
+
credentials: 'same-origin',
|
|
3942
|
+
});
|
|
3943
|
+
if (!res.ok) {
|
|
3944
|
+
const err = await res.json().catch(() => ({}));
|
|
3945
|
+
throw new Error(err.error || 'Upload failed');
|
|
3946
|
+
}
|
|
3947
|
+
await res.json();
|
|
3948
|
+
uploaded++;
|
|
3949
|
+
} catch (err) {
|
|
3950
|
+
failed++;
|
|
3951
|
+
console.error('Upload error:', file.name, err);
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
if (uploaded > 0) {
|
|
3956
|
+
const dir = targetDir || 'session directory';
|
|
3957
|
+
showToast(`${uploaded} file${uploaded > 1 ? 's' : ''} uploaded to ${dir}`);
|
|
3958
|
+
}
|
|
3959
|
+
if (failed > 0) {
|
|
3960
|
+
showToast(`${failed} file${failed > 1 ? 's' : ''} failed to upload`);
|
|
3961
|
+
}
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3742
3965
|
// ===== New Session Modal =====
|
|
3743
3966
|
let shellsLoaded = false;
|
|
3744
3967
|
|
|
@@ -3785,7 +4008,10 @@
|
|
|
3785
4008
|
document.getElementById('ns-browse-btn').addEventListener('click', async () => {
|
|
3786
4009
|
if (serverCwd === '/') {
|
|
3787
4010
|
try {
|
|
3788
|
-
const data = await fetch('/api/shells').then((r) =>
|
|
4011
|
+
const data = await fetch('/api/shells').then((r) => {
|
|
4012
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
4013
|
+
return r.json();
|
|
4014
|
+
});
|
|
3789
4015
|
if (data.cwd) serverCwd = data.cwd;
|
|
3790
4016
|
} catch {}
|
|
3791
4017
|
}
|
|
@@ -3813,6 +4039,7 @@
|
|
|
3813
4039
|
nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
|
|
3814
4040
|
try {
|
|
3815
4041
|
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
|
|
4042
|
+
if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
|
|
3816
4043
|
const data = await res.json();
|
|
3817
4044
|
let items = '';
|
|
3818
4045
|
// Add parent (..) entry unless at root
|
|
@@ -3870,7 +4097,10 @@
|
|
|
3870
4097
|
if (shellsLoaded) return;
|
|
3871
4098
|
const sel = document.getElementById('ns-shell');
|
|
3872
4099
|
try {
|
|
3873
|
-
const data = await fetch('/api/shells').then((r) =>
|
|
4100
|
+
const data = await fetch('/api/shells').then((r) => {
|
|
4101
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
4102
|
+
return r.json();
|
|
4103
|
+
});
|
|
3874
4104
|
if (data.cwd) {
|
|
3875
4105
|
serverCwd = data.cwd;
|
|
3876
4106
|
document.getElementById('ns-cwd').placeholder = data.cwd;
|
|
@@ -3928,10 +4158,16 @@
|
|
|
3928
4158
|
headers: { 'Content-Type': 'application/json' },
|
|
3929
4159
|
body: JSON.stringify(body),
|
|
3930
4160
|
});
|
|
4161
|
+
if (!res.ok) {
|
|
4162
|
+
console.error(`Failed to create session: ${res.status}`);
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
3931
4165
|
const data = await res.json();
|
|
3932
4166
|
|
|
3933
4167
|
// Fetch full session list to get the new session data
|
|
3934
|
-
const
|
|
4168
|
+
const listRes = await fetch('/api/sessions');
|
|
4169
|
+
if (!listRes.ok) throw new Error(`Failed to list sessions: ${listRes.status}`);
|
|
4170
|
+
const list = await listRes.json();
|
|
3935
4171
|
const newSession = list.find((s) => s.id === data.id);
|
|
3936
4172
|
if (newSession) {
|
|
3937
4173
|
addSession(newSession);
|
|
@@ -3951,7 +4187,9 @@
|
|
|
3951
4187
|
function startPolling() {
|
|
3952
4188
|
setInterval(async () => {
|
|
3953
4189
|
try {
|
|
3954
|
-
const
|
|
4190
|
+
const pollRes = await fetch('/api/sessions');
|
|
4191
|
+
if (!pollRes.ok) throw new Error(`${pollRes.status}`);
|
|
4192
|
+
const list = await pollRes.json();
|
|
3955
4193
|
const serverIds = new Set(list.map((s) => s.id));
|
|
3956
4194
|
|
|
3957
4195
|
// Add new sessions created elsewhere
|
|
@@ -4164,6 +4402,19 @@
|
|
|
4164
4402
|
category: 'Session',
|
|
4165
4403
|
action: () => openNewSessionModal(),
|
|
4166
4404
|
},
|
|
4405
|
+
{
|
|
4406
|
+
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
|
|
4407
|
+
label: 'Upload files',
|
|
4408
|
+
category: 'Session',
|
|
4409
|
+
action: () => {
|
|
4410
|
+
if (!activeId) {
|
|
4411
|
+
showToast('No active session');
|
|
4412
|
+
return;
|
|
4413
|
+
}
|
|
4414
|
+
document.getElementById('upload-input').value = '';
|
|
4415
|
+
document.getElementById('upload-input').click();
|
|
4416
|
+
},
|
|
4417
|
+
},
|
|
4167
4418
|
{
|
|
4168
4419
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
4169
4420
|
label: 'Close tab',
|
|
@@ -4348,8 +4599,8 @@
|
|
|
4348
4599
|
btn.innerHTML =
|
|
4349
4600
|
'<span class="palette-action-icon">' + a.icon + '</span>' + esc(a.label);
|
|
4350
4601
|
btn.addEventListener('click', () => {
|
|
4351
|
-
if (!a.keepOpen) closePalette();
|
|
4352
4602
|
a.action();
|
|
4603
|
+
if (!a.keepOpen) closePalette();
|
|
4353
4604
|
});
|
|
4354
4605
|
body.appendChild(btn);
|
|
4355
4606
|
});
|
package/src/auth.js
CHANGED
|
@@ -177,7 +177,7 @@ function createAuth(password) {
|
|
|
177
177
|
const shareTokens = new Map(); // share tokens: token -> expiry
|
|
178
178
|
|
|
179
179
|
// Periodically clean up expired tokens and stale rate-limit entries
|
|
180
|
-
setInterval(
|
|
180
|
+
const cleanupInterval = setInterval(
|
|
181
181
|
() => {
|
|
182
182
|
const now = Date.now();
|
|
183
183
|
for (const [token, expiry] of tokens) {
|
|
@@ -296,6 +296,7 @@ function createAuth(password) {
|
|
|
296
296
|
rateLimit,
|
|
297
297
|
parseCookies,
|
|
298
298
|
loginHTML: LOGIN_HTML,
|
|
299
|
+
cleanup: () => clearInterval(cleanupInterval),
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
|
package/src/cli.js
CHANGED
|
@@ -270,6 +270,10 @@ function parseArgs() {
|
|
|
270
270
|
publicTunnel = true;
|
|
271
271
|
} else if (args[i].startsWith('--password=')) {
|
|
272
272
|
password = args[i].split('=')[1];
|
|
273
|
+
if (!password) {
|
|
274
|
+
console.error('Error: --password= requires a non-empty value\n');
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
273
277
|
explicitPassword = true;
|
|
274
278
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
275
279
|
printHelp();
|
|
@@ -286,6 +290,10 @@ function parseArgs() {
|
|
|
286
290
|
explicitPassword = true;
|
|
287
291
|
} else if (args[i] === '--port' && args[i + 1]) {
|
|
288
292
|
port = parseInt(args[++i], 10);
|
|
293
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
294
|
+
console.error('Error: --port must be a number between 1 and 65535\n');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
289
297
|
} else if (args[i] === '--lan') {
|
|
290
298
|
host = '0.0.0.0';
|
|
291
299
|
} else if (args[i] === '--host' && args[i + 1]) {
|
|
@@ -307,6 +315,12 @@ function parseArgs() {
|
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
|
|
318
|
+
const validLogLevels = ['error', 'warn', 'info', 'debug'];
|
|
319
|
+
if (!validLogLevels.includes(logLevel)) {
|
|
320
|
+
console.error(`Error: --log-level must be one of: ${validLogLevels.join(', ')}\n`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
|
|
310
324
|
// Default: auto-generate password if none specified
|
|
311
325
|
if (!explicitPassword && !password) {
|
|
312
326
|
password = crypto.randomBytes(16).toString('base64url');
|
package/src/client.js
CHANGED
package/src/preview.js
CHANGED
|
@@ -96,7 +96,7 @@ function createPreviewProxy() {
|
|
|
96
96
|
proxyReq.on('error', (err) => {
|
|
97
97
|
log.warn(`Preview proxy error (port ${port}): ${err.message}`);
|
|
98
98
|
if (!res.headersSent) {
|
|
99
|
-
res.status(502).json({ error:
|
|
99
|
+
res.status(502).json({ error: 'Bad gateway: upstream server is not responding' });
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
102
|
|
package/src/routes.js
CHANGED
|
@@ -185,9 +185,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
185
185
|
rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
|
|
186
186
|
});
|
|
187
187
|
} catch (err) {
|
|
188
|
-
|
|
188
|
+
log.warn(`Session creation failed: ${err.message}`);
|
|
189
|
+
return res.status(400).json({ error: 'Failed to create session' });
|
|
189
190
|
}
|
|
190
|
-
res.json({ id, url: `/terminal?id=${id}` });
|
|
191
|
+
res.status(201).json({ id, url: `/terminal?id=${id}` });
|
|
191
192
|
});
|
|
192
193
|
|
|
193
194
|
// Available shells
|
|
@@ -220,7 +221,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
220
221
|
|
|
221
222
|
app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
|
|
222
223
|
if (sessions.delete(req.params.id)) {
|
|
223
|
-
res.
|
|
224
|
+
res.status(204).end();
|
|
224
225
|
} else {
|
|
225
226
|
res.status(404).json({ error: 'not found' });
|
|
226
227
|
}
|
|
@@ -288,7 +289,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
288
289
|
fs.writeFileSync(filepath, buffer);
|
|
289
290
|
uploadedFiles.set(id, filepath);
|
|
290
291
|
log.info(`Upload: ${filename} (${buffer.length} bytes)`);
|
|
291
|
-
res.json({ id, url: `/uploads/${id}`, path: filepath });
|
|
292
|
+
res.status(201).json({ id, url: `/uploads/${id}`, path: filepath });
|
|
292
293
|
});
|
|
293
294
|
|
|
294
295
|
req.on('error', (err) => {
|
|
@@ -308,6 +309,112 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
308
309
|
res.sendFile(filepath);
|
|
309
310
|
});
|
|
310
311
|
|
|
312
|
+
// General file upload to a session's working directory
|
|
313
|
+
app.post('/api/sessions/:id/upload', apiRateLimit, auth.middleware, (req, res) => {
|
|
314
|
+
const session = sessions.get(req.params.id);
|
|
315
|
+
if (!session) {
|
|
316
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const rawName = req.headers['x-filename'];
|
|
320
|
+
if (!rawName || typeof rawName !== 'string') {
|
|
321
|
+
return res.status(400).json({ error: 'Missing X-Filename header' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Sanitize: take only the basename, strip control chars, collapse whitespace
|
|
325
|
+
const sanitized = path
|
|
326
|
+
.basename(rawName)
|
|
327
|
+
.replace(/[\x00-\x1f]/g, '')
|
|
328
|
+
.replace(/\s+/g, ' ')
|
|
329
|
+
.trim();
|
|
330
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
|
331
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Resolve target directory: optional X-Target-Dir header, falls back to session cwd
|
|
335
|
+
const rawTargetDir = req.headers['x-target-dir'];
|
|
336
|
+
let targetDir = session.cwd;
|
|
337
|
+
if (rawTargetDir && typeof rawTargetDir === 'string') {
|
|
338
|
+
if (!path.isAbsolute(rawTargetDir)) {
|
|
339
|
+
return res.status(400).json({ error: 'Target directory must be an absolute path' });
|
|
340
|
+
}
|
|
341
|
+
const resolved = path.resolve(rawTargetDir);
|
|
342
|
+
try {
|
|
343
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
344
|
+
targetDir = resolved;
|
|
345
|
+
} else {
|
|
346
|
+
return res.status(400).json({ error: 'Target directory is not a directory' });
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
return res.status(400).json({ error: 'Target directory does not exist' });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Defense-in-depth: ensure destPath is still inside targetDir after join
|
|
353
|
+
const destPath = path.join(targetDir, sanitized);
|
|
354
|
+
if (
|
|
355
|
+
!path.resolve(destPath).startsWith(path.resolve(targetDir) + path.sep) &&
|
|
356
|
+
path.resolve(destPath) !== path.resolve(targetDir)
|
|
357
|
+
) {
|
|
358
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const chunks = [];
|
|
362
|
+
let size = 0;
|
|
363
|
+
let aborted = false;
|
|
364
|
+
const limit = 10 * 1024 * 1024;
|
|
365
|
+
|
|
366
|
+
req.on('data', (chunk) => {
|
|
367
|
+
if (aborted) return;
|
|
368
|
+
size += chunk.length;
|
|
369
|
+
if (size > limit) {
|
|
370
|
+
aborted = true;
|
|
371
|
+
log.warn(`File upload rejected: too large (${size} bytes)`);
|
|
372
|
+
res.status(413).json({ error: 'File too large (max 10 MB)' });
|
|
373
|
+
req.resume();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
chunks.push(chunk);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
req.on('end', () => {
|
|
380
|
+
if (aborted) return;
|
|
381
|
+
const buffer = Buffer.concat(chunks);
|
|
382
|
+
if (!buffer.length) {
|
|
383
|
+
return res.status(400).json({ error: 'Empty file' });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Atomic write with dedup: use wx flag to fail on existing file, retry with suffix
|
|
387
|
+
const ext = path.extname(sanitized);
|
|
388
|
+
const base = path.basename(sanitized, ext);
|
|
389
|
+
let destPath = path.join(targetDir, sanitized);
|
|
390
|
+
let written = false;
|
|
391
|
+
for (let n = 0; n < 100; n++) {
|
|
392
|
+
const candidate = n === 0 ? destPath : path.join(targetDir, `${base} (${n})${ext}`);
|
|
393
|
+
try {
|
|
394
|
+
fs.writeFileSync(candidate, buffer, { flag: 'wx' });
|
|
395
|
+
destPath = candidate;
|
|
396
|
+
written = true;
|
|
397
|
+
break;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (err.code === 'EEXIST') continue;
|
|
400
|
+
log.error(`File upload write error: ${err.message}`);
|
|
401
|
+
return res.status(500).json({ error: 'Failed to write file' });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!written) {
|
|
405
|
+
return res.status(409).json({ error: 'Too many filename collisions' });
|
|
406
|
+
}
|
|
407
|
+
const finalName = path.basename(destPath);
|
|
408
|
+
log.info(`File upload: ${finalName} → ${targetDir} (${buffer.length} bytes)`);
|
|
409
|
+
res.status(201).json({ name: finalName, path: destPath, size: buffer.length });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
req.on('error', (err) => {
|
|
413
|
+
log.error(`File upload error: ${err.message}`);
|
|
414
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
311
418
|
// Directory listing for folder browser
|
|
312
419
|
app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
|
|
313
420
|
const query = req.query.q || config.cwd + path.sep;
|
package/src/server.js
CHANGED
|
@@ -83,10 +83,14 @@ function createTermBeamServer(overrides = {}) {
|
|
|
83
83
|
function shutdown() {
|
|
84
84
|
if (shuttingDown) return;
|
|
85
85
|
shuttingDown = true;
|
|
86
|
+
auth.cleanup();
|
|
86
87
|
sessions.shutdown();
|
|
87
88
|
cleanupUploadedFiles();
|
|
88
89
|
cleanupTunnel();
|
|
89
90
|
removeConnectionConfig();
|
|
91
|
+
for (const client of wss.clients) {
|
|
92
|
+
client.close(1001, 'Server shutting down');
|
|
93
|
+
}
|
|
90
94
|
server.close();
|
|
91
95
|
wss.close();
|
|
92
96
|
}
|
package/src/sessions.js
CHANGED
|
@@ -1,30 +1,10 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const {
|
|
4
|
-
const fs = require('fs');
|
|
3
|
+
const { exec } = require('child_process');
|
|
5
4
|
const pty = require('node-pty');
|
|
6
5
|
const log = require('./logger');
|
|
7
6
|
const { getGitInfo } = require('./git');
|
|
8
7
|
|
|
9
|
-
function _getProcessCwd(pid) {
|
|
10
|
-
try {
|
|
11
|
-
if (process.platform === 'linux') {
|
|
12
|
-
return fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
13
|
-
}
|
|
14
|
-
if (process.platform === 'darwin') {
|
|
15
|
-
const out = execSync(`lsof -a -p ${pid} -d cwd -Fn`, {
|
|
16
|
-
stdio: 'pipe',
|
|
17
|
-
timeout: 2000,
|
|
18
|
-
}).toString();
|
|
19
|
-
const match = out.match(/\nn(.+)/);
|
|
20
|
-
if (match) return match[1];
|
|
21
|
-
}
|
|
22
|
-
} catch {
|
|
23
|
-
/* process may have exited */
|
|
24
|
-
}
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
8
|
// Cache git info per session to avoid blocking the event loop on every list() call.
|
|
29
9
|
// lsof + git commands take ~200-500ms and block WebSocket traffic, causing
|
|
30
10
|
// xterm.js cursor position report responses to leak as visible text.
|
|
@@ -69,6 +49,7 @@ function scheduleGitRefresh(sessionId, pid, originalCwd) {
|
|
|
69
49
|
}
|
|
70
50
|
|
|
71
51
|
exec(cmd, { timeout: 2000 }, (err, stdout) => {
|
|
52
|
+
if (err) log.debug(`Git cwd detection failed: ${err.message}`);
|
|
72
53
|
let liveCwd = originalCwd;
|
|
73
54
|
if (!err && stdout) {
|
|
74
55
|
if (process.platform === 'darwin') {
|
package/src/tunnel.js
CHANGED
|
@@ -67,21 +67,6 @@ function savePersistedTunnel(id) {
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function _deletePersisted() {
|
|
71
|
-
const persisted = loadPersistedTunnel();
|
|
72
|
-
if (persisted) {
|
|
73
|
-
try {
|
|
74
|
-
if (SAFE_ID_RE.test(persisted.tunnelId)) {
|
|
75
|
-
execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
|
|
76
|
-
log.info(`Deleted persisted tunnel ${persisted.tunnelId}`);
|
|
77
|
-
}
|
|
78
|
-
} catch {}
|
|
79
|
-
try {
|
|
80
|
-
fs.unlinkSync(TUNNEL_CONFIG_PATH);
|
|
81
|
-
} catch {}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
70
|
function isTunnelValid(id) {
|
|
86
71
|
try {
|
|
87
72
|
if (!SAFE_ID_RE.test(id)) return false;
|
package/src/websocket.js
CHANGED
|
@@ -17,6 +17,10 @@ function recalcPtySize(session) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function setupWebSocket(wss, { auth, sessions }) {
|
|
20
|
+
const wsAuthAttempts = new Map(); // ip -> [timestamps]
|
|
21
|
+
const WS_AUTH_WINDOW = 60 * 1000; // 1 minute
|
|
22
|
+
const WS_MAX_AUTH_ATTEMPTS = 5;
|
|
23
|
+
|
|
20
24
|
wss.on('connection', (ws, req) => {
|
|
21
25
|
const origin = req.headers.origin;
|
|
22
26
|
if (origin) {
|
|
@@ -51,11 +55,26 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
51
55
|
const msg = JSON.parse(raw);
|
|
52
56
|
|
|
53
57
|
if (msg.type === 'auth') {
|
|
58
|
+
const ip = req.socket.remoteAddress;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const attempts = (wsAuthAttempts.get(ip) || []).filter((t) => now - t < WS_AUTH_WINDOW);
|
|
61
|
+
|
|
62
|
+
if (attempts.length >= WS_MAX_AUTH_ATTEMPTS) {
|
|
63
|
+
log.warn(`WS: rate limit exceeded for ${ip}`);
|
|
64
|
+
ws.send(
|
|
65
|
+
JSON.stringify({ type: 'error', message: 'Too many attempts. Try again later.' }),
|
|
66
|
+
);
|
|
67
|
+
ws.close();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
if (msg.password === auth.password || auth.validateToken(msg.token)) {
|
|
55
72
|
authenticated = true;
|
|
56
73
|
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
57
74
|
log.info('WS: auth success');
|
|
58
75
|
} else {
|
|
76
|
+
attempts.push(now);
|
|
77
|
+
wsAuthAttempts.set(ip, attempts);
|
|
59
78
|
log.warn('WS: auth failed');
|
|
60
79
|
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
|
|
61
80
|
ws.close();
|