@pixelbyte-software/pixcode 1.53.12 → 1.53.14

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 (30) hide show
  1. package/dist/assets/{index-kP_gZ-wL.js → index-CV_x2mrI.js} +173 -173
  2. package/dist/index.html +1 -1
  3. package/dist-server/server/index.js +51 -5
  4. package/dist-server/server/index.js.map +1 -1
  5. package/dist-server/server/routes/auth.js +41 -1
  6. package/dist-server/server/routes/auth.js.map +1 -1
  7. package/dist-server/server/routes/git.js +60 -3
  8. package/dist-server/server/routes/git.js.map +1 -1
  9. package/dist-server/server/routes/network.js +17 -1
  10. package/dist-server/server/routes/network.js.map +1 -1
  11. package/dist-server/server/services/external-access.js +45 -21
  12. package/dist-server/server/services/external-access.js.map +1 -1
  13. package/dist-server/server/services/qr-login.js +115 -0
  14. package/dist-server/server/services/qr-login.js.map +1 -0
  15. package/dist-server/server/services/telegram/bot.js +18 -1
  16. package/dist-server/server/services/telegram/bot.js.map +1 -1
  17. package/dist-server/server/services/telegram/control-center.js +225 -9
  18. package/dist-server/server/services/telegram/control-center.js.map +1 -1
  19. package/dist-server/server/services/telegram/translations.js +22 -2
  20. package/dist-server/server/services/telegram/translations.js.map +1 -1
  21. package/package.json +1 -1
  22. package/server/index.js +59 -5
  23. package/server/routes/auth.js +47 -1
  24. package/server/routes/git.js +70 -3
  25. package/server/routes/network.js +18 -1
  26. package/server/services/external-access.js +49 -21
  27. package/server/services/qr-login.js +126 -0
  28. package/server/services/telegram/bot.js +15 -0
  29. package/server/services/telegram/control-center.js +241 -9
  30. package/server/services/telegram/translations.js +22 -2
package/server/index.js CHANGED
@@ -150,7 +150,7 @@ import {
150
150
  import networkRoutes from './routes/network.js';
151
151
  import telegramRoutes from './routes/telegram.js';
152
152
  import { restoreRequestedTunnel } from './services/external-access.js';
153
- import { restoreBotFromConfig } from './services/telegram/bot.js';
153
+ import { notifyTelegramTerminalAttached, restoreBotFromConfig } from './services/telegram/bot.js';
154
154
  import { ensurePortOpen } from './utils/port-access.js';
155
155
  import {
156
156
  applyAllStoredCredentialsToEnv,
@@ -1237,6 +1237,7 @@ function appendPtySessionBuffer(session, data) {
1237
1237
  valueBytes = Buffer.byteLength(value, 'utf8');
1238
1238
  }
1239
1239
 
1240
+ session.totalOutputBytes = (session.totalOutputBytes || 0) + valueBytes;
1240
1241
  session.buffer.push(value);
1241
1242
  session.bufferBytes = (session.bufferBytes || 0) + valueBytes;
1242
1243
 
@@ -1247,6 +1248,31 @@ function appendPtySessionBuffer(session, data) {
1247
1248
  if (session.bufferBytes < 0) session.bufferBytes = 0;
1248
1249
  }
1249
1250
 
1251
+ function readPtySessionBufferedOutput(session, { maxChars = 12000, sinceCursor = null } = {}) {
1252
+ const rawBuffer = (session?.buffer || []).join('');
1253
+ const bufferBytes = session?.bufferBytes || Buffer.byteLength(rawBuffer, 'utf8');
1254
+ const outputCursor = session?.totalOutputBytes || bufferBytes;
1255
+ const bufferStartCursor = Math.max(0, outputCursor - bufferBytes);
1256
+ let rawOutput = rawBuffer;
1257
+
1258
+ if (Number.isFinite(sinceCursor)) {
1259
+ const skipBytes = Math.max(0, sinceCursor - bufferStartCursor);
1260
+ if (skipBytes > 0) {
1261
+ rawOutput = Buffer.from(rawBuffer, 'utf8').slice(skipBytes).toString('utf8');
1262
+ }
1263
+ }
1264
+
1265
+ if (rawOutput.length > maxChars) {
1266
+ rawOutput = rawOutput.slice(-maxChars);
1267
+ }
1268
+
1269
+ return {
1270
+ rawOutput,
1271
+ outputCursor,
1272
+ bufferStartCursor,
1273
+ };
1274
+ }
1275
+
1250
1276
  function normalizeTerminalStartupInput(input) {
1251
1277
  return `\x15${String(input || '').replace(/(?:\r\n|\r|\n)+$/u, '')}\r`;
1252
1278
  }
@@ -1627,6 +1653,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1627
1653
  20000,
1628
1654
  Math.max(1000, Number.parseInt(String(req.query.maxChars || '12000'), 10) || 12000)
1629
1655
  );
1656
+ const sinceCursorRaw = Number.parseInt(String(req.query.sinceCursor ?? ''), 10);
1657
+ const sinceCursor = Number.isFinite(sinceCursorRaw) ? Math.max(0, sinceCursorRaw) : null;
1630
1658
 
1631
1659
  if (!SHELL_CLI_PROVIDERS.has(provider)) {
1632
1660
  return res.status(400).json({ error: 'Unsupported provider' });
@@ -1643,6 +1671,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1643
1671
  tabId,
1644
1672
  sessionId,
1645
1673
  output: '',
1674
+ outputCursor: 0,
1675
+ bufferStartCursor: 0,
1646
1676
  message: 'Multiple provider terminal sessions match this target. Pick a specific tab.',
1647
1677
  });
