termites 1.0.37 → 1.0.39

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +244 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termites",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "description": "Local multi-terminal manager with web interface",
5
5
  "main": "server.js",
6
6
  "scripts": {
package/server.js CHANGED
@@ -113,6 +113,11 @@ class TermitesServer {
113
113
  return cookies;
114
114
  }
115
115
 
116
+ isAuthenticated(req) {
117
+ const cookies = this.parseCookies(req.headers.cookie || '');
118
+ return cookies.session === this.sessionToken;
119
+ }
120
+
116
121
  // Create a new local terminal
117
122
  createTerminal() {
118
123
  const terminalId = crypto.randomUUID();
@@ -416,6 +421,81 @@ class TermitesServer {
416
421
  return;
417
422
  }
418
423
 
424
+ // Change password API
425
+ if (url === '/api/change-password' && req.method === 'POST') {
426
+ if (!this.isAuthenticated(req)) {
427
+ res.writeHead(401, { 'Content-Type': 'application/json' });
428
+ res.end(JSON.stringify({ success: false, error: 'Not authenticated' }));
429
+ return;
430
+ }
431
+ let body = '';
432
+ req.on('data', chunk => body += chunk);
433
+ req.on('end', () => {
434
+ try {
435
+ const { currentPassword, newPassword } = JSON.parse(body);
436
+ if (!verifyPassword(currentPassword, this.config.passwordHash)) {
437
+ res.writeHead(401, { 'Content-Type': 'application/json' });
438
+ res.end(JSON.stringify({ success: false, error: 'Current password is incorrect' }));
439
+ return;
440
+ }
441
+ if (!newPassword || newPassword.length < 4) {
442
+ res.writeHead(400, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ success: false, error: 'New password must be at least 4 characters' }));
444
+ return;
445
+ }
446
+ this.config.passwordHash = hashPassword(newPassword);
447
+ this.config.sessionToken = crypto.randomBytes(32).toString('hex');
448
+ this.sessionToken = this.config.sessionToken;
449
+ saveConfig(this.config);
450
+ res.writeHead(200, {
451
+ 'Content-Type': 'application/json',
452
+ 'Set-Cookie': `session=${this.sessionToken}; Path=/; HttpOnly; SameSite=Strict`
453
+ });
454
+ res.end(JSON.stringify({ success: true }));
455
+ } catch (e) {
456
+ res.writeHead(400, { 'Content-Type': 'application/json' });
457
+ res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
458
+ }
459
+ });
460
+ return;
461
+ }
462
+
463
+ // Get settings API
464
+ if (url === '/api/settings' && req.method === 'GET') {
465
+ if (!this.isAuthenticated(req)) {
466
+ res.writeHead(401, { 'Content-Type': 'application/json' });
467
+ res.end(JSON.stringify({ success: false, error: 'Not authenticated' }));
468
+ return;
469
+ }
470
+ res.writeHead(200, { 'Content-Type': 'application/json' });
471
+ res.end(JSON.stringify({ success: true, settings: this.config.settings || {} }));
472
+ return;
473
+ }
474
+
475
+ // Save settings API
476
+ if (url === '/api/settings' && req.method === 'POST') {
477
+ if (!this.isAuthenticated(req)) {
478
+ res.writeHead(401, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ success: false, error: 'Not authenticated' }));
480
+ return;
481
+ }
482
+ let body = '';
483
+ req.on('data', chunk => body += chunk);
484
+ req.on('end', () => {
485
+ try {
486
+ const settings = JSON.parse(body);
487
+ this.config.settings = settings;
488
+ saveConfig(this.config);
489
+ res.writeHead(200, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ success: true }));
491
+ } catch (e) {
492
+ res.writeHead(400, { 'Content-Type': 'application/json' });
493
+ res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
494
+ }
495
+ });
496
+ return;
497
+ }
498
+
419
499
  // No password set - show setup page
