pal-explorer-cli 0.4.11 → 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.
- package/README.md +149 -149
- package/bin/pal.js +63 -2
- package/extensions/@palexplorer/analytics/extension.json +20 -1
- package/extensions/@palexplorer/analytics/index.js +19 -9
- package/extensions/@palexplorer/audit/extension.json +14 -0
- package/extensions/@palexplorer/auth-email/extension.json +15 -0
- package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
- package/extensions/@palexplorer/chat/extension.json +14 -0
- package/extensions/@palexplorer/discovery/extension.json +17 -0
- package/extensions/@palexplorer/discovery/index.js +1 -1
- package/extensions/@palexplorer/email-notifications/extension.json +23 -0
- package/extensions/@palexplorer/groups/extension.json +15 -0
- package/extensions/@palexplorer/share-links/extension.json +15 -0
- package/extensions/@palexplorer/sync/extension.json +16 -0
- package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
- package/lib/capabilities.js +24 -24
- package/lib/commands/analytics.js +175 -175
- package/lib/commands/api-keys.js +131 -131
- package/lib/commands/audit.js +235 -235
- package/lib/commands/auth.js +137 -137
- package/lib/commands/backup.js +76 -76
- package/lib/commands/billing.js +148 -148
- package/lib/commands/chat.js +217 -217
- package/lib/commands/cloud-backup.js +231 -231
- package/lib/commands/comment.js +99 -99
- package/lib/commands/completion.js +203 -203
- package/lib/commands/compliance.js +218 -218
- package/lib/commands/config.js +136 -136
- package/lib/commands/connect.js +44 -44
- package/lib/commands/dept.js +294 -294
- package/lib/commands/device.js +146 -146
- package/lib/commands/download.js +240 -226
- package/lib/commands/explorer.js +178 -178
- package/lib/commands/extension.js +1060 -970
- package/lib/commands/favorite.js +90 -90
- package/lib/commands/federation.js +270 -270
- package/lib/commands/file.js +533 -533
- package/lib/commands/group.js +271 -271
- package/lib/commands/gui-share.js +29 -29
- package/lib/commands/init.js +61 -61
- package/lib/commands/invite.js +59 -59
- package/lib/commands/list.js +58 -58
- package/lib/commands/log.js +116 -116
- package/lib/commands/nearby.js +108 -108
- package/lib/commands/network.js +251 -251
- package/lib/commands/notify.js +198 -198
- package/lib/commands/org.js +273 -273
- package/lib/commands/pal.js +403 -180
- package/lib/commands/permissions.js +216 -216
- package/lib/commands/pin.js +97 -97
- package/lib/commands/protocol.js +357 -357
- package/lib/commands/rbac.js +147 -147
- package/lib/commands/recover.js +36 -36
- package/lib/commands/register.js +171 -171
- package/lib/commands/relay.js +131 -131
- package/lib/commands/remote.js +368 -368
- package/lib/commands/revoke.js +50 -50
- package/lib/commands/scanner.js +280 -280
- package/lib/commands/schedule.js +344 -344
- package/lib/commands/scim.js +203 -203
- package/lib/commands/search.js +181 -181
- package/lib/commands/serve.js +438 -438
- package/lib/commands/server.js +350 -350
- package/lib/commands/share-link.js +199 -199
- package/lib/commands/share.js +336 -323
- package/lib/commands/sso.js +200 -200
- package/lib/commands/status.js +145 -145
- package/lib/commands/stream.js +562 -562
- package/lib/commands/su.js +187 -187
- package/lib/commands/sync.js +979 -979
- package/lib/commands/transfers.js +152 -152
- package/lib/commands/uninstall.js +188 -188
- package/lib/commands/update.js +204 -204
- package/lib/commands/user.js +276 -276
- package/lib/commands/vfs.js +84 -84
- package/lib/commands/web-login.js +79 -79
- package/lib/commands/web.js +52 -52
- package/lib/commands/webhook.js +180 -180
- package/lib/commands/whoami.js +59 -59
- package/lib/commands/workspace.js +121 -121
- package/lib/core/billing.js +16 -5
- package/lib/core/dhtDiscovery.js +9 -2
- package/lib/core/discoveryClient.js +13 -7
- package/lib/core/extensions.js +142 -1
- package/lib/core/identity.js +33 -2
- package/lib/core/imageProcessor.js +109 -0
- package/lib/core/imageTorrent.js +167 -0
- package/lib/core/permissions.js +1 -1
- package/lib/core/pro.js +11 -4
- package/lib/core/serverList.js +4 -1
- package/lib/core/shares.js +12 -1
- package/lib/core/signalingServer.js +14 -2
- package/lib/core/su.js +1 -1
- package/lib/core/users.js +1 -1
- package/lib/protocol/messages.js +12 -3
- package/lib/utils/explorer.js +1 -1
- package/lib/utils/help.js +357 -357
- package/lib/utils/torrent.js +1 -0
- package/package.json +4 -3
package/lib/commands/serve.js
CHANGED
|
@@ -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
|
-
$
|
|
53
|
-
$
|
|
54
|
-
$
|
|
55
|
-
$
|
|
56
|
-
$
|
|
57
|
-
$
|
|
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
|
+
}
|