@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.
- package/dist/assets/{index-kP_gZ-wL.js → index-CV_x2mrI.js} +173 -173
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +51 -5
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/routes/auth.js +41 -1
- package/dist-server/server/routes/auth.js.map +1 -1
- package/dist-server/server/routes/git.js +60 -3
- package/dist-server/server/routes/git.js.map +1 -1
- package/dist-server/server/routes/network.js +17 -1
- package/dist-server/server/routes/network.js.map +1 -1
- package/dist-server/server/services/external-access.js +45 -21
- package/dist-server/server/services/external-access.js.map +1 -1
- package/dist-server/server/services/qr-login.js +115 -0
- package/dist-server/server/services/qr-login.js.map +1 -0
- package/dist-server/server/services/telegram/bot.js +18 -1
- package/dist-server/server/services/telegram/bot.js.map +1 -1
- package/dist-server/server/services/telegram/control-center.js +225 -9
- package/dist-server/server/services/telegram/control-center.js.map +1 -1
- package/dist-server/server/services/telegram/translations.js +22 -2
- package/dist-server/server/services/telegram/translations.js.map +1 -1
- package/package.json +1 -1
- package/server/index.js +59 -5
- package/server/routes/auth.js +47 -1
- package/server/routes/git.js +70 -3
- package/server/routes/network.js +18 -1
- package/server/services/external-access.js +49 -21
- package/server/services/qr-login.js +126 -0
- package/server/services/telegram/bot.js +15 -0
- package/server/services/telegram/control-center.js +241 -9
- 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
|
|
1693
|
+
const {
|
|
1694
|
+
rawOutput,
|
|
1695
|
+
outputCursor,
|
|
1696
|
+
bufferStartCursor,
|
|
1697
|
+
} = readPtySessionBufferedOutput(matchedSession, { maxChars, sinceCursor });
|
|
1662
1698
|
const output = stripAnsiSequences(rawOutput);
|
|
1663
|
-
const
|
|
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,
|
package/server/routes/auth.js
CHANGED
|
@@ -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 {
|
package/server/routes/git.js
CHANGED
|
@@ -46,9 +46,11 @@ router.use((req, res, next) => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
function isNotGitRepositoryMessage(message = '') {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
||
|
|
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
|
}
|
package/server/routes/network.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
101
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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: '
|
|
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(
|
|
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
|
|
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 () => {
|