pal-explorer-cli 0.4.12 → 0.4.13

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 (99) hide show
  1. package/README.md +149 -149
  2. package/bin/pal.js +63 -2
  3. package/extensions/@palexplorer/analytics/extension.json +20 -1
  4. package/extensions/@palexplorer/analytics/index.js +19 -9
  5. package/extensions/@palexplorer/audit/extension.json +14 -0
  6. package/extensions/@palexplorer/auth-email/extension.json +15 -0
  7. package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
  8. package/extensions/@palexplorer/chat/extension.json +14 -0
  9. package/extensions/@palexplorer/discovery/extension.json +17 -0
  10. package/extensions/@palexplorer/discovery/index.js +1 -1
  11. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  12. package/extensions/@palexplorer/groups/extension.json +15 -0
  13. package/extensions/@palexplorer/share-links/extension.json +15 -0
  14. package/extensions/@palexplorer/sync/extension.json +16 -0
  15. package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
  16. package/lib/capabilities.js +24 -24
  17. package/lib/commands/analytics.js +175 -175
  18. package/lib/commands/api-keys.js +131 -131
  19. package/lib/commands/audit.js +235 -235
  20. package/lib/commands/auth.js +137 -137
  21. package/lib/commands/backup.js +76 -76
  22. package/lib/commands/billing.js +148 -148
  23. package/lib/commands/chat.js +217 -217
  24. package/lib/commands/cloud-backup.js +231 -231
  25. package/lib/commands/comment.js +99 -99
  26. package/lib/commands/completion.js +203 -203
  27. package/lib/commands/compliance.js +218 -218
  28. package/lib/commands/config.js +136 -136
  29. package/lib/commands/connect.js +44 -44
  30. package/lib/commands/dept.js +294 -294
  31. package/lib/commands/device.js +146 -146
  32. package/lib/commands/download.js +240 -226
  33. package/lib/commands/explorer.js +178 -178
  34. package/lib/commands/extension.js +1060 -970
  35. package/lib/commands/favorite.js +90 -90
  36. package/lib/commands/federation.js +270 -270
  37. package/lib/commands/file.js +533 -533
  38. package/lib/commands/group.js +271 -271
  39. package/lib/commands/gui-share.js +29 -29
  40. package/lib/commands/init.js +61 -61
  41. package/lib/commands/invite.js +59 -59
  42. package/lib/commands/list.js +58 -58
  43. package/lib/commands/log.js +116 -116
  44. package/lib/commands/nearby.js +108 -108
  45. package/lib/commands/network.js +251 -251
  46. package/lib/commands/notify.js +198 -198
  47. package/lib/commands/org.js +273 -273
  48. package/lib/commands/pal.js +403 -180
  49. package/lib/commands/permissions.js +216 -216
  50. package/lib/commands/pin.js +97 -97
  51. package/lib/commands/protocol.js +357 -357
  52. package/lib/commands/rbac.js +147 -147
  53. package/lib/commands/recover.js +36 -36
  54. package/lib/commands/register.js +171 -171
  55. package/lib/commands/relay.js +131 -131
  56. package/lib/commands/remote.js +368 -368
  57. package/lib/commands/revoke.js +50 -50
  58. package/lib/commands/scanner.js +280 -280
  59. package/lib/commands/schedule.js +344 -344
  60. package/lib/commands/scim.js +203 -203
  61. package/lib/commands/search.js +181 -181
  62. package/lib/commands/serve.js +438 -438
  63. package/lib/commands/server.js +350 -350
  64. package/lib/commands/share-link.js +199 -199
  65. package/lib/commands/share.js +336 -323
  66. package/lib/commands/sso.js +200 -200
  67. package/lib/commands/status.js +145 -145
  68. package/lib/commands/stream.js +562 -562
  69. package/lib/commands/su.js +187 -187
  70. package/lib/commands/sync.js +979 -979
  71. package/lib/commands/transfers.js +152 -152
  72. package/lib/commands/uninstall.js +188 -188
  73. package/lib/commands/update.js +204 -204
  74. package/lib/commands/user.js +276 -276
  75. package/lib/commands/vfs.js +84 -84
  76. package/lib/commands/web-login.js +79 -79
  77. package/lib/commands/web.js +52 -52
  78. package/lib/commands/webhook.js +180 -180
  79. package/lib/commands/whoami.js +59 -59
  80. package/lib/commands/workspace.js +121 -121
  81. package/lib/core/billing.js +16 -5
  82. package/lib/core/dhtDiscovery.js +9 -2
  83. package/lib/core/discoveryClient.js +13 -7
  84. package/lib/core/extensions.js +142 -1
  85. package/lib/core/identity.js +33 -2
  86. package/lib/core/imageProcessor.js +109 -0
  87. package/lib/core/imageTorrent.js +167 -0
  88. package/lib/core/permissions.js +1 -1
  89. package/lib/core/pro.js +11 -4
  90. package/lib/core/serverList.js +4 -1
  91. package/lib/core/shares.js +12 -1
  92. package/lib/core/signalingServer.js +14 -2
  93. package/lib/core/su.js +1 -1
  94. package/lib/core/users.js +1 -1
  95. package/lib/protocol/messages.js +12 -3
  96. package/lib/utils/explorer.js +1 -1
  97. package/lib/utils/help.js +357 -357
  98. package/lib/utils/torrent.js +1 -0
  99. package/package.json +4 -3