1648
1678
  }
@@ -1653,14 +1683,21 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1653
1683
  tabId,
1654
1684
  sessionId,
1655
1685
  output: '',
1686
+ outputCursor: 0,
1687
+ bufferStartCursor: 0,
1656
1688
  message: 'No active provider terminal session found for this project.',
1657
1689
  });
1658
1690
  }
1659
1691
 
1660
1692
  const matchedSession = match.session;
1661
- const rawOutput = matchedSession.buffer.join('').slice(-maxChars);
1693
+ const {
1694
+ rawOutput,
1695
+ outputCursor,
1696
+ bufferStartCursor,
1697
+ } = readPtySessionBufferedOutput(matchedSession, { maxChars, sinceCursor });
1662
1698
  const output = stripAnsiSequences(rawOutput);
1663
- const terminalState = resolveProviderTerminalState(matchedSession, provider, output);
1699
+ const fullOutput = readSessionOutputForState(matchedSession);
1700
+ const terminalState = resolveProviderTerminalState(matchedSession, provider, fullOutput);
1664
1701
  res.json({
1665
1702
  active: true,
1666
1703
  provider,
@@ -1671,6 +1708,9 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1671
1708
  matchStatus: match.status,
1672
1709
  ...terminalState,
1673
1710
  output,
1711
+ outputCursor,
1712
+ bufferStartCursor,
1713
+ sinceCursor,
1674
1714
  });
1675
1715
  });
1676
1716
 
@@ -1723,6 +1763,7 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1723
1763
 
1724
1764
  const matchedSession = match.session;
1725
1765
  const data = submit ? normalizeTerminalStartupInput(input) : input;
1766
+ const outputCursorBefore = matchedSession.totalOutputBytes || matchedSession.bufferBytes || 0;
1726
1767
  try {
1727
1768
  writeTerminalInputChunks(matchedSession.pty, data);
1728
1769
  matchedSession.updatedAt = Date.now();
@@ -1736,6 +1777,8 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1736
1777
  submitted: submit,
1737
1778
  bytes: Buffer.byteLength(data),
1738
1779
  matchStatus: match.status,
1780
+ outputCursorBefore,
1781
+ outputCursorAfter: matchedSession.totalOutputBytes || matchedSession.bufferBytes || 0,
1739
1782
  });
