termbeam 0.1.0 → 0.1.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 +28 -19
- package/package.json +1 -1
- package/public/index.html +26 -11
- package/public/sw.js +16 -2
- package/public/terminal.html +292 -27
- package/src/auth.js +1 -0
- package/src/cli.js +32 -8
- package/src/routes.js +60 -4
- package/src/server.js +19 -13
- package/src/sessions.js +1 -0
- package/src/tunnel.js +2 -1
- package/src/websocket.js +5 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ I built this because I kept needing to run quick commands on my dev machine whil
|
|
|
13
13
|
|
|
14
14
|
[Full documentation](https://dorlugasigal.github.io/TermBeam/)
|
|
15
15
|
|
|
16
|
-
https://github.com/user-attachments/assets/
|
|
16
|
+
https://github.com/user-attachments/assets/9dd4f3d7-f017-4314-9b3a-f6a5688e3671
|
|
17
17
|
|
|
18
18
|
## Quick Start
|
|
19
19
|
|
|
@@ -30,18 +30,22 @@ termbeam
|
|
|
30
30
|
|
|
31
31
|
Scan the QR code printed in your terminal, or open the URL on any device.
|
|
32
32
|
|
|
33
|
-
###
|
|
33
|
+
### Secure by default
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
termbeam --generate-password
|
|
35
|
+
TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
termbeam
|
|
37
|
+
```bash
|
|
38
|
+
termbeam # tunnel + auto-password (default)
|
|
39
|
+
termbeam --password mysecret # use a specific password
|
|
40
|
+
termbeam --no-tunnel # LAN-only (no tunnel)
|
|
41
|
+
termbeam --no-password # disable password protection
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
## Features
|
|
43
45
|
|
|
44
46
|
- **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Enter, Ctrl shortcuts, Esc) and touch-optimized controls
|
|
47
|
+
- **Copy/paste support** — Copy button opens text overlay for finger-selectable terminal content; Paste button with clipboard API + fallback modal
|
|
48
|
+
- **Image paste** — paste images from clipboard, uploaded to server
|
|
45
49
|
- **Tabbed multi-session terminal** — open, switch, and manage multiple sessions from a single tab bar with drag-to-reorder
|
|
46
50
|
- **Split view** — view two sessions side-by-side (horizontal on desktop, vertical on mobile)
|
|
47
51
|
- **Session colors** — assign a color to each session for quick identification
|
|
@@ -65,11 +69,14 @@ termbeam --password mysecret
|
|
|
65
69
|
## Remote Access
|
|
66
70
|
|
|
67
71
|
```bash
|
|
68
|
-
#
|
|
69
|
-
termbeam
|
|
72
|
+
# Tunnel is on by default
|
|
73
|
+
termbeam
|
|
70
74
|
|
|
71
75
|
# Persisted tunnel (stable URL you can bookmark, reused across restarts, 30-day expiry)
|
|
72
|
-
termbeam --persisted-tunnel
|
|
76
|
+
termbeam --persisted-tunnel
|
|
77
|
+
|
|
78
|
+
# LAN-only (no tunnel)
|
|
79
|
+
termbeam --no-tunnel
|
|
73
80
|
```
|
|
74
81
|
|
|
75
82
|
Requires the [Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
|
|
@@ -88,20 +95,22 @@ termbeam --port 8080 # custom port (default: 3456)
|
|
|
88
95
|
termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
|
|
89
96
|
```
|
|
90
97
|
|
|
91
|
-
| Flag | Description | Default
|
|
92
|
-
| --------------------- | ---------------------------------------- |
|
|
93
|
-
| `--password <pw>` | Set access password (also accepts `--password=<pw>`) |
|
|
94
|
-
| `--
|
|
95
|
-
| `--
|
|
96
|
-
| `--
|
|
97
|
-
| `--
|
|
98
|
-
| `--
|
|
98
|
+
| Flag | Description | Default |
|
|
99
|
+
| --------------------- | ---------------------------------------- | ---------------- |
|
|
100
|
+
| `--password <pw>` | Set access password (also accepts `--password=<pw>`) | Auto-generated |
|
|
101
|
+
| `--no-password` | Disable password | — |
|
|
102
|
+
| `--generate-password` | Auto-generate a secure password | On |
|
|
103
|
+
| `--tunnel` | Create an ephemeral devtunnel URL | On |
|
|
104
|
+
| `--no-tunnel` | Disable tunnel (LAN-only) | — |
|
|
105
|
+
| `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
|
|
106
|
+
| `--port <port>` | Server port | `3456` |
|
|
107
|
+
| `--host <addr>` | Bind address | `0.0.0.0` |
|
|
99
108
|
|
|
100
109
|
Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
|
|
101
110
|
|
|
102
111
|
## Security
|
|
103
112
|
|
|
104
|
-
TermBeam
|
|
113
|
+
TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. Be aware that the tunnel exposes your terminal to the internet — use `--no-tunnel` for LAN-only access, or `--host 127.0.0.1` to restrict to your machine only.
|
|
105
114
|
|
|
106
115
|
Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header. See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for more.
|
|
107
116
|
|
|
@@ -115,4 +124,4 @@ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
115
124
|
|
|
116
125
|
## Acknowledgments
|
|
117
126
|
|
|
118
|
-
Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
|
|
127
|
+
Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -647,7 +647,7 @@
|
|
|
647
647
|
</div>
|
|
648
648
|
<label for="sess-cwd">Working Directory</label>
|
|
649
649
|
<div class="cwd-picker">
|
|
650
|
-
<input type="text" id="sess-cwd" placeholder="
|
|
650
|
+
<input type="text" id="sess-cwd" placeholder="Uses server default" />
|
|
651
651
|
<button type="button" class="cwd-browse-btn" id="browse-btn" title="Browse folders">
|
|
652
652
|
<svg
|
|
653
653
|
width="18"
|
|
@@ -838,6 +838,10 @@
|
|
|
838
838
|
try {
|
|
839
839
|
const res = await fetch('/api/shells');
|
|
840
840
|
const data = await res.json();
|
|
841
|
+
if (data.cwd) {
|
|
842
|
+
document.getElementById('sess-cwd').placeholder = data.cwd;
|
|
843
|
+
hubServerCwd = data.cwd;
|
|
844
|
+
}
|
|
841
845
|
shellSelect.innerHTML = '';
|
|
842
846
|
for (const s of data.shells) {
|
|
843
847
|
const opt = document.createElement('option');
|
|
@@ -952,9 +956,10 @@
|
|
|
952
956
|
const browserBreadcrumb = document.getElementById('browser-breadcrumb');
|
|
953
957
|
const browserPath = document.getElementById('browser-path');
|
|
954
958
|
let currentBrowsePath = '/';
|
|
959
|
+
let hubServerCwd = '/';
|
|
955
960
|
|
|
956
961
|
document.getElementById('browse-btn').addEventListener('click', () => {
|
|
957
|
-
const initial = cwdInput.value.trim() ||
|
|
962
|
+
const initial = cwdInput.value.trim() || hubServerCwd;
|
|
958
963
|
navigateTo(initial);
|
|
959
964
|
browserOverlay.classList.add('visible');
|
|
960
965
|
});
|
|
@@ -980,11 +985,17 @@
|
|
|
980
985
|
try {
|
|
981
986
|
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
|
|
982
987
|
const data = await res.json();
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
988
|
+
let items = '';
|
|
989
|
+
// Add parent (..) entry unless at root
|
|
990
|
+
const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
|
|
991
|
+
if (parent && parent !== dir) {
|
|
992
|
+
items += `<div class="folder-item" data-path="${esc(parent)}">
|
|
993
|
+
<span class="folder-icon">📁</span>
|
|
994
|
+
<span class="folder-name">..</span>
|
|
995
|
+
<span class="folder-arrow">›</span>
|
|
996
|
+
</div>`;
|
|
986
997
|
}
|
|
987
|
-
|
|
998
|
+
items += data.dirs
|
|
988
999
|
.map((d) => {
|
|
989
1000
|
const name = d.split(/[/\\]/).pop();
|
|
990
1001
|
return `<div class="folder-item" data-path="${esc(d)}">
|
|
@@ -995,6 +1006,7 @@
|
|
|
995
1006
|
})
|
|
996
1007
|
.join('');
|
|
997
1008
|
|
|
1009
|
+
browserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
|
|
998
1010
|
browserList.querySelectorAll('.folder-item').forEach((el) => {
|
|
999
1011
|
el.addEventListener('click', () => navigateTo(el.dataset.path));
|
|
1000
1012
|
});
|
|
@@ -1005,13 +1017,15 @@
|
|
|
1005
1017
|
}
|
|
1006
1018
|
|
|
1007
1019
|
function renderBreadcrumb(dir) {
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1020
|
+
const sep = dir.includes('\\') ? '\\' : '/';
|
|
1021
|
+
const parts = dir.split(/[/\\]/).filter(Boolean);
|
|
1022
|
+
const isWindows = /^[A-Za-z]:/.test(dir);
|
|
1023
|
+
let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
|
|
1024
|
+
let accumulated = isWindows ? '' : '';
|
|
1011
1025
|
parts.forEach((part, i) => {
|
|
1012
|
-
accumulated += '
|
|
1026
|
+
accumulated += (i === 0 && isWindows ? '' : sep) + part;
|
|
1013
1027
|
const isCurrent = i === parts.length - 1;
|
|
1014
|
-
html += `<span class="crumb-sep">›</span>`;
|
|
1028
|
+
if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
|
|
1015
1029
|
html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${esc(accumulated)}">${esc(part)}</button>`;
|
|
1016
1030
|
});
|
|
1017
1031
|
browserBreadcrumb.innerHTML = html;
|
|
@@ -1031,6 +1045,7 @@
|
|
|
1031
1045
|
.catch(() => {});
|
|
1032
1046
|
|
|
1033
1047
|
loadSessions();
|
|
1048
|
+
loadShells();
|
|
1034
1049
|
setInterval(loadSessions, 3000);
|
|
1035
1050
|
|
|
1036
1051
|
// Share button
|
package/public/sw.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CACHE_NAME = 'termbeam-
|
|
1
|
+
const CACHE_NAME = 'termbeam-v5';
|
|
2
2
|
const SHELL_URLS = ['/', '/terminal'];
|
|
3
3
|
|
|
4
4
|
self.addEventListener('install', (event) => {
|
|
@@ -56,7 +56,21 @@ self.addEventListener('fetch', (event) => {
|
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
59
|
+
// Network-first for HTML pages (always get latest code)
|
|
60
|
+
if (event.request.mode === 'navigate' || event.request.headers.get('accept')?.includes('text/html')) {
|
|
61
|
+
event.respondWith(
|
|
62
|
+
fetch(event.request).then((response) => {
|
|
63
|
+
if (response.ok) {
|
|
64
|
+
const clone = response.clone();
|
|
65
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
66
|
+
}
|
|
67
|
+
return response;
|
|
68
|
+
}).catch(() => caches.match(event.request))
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cache-first for static assets (JS, CSS, images)
|
|
60
74
|
event.respondWith(
|
|
61
75
|
caches.match(event.request).then((cached) => {
|
|
62
76
|
if (cached) return cached;
|
package/public/terminal.html
CHANGED
|
@@ -309,7 +309,8 @@
|
|
|
309
309
|
#paste-overlay {
|
|
310
310
|
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
311
311
|
background: var(--overlay-bg); z-index: 150;
|
|
312
|
-
flex-direction: column; align-items: center; justify-content:
|
|
312
|
+
flex-direction: column; align-items: center; justify-content: flex-start;
|
|
313
|
+
padding-top: calc(80px + env(safe-area-inset-top, 0px)); gap: 12px;
|
|
313
314
|
}
|
|
314
315
|
#paste-overlay.visible { display: flex; }
|
|
315
316
|
#paste-overlay label { font-size: 15px; color: #fff; font-weight: 600; }
|
|
@@ -319,6 +320,38 @@
|
|
|
319
320
|
padding: 10px; font-size: 14px; font-family: 'NerdFont', 'JetBrains Mono', monospace; resize: vertical;
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
#select-overlay {
|
|
324
|
+
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
325
|
+
background: var(--bg); z-index: 160;
|
|
326
|
+
flex-direction: column;
|
|
327
|
+
}
|
|
328
|
+
#select-overlay.visible { display: flex; }
|
|
329
|
+
.select-overlay-header {
|
|
330
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
331
|
+
padding: 12px 12px; border-bottom: 1px solid var(--border);
|
|
332
|
+
font-size: 15px; font-weight: 600; color: var(--text);
|
|
333
|
+
padding-top: calc(12px + env(safe-area-inset-top, 0px));
|
|
334
|
+
min-height: 48px; box-sizing: border-box;
|
|
335
|
+
}
|
|
336
|
+
.select-overlay-header button {
|
|
337
|
+
padding: 6px 12px; border: none; border-radius: 8px;
|
|
338
|
+
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
339
|
+
white-space: nowrap; flex-shrink: 0;
|
|
340
|
+
transition: background 0.15s, transform 0.1s;
|
|
341
|
+
}
|
|
342
|
+
.select-overlay-header button:active { transform: scale(0.95); }
|
|
343
|
+
#select-copy { background: var(--accent); color: #fff; }
|
|
344
|
+
#select-close { background: var(--border); color: var(--text); }
|
|
345
|
+
#select-content {
|
|
346
|
+
flex: 1; overflow: auto; padding: 12px 16px;
|
|
347
|
+
font-family: 'NerdFont', 'JetBrains Mono', monospace;
|
|
348
|
+
font-size: 13px; line-height: 1.4; color: var(--text);
|
|
349
|
+
white-space: pre; word-break: normal;
|
|
350
|
+
-webkit-user-select: text; user-select: text;
|
|
351
|
+
margin: 0;
|
|
352
|
+
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
|
353
|
+
}
|
|
354
|
+
|
|
322
355
|
#reconnect-overlay {
|
|
323
356
|
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
324
357
|
background: var(--overlay-bg); z-index: 100;
|
|
@@ -714,6 +747,7 @@
|
|
|
714
747
|
<button class="key-btn" data-key="[H" title="Home">Home</button>
|
|
715
748
|
<button class="key-btn" data-key="[F" title="End">End</button>
|
|
716
749
|
<div class="key-sep"></div>
|
|
750
|
+
<button class="key-btn" id="select-btn" title="Copy text">Copy</button>
|
|
717
751
|
<button class="key-btn" id="paste-btn" title="Paste from clipboard">Paste</button>
|
|
718
752
|
<div class="key-sep"></div>
|
|
719
753
|
<button class="key-btn wide" data-key="	" title="Autocomplete">Tab</button>
|
|
@@ -740,6 +774,18 @@
|
|
|
740
774
|
</div>
|
|
741
775
|
</div>
|
|
742
776
|
|
|
777
|
+
<div id="select-overlay">
|
|
778
|
+
<div class="select-overlay-header">
|
|
779
|
+
<span id="select-title">Copy Text</span>
|
|
780
|
+
<div style="display:flex;gap:8px">
|
|
781
|
+
<button id="select-copy">Copy</button>
|
|
782
|
+
<button id="select-close">Done</button>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
<button id="select-load-more" style="display:none;width:100%;padding:8px;background:var(--surface);color:var(--accent);border:1px solid var(--border);border-radius:6px;font-size:13px;cursor:pointer;">▲ Load more</button>
|
|
786
|
+
<pre id="select-content"></pre>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
743
789
|
<!-- New Session Modal -->
|
|
744
790
|
<div class="modal-overlay" id="new-session-modal">
|
|
745
791
|
<div class="modal">
|
|
@@ -804,8 +850,20 @@
|
|
|
804
850
|
const managed = new Map(); // sessionId -> ManagedSession
|
|
805
851
|
let activeId = null;
|
|
806
852
|
let splitMode = false;
|
|
853
|
+
|
|
807
854
|
let splitSecondId = null;
|
|
808
855
|
|
|
856
|
+
// Clipboard copy fallback for non-secure contexts (HTTP over LAN)
|
|
857
|
+
function copyFallback(text) {
|
|
858
|
+
const ta = document.createElement('textarea');
|
|
859
|
+
ta.value = text;
|
|
860
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
|
861
|
+
document.body.appendChild(ta);
|
|
862
|
+
ta.select();
|
|
863
|
+
try { document.execCommand('copy'); showToast('Copied!'); } catch {}
|
|
864
|
+
document.body.removeChild(ta);
|
|
865
|
+
}
|
|
866
|
+
|
|
809
867
|
// ===== DOM Refs =====
|
|
810
868
|
const statusDot = document.getElementById('status-dot');
|
|
811
869
|
const statusText = document.getElementById('status-text');
|
|
@@ -937,7 +995,10 @@
|
|
|
937
995
|
renderTabs();
|
|
938
996
|
setupKeyBar();
|
|
939
997
|
setupPaste();
|
|
998
|
+
setupImagePaste();
|
|
999
|
+
setupSelectMode();
|
|
940
1000
|
setupNewSessionModal();
|
|
1001
|
+
loadShellsForModal();
|
|
941
1002
|
startPolling();
|
|
942
1003
|
|
|
943
1004
|
// Zoom
|
|
@@ -1120,8 +1181,14 @@
|
|
|
1120
1181
|
// Copy on selection
|
|
1121
1182
|
term.onSelectionChange(() => {
|
|
1122
1183
|
const sel = term.getSelection();
|
|
1123
|
-
if (sel
|
|
1124
|
-
|
|
1184
|
+
if (!sel) return;
|
|
1185
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1186
|
+
navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {
|
|
1187
|
+
// Fallback for non-secure contexts (HTTP over LAN)
|
|
1188
|
+
copyFallback(sel);
|
|
1189
|
+
});
|
|
1190
|
+
} else {
|
|
1191
|
+
copyFallback(sel);
|
|
1125
1192
|
}
|
|
1126
1193
|
});
|
|
1127
1194
|
|
|
@@ -1203,6 +1270,11 @@
|
|
|
1203
1270
|
reconnectOverlay.classList.add('visible');
|
|
1204
1271
|
}
|
|
1205
1272
|
} else if (msg.type === 'error') {
|
|
1273
|
+
if (msg.message === 'Session not found') {
|
|
1274
|
+
ms.exited = true;
|
|
1275
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1276
|
+
renderTabs();
|
|
1277
|
+
}
|
|
1206
1278
|
if (ms.id === activeId) {
|
|
1207
1279
|
statusText.textContent = msg.message;
|
|
1208
1280
|
reconnectOverlay.querySelector('.msg').textContent = msg.message;
|
|
@@ -1655,18 +1727,25 @@
|
|
|
1655
1727
|
}, 400);
|
|
1656
1728
|
}
|
|
1657
1729
|
|
|
1730
|
+
let keyBarTouched = false;
|
|
1658
1731
|
keyBar.addEventListener('mousedown', e => {
|
|
1732
|
+
if (keyBarTouched) { keyBarTouched = false; return; }
|
|
1659
1733
|
const btn = e.target.closest('.key-btn');
|
|
1660
|
-
if (btn) { e.preventDefault(); startRepeat(btn); }
|
|
1734
|
+
if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
|
|
1661
1735
|
});
|
|
1662
1736
|
keyBar.addEventListener('mouseup', stopRepeat);
|
|
1663
1737
|
keyBar.addEventListener('mouseleave', stopRepeat);
|
|
1664
1738
|
|
|
1665
1739
|
keyBar.addEventListener('touchstart', e => {
|
|
1740
|
+
keyBarTouched = true;
|
|
1666
1741
|
const btn = e.target.closest('.key-btn');
|
|
1667
|
-
if (btn) startRepeat(btn);
|
|
1668
|
-
}, { passive:
|
|
1669
|
-
keyBar.addEventListener('touchend',
|
|
1742
|
+
if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
|
|
1743
|
+
}, { passive: false });
|
|
1744
|
+
keyBar.addEventListener('touchend', (e) => {
|
|
1745
|
+
const btn = e.target.closest('.key-btn');
|
|
1746
|
+
if (btn && btn.dataset.key) e.preventDefault();
|
|
1747
|
+
stopRepeat();
|
|
1748
|
+
});
|
|
1670
1749
|
keyBar.addEventListener('touchcancel', stopRepeat);
|
|
1671
1750
|
|
|
1672
1751
|
keyBar.addEventListener('click', e => {
|
|
@@ -1679,6 +1758,7 @@
|
|
|
1679
1758
|
function setupPaste() {
|
|
1680
1759
|
const pasteOverlay = document.getElementById('paste-overlay');
|
|
1681
1760
|
const pasteInput = document.getElementById('paste-input');
|
|
1761
|
+
const pasteBtn = document.getElementById('paste-btn');
|
|
1682
1762
|
|
|
1683
1763
|
function openPasteModal() {
|
|
1684
1764
|
pasteInput.value = '';
|
|
@@ -1686,19 +1766,65 @@
|
|
|
1686
1766
|
pasteInput.focus();
|
|
1687
1767
|
}
|
|
1688
1768
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1769
|
+
async function handlePaste() {
|
|
1770
|
+
// Try clipboard API for text first (most common), then images, then fallback to modal
|
|
1691
1771
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
1692
|
-
|
|
1693
|
-
const
|
|
1694
|
-
if (text
|
|
1695
|
-
ms.
|
|
1696
|
-
|
|
1772
|
+
try {
|
|
1773
|
+
const text = await navigator.clipboard.readText();
|
|
1774
|
+
if (text) {
|
|
1775
|
+
const ms = managed.get(activeId);
|
|
1776
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1777
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
1778
|
+
showToast('Pasted!');
|
|
1779
|
+
}
|
|
1780
|
+
return;
|
|
1697
1781
|
}
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1782
|
+
} catch (err) {
|
|
1783
|
+
console.warn('clipboard.readText failed:', err.message);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
// Image paste: try clipboard.read() only if readText didn't work
|
|
1787
|
+
if (navigator.clipboard && navigator.clipboard.read) {
|
|
1788
|
+
try {
|
|
1789
|
+
const items = await navigator.clipboard.read();
|
|
1790
|
+
for (const item of items) {
|
|
1791
|
+
const imageType = item.types.find(t => t.startsWith('image/'));
|
|
1792
|
+
if (imageType) {
|
|
1793
|
+
const blob = await item.getType(imageType);
|
|
1794
|
+
const res = await fetch('/api/upload', {
|
|
1795
|
+
method: 'POST',
|
|
1796
|
+
headers: { 'Content-Type': imageType },
|
|
1797
|
+
body: blob,
|
|
1798
|
+
credentials: 'same-origin',
|
|
1799
|
+
});
|
|
1800
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
1801
|
+
const data = await res.json();
|
|
1802
|
+
const ms = managed.get(activeId);
|
|
1803
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1804
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
|
|
1805
|
+
showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
|
|
1806
|
+
}
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
console.warn('clipboard.read failed:', err.message);
|
|
1812
|
+
}
|
|
1701
1813
|
}
|
|
1814
|
+
openPasteModal();
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Use touchend directly for iOS Safari (click may not fire reliably)
|
|
1818
|
+
let pasteTouched = false;
|
|
1819
|
+
pasteBtn.addEventListener('touchend', (e) => {
|
|
1820
|
+
e.preventDefault();
|
|
1821
|
+
pasteTouched = true;
|
|
1822
|
+
handlePaste();
|
|
1823
|
+
});
|
|
1824
|
+
pasteBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
1825
|
+
pasteBtn.addEventListener('click', () => {
|
|
1826
|
+
if (pasteTouched) { pasteTouched = false; return; }
|
|
1827
|
+
handlePaste();
|
|
1702
1828
|
});
|
|
1703
1829
|
|
|
1704
1830
|
document.getElementById('paste-send').addEventListener('click', () => {
|
|
@@ -1717,6 +1843,130 @@
|
|
|
1717
1843
|
});
|
|
1718
1844
|
}
|
|
1719
1845
|
|
|
1846
|
+
// ===== Select Text Overlay =====
|
|
1847
|
+
function setupSelectMode() {
|
|
1848
|
+
const selectBtn = document.getElementById('select-btn');
|
|
1849
|
+
const selectOverlay = document.getElementById('select-overlay');
|
|
1850
|
+
const selectContent = document.getElementById('select-content');
|
|
1851
|
+
const PAGE_SIZE = 200;
|
|
1852
|
+
let allLines = [];
|
|
1853
|
+
let loadedFrom = 0;
|
|
1854
|
+
|
|
1855
|
+
function renderLines(from, to) {
|
|
1856
|
+
return allLines.slice(from, to).join('\n');
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function openSelectOverlay() {
|
|
1860
|
+
const ms = managed.get(activeId);
|
|
1861
|
+
if (!ms) return;
|
|
1862
|
+
const buf = ms.term.buffer.active;
|
|
1863
|
+
allLines = [];
|
|
1864
|
+
for (let i = 0; i < buf.length; i++) {
|
|
1865
|
+
const line = buf.getLine(i);
|
|
1866
|
+
if (line) allLines.push(line.translateToString(true));
|
|
1867
|
+
}
|
|
1868
|
+
// Trim trailing empty lines
|
|
1869
|
+
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') allLines.pop();
|
|
1870
|
+
|
|
1871
|
+
// Show last PAGE_SIZE lines
|
|
1872
|
+
loadedFrom = Math.max(0, allLines.length - PAGE_SIZE);
|
|
1873
|
+
selectContent.textContent = renderLines(loadedFrom, allLines.length);
|
|
1874
|
+
|
|
1875
|
+
// Show/hide "Load more" button
|
|
1876
|
+
const loadMoreBtn = document.getElementById('select-load-more');
|
|
1877
|
+
loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
|
|
1878
|
+
loadMoreBtn.textContent = `▲ Load more (${loadedFrom} lines above)`;
|
|
1879
|
+
|
|
1880
|
+
// Show line count in title
|
|
1881
|
+
const title = document.getElementById('select-title');
|
|
1882
|
+
const shown = allLines.length - loadedFrom;
|
|
1883
|
+
title.textContent = allLines.length <= PAGE_SIZE
|
|
1884
|
+
? `Copy Text (${allLines.length} lines)`
|
|
1885
|
+
: `Copy Text (${shown}/${allLines.length} lines)`;
|
|
1886
|
+
|
|
1887
|
+
selectBtn.style.display = 'none';
|
|
1888
|
+
selectOverlay.classList.add('visible');
|
|
1889
|
+
selectContent.scrollTop = selectContent.scrollHeight;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
document.getElementById('select-load-more').addEventListener('click', () => {
|
|
1893
|
+
const prevHeight = selectContent.scrollHeight;
|
|
1894
|
+
const newFrom = Math.max(0, loadedFrom - PAGE_SIZE);
|
|
1895
|
+
const chunk = renderLines(newFrom, loadedFrom);
|
|
1896
|
+
selectContent.textContent = chunk + '\n' + selectContent.textContent;
|
|
1897
|
+
loadedFrom = newFrom;
|
|
1898
|
+
// Keep scroll position stable
|
|
1899
|
+
selectContent.scrollTop = selectContent.scrollHeight - prevHeight;
|
|
1900
|
+
const loadMoreBtn = document.getElementById('select-load-more');
|
|
1901
|
+
loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
|
|
1902
|
+
loadMoreBtn.textContent = loadedFrom > 0 ? `▲ Load more (${loadedFrom} lines above)` : '';
|
|
1903
|
+
// Update title
|
|
1904
|
+
const title = document.getElementById('select-title');
|
|
1905
|
+
const shown = allLines.length - loadedFrom;
|
|
1906
|
+
title.textContent = `Copy Text (${shown}/${allLines.length} lines)`;
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
selectBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
1910
|
+
selectBtn.addEventListener('click', openSelectOverlay);
|
|
1911
|
+
|
|
1912
|
+
document.getElementById('select-copy').addEventListener('click', () => {
|
|
1913
|
+
// Copy finger selection if any, otherwise copy all loaded text
|
|
1914
|
+
const sel = window.getSelection();
|
|
1915
|
+
const text = (sel && sel.toString()) ? sel.toString() : selectContent.textContent;
|
|
1916
|
+
if (!text) return;
|
|
1917
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1918
|
+
navigator.clipboard.writeText(text).then(() => showToast('Copied!')).catch(() => {
|
|
1919
|
+
copyFallback(text);
|
|
1920
|
+
});
|
|
1921
|
+
} else {
|
|
1922
|
+
copyFallback(text);
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
document.getElementById('select-close').addEventListener('click', () => {
|
|
1927
|
+
selectOverlay.classList.remove('visible');
|
|
1928
|
+
selectContent.textContent = '';
|
|
1929
|
+
allLines = [];
|
|
1930
|
+
selectBtn.style.display = '';
|
|
1931
|
+
const ms = managed.get(activeId);
|
|
1932
|
+
if (ms) ms.term.focus();
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// ===== Image Paste =====
|
|
1937
|
+
function setupImagePaste() {
|
|
1938
|
+
document.addEventListener('paste', async (e) => {
|
|
1939
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
1940
|
+
if (!items) return;
|
|
1941
|
+
|
|
1942
|
+
for (const item of items) {
|
|
1943
|
+
if (item.type.startsWith('image/')) {
|
|
1944
|
+
e.preventDefault();
|
|
1945
|
+
const blob = item.getAsFile();
|
|
1946
|
+
if (!blob) return;
|
|
1947
|
+
|
|
1948
|
+
try {
|
|
1949
|
+
const res = await fetch('/api/upload', {
|
|
1950
|
+
method: 'POST',
|
|
1951
|
+
headers: { 'Content-Type': item.type },
|
|
1952
|
+
body: blob,
|
|
1953
|
+
});
|
|
1954
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
1955
|
+
const data = await res.json();
|
|
1956
|
+
const ms = managed.get(activeId);
|
|
1957
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1958
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
|
|
1959
|
+
showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
|
|
1960
|
+
}
|
|
1961
|
+
} catch (err) {
|
|
1962
|
+
showToast('Image paste failed');
|
|
1963
|
+
}
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1720
1970
|
// ===== New Session Modal =====
|
|
1721
1971
|
let shellsLoaded = false;
|
|
1722
1972
|
|
|
@@ -1755,9 +2005,10 @@
|
|
|
1755
2005
|
const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
|
|
1756
2006
|
const nsBrowserPath = document.getElementById('ns-browser-path');
|
|
1757
2007
|
let nsBrowsePath = '/';
|
|
2008
|
+
let serverCwd = '/';
|
|
1758
2009
|
|
|
1759
2010
|
document.getElementById('ns-browse-btn').addEventListener('click', () => {
|
|
1760
|
-
const initial = nsCwdInput.value.trim() ||
|
|
2011
|
+
const initial = nsCwdInput.value.trim() || serverCwd;
|
|
1761
2012
|
nsBrowseNavigate(initial);
|
|
1762
2013
|
nsBrowserOverlay.classList.add('visible');
|
|
1763
2014
|
});
|
|
@@ -1782,11 +2033,17 @@
|
|
|
1782
2033
|
try {
|
|
1783
2034
|
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
|
|
1784
2035
|
const data = await res.json();
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2036
|
+
let items = '';
|
|
2037
|
+
// Add parent (..) entry unless at root
|
|
2038
|
+
const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
|
|
2039
|
+
if (parent && parent !== dir) {
|
|
2040
|
+
items += `<div class="folder-item" data-path="${escAttr(parent)}">
|
|
2041
|
+
<span class="folder-icon">📁</span>
|
|
2042
|
+
<span class="folder-name">..</span>
|
|
2043
|
+
<span class="folder-arrow">›</span>
|
|
2044
|
+
</div>`;
|
|
1788
2045
|
}
|
|
1789
|
-
|
|
2046
|
+
items += data.dirs.map(d => {
|
|
1790
2047
|
const name = d.split(/[/\\]/).pop();
|
|
1791
2048
|
return `<div class="folder-item" data-path="${escAttr(d)}">
|
|
1792
2049
|
<span class="folder-icon">📁</span>
|
|
@@ -1794,6 +2051,7 @@
|
|
|
1794
2051
|
<span class="folder-arrow">›</span>
|
|
1795
2052
|
</div>`;
|
|
1796
2053
|
}).join('');
|
|
2054
|
+
nsBrowserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
|
|
1797
2055
|
nsBrowserList.querySelectorAll('.folder-item').forEach(el => {
|
|
1798
2056
|
el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
|
|
1799
2057
|
});
|
|
@@ -1804,13 +2062,15 @@
|
|
|
1804
2062
|
}
|
|
1805
2063
|
|
|
1806
2064
|
function nsBrowseRenderBreadcrumb(dir) {
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
2065
|
+
const sep = dir.includes('\\') ? '\\' : '/';
|
|
2066
|
+
const parts = dir.split(/[/\\]/).filter(Boolean);
|
|
2067
|
+
const isWindows = /^[A-Za-z]:/.test(dir);
|
|
2068
|
+
let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
|
|
2069
|
+
let accumulated = isWindows ? '' : '';
|
|
1810
2070
|
parts.forEach((part, i) => {
|
|
1811
|
-
accumulated += '
|
|
2071
|
+
accumulated += (i === 0 && isWindows ? '' : sep) + part;
|
|
1812
2072
|
const isCurrent = i === parts.length - 1;
|
|
1813
|
-
html += `<span class="crumb-sep">›</span>`;
|
|
2073
|
+
if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
|
|
1814
2074
|
html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
|
|
1815
2075
|
});
|
|
1816
2076
|
nsBrowserBreadcrumb.innerHTML = html;
|
|
@@ -1826,6 +2086,10 @@
|
|
|
1826
2086
|
const sel = document.getElementById('ns-shell');
|
|
1827
2087
|
try {
|
|
1828
2088
|
const data = await fetch('/api/shells').then(r => r.json());
|
|
2089
|
+
if (data.cwd) {
|
|
2090
|
+
serverCwd = data.cwd;
|
|
2091
|
+
document.getElementById('ns-cwd').placeholder = data.cwd;
|
|
2092
|
+
}
|
|
1829
2093
|
sel.innerHTML = data.shells.map(s =>
|
|
1830
2094
|
'<option value="' + escAttr(s.cmd) + '"' + (s.cmd === data.default ? ' selected' : '') + '>'
|
|
1831
2095
|
+ esc(s.name) + ' (' + esc(s.cmd) + ')</option>'
|
|
@@ -1913,6 +2177,7 @@
|
|
|
1913
2177
|
statusText.textContent = '';
|
|
1914
2178
|
statusDot.className = '';
|
|
1915
2179
|
document.getElementById('stop-btn').style.display = 'none';
|
|
2180
|
+
reconnectOverlay.classList.remove('visible');
|
|
1916
2181
|
}
|
|
1917
2182
|
}
|
|
1918
2183
|
}
|
package/src/auth.js
CHANGED
|
@@ -107,6 +107,7 @@ function createAuth(password) {
|
|
|
107
107
|
const attempts = authAttempts.get(ip) || [];
|
|
108
108
|
const recent = attempts.filter((t) => now - t < window);
|
|
109
109
|
if (recent.length >= maxAttempts) {
|
|
110
|
+
console.warn(`[termbeam] Auth: rate limit exceeded for ${ip}`);
|
|
110
111
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
111
112
|
}
|
|
112
113
|
recent.push(now);
|
package/src/cli.js
CHANGED
|
@@ -11,19 +11,26 @@ Usage:
|
|
|
11
11
|
|
|
12
12
|
Options:
|
|
13
13
|
--password <pw> Set access password (or TERMBEAM_PASSWORD env var)
|
|
14
|
-
--generate-password Auto-generate a secure password
|
|
15
|
-
--
|
|
14
|
+
--generate-password Auto-generate a secure password (default: auto)
|
|
15
|
+
--no-password Disable password authentication
|
|
16
|
+
--tunnel Create a public devtunnel URL (default: on)
|
|
17
|
+
--no-tunnel Disable tunnel (LAN-only mode)
|
|
16
18
|
--persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
|
|
17
19
|
--port <port> Set port (default: 3456, or PORT env var)
|
|
18
20
|
--host <addr> Bind address (default: 0.0.0.0)
|
|
19
21
|
-h, --help Show this help
|
|
20
22
|
-v, --version Show version
|
|
21
23
|
|
|
24
|
+
Defaults:
|
|
25
|
+
By default, TermBeam enables tunnel + auto-generated password for secure
|
|
26
|
+
mobile access (clipboard, HTTPS). Use --no-tunnel for LAN-only mode.
|
|
27
|
+
|
|
22
28
|
Examples:
|
|
23
|
-
termbeam Start with
|
|
24
|
-
termbeam --
|
|
25
|
-
termbeam --
|
|
26
|
-
termbeam --
|
|
29
|
+
termbeam Start with tunnel + auto password
|
|
30
|
+
termbeam --no-tunnel LAN-only, no tunnel
|
|
31
|
+
termbeam --no-tunnel --no-password LAN-only, no auth (local use)
|
|
32
|
+
termbeam --password secret Start with specific password
|
|
33
|
+
termbeam --persisted-tunnel Stable tunnel URL across restarts
|
|
27
34
|
termbeam /bin/bash Use bash instead of default shell
|
|
28
35
|
|
|
29
36
|
Environment:
|
|
@@ -146,8 +153,10 @@ function parseArgs() {
|
|
|
146
153
|
const defaultShell = getDefaultShell();
|
|
147
154
|
const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
|
|
148
155
|
let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
|
|
149
|
-
let useTunnel =
|
|
156
|
+
let useTunnel = true;
|
|
157
|
+
let noTunnel = false;
|
|
150
158
|
let persistedTunnel = false;
|
|
159
|
+
let explicitPassword = !!password;
|
|
151
160
|
|
|
152
161
|
const args = process.argv.slice(2);
|
|
153
162
|
const filteredArgs = [];
|
|
@@ -155,13 +164,17 @@ function parseArgs() {
|
|
|
155
164
|
for (let i = 0; i < args.length; i++) {
|
|
156
165
|
if (args[i] === '--password' && args[i + 1]) {
|
|
157
166
|
password = args[++i];
|
|
167
|
+
explicitPassword = true;
|
|
158
168
|
} else if (args[i] === '--tunnel') {
|
|
159
169
|
useTunnel = true;
|
|
170
|
+
} else if (args[i] === '--no-tunnel') {
|
|
171
|
+
noTunnel = true;
|
|
160
172
|
} else if (args[i] === '--persisted-tunnel') {
|
|
161
173
|
useTunnel = true;
|
|
162
174
|
persistedTunnel = true;
|
|
163
175
|
} else if (args[i].startsWith('--password=')) {
|
|
164
176
|
password = args[i].split('=')[1];
|
|
177
|
+
explicitPassword = true;
|
|
165
178
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
166
179
|
printHelp();
|
|
167
180
|
process.exit(0);
|
|
@@ -171,7 +184,10 @@ function parseArgs() {
|
|
|
171
184
|
process.exit(0);
|
|
172
185
|
} else if (args[i] === '--generate-password') {
|
|
173
186
|
password = crypto.randomBytes(16).toString('base64url');
|
|
174
|
-
|
|
187
|
+
explicitPassword = true;
|
|
188
|
+
} else if (args[i] === '--no-password') {
|
|
189
|
+
password = null;
|
|
190
|
+
explicitPassword = true;
|
|
175
191
|
} else if (args[i] === '--port' && args[i + 1]) {
|
|
176
192
|
port = parseInt(args[++i], 10);
|
|
177
193
|
} else if (args[i] === '--host' && args[i + 1]) {
|
|
@@ -181,6 +197,14 @@ function parseArgs() {
|
|
|
181
197
|
}
|
|
182
198
|
}
|
|
183
199
|
|
|
200
|
+
// Default: auto-generate password if none specified
|
|
201
|
+
if (!explicitPassword && !password) {
|
|
202
|
+
password = crypto.randomBytes(16).toString('base64url');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --no-tunnel disables the default tunnel
|
|
206
|
+
if (noTunnel) useTunnel = false;
|
|
207
|
+
|
|
184
208
|
const shell = filteredArgs[0] || defaultShell;
|
|
185
209
|
const shellArgs = filteredArgs.slice(1);
|
|
186
210
|
|
package/src/routes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const express = require('express');
|
|
5
6
|
const { detectShells } = require('./shells');
|
|
6
7
|
|
|
@@ -27,8 +28,10 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
27
28
|
maxAge: 24 * 60 * 60 * 1000,
|
|
28
29
|
secure: false,
|
|
29
30
|
});
|
|
31
|
+
console.log(`[termbeam] Auth: login success from ${req.ip}`);
|
|
30
32
|
res.json({ ok: true });
|
|
31
33
|
} else {
|
|
34
|
+
console.warn(`[termbeam] Auth: login failed from ${req.ip}`);
|
|
32
35
|
res.status(401).json({ error: 'wrong password' });
|
|
33
36
|
}
|
|
34
37
|
});
|
|
@@ -66,7 +69,7 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
66
69
|
// Available shells
|
|
67
70
|
app.get('/api/shells', auth.middleware, (_req, res) => {
|
|
68
71
|
const shells = detectShells();
|
|
69
|
-
res.json({ shells, default: config.defaultShell });
|
|
72
|
+
res.json({ shells, default: config.defaultShell, cwd: config.cwd });
|
|
70
73
|
});
|
|
71
74
|
|
|
72
75
|
app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
|
|
@@ -89,11 +92,64 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
89
92
|
}
|
|
90
93
|
});
|
|
91
94
|
|
|
95
|
+
// Image upload
|
|
96
|
+
app.post('/api/upload', auth.middleware, (req, res) => {
|
|
97
|
+
const contentType = req.headers['content-type'] || '';
|
|
98
|
+
if (!contentType.startsWith('image/')) {
|
|
99
|
+
console.warn(`[termbeam] Upload rejected: invalid content-type "${contentType}"`);
|
|
100
|
+
return res.status(400).json({ error: 'Invalid content type' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let size = 0;
|
|
105
|
+
let aborted = false;
|
|
106
|
+
const limit = 10 * 1024 * 1024;
|
|
107
|
+
|
|
108
|
+
req.on('data', (chunk) => {
|
|
109
|
+
if (aborted) return;
|
|
110
|
+
size += chunk.length;
|
|
111
|
+
if (size > limit) {
|
|
112
|
+
aborted = true;
|
|
113
|
+
console.warn(`[termbeam] Upload rejected: file too large (${size} bytes)`);
|
|
114
|
+
res.status(413).json({ error: 'File too large' });
|
|
115
|
+
req.resume(); // drain remaining data
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(chunk);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
req.on('end', () => {
|
|
122
|
+
if (aborted) return;
|
|
123
|
+
const buffer = Buffer.concat(chunks);
|
|
124
|
+
if (!buffer.length) {
|
|
125
|
+
return res.status(400).json({ error: 'No image data' });
|
|
126
|
+
}
|
|
127
|
+
const ext = {
|
|
128
|
+
'image/png': '.png',
|
|
129
|
+
'image/jpeg': '.jpg',
|
|
130
|
+
'image/gif': '.gif',
|
|
131
|
+
'image/webp': '.webp',
|
|
132
|
+
'image/bmp': '.bmp',
|
|
133
|
+
}[contentType] || '.png';
|
|
134
|
+
const filename = `termbeam-${crypto.randomUUID()}${ext}`;
|
|
135
|
+
const filepath = path.join(os.tmpdir(), filename);
|
|
136
|
+
fs.writeFileSync(filepath, buffer);
|
|
137
|
+
console.log(`[termbeam] Upload: ${filename} (${buffer.length} bytes)`);
|
|
138
|
+
res.json({ path: filepath });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
req.on('error', (err) => {
|
|
142
|
+
console.error(`[termbeam] Upload error: ${err.message}`);
|
|
143
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
92
147
|
// Directory listing for folder browser
|
|
93
148
|
app.get('/api/dirs', auth.middleware, (req, res) => {
|
|
94
|
-
const query = req.query.q ||
|
|
95
|
-
const
|
|
96
|
-
const
|
|
149
|
+
const query = req.query.q || (config.cwd + path.sep);
|
|
150
|
+
const endsWithSep = query.endsWith('/') || query.endsWith('\\');
|
|
151
|
+
const dir = endsWithSep ? query : path.dirname(query);
|
|
152
|
+
const prefix = endsWithSep ? '' : path.basename(query);
|
|
97
153
|
|
|
98
154
|
try {
|
|
99
155
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
package/src/server.js
CHANGED
|
@@ -26,13 +26,14 @@ app.use(cookieParser());
|
|
|
26
26
|
app.use((_req, res, next) => {
|
|
27
27
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
28
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
29
|
-
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
30
29
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
30
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
31
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:; font-src 'self' https://cdn.jsdelivr.net");
|
|
31
32
|
next();
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
const server = http.createServer(app);
|
|
35
|
-
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
36
|
+
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
|
|
36
37
|
|
|
37
38
|
setupRoutes(app, { auth, sessions, config });
|
|
38
39
|
setupWebSocket(wss, { auth, sessions });
|
|
@@ -105,32 +106,37 @@ server.listen(config.port, config.host, async () => {
|
|
|
105
106
|
console.log('');
|
|
106
107
|
console.log(` Beam your terminal to any device 📡 v${config.version}`);
|
|
107
108
|
console.log('');
|
|
108
|
-
console.log(` Local: http://localhost:${config.port}`);
|
|
109
109
|
const isLanReachable = config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
110
|
-
if (isLanReachable) {
|
|
111
|
-
console.log(` LAN: ${localUrl}`);
|
|
112
|
-
}
|
|
113
|
-
console.log(` Shell: ${config.shell}`);
|
|
114
|
-
console.log(` Session: ${defaultId}`);
|
|
115
110
|
const gn = '\x1b[38;5;114m'; // green
|
|
116
|
-
|
|
111
|
+
const dm = '\x1b[2m'; // dim
|
|
117
112
|
|
|
118
113
|
let publicUrl = null;
|
|
119
114
|
if (config.useTunnel) {
|
|
120
115
|
const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
|
|
121
116
|
if (tunnel) {
|
|
122
117
|
publicUrl = tunnel.url;
|
|
123
|
-
console.log('');
|
|
124
|
-
console.log(` 🌐 Public: ${publicUrl}`);
|
|
125
|
-
console.log(` Tunnel: ${tunnel.mode} (expires in ${tunnel.expiry})`);
|
|
126
118
|
} else {
|
|
127
|
-
console.log('');
|
|
128
119
|
console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
|
|
129
120
|
}
|
|
130
121
|
}
|
|
131
122
|
|
|
123
|
+
console.log(` Shell: ${config.shell}`);
|
|
124
|
+
console.log(` Session: ${defaultId}`);
|
|
125
|
+
console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
|
|
126
|
+
console.log('');
|
|
127
|
+
|
|
128
|
+
if (publicUrl) {
|
|
129
|
+
console.log(` 🌐 Public: ${publicUrl}`);
|
|
130
|
+
}
|
|
131
|
+
console.log(` Local: http://localhost:${config.port}`);
|
|
132
|
+
if (isLanReachable) {
|
|
133
|
+
console.log(` LAN: ${localUrl}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
132
136
|
const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${config.port}`);
|
|
133
137
|
console.log('');
|
|
138
|
+
console.log(` ${dm}📋 Clipboard requires HTTPS — use the Public or localhost URL${rs}`);
|
|
139
|
+
console.log('');
|
|
134
140
|
try {
|
|
135
141
|
const qr = await QRCode.toString(qrUrl, { type: 'terminal', small: true });
|
|
136
142
|
console.log(qr);
|
package/src/sessions.js
CHANGED
package/src/tunnel.js
CHANGED
|
@@ -173,7 +173,8 @@ async function startTunnel(port, options = {}) {
|
|
|
173
173
|
hostProc.stderr.on('data', (data) => {
|
|
174
174
|
output += data.toString();
|
|
175
175
|
});
|
|
176
|
-
hostProc.on('error', () => {
|
|
176
|
+
hostProc.on('error', (err) => {
|
|
177
|
+
console.error(`[termbeam] Tunnel process error: ${err.message}`);
|
|
177
178
|
clearTimeout(timeout);
|
|
178
179
|
resolve(null);
|
|
179
180
|
});
|
package/src/websocket.js
CHANGED
|
@@ -35,7 +35,9 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
35
35
|
if (msg.password === auth.password || auth.validateToken(msg.token)) {
|
|
36
36
|
authenticated = true;
|
|
37
37
|
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
38
|
+
console.log('[termbeam] WS: auth success');
|
|
38
39
|
} else {
|
|
40
|
+
console.warn('[termbeam] WS: auth failed');
|
|
39
41
|
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
|
|
40
42
|
ws.close();
|
|
41
43
|
}
|
|
@@ -52,6 +54,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
52
54
|
const session = sessions.get(msg.sessionId);
|
|
53
55
|
if (!session) {
|
|
54
56
|
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
|
|
57
|
+
console.warn(`[termbeam] WS: attach failed — session ${msg.sessionId} not found`);
|
|
55
58
|
return;
|
|
56
59
|
}
|
|
57
60
|
attached = session;
|
|
@@ -76,8 +79,8 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
76
79
|
recalcPtySize(attached);
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
|
-
} catch {
|
|
80
|
-
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.warn('WS: dropped unparseable message:', err.message);
|
|
81
84
|
}
|
|
82
85
|
});
|
|
83
86
|
|