pal-explorer-cli 0.4.0
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/LICENSE.md +18 -0
- package/README.md +314 -0
- package/bin/pal.js +230 -0
- package/extensions/@palexplorer/analytics/README.md +45 -0
- package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
- package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
- package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
- package/extensions/@palexplorer/analytics/extension.json +27 -0
- package/extensions/@palexplorer/analytics/index.js +186 -0
- package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
- package/extensions/@palexplorer/audit/extension.json +17 -0
- package/extensions/@palexplorer/audit/index.js +2 -0
- package/extensions/@palexplorer/auth-email/extension.json +17 -0
- package/extensions/@palexplorer/auth-email/index.js +102 -0
- package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
- package/extensions/@palexplorer/auth-oauth/index.js +199 -0
- package/extensions/@palexplorer/chat/extension.json +17 -0
- package/extensions/@palexplorer/chat/index.js +2 -0
- package/extensions/@palexplorer/discovery/extension.json +16 -0
- package/extensions/@palexplorer/discovery/index.js +111 -0
- package/extensions/@palexplorer/email-notifications/extension.json +23 -0
- package/extensions/@palexplorer/email-notifications/index.js +242 -0
- package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
- package/extensions/@palexplorer/explorer-integration/index.js +122 -0
- package/extensions/@palexplorer/groups/extension.json +17 -0
- package/extensions/@palexplorer/groups/index.js +2 -0
- package/extensions/@palexplorer/networks/extension.json +17 -0
- package/extensions/@palexplorer/networks/index.js +2 -0
- package/extensions/@palexplorer/share-links/extension.json +17 -0
- package/extensions/@palexplorer/share-links/index.js +2 -0
- package/extensions/@palexplorer/sync/extension.json +17 -0
- package/extensions/@palexplorer/sync/index.js +2 -0
- package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
- package/extensions/@palexplorer/user-mgmt/index.js +2 -0
- package/extensions/@palexplorer/vfs/extension.json +17 -0
- package/extensions/@palexplorer/vfs/index.js +167 -0
- package/lib/capabilities.js +263 -0
- package/lib/commands/analytics.js +175 -0
- package/lib/commands/api-keys.js +131 -0
- package/lib/commands/audit.js +235 -0
- package/lib/commands/auth.js +137 -0
- package/lib/commands/backup.js +76 -0
- package/lib/commands/billing.js +148 -0
- package/lib/commands/chat.js +217 -0
- package/lib/commands/cloud-backup.js +231 -0
- package/lib/commands/comment.js +99 -0
- package/lib/commands/completion.js +203 -0
- package/lib/commands/compliance.js +218 -0
- package/lib/commands/config.js +136 -0
- package/lib/commands/connect.js +44 -0
- package/lib/commands/dept.js +294 -0
- package/lib/commands/device.js +146 -0
- package/lib/commands/download.js +226 -0
- package/lib/commands/explorer.js +178 -0
- package/lib/commands/extension.js +970 -0
- package/lib/commands/favorite.js +90 -0
- package/lib/commands/federation.js +270 -0
- package/lib/commands/file.js +533 -0
- package/lib/commands/group.js +271 -0
- package/lib/commands/gui-share.js +29 -0
- package/lib/commands/init.js +61 -0
- package/lib/commands/invite.js +59 -0
- package/lib/commands/list.js +59 -0
- package/lib/commands/log.js +116 -0
- package/lib/commands/nearby.js +108 -0
- package/lib/commands/network.js +251 -0
- package/lib/commands/notify.js +198 -0
- package/lib/commands/org.js +273 -0
- package/lib/commands/pal.js +180 -0
- package/lib/commands/permissions.js +216 -0
- package/lib/commands/pin.js +97 -0
- package/lib/commands/protocol.js +357 -0
- package/lib/commands/rbac.js +147 -0
- package/lib/commands/recover.js +36 -0
- package/lib/commands/register.js +171 -0
- package/lib/commands/relay.js +131 -0
- package/lib/commands/remote.js +368 -0
- package/lib/commands/revoke.js +50 -0
- package/lib/commands/scanner.js +280 -0
- package/lib/commands/schedule.js +344 -0
- package/lib/commands/scim.js +203 -0
- package/lib/commands/search.js +181 -0
- package/lib/commands/serve.js +438 -0
- package/lib/commands/server.js +350 -0
- package/lib/commands/share-link.js +199 -0
- package/lib/commands/share.js +323 -0
- package/lib/commands/sso.js +200 -0
- package/lib/commands/status.js +136 -0
- package/lib/commands/stream.js +562 -0
- package/lib/commands/su.js +187 -0
- package/lib/commands/sync.js +827 -0
- package/lib/commands/transfers.js +152 -0
- package/lib/commands/uninstall.js +188 -0
- package/lib/commands/update.js +204 -0
- package/lib/commands/user.js +276 -0
- package/lib/commands/vfs.js +84 -0
- package/lib/commands/web.js +52 -0
- package/lib/commands/webhook.js +180 -0
- package/lib/commands/whoami.js +59 -0
- package/lib/commands/workspace.js +121 -0
- package/lib/core/accessLog.js +54 -0
- package/lib/core/analytics.js +99 -0
- package/lib/core/backup.js +84 -0
- package/lib/core/billing.js +336 -0
- package/lib/core/bitfieldStore.js +53 -0
- package/lib/core/connectionManager.js +182 -0
- package/lib/core/dhtDiscovery.js +148 -0
- package/lib/core/discoveryClient.js +408 -0
- package/lib/core/extensionAnalyzer.js +357 -0
- package/lib/core/extensionSandbox.js +250 -0
- package/lib/core/extensionWorkerHost.js +166 -0
- package/lib/core/extensions.js +1082 -0
- package/lib/core/fileDiff.js +69 -0
- package/lib/core/groups.js +119 -0
- package/lib/core/identity.js +340 -0
- package/lib/core/mdnsService.js +126 -0
- package/lib/core/networks.js +81 -0
- package/lib/core/permissions.js +109 -0
- package/lib/core/pro.js +27 -0
- package/lib/core/resolver.js +74 -0
- package/lib/core/serverList.js +224 -0
- package/lib/core/sharePolicy.js +69 -0
- package/lib/core/shares.js +325 -0
- package/lib/core/signalingServer.js +441 -0
- package/lib/core/streamTransport.js +106 -0
- package/lib/core/su.js +55 -0
- package/lib/core/syncEngine.js +264 -0
- package/lib/core/syncState.js +159 -0
- package/lib/core/transfers.js +259 -0
- package/lib/core/users.js +225 -0
- package/lib/core/vfs.js +216 -0
- package/lib/core/webServer.js +702 -0
- package/lib/core/webrtcStream.js +396 -0
- package/lib/crypto/chatEncryption.js +57 -0
- package/lib/crypto/shareEncryption.js +195 -0
- package/lib/crypto/sharePassword.js +35 -0
- package/lib/crypto/streamEncryption.js +189 -0
- package/lib/package.json +1 -0
- package/lib/protocol/envelope.js +271 -0
- package/lib/protocol/handler.js +191 -0
- package/lib/protocol/index.js +27 -0
- package/lib/protocol/messages.js +247 -0
- package/lib/protocol/negotiation.js +127 -0
- package/lib/protocol/policy.js +142 -0
- package/lib/protocol/router.js +86 -0
- package/lib/protocol/sync.js +122 -0
- package/lib/utils/cli.js +15 -0
- package/lib/utils/config.js +123 -0
- package/lib/utils/configIntegrity.js +87 -0
- package/lib/utils/downloadDir.js +9 -0
- package/lib/utils/explorer.js +83 -0
- package/lib/utils/format.js +12 -0
- package/lib/utils/help.js +357 -0
- package/lib/utils/logger.js +103 -0
- package/lib/utils/torrent.js +203 -0
- package/package.json +71 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import config from '../utils/config.js';
|
|
9
|
+
import { getDownloadDir } from '../utils/downloadDir.js';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import { listShares, addShare, removeShare } from './shares.js';
|
|
12
|
+
import { getTransfers, trackTransfer, removeTransfer, setTransferPaused } from './transfers.js';
|
|
13
|
+
import { getFriends, saveFriends } from './users.js';
|
|
14
|
+
import { getGroups, getGroup, createGroup, deleteGroup, addMemberToGroup, removeMemberFromGroup, getShareComments, addShareComment, deleteShareComment } from './groups.js';
|
|
15
|
+
import { isPro } from './pro.js';
|
|
16
|
+
|
|
17
|
+
const TOKEN_KEY = 'webDashboardToken';
|
|
18
|
+
|
|
19
|
+
function getOrCreateToken() {
|
|
20
|
+
let token = config.get(TOKEN_KEY);
|
|
21
|
+
if (!token) {
|
|
22
|
+
token = crypto.randomBytes(32).toString('hex');
|
|
23
|
+
config.set(TOKEN_KEY, token);
|
|
24
|
+
}
|
|
25
|
+
return token;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authMiddleware(req, res, next) {
|
|
29
|
+
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
30
|
+
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
31
|
+
const expected = Buffer.from(getOrCreateToken(), 'hex');
|
|
32
|
+
const provided = Buffer.from(token, 'hex');
|
|
33
|
+
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
34
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
35
|
+
}
|
|
36
|
+
next();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { getOrCreateToken };
|
|
40
|
+
|
|
41
|
+
export function startWebServer(port, torrentClient, { bindAddress = '127.0.0.1' } = {}) {
|
|
42
|
+
const app = express();
|
|
43
|
+
const server = createServer(app);
|
|
44
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
45
|
+
|
|
46
|
+
app.use(express.json({ limit: '64kb' }));
|
|
47
|
+
|
|
48
|
+
// Security headers
|
|
49
|
+
app.use((req, res, next) => {
|
|
50
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
51
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
52
|
+
res.setHeader('X-XSS-Protection', '0');
|
|
53
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
54
|
+
next();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// CORS: localhost only
|
|
58
|
+
app.use((req, res, next) => {
|
|
59
|
+
const origin = req.headers.origin;
|
|
60
|
+
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
61
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
62
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
63
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
64
|
+
}
|
|
65
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Serve static web UI (built dist preferred, raw web/ as fallback)
|
|
70
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
71
|
+
const webDistPath = path.join(__dirname, '..', '..', 'web', 'dist');
|
|
72
|
+
const webRootPath = path.join(__dirname, '..', '..', 'web');
|
|
73
|
+
if (fs.existsSync(webDistPath)) {
|
|
74
|
+
app.use(express.static(webDistPath));
|
|
75
|
+
} else if (fs.existsSync(webRootPath)) {
|
|
76
|
+
app.use(express.static(webRootPath));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Mobile Auth (session-based, uses same token as Bearer) ──
|
|
80
|
+
|
|
81
|
+
app.get('/api/auth/status', (req, res) => {
|
|
82
|
+
res.json({ setup: true, version: '0.4.0' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.post('/api/auth/login', (req, res) => {
|
|
86
|
+
const { token } = req.body;
|
|
87
|
+
if (!token) return res.status(400).json({ error: 'token required' });
|
|
88
|
+
try {
|
|
89
|
+
const expected = Buffer.from(getOrCreateToken(), 'hex');
|
|
90
|
+
const provided = Buffer.from(token, 'hex');
|
|
91
|
+
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
92
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
96
|
+
}
|
|
97
|
+
res.json({ ok: true, token, expiresIn: 86400 * 7 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
app.post('/api/auth/logout', (req, res) => {
|
|
101
|
+
res.json({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.post('/api/auth/refresh', (req, res) => {
|
|
105
|
+
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
106
|
+
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
107
|
+
try {
|
|
108
|
+
const expected = Buffer.from(getOrCreateToken(), 'hex');
|
|
109
|
+
const provided = Buffer.from(token, 'hex');
|
|
110
|
+
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
111
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
115
|
+
}
|
|
116
|
+
res.json({ ok: true, token, expiresIn: 86400 * 7 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Health check (no auth)
|
|
120
|
+
app.get('/api/status', (req, res) => {
|
|
121
|
+
res.json({ status: 'ok', uptime: process.uptime() });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// P2P browse — returns one directory level of a non-private share
|
|
125
|
+
// HIGH-4 fix: require auth token, no CORS wildcard
|
|
126
|
+
app.get('/p2p/browse', authMiddleware, (req, res) => {
|
|
127
|
+
const { share: shareName, path: dirPath = '' } = req.query;
|
|
128
|
+
if (!shareName) return res.status(400).json({ error: 'share required' });
|
|
129
|
+
const shares = listShares();
|
|
130
|
+
const share = shares.find(s => s.name === shareName && s.visibility !== 'private');
|
|
131
|
+
if (!share?.path) return res.status(404).json({ error: 'Share not found' });
|
|
132
|
+
try {
|
|
133
|
+
const base = path.resolve(share.path);
|
|
134
|
+
const target = dirPath ? path.resolve(path.join(base, dirPath)) : base;
|
|
135
|
+
if (!target.startsWith(base)) return res.status(403).json({ error: 'Forbidden' });
|
|
136
|
+
const entries = fs.readdirSync(target, { withFileTypes: true });
|
|
137
|
+
res.json(entries.map(e => {
|
|
138
|
+
const entryPath = dirPath ? `${dirPath}/${e.name}` : e.name;
|
|
139
|
+
let size = null;
|
|
140
|
+
if (e.isFile()) { try { size = fs.statSync(path.join(target, e.name)).size; } catch {} }
|
|
141
|
+
return { name: e.name, path: entryPath, isDir: e.isDirectory(), size };
|
|
142
|
+
}));
|
|
143
|
+
} catch {
|
|
144
|
+
res.status(500).json({ error: 'Failed to read directory' });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// P2P file download — serves a single file from a non-private share
|
|
149
|
+
// HIGH-4 fix: require auth token, no CORS wildcard
|
|
150
|
+
app.get('/p2p/file', authMiddleware, (req, res) => {
|
|
151
|
+
const { share: shareName, path: filePath } = req.query;
|
|
152
|
+
if (!shareName || !filePath) return res.status(400).json({ error: 'share and path required' });
|
|
153
|
+
const shares = listShares();
|
|
154
|
+
const share = shares.find(s => s.name === shareName && s.visibility !== 'private');
|
|
155
|
+
if (!share?.path) return res.status(404).json({ error: 'Share not found' });
|
|
156
|
+
try {
|
|
157
|
+
const base = path.resolve(share.path);
|
|
158
|
+
const target = path.resolve(path.join(base, filePath));
|
|
159
|
+
if (!target.startsWith(base)) return res.status(403).json({ error: 'Forbidden' });
|
|
160
|
+
if (!fs.existsSync(target) || fs.statSync(target).isDirectory()) {
|
|
161
|
+
return res.status(404).json({ error: 'File not found' });
|
|
162
|
+
}
|
|
163
|
+
res.sendFile(target);
|
|
164
|
+
} catch {
|
|
165
|
+
res.status(500).json({ error: 'Failed to serve file' });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// All other API routes require auth
|
|
170
|
+
app.use('/api', authMiddleware);
|
|
171
|
+
|
|
172
|
+
// Identity (allowlist safe fields)
|
|
173
|
+
app.get('/api/identity', (req, res) => {
|
|
174
|
+
const identity = config.get('identity');
|
|
175
|
+
if (!identity) return res.json({ error: 'No identity configured' });
|
|
176
|
+
res.json({
|
|
177
|
+
publicKey: identity.publicKey,
|
|
178
|
+
name: identity.name,
|
|
179
|
+
handle: identity.handle || null,
|
|
180
|
+
createdAt: identity.createdAt || null,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Shares
|
|
185
|
+
app.get('/api/shares', (req, res) => {
|
|
186
|
+
res.json(listShares());
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.post('/api/shares', (req, res) => {
|
|
190
|
+
try {
|
|
191
|
+
const { path: sharePath, visibility, recipients } = req.body;
|
|
192
|
+
if (!sharePath) return res.status(400).json({ error: 'path is required' });
|
|
193
|
+
const share = addShare(sharePath, 'file', visibility || 'global', { read: true, write: false }, recipients || []);
|
|
194
|
+
res.status(201).json(share);
|
|
195
|
+
broadcast({ type: 'share:created', share });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
res.status(400).json({ error: err.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
app.delete('/api/shares/:id', (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
removeShare(req.params.id);
|
|
204
|
+
res.json({ ok: true });
|
|
205
|
+
broadcast({ type: 'share:removed', id: req.params.id });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
res.status(404).json({ error: err.message });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Pals (per-user)
|
|
212
|
+
app.get('/api/pals', (req, res) => {
|
|
213
|
+
res.json(getFriends());
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
app.post('/api/pals', (req, res) => {
|
|
217
|
+
const { target, name } = req.body;
|
|
218
|
+
if (!target) return res.status(400).json({ error: 'target is required' });
|
|
219
|
+
if (!/^[0-9a-f]{64}$/i.test(target)) return res.status(400).json({ error: 'Invalid public key format' });
|
|
220
|
+
|
|
221
|
+
const friends = getFriends();
|
|
222
|
+
if (friends.find(f => f.id === target)) {
|
|
223
|
+
return res.status(409).json({ error: 'Pal already exists' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const pal = { id: target, name: name || 'Unnamed Pal', handle: null, addedAt: new Date().toISOString() };
|
|
227
|
+
friends.push(pal);
|
|
228
|
+
saveFriends(friends);
|
|
229
|
+
res.status(201).json(pal);
|
|
230
|
+
broadcast({ type: 'pal:added', pal });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
app.delete('/api/pals/:id', (req, res) => {
|
|
234
|
+
const friends = getFriends();
|
|
235
|
+
const index = friends.findIndex(f => f.id === req.params.id);
|
|
236
|
+
if (index === -1) return res.status(404).json({ error: 'Pal not found' });
|
|
237
|
+
|
|
238
|
+
const [removed] = friends.splice(index, 1);
|
|
239
|
+
saveFriends(friends);
|
|
240
|
+
res.json({ ok: true });
|
|
241
|
+
broadcast({ type: 'pal:removed', id: removed.id });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Transfers
|
|
245
|
+
app.get('/api/transfers', (req, res) => {
|
|
246
|
+
res.json(getTransfers());
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Downloads
|
|
250
|
+
app.post('/api/downloads', (req, res) => {
|
|
251
|
+
const { magnet, name, encryptedShareKey } = req.body;
|
|
252
|
+
if (!magnet) return res.status(400).json({ error: 'magnet is required' });
|
|
253
|
+
if (!torrentClient) return res.status(503).json({ error: 'Torrent client not available' });
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const downloadDir = getDownloadDir();
|
|
257
|
+
trackTransfer(magnet, name || 'download', downloadDir, encryptedShareKey || null);
|
|
258
|
+
torrentClient.add(magnet, { path: downloadDir }, (torrent) => {
|
|
259
|
+
broadcast({ type: 'download:started', name: torrent.name, magnet });
|
|
260
|
+
});
|
|
261
|
+
res.status(202).json({ ok: true, magnet });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
res.status(400).json({ error: err.message });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Scheduled Tasks
|
|
268
|
+
app.get('/api/schedule', (req, res) => {
|
|
269
|
+
const tasks = config.get('scheduledTasks') || [];
|
|
270
|
+
res.json(tasks);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
app.post('/api/schedule', (req, res) => {
|
|
274
|
+
const { type, executeAt, data } = req.body;
|
|
275
|
+
if (!type || !executeAt) return res.status(400).json({ error: 'type and executeAt required' });
|
|
276
|
+
if (!['share', 'download', 'revoke'].includes(type)) return res.status(400).json({ error: 'Invalid type' });
|
|
277
|
+
const ts = new Date(executeAt).getTime();
|
|
278
|
+
if (isNaN(ts) || ts <= Date.now()) return res.status(400).json({ error: 'executeAt must be a valid future datetime' });
|
|
279
|
+
|
|
280
|
+
const tasks = config.get('scheduledTasks') || [];
|
|
281
|
+
const task = {
|
|
282
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
283
|
+
type, executeAt: ts, data: data || {},
|
|
284
|
+
status: 'pending', createdAt: new Date().toISOString(),
|
|
285
|
+
};
|
|
286
|
+
tasks.push(task);
|
|
287
|
+
config.set('scheduledTasks', tasks);
|
|
288
|
+
res.status(201).json(task);
|
|
289
|
+
broadcast({ type: 'schedule:created', task });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
app.delete('/api/schedule/:id', (req, res) => {
|
|
293
|
+
const tasks = config.get('scheduledTasks') || [];
|
|
294
|
+
const task = tasks.find(t => t.id === req.params.id);
|
|
295
|
+
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
296
|
+
if (task.status !== 'pending') return res.status(400).json({ error: `Cannot cancel task with status: ${task.status}` });
|
|
297
|
+
task.status = 'cancelled';
|
|
298
|
+
config.set('scheduledTasks', tasks);
|
|
299
|
+
res.json({ ok: true });
|
|
300
|
+
broadcast({ type: 'schedule:cancelled', id: req.params.id });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Chat messages (local history)
|
|
304
|
+
app.get('/api/chat', (req, res) => {
|
|
305
|
+
const chatHistory = config.get('chatHistory') || {};
|
|
306
|
+
res.json(chatHistory);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
app.get('/api/chat/:handle', (req, res) => {
|
|
310
|
+
const chatHistory = config.get('chatHistory') || {};
|
|
311
|
+
res.json(chatHistory[req.params.handle] || []);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
app.post('/api/chat', async (req, res) => {
|
|
315
|
+
const { toHandle, text } = req.body;
|
|
316
|
+
if (!toHandle || !text) return res.status(400).json({ error: 'toHandle and text required' });
|
|
317
|
+
|
|
318
|
+
const identity = config.get('identity');
|
|
319
|
+
if (!identity?.handle) return res.status(400).json({ error: 'No handle registered' });
|
|
320
|
+
|
|
321
|
+
const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'http://localhost:3000';
|
|
322
|
+
const timestamp = Date.now();
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const r = await fetch(`${discoveryUrl}/api/v1/messages`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { 'Content-Type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
toHandle, fromHandle: identity.handle,
|
|
330
|
+
payload: { type: 'chat', text, timestamp },
|
|
331
|
+
}),
|
|
332
|
+
signal: AbortSignal.timeout(10000),
|
|
333
|
+
});
|
|
334
|
+
if (!r.ok) return res.status(502).json({ error: 'Failed to send message' });
|
|
335
|
+
|
|
336
|
+
const chatHistory = config.get('chatHistory') || {};
|
|
337
|
+
if (!chatHistory[toHandle]) chatHistory[toHandle] = [];
|
|
338
|
+
chatHistory[toHandle].push({ id: timestamp, text, sent: true, timestamp, fromHandle: identity.handle, toHandle });
|
|
339
|
+
config.set('chatHistory', chatHistory);
|
|
340
|
+
|
|
341
|
+
res.status(201).json({ ok: true, timestamp });
|
|
342
|
+
broadcast({ type: 'chat:sent', toHandle, text, timestamp });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
res.status(502).json({ error: err.message });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Transfer management (pause/resume/cancel)
|
|
349
|
+
app.post('/api/transfers/:magnet/pause', (req, res) => {
|
|
350
|
+
try {
|
|
351
|
+
setTransferPaused(req.params.magnet, true);
|
|
352
|
+
res.json({ ok: true });
|
|
353
|
+
} catch (err) {
|
|
354
|
+
res.status(400).json({ error: err.message });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
app.post('/api/transfers/:magnet/resume', (req, res) => {
|
|
359
|
+
try {
|
|
360
|
+
setTransferPaused(req.params.magnet, false);
|
|
361
|
+
res.json({ ok: true });
|
|
362
|
+
} catch (err) {
|
|
363
|
+
res.status(400).json({ error: err.message });
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
app.delete('/api/transfers/:magnet', (req, res) => {
|
|
368
|
+
try {
|
|
369
|
+
removeTransfer(req.params.magnet);
|
|
370
|
+
if (torrentClient) {
|
|
371
|
+
const torrent = torrentClient.torrents.find(t => t.magnetURI === req.params.magnet);
|
|
372
|
+
if (torrent) torrentClient.remove(torrent);
|
|
373
|
+
}
|
|
374
|
+
res.json({ ok: true });
|
|
375
|
+
} catch (err) {
|
|
376
|
+
res.status(400).json({ error: err.message });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Stats
|
|
381
|
+
app.get('/api/stats', (req, res) => {
|
|
382
|
+
if (!torrentClient) return res.json({ torrents: 0, uploadSpeed: 0, downloadSpeed: 0 });
|
|
383
|
+
res.json({
|
|
384
|
+
torrents: torrentClient.torrents.length,
|
|
385
|
+
uploadSpeed: torrentClient.uploadSpeed,
|
|
386
|
+
downloadSpeed: torrentClient.downloadSpeed,
|
|
387
|
+
ratio: torrentClient.ratio,
|
|
388
|
+
torrentsDetail: torrentClient.torrents.map(t => ({
|
|
389
|
+
name: t.name,
|
|
390
|
+
progress: t.progress,
|
|
391
|
+
uploadSpeed: t.uploadSpeed,
|
|
392
|
+
downloadSpeed: t.downloadSpeed,
|
|
393
|
+
numPeers: t.numPeers,
|
|
394
|
+
uploaded: t.uploaded,
|
|
395
|
+
downloaded: t.downloaded,
|
|
396
|
+
length: t.length,
|
|
397
|
+
magnetURI: t.magnetURI
|
|
398
|
+
}))
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ── PAL Protocol API ──
|
|
403
|
+
|
|
404
|
+
app.get('/api/protocol', async (req, res) => {
|
|
405
|
+
try {
|
|
406
|
+
const { PROTOCOL_NAME, PROTOCOL_VERSION } = await import('../protocol/index.js');
|
|
407
|
+
const { isPro } = await import('./pro.js');
|
|
408
|
+
const { getRelayLimits } = await import('../protocol/router.js');
|
|
409
|
+
const { FREE_POLICY_LIMITS, PRO_POLICY_LIMITS } = await import('../protocol/policy.js');
|
|
410
|
+
const pro = isPro();
|
|
411
|
+
const caps = ['share', 'sync', 'chat', 'relay'];
|
|
412
|
+
if (pro) caps.push('delta-sync', 'receipts');
|
|
413
|
+
res.json({
|
|
414
|
+
name: PROTOCOL_NAME,
|
|
415
|
+
version: PROTOCOL_VERSION,
|
|
416
|
+
tier: pro ? 'pro' : 'free',
|
|
417
|
+
capabilities: caps,
|
|
418
|
+
relayLimits: getRelayLimits(),
|
|
419
|
+
policyLimits: pro ? PRO_POLICY_LIMITS : FREE_POLICY_LIMITS,
|
|
420
|
+
});
|
|
421
|
+
} catch (err) {
|
|
422
|
+
res.status(500).json({ error: 'Internal error' });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
app.get('/api/protocol/policies', (req, res) => {
|
|
427
|
+
try {
|
|
428
|
+
const { listPolicies } = require('./sharePolicy.js');
|
|
429
|
+
res.json(listPolicies());
|
|
430
|
+
} catch (err) {
|
|
431
|
+
res.status(500).json({ error: 'Internal error' });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
app.post('/api/protocol/policies/:shareId', async (req, res) => {
|
|
436
|
+
try {
|
|
437
|
+
const { validatePolicy } = await import('../protocol/policy.js');
|
|
438
|
+
const { setSharePolicy } = await import('./sharePolicy.js');
|
|
439
|
+
const validation = validatePolicy(req.body);
|
|
440
|
+
if (!validation.valid) return res.status(400).json({ errors: validation.errors });
|
|
441
|
+
setSharePolicy(req.params.shareId, req.body);
|
|
442
|
+
res.json({ success: true });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
res.status(500).json({ error: 'Internal error' });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
app.delete('/api/protocol/policies/:shareId', async (req, res) => {
|
|
449
|
+
try {
|
|
450
|
+
const { removeSharePolicy } = await import('./sharePolicy.js');
|
|
451
|
+
removeSharePolicy(req.params.shareId);
|
|
452
|
+
res.json({ success: true });
|
|
453
|
+
} catch (err) {
|
|
454
|
+
res.status(500).json({ error: 'Internal error' });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
app.post('/api/protocol/probe/:peerPK', async (req, res) => {
|
|
459
|
+
try {
|
|
460
|
+
const { probeRoutes, selectRoute, buildRouteInfo } = await import('../protocol/router.js');
|
|
461
|
+
const results = await probeRoutes(req.params.peerPK);
|
|
462
|
+
res.json({
|
|
463
|
+
routes: results,
|
|
464
|
+
recommended: selectRoute(results),
|
|
465
|
+
routeInfo: buildRouteInfo(results),
|
|
466
|
+
});
|
|
467
|
+
} catch (err) {
|
|
468
|
+
res.status(500).json({ error: 'Internal error' });
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ── Groups ──
|
|
473
|
+
|
|
474
|
+
app.get('/api/groups', (req, res) => {
|
|
475
|
+
res.json(getGroups());
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
app.get('/api/groups/:id', (req, res) => {
|
|
479
|
+
const group = getGroup(req.params.id);
|
|
480
|
+
if (!group) return res.status(404).json({ error: 'Group not found' });
|
|
481
|
+
res.json(group);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
app.post('/api/groups', (req, res) => {
|
|
485
|
+
const { name } = req.body;
|
|
486
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
487
|
+
const identity = config.get('identity');
|
|
488
|
+
try {
|
|
489
|
+
const group = createGroup(name, identity?.publicKey, req.body);
|
|
490
|
+
res.status(201).json(group);
|
|
491
|
+
broadcast({ type: 'group:created', group });
|
|
492
|
+
} catch (err) {
|
|
493
|
+
res.status(400).json({ error: err.message });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
app.delete('/api/groups/:id', (req, res) => {
|
|
498
|
+
const identity = config.get('identity');
|
|
499
|
+
try {
|
|
500
|
+
deleteGroup(req.params.id, identity?.publicKey);
|
|
501
|
+
res.json({ ok: true });
|
|
502
|
+
broadcast({ type: 'group:deleted', id: req.params.id });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
res.status(400).json({ error: err.message });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
app.post('/api/groups/:id/members', (req, res) => {
|
|
509
|
+
const { palId, name, handle } = req.body;
|
|
510
|
+
if (!palId) return res.status(400).json({ error: 'palId required' });
|
|
511
|
+
try {
|
|
512
|
+
addMemberToGroup(req.params.id, { id: palId, name: name || 'Unknown', handle });
|
|
513
|
+
res.status(201).json({ ok: true });
|
|
514
|
+
} catch (err) {
|
|
515
|
+
res.status(400).json({ error: err.message });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
app.delete('/api/groups/:id/members/:palId', (req, res) => {
|
|
520
|
+
try {
|
|
521
|
+
removeMemberFromGroup(req.params.id, req.params.palId);
|
|
522
|
+
res.json({ ok: true });
|
|
523
|
+
} catch (err) {
|
|
524
|
+
res.status(400).json({ error: err.message });
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ── Share Comments ──
|
|
529
|
+
|
|
530
|
+
app.get('/api/shares/:shareId/comments', (req, res) => {
|
|
531
|
+
res.json(getShareComments(req.params.shareId));
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
app.post('/api/shares/:shareId/comments', (req, res) => {
|
|
535
|
+
const { text } = req.body;
|
|
536
|
+
if (!text) return res.status(400).json({ error: 'text required' });
|
|
537
|
+
const identity = config.get('identity');
|
|
538
|
+
try {
|
|
539
|
+
const comment = addShareComment(req.params.shareId, {
|
|
540
|
+
authorHandle: identity?.handle,
|
|
541
|
+
authorName: identity?.name,
|
|
542
|
+
text,
|
|
543
|
+
});
|
|
544
|
+
res.status(201).json(comment);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
res.status(400).json({ error: err.message });
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
app.delete('/api/shares/:shareId/comments/:commentId', (req, res) => {
|
|
551
|
+
try {
|
|
552
|
+
deleteShareComment(req.params.shareId, req.params.commentId);
|
|
553
|
+
res.json({ ok: true });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
res.status(400).json({ error: err.message });
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// ── Billing / Pro Status ──
|
|
560
|
+
|
|
561
|
+
app.get('/api/billing/status', (req, res) => {
|
|
562
|
+
const pro = isPro();
|
|
563
|
+
const license = config.get('license') || {};
|
|
564
|
+
res.json({
|
|
565
|
+
plan: pro ? 'pro' : 'free',
|
|
566
|
+
active: pro,
|
|
567
|
+
expiresAt: license.expiresAt || null,
|
|
568
|
+
licenseKey: license.key ? `${license.key.slice(0, 8)}...` : null,
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// ── Chat by pal handle (mobile format) ──
|
|
573
|
+
|
|
574
|
+
app.get('/api/pals/messages', (req, res) => {
|
|
575
|
+
const { pal, unread } = req.query;
|
|
576
|
+
const chatHistory = config.get('chatHistory') || {};
|
|
577
|
+
if (pal) {
|
|
578
|
+
const messages = chatHistory[pal] || [];
|
|
579
|
+
if (unread === 'true') {
|
|
580
|
+
return res.json(messages.filter(m => !m.read && !m.sent));
|
|
581
|
+
}
|
|
582
|
+
return res.json(messages);
|
|
583
|
+
}
|
|
584
|
+
if (unread === 'true') {
|
|
585
|
+
const result = {};
|
|
586
|
+
for (const [handle, msgs] of Object.entries(chatHistory)) {
|
|
587
|
+
const unreadMsgs = msgs.filter(m => !m.read && !m.sent);
|
|
588
|
+
if (unreadMsgs.length > 0) result[handle] = unreadMsgs;
|
|
589
|
+
}
|
|
590
|
+
return res.json(result);
|
|
591
|
+
}
|
|
592
|
+
res.json(chatHistory);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
app.post('/api/pals/messages', async (req, res) => {
|
|
596
|
+
const { to, message } = req.body;
|
|
597
|
+
if (!to || !message) return res.status(400).json({ error: 'to and message required' });
|
|
598
|
+
const identity = config.get('identity');
|
|
599
|
+
if (!identity?.handle) return res.status(400).json({ error: 'No handle registered' });
|
|
600
|
+
|
|
601
|
+
const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'http://localhost:3000';
|
|
602
|
+
const timestamp = Date.now();
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const r = await fetch(`${discoveryUrl}/api/v1/messages`, {
|
|
606
|
+
method: 'POST',
|
|
607
|
+
headers: { 'Content-Type': 'application/json' },
|
|
608
|
+
body: JSON.stringify({
|
|
609
|
+
toHandle: to, fromHandle: identity.handle,
|
|
610
|
+
payload: { type: 'chat', text: message, timestamp },
|
|
611
|
+
}),
|
|
612
|
+
signal: AbortSignal.timeout(10000),
|
|
613
|
+
});
|
|
614
|
+
if (!r.ok) return res.status(502).json({ error: 'Failed to send message' });
|
|
615
|
+
|
|
616
|
+
const chatHistory = config.get('chatHistory') || {};
|
|
617
|
+
if (!chatHistory[to]) chatHistory[to] = [];
|
|
618
|
+
chatHistory[to].push({ id: timestamp, text: message, sent: true, timestamp, fromHandle: identity.handle, toHandle: to });
|
|
619
|
+
config.set('chatHistory', chatHistory);
|
|
620
|
+
|
|
621
|
+
res.status(201).json({ ok: true, timestamp });
|
|
622
|
+
broadcast({ type: 'chat:sent', toHandle: to, text: message, timestamp });
|
|
623
|
+
} catch (err) {
|
|
624
|
+
res.status(502).json({ error: err.message });
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// ── Download (mobile format) ──
|
|
629
|
+
|
|
630
|
+
app.post('/api/transfers/download', (req, res) => {
|
|
631
|
+
const { magnet, name, encryptedShareKey } = req.body;
|
|
632
|
+
if (!magnet) return res.status(400).json({ error: 'magnet is required' });
|
|
633
|
+
if (!torrentClient) return res.status(503).json({ error: 'Torrent client not available' });
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
const downloadDir = getDownloadDir();
|
|
637
|
+
trackTransfer(magnet, name || 'download', downloadDir, encryptedShareKey || null);
|
|
638
|
+
torrentClient.add(magnet, { path: downloadDir }, (torrent) => {
|
|
639
|
+
broadcast({ type: 'download:started', name: torrent.name, magnet });
|
|
640
|
+
});
|
|
641
|
+
res.status(202).json({ ok: true, magnet });
|
|
642
|
+
} catch (err) {
|
|
643
|
+
res.status(400).json({ error: err.message });
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Fallback: serve index.html for SPA routing
|
|
648
|
+
app.get('{*path}', (req, res) => {
|
|
649
|
+
const indexPath = path.join(webDistPath, 'index.html');
|
|
650
|
+
if (fs.existsSync(indexPath)) {
|
|
651
|
+
res.sendFile(indexPath);
|
|
652
|
+
} else {
|
|
653
|
+
res.status(404).json({ error: 'Web UI not built. Run: cd web && npm run build' });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// WebSocket
|
|
658
|
+
const clients = new Set();
|
|
659
|
+
|
|
660
|
+
wss.on('connection', (ws, req) => {
|
|
661
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
662
|
+
const token = url.searchParams.get('token');
|
|
663
|
+
const wsExpected = Buffer.from(getOrCreateToken(), 'hex');
|
|
664
|
+
const wsProvided = Buffer.from(token || '', 'hex');
|
|
665
|
+
if (wsProvided.length !== wsExpected.length || !crypto.timingSafeEqual(wsProvided, wsExpected)) {
|
|
666
|
+
ws.close(4001, 'Unauthorized');
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
clients.add(ws);
|
|
670
|
+
ws.on('close', () => clients.delete(ws));
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
function broadcast(data) {
|
|
674
|
+
const msg = JSON.stringify(data);
|
|
675
|
+
for (const ws of clients) {
|
|
676
|
+
if (ws.readyState === 1) ws.send(msg);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Broadcast transfer progress every 2 seconds
|
|
681
|
+
const progressInterval = setInterval(() => {
|
|
682
|
+
if (clients.size === 0 || !torrentClient) return;
|
|
683
|
+
const transfers = torrentClient.torrents.map(t => ({
|
|
684
|
+
name: t.name,
|
|
685
|
+
progress: t.progress,
|
|
686
|
+
uploadSpeed: t.uploadSpeed,
|
|
687
|
+
downloadSpeed: t.downloadSpeed,
|
|
688
|
+
numPeers: t.numPeers,
|
|
689
|
+
done: t.done
|
|
690
|
+
}));
|
|
691
|
+
broadcast({ type: 'transfer:progress', transfers });
|
|
692
|
+
}, 2000);
|
|
693
|
+
|
|
694
|
+
server.on('close', () => clearInterval(progressInterval));
|
|
695
|
+
|
|
696
|
+
return new Promise((resolve, reject) => {
|
|
697
|
+
server.listen(port, bindAddress, () => {
|
|
698
|
+
resolve({ server, port, token: getOrCreateToken() });
|
|
699
|
+
});
|
|
700
|
+
server.on('error', (err) => { clearInterval(progressInterval); reject(err); });
|
|
701
|
+
});
|
|
702
|
+
}
|