1740
1783
  } catch (error) {
1741
1784
  res.status(500).json({
@@ -1764,7 +1807,7 @@ function requireVerifiedTelegramLink(req, res) {
1764
1807
 
1765
1808
  // Bind the paired Telegram chat to an already running provider terminal tab.
1766
1809
  // This route lives next to the PTY registry so it can verify the target is live.
1767
- app.post('/api/telegram/active-terminal', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
1810
+ app.post('/api/telegram/active-terminal', authenticateToken, requireProjectPathAccess('useShell'), async (req, res) => {
1768
1811
  try {
1769
1812
  const link = requireVerifiedTelegramLink(req, res);
1770
1813
  if (!link) return;
@@ -1824,8 +1867,18 @@ app.post('/api/telegram/active-terminal', authenticateToken, requireProjectPathA
1824
1867
  selectedProjectName: projectName,
1825
1868
  selectedProjectPath: resolvedMatchedProjectPath,
1826
1869
  });
1870
+ notifyTelegramTerminalAttached({
1871
+ userId: req.user.id,
1872
+ terminal: control.activeTerminal,
1873
+ }).then((telegramNotice) => {
1874
+ if (telegramNotice?.ok === false) {
1875
+ console.warn('[telegram] terminal attach notice not delivered:', telegramNotice.reason);
1876
+ }
1877
+ }).catch((error) => {
1878
+ console.warn('[telegram] terminal attach notice failed:', error?.message || error);
1879
+ });
1827
1880
 
1828
- res.json({ success: true, activeTerminal: control.activeTerminal, control, matchStatus: match.status });
1881
+ res.json({ success: true, activeTerminal: control.activeTerminal, control, matchStatus: match.status, telegramNotice: { queued: true } });
1829
1882
  } catch (error) {
1830
1883
  console.error('telegram/active-terminal failed:', error);
1831
1884
  res.status(500).json({ error: 'Failed to attach Telegram to this terminal.' });
@@ -4142,6 +4195,7 @@ function handleShellConnection(ws, request) {
4142
4195
  ws: ws,
4143
4196
  buffer: [],
4144
4197
  bufferBytes: 0,
4198
+ totalOutputBytes: 0,
4145
4199
  droppedOutputBytes: 0,
4146
4200
  timeoutId: null,
4147
4201
  userId: ownerUserId,
@@ -6,7 +6,13 @@ import express from 'express';
6
6
  import bcrypt from 'bcryptjs';
7
7
 
8
8
  import { userDb, db } from '../database/db.js';
9
- import { generateToken, authenticateToken } from '../middleware/auth.js';
9
+ import { generateToken, authenticateToken, requireAdmin } from '../middleware/auth.js';
10
+ import {
11
+ consumeQrLoginToken,
12
+ createQrLoginToken,
13
+ getQrLoginSettings,
14
+ saveQrLoginSettings,
15
+ } from '../services/qr-login.js';
10
16
  import {
11
17
  getPublicRemoteConnectionConfig,
12
18
  saveRemoteConnectionConfig,
@@ -51,6 +57,46 @@ router.put('/connection-mode', (req, res) => {
51
57
  }
52
58
  });
53
59
 
60
+ router.get('/qr-login/settings', authenticateToken, requireAdmin, (_req, res) => {
61
+ res.json({ success: true, settings: getQrLoginSettings() });
62
+ });
63
+
64
+ router.put('/qr-login/settings', authenticateToken, requireAdmin, (req, res) => {
65
+ try {
66
+ res.json({ success: true, settings: saveQrLoginSettings(req.body || {}) });
67
+ } catch (error) {
68
+ res.status(400).json({ success: false, error: error.message });
69
+ }
70
+ });
71
+
72
+ router.post('/qr-login/token', authenticateToken, requireAdmin, (req, res) => {
73
+ try {
74
+ const baseUrl = typeof req.body?.baseUrl === 'string' && req.body.baseUrl.trim()
75
+ ? req.body.baseUrl.trim()
76
+ : null;
77
+ res.json({ success: true, ...createQrLoginToken({ userId: req.user.id, baseUrl }) });
78
+ } catch (error) {
79
+ const status = error?.code === 'QR_LOGIN_DISABLED' ? 409 : 400;
80
+ res.status(status).json({ success: false, error: error.message });
81
+ }
82
+ });
83
+
84
+ router.post('/qr-login', (req, res) => {
85
+ try {
86
+ const user = consumeQrLoginToken(req.body?.token);
87
+ const token = generateToken(user);
88
+ userDb.updateLastLogin(user.id);
89
+ res.json({
90
+ success: true,
91
+ user: publicUser(user),
92
+ token,
93
+ });
94
+ } catch (error) {
95
+ const status = error?.code === 'QR_LOGIN_DISABLED' ? 409 : 401;
96
+ res.status(status).json({ success: false, error: error.message });
97
+ }
98
+ });
99
+
54
100
  // User registration (setup) - only allowed if no users exist
55
101
  router.post('/register', async (req, res) => {
56
102
  try {
@@ -46,9 +46,11 @@ router.use((req, res, next) => {
46
46
  });
47
47
 
48
48
  function isNotGitRepositoryMessage(message = '') {
49
- return message.includes('Not a git repository')
50
- || message.includes('not a git repository')
51
- || message.includes('Project directory is not a git repository');
49
+ const normalized = String(message || '').toLowerCase();
50
+ return normalized.includes('not a git repository')
51
+ || normalized.includes('not inside a git work tree')
52
+ || normalized.includes('does not contain a .git folder')
53
+ || normalized.includes('project directory is not a git repository');
52
54
  }
53
55
 
54
56
  function isMissingFileError(error) {
@@ -178,6 +180,35 @@ async function buildFilesystemStatus(projectPath) {
178
180
  };
179
181
  }
180
182
 
183
+ async function readFilesystemFileWithDiff(projectPath, filePath) {
184
+ const projectRoot = path.resolve(projectPath);
185
+ const resolvedFilePath = path.resolve(projectRoot, String(filePath || ''));
186
+ const relativePath = path.relative(projectRoot, resolvedFilePath);
187
+
188
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
189
+ const error = new Error('Invalid file path: path traversal detected');
190
+ error.statusCode = 400;
191
+ throw error;
192
+ }
193
+
194
+ const stats = await fs.stat(resolvedFilePath);
195
+ if (stats.isDirectory()) {
196
+ const error = new Error('Cannot show diff for directories');
197
+ error.statusCode = 400;
198
+ throw error;
199
+ }
200
+
201
+ const currentContent = await fs.readFile(resolvedFilePath, 'utf-8');
202
+ return {
203
+ currentContent,
204
+ oldContent: currentContent,
205
+ isDeleted: false,
206
+ isUntracked: false,
207
+ isGitRepository: false,
208
+ trackingMode: 'filesystem',
209
+ };
210
+ }
211
+
181
212
  function spawnAsync(command, args, options = {}) {
182
213
  return new Promise((resolve, reject) => {
183
214
  const child = spawn(command, args, {
@@ -634,6 +665,14 @@ router.get('/diff', async (req, res) => {
634
665
 
635
666
  res.json({ diff });
636
667
  } catch (error) {
668
+ if (isNotGitRepositoryMessage(getGitErrorDetails(error))) {
669
+ return res.json({
670
+ diff: '',
671
+ isGitRepository: false,
672
+ trackingMode: 'filesystem',
673
+ });
674
+ }
675
+
637
676
  console.error('Git diff error:', error);
638
677
  res.json({ error: error.message });
639
678
  }
@@ -763,6 +802,34 @@ router.get('/file-with-diff', async (req, res) => {
763
802
  isUntracked: false,
764
803
  });
765
804
  }
805
+
806
+ if (isNotGitRepositoryMessage(getGitErrorDetails(error))) {
807
+ try {
808
+ return res.json(await readFilesystemFileWithDiff(
809
+ await getActualProjectPath(project),
810
+ file,
811
+ ));
812
+ } catch (fallbackError) {
813
+ if (isMissingFileError(fallbackError)) {
814
+ return res.status(404).json({
815
+ error: 'File not found',
816
+ currentContent: '',
817
+ oldContent: '',
818
+ isDeleted: true,
819
+ isUntracked: false,
820
+ isGitRepository: false,
821
+ trackingMode: 'filesystem',
822
+ });
823
+ }
824
+
825
+ return res.status(fallbackError.statusCode || 500).json({
826
+ error: fallbackError.message || 'Failed to read file',
827
+ isGitRepository: false,
828
+ trackingMode: 'filesystem',
829
+ });
830
+ }
831
+ }
832
+
766
833
  console.error('Git file-with-diff error:', error);
767
834
  res.json({ error: error.message });
768
835
  }
@@ -19,6 +19,19 @@ const resolveServerPort = () => {
19
19
  return Number.isFinite(port) && port > 0 ? port : 3001;
20
20
  };
21
21
 
22
+ const PRIVATE_IPV4_REGEX = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/u;
23
+ const VIRTUAL_INTERFACE_REGEX = /(docker|veth|bridge|br-|vmnet|vbox|virtualbox|tailscale|utun|wg|tun|tap|zerotier|zt)/iu;
24
+
25
+ const classifyEndpoint = (ifaceName, address) => {
26
+ const isPrivate = PRIVATE_IPV4_REGEX.test(address);
27
+ const isVirtual = VIRTUAL_INTERFACE_REGEX.test(ifaceName);
28
+ return {
29
+ scope: isPrivate ? 'private' : 'public',
30
+ kind: isVirtual ? 'virtual' : (isPrivate ? 'lan' : 'public'),
31
+ usableForSameNetwork: isPrivate && !isVirtual,
32
+ };
33
+ };
34
+
22
35
  // Filter to usable LAN addresses. We skip:
23
36
  // - internal (loopback) addresses, since QR-ing "127.0.0.1" is useless for a phone
24
37
  // - link-local IPv6, since pasting "fe80::…%iface" into a phone browser won't resolve
@@ -32,10 +45,13 @@ const listLanEndpoints = () => {
32
45
  for (const addr of addrs) {
33
46
  if (addr.internal) continue;
34
47
  if (addr.family !== 'IPv4' && addr.family !== 4) continue;
48
+ const classification = classifyEndpoint(ifaceName, addr.address);
49
+ if (!classification.usableForSameNetwork) continue;
35
50
  endpoints.push({
36
51
  host: addr.address,
37
52
  label: ifaceName,
38
53
  family: 'IPv4',
54
+ ...classification,
39
55
  });
40
56
  }
41
57
  }
@@ -97,7 +113,8 @@ router.delete('/upnp', (_req, res) => {
97
113
  router.post('/tunnel', requireAdmin, async (req, res) => {
98
114
  const port = resolveServerPort();
99
115
  try {
100
- const state = await startTunnel({ port, persistPreference: true });
116
+ const provider = req.body?.provider === 'ngrok' ? 'ngrok' : 'cloudflare';
117
+ const state = await startTunnel({ port, provider, persistPreference: true });
101
118
  res.json({ success: true, tunnel: state });
102
119
  } catch (error) {
103
120
  console.error('Tunnel start failed:', error);
@@ -44,6 +44,7 @@ let restoreInFlight = null;
44
44
  let tunnelState = {
45
45
  running: false,
46
46
  binary: null, // 'cloudflared' | 'ngrok'
47
+ provider: null, // 'cloudflare' | 'ngrok'
47
48
  url: null,
48
49
  error: null,
49
50
  installHint: null,
@@ -55,7 +56,7 @@ let tunnelState = {
55
56
  const DEFAULT_TUNNEL_PREFERENCE = Object.freeze({
56
57
  desired: false,
57
58
  port: null,
58
- provider: null,
59
+ provider: 'cloudflare',
59
60
  lastUrl: null,
60
61
  lastStartedAt: null,
61
62
  lastStoppedAt: null,
@@ -97,8 +98,14 @@ const appendLog = (line) => {
97
98
  if (tunnelState.log.length > 200) tunnelState.log.shift();
98
99
  };
99
100
 
100
- const detectBinary = async () => {
101
- const candidates = ['cloudflared', 'ngrok'];
101
+ const normalizeTunnelProvider = (provider) => (provider === 'ngrok' ? 'ngrok' : 'cloudflare');
102
+
103
+ const tunnelProviderBinary = (provider) => (normalizeTunnelProvider(provider) === 'ngrok' ? 'ngrok' : 'cloudflared');
104
+
105
+ const tunnelBinaryProvider = (binary) => (binary === 'ngrok' ? 'ngrok' : 'cloudflare');
106
+
107
+ const detectBinary = async (provider = null) => {
108
+ const candidates = provider ? [tunnelProviderBinary(provider)] : ['cloudflared', 'ngrok'];
102
109
  for (const name of candidates) {
103
110
  try {
104
111
  // `which` isn't guaranteed on Windows; we probe with `--version` instead
@@ -116,17 +123,31 @@ const detectBinary = async () => {
116
123
  return null;
117
124
  };
118
125
 
119
- const createTunnelInstallHint = () => ({
120
- title: 'Tunnel binary required',
121
- message: 'Install cloudflared or ngrok to create a public mobile URL. Local LAN QR codes still work on the same Wi-Fi/network.',
122
- commands: [
123
- 'macOS: brew install cloudflared',
124
- 'Windows: winget install Cloudflare.cloudflared',
125
- 'Linux: install cloudflared from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
126
- 'Alternative: install and authenticate ngrok from https://ngrok.com/download',
127
- ],
128
- docsUrl: 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
129
- });
126
+ const createTunnelInstallHint = (provider = 'cloudflare') => {
127
+ if (normalizeTunnelProvider(provider) === 'ngrok') {
128
+ return {
129
+ title: 'ngrok binary required',
130
+ message: 'Install and authenticate ngrok to create a public mobile URL. Local LAN QR codes still work on the same Wi-Fi/network.',
131
+ commands: [
132
+ 'macOS: brew install ngrok/ngrok/ngrok',
133
+ 'Windows: winget install ngrok.ngrok',
134
+ 'Linux: install ngrok from https://ngrok.com/download',
135
+ ],
136
+ docsUrl: 'https://ngrok.com/download',
137
+ };
138
+ }
139
+
140
+ return {
141
+ title: 'Cloudflare Tunnel binary required',
142
+ message: 'Install cloudflared to create a public Cloudflare URL. Local LAN QR codes still work on the same Wi-Fi/network.',
143
+ commands: [
144
+ 'macOS: brew install cloudflared',
145
+ 'Windows: winget install Cloudflare.cloudflared',
146
+ 'Linux: install cloudflared from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
147
+ ],
148
+ docsUrl: 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
149
+ };
150
+ };
130
151
 
131
152
  const cloudflareUrlRegex = /https?:\/\/[a-z0-9.-]+trycloudflare\.com/i;
132
153
  const ngrokUrlRegex = /https?:\/\/[a-z0-9.-]+\.ngrok(-free)?\.(app|io)/i;
@@ -143,13 +164,14 @@ const extractUrl = (binary, text) => {
143
164
  return null;
144
165
  };
145
166
 
146
- export const startTunnel = async ({ port, persistPreference = false, restoring = false } = {}) => {
167
+ export const startTunnel = async ({ port, provider: requestedProvider = 'cloudflare', persistPreference = false, restoring = false } = {}) => {
168
+ const provider = normalizeTunnelProvider(requestedProvider);
147
169
  if (tunnelProc) {
148
170
  if (persistPreference) {
149
171
  await persistTunnelPreference({
150
172
  desired: true,
151
173
  port,
152
- provider: tunnelState.binary,
174
+ provider: tunnelState.provider || tunnelBinaryProvider(tunnelState.binary),
153
175
  lastUrl: tunnelState.url,
154
176
  });
155
177
  tunnelState = { ...tunnelState, desired: true, restoring: false };
@@ -158,20 +180,21 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
158
180
  }
159
181
  suppressNextTunnelRestore = false;
160
182
 
161
- const binary = await detectBinary();
183
+ const binary = await detectBinary(provider);
162
184
  if (!binary) {
163
- const installHint = createTunnelInstallHint();
185
+ const installHint = createTunnelInstallHint(provider);
164
186
  tunnelState = {
165
187
  running: false,
166
188
  binary: null,
189
+ provider,
167
190
  url: null,
168
- error: 'No tunnel binary found',
191
+ error: provider === 'cloudflare' ? 'cloudflared was not found' : 'ngrok was not found',
169
192
  installHint,
170
193
  desired: Boolean(persistPreference || restoring),
171
194
  restoring,
172
195
  log: [],
173
196
  };
174
- const err = new Error('No tunnel binary found (tried cloudflared, ngrok)');
197
+ const err = new Error(tunnelState.error);
175
198
  err.code = 'ENOENT_TUNNEL';
176
199
  err.installHint = installHint;
177
200
  throw err;
@@ -183,6 +206,7 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
183
206
  tunnelState = {
184
207
  running: true,
185
208
  binary,
209
+ provider,
186
210
  url: null,
187
211
  error: null,
188
212
  installHint: null,
@@ -207,6 +231,7 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
207
231
  tunnelState = {
208
232
  running: false,
209
233
  binary,
234
+ provider,
210
235
  url: null,
211
236
  error: code === 0 ? null : `Tunnel exited with code ${code}`,
212
237
  installHint: null,
@@ -242,7 +267,7 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
242
267
  await persistTunnelPreference({
243
268
  desired: true,
244
269
  port,
245
- provider: binary,
270
+ provider,
246
271
  lastUrl: tunnelState.url,
247
272
  lastStartedAt: new Date().toISOString(),
248
273
  });
@@ -284,6 +309,7 @@ export const stopTunnel = async ({ persistPreference = true } = {}) => {
284
309
  tunnelState = {
285
310
  running: false,
286
311
  binary: null,
312
+ provider: null,
287
313
  url: null,
288
314
  error: null,
289
315
  installHint: null,
@@ -302,6 +328,7 @@ export const stopTunnel = async ({ persistPreference = true } = {}) => {
302
328
  tunnelState = {
303
329
  running: false,
304
330
  binary: null,
331
+ provider: null,
305
332
  url: null,
306
333
  error: null,
307
334
  installHint: null,
@@ -341,6 +368,7 @@ export const restoreRequestedTunnel = async ({ port } = {}) => {
341
368
  try {
342
369
  return await startTunnel({
343
370
  port: restorePort,
371
+ provider: preference.provider || 'cloudflare',
344
372
  persistPreference: true,
345
373
  restoring: true,
346
374
  });
@@ -0,0 +1,126 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import { appConfigDb, userDb } from '../database/db.js';
4
+
5
+ const CONFIG_KEY = 'qr_login_settings';
6
+ const DEFAULT_SETTINGS = Object.freeze({
7
+ enabled: false,
8
+ ttlSeconds: 300,
9
+ });
10
+ const MIN_TTL_SECONDS = 60;
11
+ const MAX_TTL_SECONDS = 15 * 60;
12
+ const qrLoginTokens = new Map();
13
+
14
+ function clampTtl(value) {
15
+ const parsed = Number.parseInt(String(value || ''), 10);
16
+ if (!Number.isFinite(parsed)) return DEFAULT_SETTINGS.ttlSeconds;
17
+ return Math.min(MAX_TTL_SECONDS, Math.max(MIN_TTL_SECONDS, parsed));
18
+ }
19
+
20
+ function normalizeSettings(value = {}) {
21
+ const raw = value && typeof value === 'object' ? value : {};
22
+ return {
23
+ enabled: raw.enabled === true,
24
+ ttlSeconds: clampTtl(raw.ttlSeconds),
25
+ };
26
+ }
27
+
28
+ function readStoredSettings() {
29
+ const raw = appConfigDb.get(CONFIG_KEY);
30
+ if (!raw) return { ...DEFAULT_SETTINGS };
31
+ try {
32
+ return normalizeSettings(JSON.parse(raw));
33
+ } catch {
34
+ return { ...DEFAULT_SETTINGS };
35
+ }
36
+ }
37
+
38
+ function cleanupExpiredTokens() {
39
+ const now = Date.now();
40
+ for (const [token, entry] of qrLoginTokens.entries()) {
41
+ if (!entry || entry.expiresAtMs <= now) {
42
+ qrLoginTokens.delete(token);
43
+ }
44
+ }
45
+ }
46
+
47
+ function appendQrLoginToken(baseUrl, token) {
48
+ const url = new URL(baseUrl);
49
+ url.searchParams.set('qrLoginToken', token);
50
+ return url.toString();
51
+ }
52
+
53
+ export function getQrLoginSettings() {
54
+ return readStoredSettings();
55
+ }
56
+
57
+ export function saveQrLoginSettings(input = {}) {
58
+ const settings = normalizeSettings(input);
59
+ appConfigDb.set(CONFIG_KEY, JSON.stringify(settings));
60
+ if (!settings.enabled) {
61
+ qrLoginTokens.clear();
62
+ }
63
+ return settings;
64
+ }
65
+
66
+ export function createQrLoginToken({ userId, baseUrl }) {
67
+ const settings = readStoredSettings();
68
+ if (!settings.enabled) {
69
+ const error = new Error('QR login is disabled.');
70
+ error.code = 'QR_LOGIN_DISABLED';
71
+ throw error;
72
+ }
73
+ const user = userDb.getUserById(userId);
74
+ if (!user) {
75
+ const error = new Error('QR login user was not found.');
76
+ error.code = 'QR_LOGIN_USER_NOT_FOUND';
77
+ throw error;
78
+ }
79
+
80
+ cleanupExpiredTokens();
81
+ const token = crypto.randomBytes(32).toString('base64url');
82
+ const expiresAtMs = Date.now() + settings.ttlSeconds * 1000;
83
+ qrLoginTokens.set(token, {
84
+ userId: user.id,
85
+ createdAtMs: Date.now(),
86
+ expiresAtMs,
87
+ });
88
+
89
+ return {
90
+ token,
91
+ expiresAt: new Date(expiresAtMs).toISOString(),
92
+ ttlSeconds: settings.ttlSeconds,
93
+ qrUrl: baseUrl ? appendQrLoginToken(baseUrl, token) : null,
94
+ };
95
+ }
96
+
97
+ export function consumeQrLoginToken(token) {
98
+ const settings = readStoredSettings();
99
+ if (!settings.enabled) {
100
+ const error = new Error('QR login is disabled.');
101
+ error.code = 'QR_LOGIN_DISABLED';
102
+ throw error;
103
+ }
104
+ if (!token || typeof token !== 'string') {
105
+ const error = new Error('QR login token is required.');
106
+ error.code = 'QR_LOGIN_TOKEN_REQUIRED';
107
+ throw error;
108
+ }
109
+
110
+ cleanupExpiredTokens();
111
+ const entry = qrLoginTokens.get(token);
112
+ qrLoginTokens.delete(token);
113
+ if (!entry) {
114
+ const error = new Error('QR login token is invalid or expired.');
115
+ error.code = 'QR_LOGIN_INVALID';
116
+ throw error;
117
+ }
118
+
119
+ const user = userDb.getUserById(entry.userId);
120
+ if (!user) {
121
+ const error = new Error('QR login user was not found.');
122
+ error.code = 'QR_LOGIN_USER_NOT_FOUND';
123
+ throw error;
124
+ }
125
+ return user;
126
+ }
@@ -7,6 +7,7 @@ import {
7
7
  handleTelegramControlCallback,
8
8
  handleTelegramControlMessage,
9
9
  isTelegramControlCommand,
10
+ sendActiveTerminalAttachedNotice,
10
11
  showMainMenu,
11
12
  } from './control-center.js';
12
13
  import { t } from './translations.js';
@@ -359,6 +360,20 @@ export const notifyUser = async ({ userId, kind, title, error }) => {
359
360
  return sendToUser(userId, text);
360
361
  };
361
362
 
363
+ export const notifyTelegramTerminalAttached = async ({ userId, terminal }) => {
364
+ const link = telegramLinksDb.getByUserId(userId);
365
+ if (!bot) return { ok: false, reason: 'bot_not_running' };
366
+ if (!link?.chat_id || !link?.verified_at) return { ok: false, reason: 'telegram_not_paired' };
367
+ if (!terminal) return { ok: false, reason: 'missing_terminal' };
368
+ try {
369
+ await sendActiveTerminalAttachedNotice({ bot, chatId: link.chat_id, link, terminal });
370
+ return { ok: true };
371
+ } catch (err) {
372
+ console.warn('[telegram] terminal attach notice failed:', err?.message || err);
373
+ return { ok: false, reason: err?.message || String(err) };
374
+ }
375
+ };
376
+
362
377
  // Boot the bot automatically if a token was previously persisted. This runs
363
378
  // once during server startup so a restart doesn't silently un-pair everyone.
364
379
  export const restoreBotFromConfig = async () => {