@@ -1,438 +1,438 @@
1
- import WebTorrent from 'webtorrent';
2
- import chalk from 'chalk';
3
- import path from 'path';
4
- import fs from 'fs';
5
- import { spawn } from 'child_process';
6
- import { fileURLToPath } from 'url';
7
- import { listShares, getShareKey, isShareExpired, expireOldShares } from '../core/shares.js';
8
- import { getTransfers, updateTransferProgress, completeTransfer } from '../core/transfers.js';
9
- import { encryptForSeed, getEncryptedSeedDir } from '../crypto/streamEncryption.js';
10
- import { generateShareKey } from '../crypto/shareEncryption.js';
11
- import config from '../utils/config.js';
12
- import logger from '../utils/logger.js';
13
- import { requireRole } from '../core/users.js';
14
- import { startWebServer } from '../core/webServer.js';
15
- import { getPrimaryServer, postTo } from '../core/discoveryClient.js';
16
- import { startMdns } from '../core/mdnsService.js';
17
- import { connect, getConnectionState } from '../core/connectionManager.js';
18
- import { startAsync as startSignaling, setMessageHandler, stop as stopSignaling } from '../core/signalingServer.js';
19
- import { DEFAULT_TRACKERS } from '../utils/torrent.js';
20
- import os from 'os';
21
-
22
- function getLanIp() {
23
- const interfaces = os.networkInterfaces();
24
- const candidates = [];
25
- for (const name of Object.keys(interfaces)) {
26
- for (const iface of interfaces[name]) {
27
- if (iface.family === 'IPv4' && !iface.internal) {
28
- // Prefer 192.168.x.x and 10.x.x.x over virtual adapters (172.x usually WSL/Docker)
29
- const priority = iface.address.startsWith('192.168.') || iface.address.startsWith('10.') ? 0 : 1;
30
- candidates.push({ address: iface.address, priority, name });
31
- }
32
- }
33
- }
34
- candidates.sort((a, b) => a.priority - b.priority);
35
- return candidates[0]?.address || null;
36
- }
37
-
38
- const pidFile = () => path.join(path.dirname(config.path), 'serve.pid');
39
-
40
- export default function serveCommand(program) {
41
- program
42
- .command('serve')
43
- .description('start the sharing daemon (seeds all active shares)')
44
- .option('--daemon', 'Run as a background daemon')
45
- .option('--stop', 'Stop a running daemon')
46
- .option('--web', 'Start the web dashboard alongside the seeder')
47
- .option('--web-port <port>', 'Web dashboard port (default: 8585)', '8585')
48
- .option('--lan', 'Bind web server to 0.0.0.0 for LAN access')
49
- .option('--public-ip <ip>', 'Public IP/hostname for serveUrl announcement (for VPS/cloud)')
50
- .addHelpText('after', `
51
- Examples:
52
- $ pe serve Start seeding in foreground
53
- $ pe serve --daemon Start as background daemon
54
- $ pe serve --stop Stop the daemon
55
- $ pe serve --web Start with web dashboard
56
- $ pe serve --web --web-port 9090 Custom web dashboard port
57
- $ pe serve --web --lan --public-ip 1.2.3.4 VPS with public IP
58
- `)
59
- .action(async (opts) => {
60
- try { requireRole('user'); } catch (e) {
61
- console.log(chalk.red(e.message));
62
- process.exitCode = 1;
63
- return;
64
- }
65
- if (opts.stop) {
66
- const pidPath = pidFile();
67
- if (!fs.existsSync(pidPath)) {
68
- console.log(chalk.yellow('No daemon PID file found.'));
69
- return;
70
- }
71
- const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
72
- try {
73
- process.kill(pid, 0); // check if alive
74
- process.kill(pid, 'SIGTERM');
75
- fs.unlinkSync(pidPath);
76
- console.log(chalk.green(`Daemon stopped (PID ${pid}).`));
77
- } catch (err) {
78
- fs.unlinkSync(pidPath);
79
- if (err.code === 'ESRCH') {
80
- console.log(chalk.yellow(`Daemon (PID ${pid}) was not running. Cleaned up stale PID file.`));
81
- } else {
82
- console.log(chalk.red(`Failed to stop daemon (PID ${pid}): ${err.message}`));
83
- process.exitCode = 1;
84
- }
85
- }
86
- return;
87
- }
88
-
89
- if (opts.daemon) {
90
- const binPath = fileURLToPath(new URL('../../bin/pal.js', import.meta.url));
91
- const args = [binPath, 'serve'];
92
- if (opts.web) {
93
- args.push('--web');
94
- if (opts.webPort && opts.webPort !== '8585') args.push('--web-port', opts.webPort);
95
- if (opts.lan) args.push('--lan');
96
- if (opts.publicIp) args.push('--public-ip', opts.publicIp);
97
- }
98
- const child = spawn(process.execPath, args, {
99
- detached: true,
100
- stdio: 'ignore'
101
- });
102
- child.unref();
103
- fs.writeFileSync(pidFile(), String(child.pid));
104
- console.log(chalk.green(`Daemon started (PID: ${child.pid})`));
105
- return;
106
- }
107
-
108
- // ── Start P2P services FIRST (signaling, mDNS, DHT) ──
109
- // These must start before seeding so peers can query us immediately
110
-
111
- // Start PAL/1.0 signaling server (port 7474)
112
- let signalingActive = false;
113
- try {
114
- const { handleIncoming } = await import('../protocol/handler.js');
115
- const identity = config.get('identity');
116
- if (identity?.privateKey) {
117
- const keyPair = {
118
- publicKey: Buffer.from(identity.publicKey, 'hex'),
119
- privateKey: Buffer.from(identity.privateKey, 'hex'),
120
- };
121
- setMessageHandler((envelope) => handleIncoming(envelope, keyPair));
122
- }
123
- const sigResult = await startSignaling();
124
- signalingActive = sigResult.bound;
125
- if (signalingActive) {
126
- console.log(chalk.cyan('PAL/1.0 signaling server active (port 7474)'));
127
- } else {
128
- console.log(chalk.yellow(`Signaling server failed to bind (${sigResult.error || 'unknown'})`));
129
- }
130
- } catch (err) {
131
- console.error(chalk.red(`Failed to start signaling: ${err.message}`));
132
- }
133
-
134
- // Start mDNS background advertisement (only if signaling bound successfully)
135
- if (signalingActive) {
136
- try {
137
- const identity = config.get('identity');
138
- if (identity) {
139
- startMdns(identity, {});
140
- console.log(chalk.cyan('mDNS discovery active (LAN peer detection)'));
141
- }
142
- } catch { /* non-fatal */ }
143
- } else {
144
- console.log(chalk.yellow('Skipping mDNS — signaling server not active (port in use by another instance?)'));
145
- }
146
-
147
- // Re-publish to DHT every 30 minutes
148
- const identity = config.get('identity');
149
- if (identity?.handle && identity?.privateKey && identity?.publicKey) {
150
- const republishDHT = async () => {
151
- try {
152
- const { DHTDiscovery } = await import('../core/dhtDiscovery.js');
153
- const dht = new DHTDiscovery();
154
- const dhtHash = await dht.publish(identity.handle, identity.publicKey, identity.privateKey);
155
- const cache = config.get('handleCache') || {};
156
- if (!cache[identity.handle]) cache[identity.handle] = {};
157
- cache[identity.handle].dhtHash = dhtHash;
158
- config.set('handleCache', cache);
159
- logger.info('DHT re-published', { handle: identity.handle, hash: dhtHash.slice(0, 12) });
160
- dht.destroy();
161
- } catch (err) {
162
- logger.warn(`DHT re-publish failed: ${err.message}`);
163
- }
164
- };
165
- republishDHT();
166
- setInterval(republishDHT, 30 * 60 * 1000);
167
- }
168
-
169
- // Register device with discovery server so peers can find us
170
- try {
171
- const ident = config.get('identity');
172
- const device = config.get('device');
173
- if (ident?.handle && ident?.publicKey && device?.id) {
174
- const { getIdentity: getFullIdentity } = await import('../core/identity.js');
175
- const fullIdent = await getFullIdentity();
176
- if (fullIdent?.privateKey) {
177
- const { signMessage } = await import('../crypto/chatEncryption.js');
178
- const timestamp = String(Date.now());
179
- const sigMsg = `${ident.handle}:${device.id}:${device.name}:${timestamp}`;
180
- const signature = signMessage(sigMsg, fullIdent.privateKey);
181
- const serverUrl = getPrimaryServer();
182
- postTo(serverUrl, '/devices/register', {
183
- handle: ident.handle,
184
- deviceId: device.id,
185
- deviceName: device.name,
186
- publicKey: ident.publicKey,
187
- timestamp,
188
- signature,
189
- }).then(() => console.log(chalk.cyan('Device registered with discovery server')))
190
- .catch(() => {});
191
- }
192
- }
193
- } catch {}
194
-
195
- // Connect to network in background (don't block serving)
196
- const connState = getConnectionState();
197
- if (connState.status !== 'connected') {
198
- console.log(chalk.blue('Connecting to network...'));
199
- connect({
200
- onProgress: (step) => console.log(chalk.gray(` ${step}`)),
201
- }).catch(() => console.log(chalk.yellow('Network connect failed — serving locally only')));
202
- }
203
-
204
- // ── Seed shares via WebTorrent ──
205
- const shares = listShares();
206
- if (shares.length === 0) {
207
- console.log(chalk.gray('No active shares to seed. Signaling server still running.'));
208
- }
209
-
210
- const client = new WebTorrent();
211
- client.setMaxListeners(Math.max(shares.length, 10) + 20);
212
-
213
- // Auto-expire old shares before seeding
214
- expireOldShares();
215
-
216
- if (shares.length > 0) {
217
- logger.info('Starting Pal Explorer Seeder...', { shares: shares.length });
218
- console.log(chalk.blue(`Seeding ${shares.length} share(s)...`));
219
- }
220
-
221
- for (const share of shares) {
222
- try {
223
- if (isShareExpired(share)) {
224
- console.log(chalk.gray(`Skipping ${share.name}: expired`));
225
- continue;
226
- }
227
-
228
- if (!fs.existsSync(share.path)) {
229
- console.log(chalk.yellow(`Skipping ${share.name}: path not found (${share.path})`));
230
- continue;
231
- }
232
-
233
- let seedPath = share.path;
234
-
235
- // Non-recursive: seed only top-level files
236
- if (share.recursive === false && fs.statSync(share.path).isDirectory()) {
237
- const items = fs.readdirSync(share.path, { withFileTypes: true });
238
- const topFiles = items.filter(i => i.isFile()).map(i => path.join(share.path, i.name));
239
- if (topFiles.length === 0) {
240
- console.log(chalk.yellow(`Skipping ${share.name}: no top-level files (non-recursive).`));
241
- continue;
242
- }
243
- seedPath = topFiles;
244
- }
245
-
246
- if (share.visibility === 'private') {
247
- const shareKeyHex = await getShareKey(share.id);
248
- if (shareKeyHex) {
249
- const shareKey = Buffer.from(shareKeyHex, 'hex');
250
- const encDir = share.encryptedPath || getEncryptedSeedDir(share.id);
251
- if (!fs.existsSync(path.join(encDir, '.manifest.enc'))) {
252
- console.log(chalk.gray(` Encrypting ${share.name} for E2EE seeding...`));
253
- encryptForSeed(share.path, encDir, shareKey);
254
- const allShares = config.get('shares') || [];
255
- const entry = allShares.find(s => s.id === share.id);
256
- if (entry) {
257
- entry.encryptedPath = encDir;
258
- config.set('shares', allShares);
259
- }
260
- }
261
- seedPath = encDir;
262
- } else if (share.encryptedPath && fs.existsSync(share.encryptedPath)) {
263
- seedPath = share.encryptedPath;
264
- }
265
- }
266
-
267
- console.log(chalk.yellow(`Initializing: ${share.name} (${share.visibility})`));
268
-
269
- const options = {
270
- name: share.name,
271
- announce: share.visibility === 'private' ? [] : [...DEFAULT_TRACKERS]
272
- };
273
-
274
- client.seed(seedPath, options, async (torrent) => {
275
- logger.info(`Seeding: ${share.name}`, { magnet: torrent.magnetURI, size: torrent.length });
276
- console.log(chalk.green(`✔ Seeding: ${share.name}`));
277
- if (share.visibility === 'global') {
278
- console.log(chalk.cyan(` Magnet: ${torrent.magnetURI}`));
279
- } else {
280
- console.log(chalk.red(` [PRIVATE E2EE] Encrypted transfer only.`));
281
- console.log(chalk.gray(` Secret Magnet: ${torrent.magnetURI}`));
282
- }
283
- console.log(chalk.gray(` Size: ${(torrent.length / 1024 / 1024).toFixed(2)} MB`));
284
-
285
- const allShares = config.get('shares') || [];
286
- const entry = allShares.find(s => s.path === share.path);
287
- const isNewMagnet = entry && !entry.magnet;
288
- if (entry) {
289
- if (isNewMagnet) entry.magnet = torrent.magnetURI;
290
- entry.files = torrent.files.map(f => ({ name: f.name, path: f.path, size: f.length }));
291
- config.set('shares', allShares);
292
- }
293
-
294
- // Send share invite to recipients via discovery server inbox
295
- if (isNewMagnet && share.visibility === 'private' && share.recipients?.length > 0) {
296
- const ident = config.get('identity');
297
- const discoveryUrl = getPrimaryServer();
298
- const { getFriends } = await import('../core/users.js');
299
- const friends = getFriends();
300
- const encKeys = share.encryptedShareKeys || {};
301
-
302
- for (const recipient of share.recipients) {
303
- const pal = friends.find(f => f.id === recipient.id || f.name === recipient.name);
304
- const handle = pal?.handle || recipient.handle;
305
- if (!handle) continue;
306
-
307
- const invite = {
308
- type: 'share-invite',
309
- shareName: share.name,
310
- shareId: share.id,
311
- magnet: torrent.magnetURI,
312
- encryptedShareKey: encKeys[pal?.id || recipient.id] || null,
313
- from: ident?.handle || ident?.name || 'unknown',
314
- };
315
-
316
- try {
317
- const res = await postTo(discoveryUrl, '/messages', { toHandle: handle, fromHandle: ident?.handle, payload: JSON.stringify(invite) });
318
- if (res.ok) {
319
- console.log(chalk.cyan(` Sent share invite to @${handle}`));
320
- } else {
321
- console.log(chalk.yellow(` Failed to notify @${handle}: ${res.status}`));
322
- }
323
- } catch {
324
- console.log(chalk.yellow(` Could not reach discovery server to notify @${handle}`));
325
- }
326
- }
327
- }
328
- });
329
- } catch (err) {
330
- logger.error(`Failed to seed ${share.name}: ${err.message}`, { share: share.id });
331
- console.error(chalk.red(`Failed to seed ${share.name}: ${err.message}`));
332
- }
333
- }
334
-
335
- // Auto-resume incomplete downloads (skip paused)
336
- const pendingDownloads = getTransfers().filter(t => !t.paused && t.status !== 'completed');
337
- if (pendingDownloads.length > 0) {
338
- console.log(chalk.blue(`Resuming ${pendingDownloads.length} incomplete download(s)...`));
339
- for (const t of pendingDownloads) {
340
- try {
341
- const torrent = client.add(t.magnet, { path: t.savePath });
342
- torrent.on('download', () => updateTransferProgress(t.magnet, torrent.progress));
343
- torrent.on('done', () => {
344
- completeTransfer(t.magnet);
345
- console.log(chalk.green(`✔ Download complete: ${t.name}`));
346
- });
347
- console.log(chalk.yellow(` Resuming: ${t.name || t.magnet.slice(0, 40) + '...'}`));
348
- } catch (err) {
349
- console.error(chalk.red(`Failed to resume download: ${err.message}`));
350
- }
351
- }
352
- }
353
-
354
- // Start web dashboard if requested
355
- if (opts.web) {
356
- try {
357
- const webPort = parseInt(opts.webPort, 10);
358
- const bindAddress = opts.lan ? '0.0.0.0' : '127.0.0.1';
359
- const { token } = await startWebServer(webPort, client, { bindAddress });
360
- console.log('');
361
- const announceIp = opts.publicIp || getLanIp() || 'localhost';
362
- const webServeUrl = `http://${announceIp}:${webPort}`;
363
- if (opts.lan) {
364
- console.log(chalk.green(`Web dashboard running at ${webServeUrl}?token=${token}`));
365
- console.log(chalk.dim(` (LAN accessible, browse from other devices)`));
366
- } else {
367
- console.log(chalk.green(`Web dashboard running at http://localhost:${webPort}?token=${token}`));
368
- }
369
-
370
- const { hooks } = await import('../core/extensions.js');
371
- await hooks.emit('on:server:start', { port: webPort, url: webServeUrl });
372
- } catch (err) {
373
- console.error(chalk.red(`Failed to start web dashboard: ${err.message}`));
374
- }
375
- }
376
-
377
- // Store torrent port in config once WebTorrent is listening
378
- const storeTorrentPort = () => {
379
- const torrentPort = client.address()?.port || null;
380
- if (torrentPort) {
381
- config.set('torrentPort', torrentPort);
382
- // Update mDNS with port info
383
- try {
384
- const ident = config.get('identity');
385
- if (ident) {
386
- const webPort = opts.web ? parseInt(opts.webPort, 10) : null;
387
- import('../core/mdnsService.js').then(({ stopMdns }) => {
388
- stopMdns();
389
- startMdns(ident, { torrentPort, webPort });
390
- });
391
- }
392
- } catch { /* non-fatal */ }
393
- }
394
- };
395
- // Try immediately, then retry after a delay (WebTorrent may not be listening yet)
396
- storeTorrentPort();
397
- setTimeout(storeTorrentPort, 5000);
398
- setTimeout(storeTorrentPort, 15000);
399
-
400
- console.log('');
401
- console.log(chalk.bgBlue('Daemon is running. Press Ctrl+C to stop sharing.'));
402
- console.log('');
403
-
404
- // Bug #20 fix: auto-seed new shares created after daemon started
405
- const seededPaths = new Set(shares.map(s => s.path));
406
- setInterval(() => {
407
- try {
408
- expireOldShares();
409
- const currentShares = listShares();
410
- for (const share of currentShares) {
411
- if (seededPaths.has(share.path)) continue;
412
- if (isShareExpired(share)) continue;
413
- if (!fs.existsSync(share.path)) continue;
414
- seededPaths.add(share.path);
415
- const seedOpts = { name: share.name, announce: share.visibility === 'private' ? [] : [...DEFAULT_TRACKERS] };
416
- client.seed(share.path, seedOpts, (torrent) => {
417
- console.log(chalk.green(`✔ Auto-seeding new share: ${share.name}`));
418
- const allShares = config.get('shares') || [];
419
- const entry = allShares.find(s => s.path === share.path);
420
- if (entry && !entry.magnet) {
421
- entry.magnet = torrent.magnetURI;
422
- entry.files = torrent.files.map(f => ({ name: f.name, path: f.path, size: f.length }));
423
- config.set('shares', allShares);
424
- }
425
- });
426
- }
427
- } catch {}
428
- }, 60000);
429
-
430
- // Periodic stats every 30 seconds
431
- setInterval(() => {
432
- const up = (client.uploadSpeed / 1024).toFixed(1);
433
- const down = (client.downloadSpeed / 1024).toFixed(1);
434
- const count = client.torrents.length;
435
- console.log(chalk.gray(`[stats] ↑ ${up} KB/s ↓ ${down} KB/s torrents: ${count}`));
436
- }, 30000);
437
- });
438
- }
1
+ import WebTorrent from 'webtorrent';
2
+ import chalk from 'chalk';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { spawn } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+ import { listShares, getShareKey, isShareExpired, expireOldShares } from '../core/shares.js';
8
+ import { getTransfers, updateTransferProgress, completeTransfer } from '../core/transfers.js';
9
+ import { encryptForSeed, getEncryptedSeedDir } from '../crypto/streamEncryption.js';
10
+ import { generateShareKey } from '../crypto/shareEncryption.js';
11
+ import config from '../utils/config.js';
12
+ import logger from '../utils/logger.js';
13
+ import { requireRole } from '../core/users.js';
14
+ import { startWebServer } from '../core/webServer.js';
15
+ import { getPrimaryServer, postTo } from '../core/discoveryClient.js';
16
+ import { startMdns } from '../core/mdnsService.js';
17
+ import { connect, getConnectionState } from '../core/connectionManager.js';
18
+ import { startAsync as startSignaling, setMessageHandler, stop as stopSignaling } from '../core/signalingServer.js';
19
+ import { DEFAULT_TRACKERS } from '../utils/torrent.js';
20
+ import os from 'os';
21
+
22
+ function getLanIp() {
23
+ const interfaces = os.networkInterfaces();
24
+ const candidates = [];
25
+ for (const name of Object.keys(interfaces)) {
26
+ for (const iface of interfaces[name]) {
27
+ if (iface.family === 'IPv4' && !iface.internal) {
28
+ // Prefer 192.168.x.x and 10.x.x.x over virtual adapters (172.x usually WSL/Docker)
29
+ const priority = iface.address.startsWith('192.168.') || iface.address.startsWith('10.') ? 0 : 1;
30
+ candidates.push({ address: iface.address, priority, name });
31
+ }
32
+ }
33
+ }
34
+ candidates.sort((a, b) => a.priority - b.priority);
35
+ return candidates[0]?.address || null;
36
+ }
37
+
38
+ const pidFile = () => path.join(path.dirname(config.path), 'serve.pid');
39
+
40
+ export default function serveCommand(program) {
41
+ program
42
+ .command('serve')
43
+ .description('start the sharing daemon (seeds all active shares)')
44
+ .option('--daemon', 'Run as a background daemon')
45
+ .option('--stop', 'Stop a running daemon')
46
+ .option('--web', 'Start the web dashboard alongside the seeder')
47
+ .option('--web-port <port>', 'Web dashboard port (default: 8585)', '8585')
48
+ .option('--lan', 'Bind web server to 0.0.0.0 for LAN access')
49
+ .option('--public-ip <ip>', 'Public IP/hostname for serveUrl announcement (for VPS/cloud)')
50
+ .addHelpText('after', `
51
+ Examples:
52
+ $ pal serve Start seeding in foreground
53
+ $ pal serve --daemon Start as background daemon
54
+ $ pal serve --stop Stop the daemon
55
+ $ pal serve --web Start with web dashboard
56
+ $ pal serve --web --web-port 9090 Custom web dashboard port
57
+ $ pal serve --web --lan --public-ip 1.2.3.4 VPS with public IP
58
+ `)
59
+ .action(async (opts) => {
60
+ try { requireRole('user'); } catch (e) {
61
+ console.log(chalk.red(e.message));
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ if (opts.stop) {
66
+ const pidPath = pidFile();
67
+ if (!fs.existsSync(pidPath)) {
68
+ console.log(chalk.yellow('No daemon PID file found.'));
69
+ return;
70
+ }
71
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
72
+ try {
73
+ process.kill(pid, 0); // check if alive
74
+ process.kill(pid, 'SIGTERM');
75
+ fs.unlinkSync(pidPath);
76
+ console.log(chalk.green(`Daemon stopped (PID ${pid}).`));
77
+ } catch (err) {
78
+ fs.unlinkSync(pidPath);
79
+ if (err.code === 'ESRCH') {
80
+ console.log(chalk.yellow(`Daemon (PID ${pid}) was not running. Cleaned up stale PID file.`));
81
+ } else {
82
+ console.log(chalk.red(`Failed to stop daemon (PID ${pid}): ${err.message}`));
83
+ process.exitCode = 1;
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (opts.daemon) {
90
+ const binPath = fileURLToPath(new URL('../../bin/pal.js', import.meta.url));
91
+ const args = [binPath, 'serve'];
92
+ if (opts.web) {
93
+ args.push('--web');
94
+ if (opts.webPort && opts.webPort !== '8585') args.push('--web-port', opts.webPort);
95
+ if (opts.lan) args.push('--lan');
96
+ if (opts.publicIp) args.push('--public-ip', opts.publicIp);
97
+ }
98
+ const child = spawn(process.execPath, args, {
99
+ detached: true,
100
+ stdio: 'ignore'
101
+ });
102
+ child.unref();
103
+ fs.writeFileSync(pidFile(), String(child.pid));
104
+ console.log(chalk.green(`Daemon started (PID: ${child.pid})`));
105
+ return;
106
+ }
107
+
108
+ // ── Start P2P services FIRST (signaling, mDNS, DHT) ──
109
+ // These must start before seeding so peers can query us immediately
110
+
111
+ // Start PAL/1.0 signaling server (port 7474)
112
+ let signalingActive = false;
113
+ try {
114
+ const { handleIncoming } = await import('../protocol/handler.js');
115
+ const identity = config.get('identity');
116
+ if (identity?.privateKey) {
117
+ const keyPair = {
118
+ publicKey: Buffer.from(identity.publicKey, 'hex'),
119
+ privateKey: Buffer.from(identity.privateKey, 'hex'),
120
+ };
121
+ setMessageHandler((envelope) => handleIncoming(envelope, keyPair));
122
+ }
123
+ const sigResult = await startSignaling();
124
+ signalingActive = sigResult.bound;
125
+ if (signalingActive) {
126
+ console.log(chalk.cyan('PAL/1.0 signaling server active (port 7474)'));
127
+ } else {
128
+ console.log(chalk.yellow(`Signaling server failed to bind (${sigResult.error || 'unknown'})`));
129
+ }
130
+ } catch (err) {
131
+ console.error(chalk.red(`Failed to start signaling: ${err.message}`));
132
+ }
133
+
134
+ // Start mDNS background advertisement (only if signaling bound successfully)
135
+ if (signalingActive) {
136
+ try {
137
+ const identity = config.get('identity');
138
+ if (identity) {
139
+ startMdns(identity, {});
140
+ console.log(chalk.cyan('mDNS discovery active (LAN peer detection)'));
141
+ }
142
+ } catch { /* non-fatal */ }
143
+ } else {
144
+ console.log(chalk.yellow('Skipping mDNS — signaling server not active (port in use by another instance?)'));
145
+ }
146
+
147
+ // Re-publish to DHT every 30 minutes
148
+ const identity = config.get('identity');
149
+ if (identity?.handle && identity?.privateKey && identity?.publicKey) {
150
+ const republishDHT = async () => {
151
+ try {
152
+ const { DHTDiscovery } = await import('../core/dhtDiscovery.js');
153
+ const dht = new DHTDiscovery();
154
+ const dhtHash = await dht.publish(identity.handle, identity.publicKey, identity.privateKey);
155
+ const cache = config.get('handleCache') || {};
156
+ if (!cache[identity.handle]) cache[identity.handle] = {};
157
+ cache[identity.handle].dhtHash = dhtHash;
158
+ config.set('handleCache', cache);
159
+ logger.info('DHT re-published', { handle: identity.handle, hash: dhtHash.slice(0, 12) });
160
+ dht.destroy();
161
+ } catch (err) {
162
+ logger.warn(`DHT re-publish failed: ${err.message}`);
163
+ }
164
+ };
165
+ republishDHT();
166
+ setInterval(republishDHT, 30 * 60 * 1000);
167
+ }
168
+
169
+ // Register device with discovery server so peers can find us
170
+ try {
171
+ const ident = config.get('identity');
172
+ const device = config.get('device');
173
+ if (ident?.handle && ident?.publicKey && device?.id) {
174
+ const { getIdentity: getFullIdentity } = await import('../core/identity.js');
175
+ const fullIdent = await getFullIdentity();
176
+ if (fullIdent?.privateKey) {
177
+ const { signMessage } = await import('../crypto/chatEncryption.js');
178
+ const timestamp = String(Date.now());
179
+ const sigMsg = `${ident.handle}:${device.id}:${device.name}:${timestamp}`;
180
+ const signature = signMessage(sigMsg, fullIdent.privateKey);
181
+ const serverUrl = getPrimaryServer();
182
+ postTo(serverUrl, '/devices/register', {
183
+ handle: ident.handle,
184
+ deviceId: device.id,
185
+ deviceName: device.name,
186
+ publicKey: ident.publicKey,
187
+ timestamp,
188
+ signature,
189
+ }).then(() => console.log(chalk.cyan('Device registered with discovery server')))
190
+ .catch(() => {});
191
+ }
192
+ }
193
+ } catch {}
194
+
195
+ // Connect to network in background (don't block serving)
196
+ const connState = getConnectionState();
197
+ if (connState.status !== 'connected') {
198
+ console.log(chalk.blue('Connecting to network...'));
199
+ connect({
200
+ onProgress: (step) => console.log(chalk.gray(` ${step}`)),
201
+ }).catch(() => console.log(chalk.yellow('Network connect failed — serving locally only')));
202
+ }
203
+
204
+ // ── Seed shares via WebTorrent ──
205
+ const shares = listShares();
206
+ if (shares.length === 0) {
207
+ console.log(chalk.gray('No active shares to seed. Signaling server still running.'));
208
+ }
209
+
210
+ const client = new WebTorrent();
211
+ client.setMaxListeners(Math.max(shares.length, 10) + 20);
212
+
213
+ // Auto-expire old shares before seeding
214
+ expireOldShares();
215
+
216
+ if (shares.length > 0) {
217
+ logger.info('Starting Pal Explorer Seeder...', { shares: shares.length });
218
+ console.log(chalk.blue(`Seeding ${shares.length} share(s)...`));
219
+ }
220
+
221
+ for (const share of shares) {
222
+ try {
223
+ if (isShareExpired(share)) {
224
+ console.log(chalk.gray(`Skipping ${share.name}: expired`));
225
+ continue;
226
+ }
227
+
228
+ if (!fs.existsSync(share.path)) {
229
+ console.log(chalk.yellow(`Skipping ${share.name}: path not found (${share.path})`));
230
+ continue;
231
+ }
232
+
233
+ let seedPath = share.path;
234
+
235
+ // Non-recursive: seed only top-level files
236
+ if (share.recursive === false && fs.statSync(share.path).isDirectory()) {
237
+ const items = fs.readdirSync(share.path, { withFileTypes: true });
238
+ const topFiles = items.filter(i => i.isFile()).map(i => path.join(share.path, i.name));
239
+ if (topFiles.length === 0) {
240
+ console.log(chalk.yellow(`Skipping ${share.name}: no top-level files (non-recursive).`));
241
+ continue;
242
+ }
243
+ seedPath = topFiles;
244
+ }
245
+
246
+ if (share.visibility === 'private') {
247
+ const shareKeyHex = await getShareKey(share.id);
248
+ if (shareKeyHex) {
249
+ const shareKey = Buffer.from(shareKeyHex, 'hex');
250
+ const encDir = share.encryptedPath || getEncryptedSeedDir(share.id);
251
+ if (!fs.existsSync(path.join(encDir, '.manifest.enc'))) {
252
+ console.log(chalk.gray(` Encrypting ${share.name} for E2EE seeding...`));
253
+ encryptForSeed(share.path, encDir, shareKey);
254
+ const allShares = config.get('shares') || [];
255
+ const entry = allShares.find(s => s.id === share.id);
256
+ if (entry) {
257
+ entry.encryptedPath = encDir;
258
+ config.set('shares', allShares);
259
+ }
260
+ }
261
+ seedPath = encDir;
262
+ } else if (share.encryptedPath && fs.existsSync(share.encryptedPath)) {
263
+ seedPath = share.encryptedPath;
264
+ }
265
+ }
266
+
267
+ console.log(chalk.yellow(`Initializing: ${share.name} (${share.visibility})`));
268
+
269
+ const options = {
270
+ name: share.name,
271
+ announce: share.visibility === 'private' ? [] : [...DEFAULT_TRACKERS]
272
+ };
273
+
274
+ client.seed(seedPath, options, async (torrent) => {
275
+ logger.info(`Seeding: ${share.name}`, { magnet: torrent.magnetURI, size: torrent.length });
276
+ console.log(chalk.green(`✔ Seeding: ${share.name}`));
277
+ if (share.visibility === 'global') {
278
+ console.log(chalk.cyan(` Magnet: ${torrent.magnetURI}`));
279
+ } else {
280
+ console.log(chalk.red(` [PRIVATE E2EE] Encrypted transfer only.`));
281
+ console.log(chalk.gray(` Secret Magnet: ${torrent.magnetURI}`));
282
+ }
283
+ console.log(chalk.gray(` Size: ${(torrent.length / 1024 / 1024).toFixed(2)} MB`));
284
+
285
+ const allShares = config.get('shares') || [];
286
+ const entry = allShares.find(s => s.path === share.path);
287
+ const isNewMagnet = entry && !entry.magnet;
288
+ if (entry) {
289
+ if (isNewMagnet) entry.magnet = torrent.magnetURI;
290
+ entry.files = torrent.files.map(f => ({ name: f.name, path: f.path, size: f.length }));
291
+ config.set('shares', allShares);
292
+ }
293
+
294
+ // Send share invite to recipients via discovery server inbox
295
+ if (isNewMagnet && share.visibility === 'private' && share.recipients?.length > 0) {
296
+ const ident = config.get('identity');
297
+ const discoveryUrl = getPrimaryServer();
298
+ const { getFriends } = await import('../core/users.js');
299
+ const friends = getFriends();
300
+ const encKeys = share.encryptedShareKeys || {};
301
+
302
+ for (const recipient of share.recipients) {
303
+ const pal = friends.find(f => f.id === recipient.id || f.name === recipient.name);
304
+ const handle = pal?.handle || recipient.handle;
305
+ if (!handle) continue;
306
+
307
+ const invite = {
308
+ type: 'share-invite',
309
+ shareName: share.name,
310
+ shareId: share.id,
311
+ magnet: torrent.magnetURI,
312
+ encryptedShareKey: encKeys[pal?.id || recipient.id] || null,
313
+ from: ident?.handle || ident?.name || 'unknown',
314
+ };
315
+
316
+ try {
317
+ const res = await postTo(discoveryUrl, '/messages', { toHandle: handle, fromHandle: ident?.handle, payload: JSON.stringify(invite) });
318
+ if (res.ok) {
319
+ console.log(chalk.cyan(` Sent share invite to @${handle}`));
320
+ } else {
321
+ console.log(chalk.yellow(` Failed to notify @${handle}: ${res.status}`));
322
+ }
323
+ } catch {
324
+ console.log(chalk.yellow(` Could not reach discovery server to notify @${handle}`));
325
+ }
326
+ }
327
+ }
328
+ });
329
+ } catch (err) {
330
+ logger.error(`Failed to seed ${share.name}: ${err.message}`, { share: share.id });
331
+ console.error(chalk.red(`Failed to seed ${share.name}: ${err.message}`));
332
+ }
333
+ }
334
+
335
+ // Auto-resume incomplete downloads (skip paused)
336
+ const pendingDownloads = getTransfers().filter(t => !t.paused && t.status !== 'completed');
337
+ if (pendingDownloads.length > 0) {
338
+ console.log(chalk.blue(`Resuming ${pendingDownloads.length} incomplete download(s)...`));
339
+ for (const t of pendingDownloads) {
340
+ try {
341
+ const torrent = client.add(t.magnet, { path: t.savePath });
342
+ torrent.on('download', () => updateTransferProgress(t.magnet, torrent.progress));
343
+ torrent.on('done', () => {
344
+ completeTransfer(t.magnet);
345
+ console.log(chalk.green(`✔ Download complete: ${t.name}`));
346
+ });
347
+ console.log(chalk.yellow(` Resuming: ${t.name || t.magnet.slice(0, 40) + '...'}`));
348
+ } catch (err) {
349
+ console.error(chalk.red(`Failed to resume download: ${err.message}`));
350
+ }
351
+ }
352
+ }
353
+
354
+ // Start web dashboard if requested
355
+ if (opts.web) {
356
+ try {
357
+ const webPort = parseInt(opts.webPort, 10);
358
+ const bindAddress = opts.lan ? '0.0.0.0' : '127.0.0.1';
359
+ const { token } = await startWebServer(webPort, client, { bindAddress });
360
+ console.log('');
361
+ const announceIp = opts.publicIp || getLanIp() || 'localhost';
362
+ const webServeUrl = `http://${announceIp}:${webPort}`;
363
+ if (opts.lan) {
364
+ console.log(chalk.green(`Web dashboard running at ${webServeUrl}?token=${token}`));
365
+ console.log(chalk.dim(` (LAN accessible, browse from other devices)`));
366
+ } else {
367
+ console.log(chalk.green(`Web dashboard running at http://localhost:${webPort}?token=${token}`));
368
+ }
369
+
370
+ const { hooks } = await import('../core/extensions.js');
371
+ await hooks.emit('on:server:start', { port: webPort, url: webServeUrl });
372
+ } catch (err) {
373
+ console.error(chalk.red(`Failed to start web dashboard: ${err.message}`));
374
+ }
375
+ }
376
+
377
+ // Store torrent port in config once WebTorrent is listening
378
+ const storeTorrentPort = () => {
379
+ const torrentPort = client.address()?.port || null;
380
+ if (torrentPort) {
381
+ config.set('torrentPort', torrentPort);
382
+ // Update mDNS with port info
383
+ try {
384
+ const ident = config.get('identity');
385
+ if (ident) {
386
+ const webPort = opts.web ? parseInt(opts.webPort, 10) : null;
387
+ import('../core/mdnsService.js').then(({ stopMdns }) => {
388
+ stopMdns();
389
+ startMdns(ident, { torrentPort, webPort });
390
+ });
391
+ }
392
+ } catch { /* non-fatal */ }
393
+ }
394
+ };
395
+ // Try immediately, then retry after a delay (WebTorrent may not be listening yet)
396
+ storeTorrentPort();
397
+ setTimeout(storeTorrentPort, 5000);
398
+ setTimeout(storeTorrentPort, 15000);
399
+
400
+ console.log('');
401
+ console.log(chalk.bgBlue('Daemon is running. Press Ctrl+C to stop sharing.'));
402
+ console.log('');
403
+
404
+ // Bug #20 fix: auto-seed new shares created after daemon started
405
+ const seededPaths = new Set(shares.map(s => s.path));
406
+ setInterval(() => {
407
+ try {
408
+ expireOldShares();
409
+ const currentShares = listShares();
410
+ for (const share of currentShares) {
411
+ if (seededPaths.has(share.path)) continue;
412
+ if (isShareExpired(share)) continue;
413
+ if (!fs.existsSync(share.path)) continue;
414
+ seededPaths.add(share.path);
415
+ const seedOpts = { name: share.name, announce: share.visibility === 'private' ? [] : [...DEFAULT_TRACKERS] };
416
+ client.seed(share.path, seedOpts, (torrent) => {
417
+ console.log(chalk.green(`✔ Auto-seeding new share: ${share.name}`));
418
+ const allShares = config.get('shares') || [];
419
+ const entry = allShares.find(s => s.path === share.path);
420
+ if (entry && !entry.magnet) {
421
+ entry.magnet = torrent.magnetURI;
422
+ entry.files = torrent.files.map(f => ({ name: f.name, path: f.path, size: f.length }));
423
+ config.set('shares', allShares);
424
+ }
425
+ });
426
+ }
427
+ } catch {}
428
+ }, 60000);
429
+
430
+ // Periodic stats every 30 seconds
431
+ setInterval(() => {
432
+ const up = (client.uploadSpeed / 1024).toFixed(1);
433
+ const down = (client.downloadSpeed / 1024).toFixed(1);
434
+ const count = client.torrents.length;
435
+ console.log(chalk.gray(`[stats] ↑ ${up} KB/s ↓ ${down} KB/s torrents: ${count}`));
436
+ }, 30000);
437
+ });
438
+ }