420
500
  if (!this.config.passwordHash) {
421
501
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -682,8 +762,23 @@ class TermitesServer {
682
762
  .client-item.selected { font-weight: bold; }
683
763
  .client-item .client-name { font-size: 13px; margin-bottom: 3px; display: flex; align-items: center; }
684
764
  .client-item .client-info { font-size: 11px; opacity: 0.6; padding-left: 14px; }
765
+ .client-item { display: flex; align-items: center; }
766
+ .client-item .client-main { flex: 1; cursor: pointer; }
767
+ .close-terminal-btn {
768
+ background: none; border: none; color: inherit; opacity: 0.4;
769
+ font-size: 18px; padding: 4px 8px; cursor: pointer; margin-right: 8px;
770
+ }
771
+ .close-terminal-btn:hover { opacity: 1; color: #ef4444; }
685
772
  .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; flex-shrink: 0; }
686
773
  .status-dot.online { background: #22c55e; }
774
+ .unread-badge {
775
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
776
+ background: #f59e0b; margin-left: 6px; animation: pulse 1.5s infinite;
777
+ }
778
+ @keyframes pulse {
779
+ 0%, 100% { opacity: 1; }
780
+ 50% { opacity: 0.5; }
781
+ }
687
782
  .settings-section { padding: 12px 16px; }
688
783
  .setting-group { margin-bottom: 16px; }
689
784
  .setting-group:last-child { margin-bottom: 0; }
@@ -701,6 +796,33 @@ class TermitesServer {
701
796
  background: transparent; color: inherit; cursor: pointer; font-size: 11px;
702
797
  }
703
798
  .add-btn:hover { opacity: 0.8; }
799
+ .change-pwd-btn {
800
+ width: 100%; padding: 8px; border: 1px solid; border-radius: 4px;
801
+ background: transparent; color: inherit; cursor: pointer; font-size: 13px;
802
+ }
803
+ .change-pwd-btn:hover { opacity: 0.8; }
804
+ .pwd-modal {
805
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
806
+ background: rgba(0,0,0,0.6); z-index: 200; display: none;
807
+ align-items: center; justify-content: center;
808
+ }
809
+ .pwd-modal.show { display: flex; }
810
+ .pwd-modal-content {
811
+ background: var(--modal-bg, #1a1a2e); padding: 24px; border-radius: 12px;
812
+ width: 320px; max-width: 90vw;
813
+ }
814
+ .pwd-modal h3 { margin-bottom: 16px; font-size: 16px; }
815
+ .pwd-modal input {
816
+ width: 100%; padding: 10px; border: 1px solid; border-radius: 6px;
817
+ background: transparent; color: inherit; margin-bottom: 12px; font-size: 14px;
818
+ }
819
+ .pwd-modal-btns { display: flex; gap: 8px; margin-top: 8px; }
820
+ .pwd-modal-btns button {
821
+ flex: 1; padding: 10px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;
822
+ }
823
+ .pwd-modal-btns .cancel-btn { background: #666; color: #fff; }
824
+ .pwd-modal-btns .save-btn { background: #22c55e; color: #fff; }
825
+ .pwd-error { color: #ef4444; font-size: 12px; margin-top: 8px; display: none; }
704
826
  /* Mobile toolbar */
705
827
  .mobile-toolbar {
706
828
  display: none; flex-shrink: 0; padding: 6px 8px; gap: 6px;
@@ -802,6 +924,10 @@ class TermitesServer {
802
924
  <option value="hide">Always hide</option>
803
925
  </select>
804
926
  </div>
927
+ <div class="setting-group">
928
+ <label>Password</label>
929
+ <button id="change-password-btn" class="change-pwd-btn">Change Password</button>
930
+ </div>
805
931
  </div>
806
932
  </div>
807
933
  <div class="drawer-section">
@@ -842,6 +968,19 @@ class TermitesServer {
842
968
  </div>
843
969
  <div class="history-content" id="history-content"></div>
844
970
  </div>
971
+ <div class="pwd-modal" id="pwd-modal">
972
+ <div class="pwd-modal-content">
973
+ <h3>Change Password</h3>
974
+ <input type="password" id="current-pwd" placeholder="Current password">
975
+ <input type="password" id="new-pwd" placeholder="New password (min 4 chars)">
976
+ <input type="password" id="confirm-pwd" placeholder="Confirm new password">
977
+ <div class="pwd-error" id="pwd-error"></div>
978
+ <div class="pwd-modal-btns">
979
+ <button class="cancel-btn" id="pwd-cancel">Cancel</button>
980
+ <button class="save-btn" id="pwd-save">Save</button>
981
+ </div>
982
+ </div>
983
+ </div>
845
984
 
846
985
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
847
986
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
@@ -850,6 +989,7 @@ class TermitesServer {
850
989
  let ws, term, fitAddon;
851
990
  let clients = [];
852
991
  let selectedClientId = null;
992
+ let unreadTerminals = new Set(); // Track terminals with unread output
853
993
  // History buffer for iTerm-like scrolling (stores raw output)
854
994
  let historyBuffer = [];
855
995
  const MAX_HISTORY_SIZE = 100000; // Max characters to store
@@ -935,14 +1075,28 @@ class TermitesServer {
935
1075
  let currentToolbar = 'auto';
936
1076
  let modifiers = { ctrl: false, alt: false };
937
1077
 
938
- function loadSettings() {
939
- const saved = localStorage.getItem('webshell-settings');
940
- if (saved) {
941
- const s = JSON.parse(saved);
942
- currentTheme = s.theme || currentTheme;
943
- currentFont = s.font || currentFont;
944
- currentFontSize = s.fontSize || currentFontSize;
945
- currentToolbar = s.toolbar || currentToolbar;
1078
+ async function loadSettings() {
1079
+ // Try to load from server first
1080
+ try {
1081
+ const res = await fetch('/api/settings');
1082
+ const data = await res.json();
1083
+ if (data.success && data.settings) {
1084
+ const s = data.settings;
1085
+ currentTheme = s.theme || currentTheme;
1086
+ currentFont = s.font || currentFont;
1087
+ currentFontSize = s.fontSize || currentFontSize;
1088
+ currentToolbar = s.toolbar || currentToolbar;
1089
+ }
1090
+ } catch (e) {
1091
+ // Fallback to localStorage
1092
+ const saved = localStorage.getItem('webshell-settings');
1093
+ if (saved) {
1094
+ const s = JSON.parse(saved);
1095
+ currentTheme = s.theme || currentTheme;
1096
+ currentFont = s.font || currentFont;
1097
+ currentFontSize = s.fontSize || currentFontSize;
1098
+ currentToolbar = s.toolbar || currentToolbar;
1099
+ }
946
1100
  }
947
1101
  document.getElementById('theme-select').value = currentTheme;
948
1102
  document.getElementById('font-select').value = currentFont;
@@ -952,9 +1106,17 @@ class TermitesServer {
952
1106
  }
953
1107
 
954
1108
  function saveSettings() {
955
- localStorage.setItem('webshell-settings', JSON.stringify({
1109
+ const settings = {
956
1110
  theme: currentTheme, font: currentFont, fontSize: currentFontSize, toolbar: currentToolbar
957
- }));
1111
+ };
1112
+ // Save to localStorage as backup
1113
+ localStorage.setItem('webshell-settings', JSON.stringify(settings));
1114
+ // Save to server
1115
+ fetch('/api/settings', {
1116
+ method: 'POST',
1117
+ headers: { 'Content-Type': 'application/json' },
1118
+ body: JSON.stringify(settings)
1119
+ }).catch(() => {});
958
1120
  }
959
1121
 
960
1122
  function applyTheme(themeName) {
@@ -1076,6 +1238,53 @@ class TermitesServer {
1076
1238
  document.getElementById('font-select').onchange = e => applyFont(e.target.value);
1077
1239
  document.getElementById('font-size').oninput = e => applyFontSize(parseInt(e.target.value));
1078
1240
  document.getElementById('toolbar-select').onchange = e => applyToolbar(e.target.value);
1241
+
1242
+ // Change password modal
1243
+ const pwdModal = document.getElementById('pwd-modal');
1244
+ const pwdError = document.getElementById('pwd-error');
1245
+ document.getElementById('change-password-btn').onclick = () => {
1246
+ pwdModal.classList.add('show');
1247
+ document.getElementById('current-pwd').value = '';
1248
+ document.getElementById('new-pwd').value = '';
1249
+ document.getElementById('confirm-pwd').value = '';
1250
+ pwdError.style.display = 'none';
1251
+ };
1252
+ document.getElementById('pwd-cancel').onclick = () => pwdModal.classList.remove('show');
1253
+ document.getElementById('pwd-save').onclick = async () => {
1254
+ const currentPwd = document.getElementById('current-pwd').value;
1255
+ const newPwd = document.getElementById('new-pwd').value;
1256
+ const confirmPwd = document.getElementById('confirm-pwd').value;
1257
+ pwdError.style.display = 'none';
1258
+
1259
+ if (newPwd.length < 4) {
1260
+ pwdError.textContent = 'New password must be at least 4 characters';
1261
+ pwdError.style.display = 'block';
1262
+ return;
1263
+ }
1264
+ if (newPwd !== confirmPwd) {
1265
+ pwdError.textContent = 'Passwords do not match';
1266
+ pwdError.style.display = 'block';
1267
+ return;
1268
+ }
1269
+ try {
1270
+ const res = await fetch('/api/change-password', {
1271
+ method: 'POST',
1272
+ headers: { 'Content-Type': 'application/json' },
1273
+ body: JSON.stringify({ currentPassword: currentPwd, newPassword: newPwd })
1274
+ });
1275
+ const data = await res.json();
1276
+ if (data.success) {
1277
+ pwdModal.classList.remove('show');
1278
+ alert('Password changed successfully');
1279
+ } else {
1280
+ pwdError.textContent = data.error || 'Failed to change password';
1281
+ pwdError.style.display = 'block';
1282
+ }
1283
+ } catch (err) {
1284
+ pwdError.textContent = 'Network error';
1285
+ pwdError.style.display = 'block';
1286
+ }
1287
+ };
1079
1288
  }
1080
1289
 
1081
1290
  function setupMobileToolbar() {
@@ -1502,16 +1711,27 @@ class TermitesServer {
1502
1711
  }
1503
1712
  listEl.innerHTML = clients.map(c => {
1504
1713
  const isSelected = c.id === selectedClientId;
1714
+ const hasUnread = unreadTerminals.has(c.id);
1505
1715
  const bgColor = isSelected ? t.sidebarSelected : 'transparent';
1506
1716
  return '<div class="client-item' + (isSelected ? ' selected' : '') + '" ' +
1507
- 'style="background: ' + bgColor + ';" ' +
1508
- 'onclick="selectClient(\\'' + c.id + '\\')">' +
1717
+ 'style="background: ' + bgColor + ';">' +
1718
+ '<div class="client-main" onclick="selectClient(\\'' + c.id + '\\')">' +
1509
1719
  '<div class="client-name"><span class="status-dot online"></span>' +
1510
- c.username + '@' + c.hostname + '</div>' +
1511
- '<div class="client-info">' + (c.cwd || '') + ' · ' + (c.platform || '') + '</div></div>';
1720
+ c.username + '@' + c.hostname +
1721
+ (hasUnread ? '<span class="unread-badge"></span>' : '') + '</div>' +
1722
+ '<div class="client-info">' + (c.cwd || '') + ' · ' + (c.platform || '') + '</div></div>' +
1723
+ '<button class="close-terminal-btn" onclick="event.stopPropagation(); closeTerminal(\\'' + c.id + '\\')" title="Close terminal">×</button></div>';
1512
1724
  }).join('');
1513
1725
  }
1514
1726
 
1727
+ function closeTerminal(clientId) {
1728
+ if (confirm('Close this terminal?')) {
1729
+ if (ws?.readyState === WebSocket.OPEN) {
1730
+ ws.send(JSON.stringify({ type: 'close-terminal', clientId }));
1731
+ }
1732
+ }
1733
+ }
1734
+
1515
1735
  function selectClient(clientId) {
1516
1736
  if (selectedClientId === clientId) {
1517
1737
  document.getElementById('drawer').classList.remove('open');
@@ -1519,6 +1739,8 @@ class TermitesServer {
1519
1739
  return;
1520
1740
  }
1521
1741
  selectedClientId = clientId;
1742
+ // Clear unread badge for this terminal
1743
+ unreadTerminals.delete(clientId);
1522
1744
  const client = clients.find(c => c.id === clientId);
1523
1745
  if (client) {
1524
1746
  document.getElementById('header-title').innerHTML =
@@ -1534,8 +1756,8 @@ class TermitesServer {
1534
1756
  term.focus();
1535
1757
  }
1536
1758
 
1537
- function init() {
1538
- loadSettings();
1759
+ async function init() {
1760
+ await loadSettings();
1539
1761
  setupDrawer();
1540
1762
  setupMobileToolbar();
1541
1763
  setupHistoryOverlay();
@@ -1696,6 +1918,12 @@ class TermitesServer {
1696
1918
  addToHistory(d.data);
1697
1919
  term.write(d.data);
1698
1920
  term.scrollToBottom();
1921
+ } else {
1922
+ // Mark terminal as having unread output
1923
+ if (!unreadTerminals.has(d.clientId)) {
1924
+ unreadTerminals.add(d.clientId);
1925
+ updateClientList();
1926
+ }
1699
1927
  }
1700
1928
  break;
1701
1929
  }