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,121 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import config from '../utils/config.js';
|
|
3
|
+
|
|
4
|
+
function genId() {
|
|
5
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function workspaceCommand(program) {
|
|
9
|
+
const cmd = program
|
|
10
|
+
.command('workspace')
|
|
11
|
+
.description('manage workspaces (collections of shares)')
|
|
12
|
+
.addHelpText('after', `
|
|
13
|
+
Examples:
|
|
14
|
+
$ pe workspace List all workspaces
|
|
15
|
+
$ pe workspace list List all workspaces
|
|
16
|
+
$ pe workspace create "My Project" Create a workspace
|
|
17
|
+
$ pe workspace delete <id> Delete a workspace
|
|
18
|
+
$ pe workspace add <wsId> <shareId> Add share to workspace
|
|
19
|
+
$ pe workspace remove <wsId> <shareId> Remove share from workspace
|
|
20
|
+
`)
|
|
21
|
+
.action(() => {
|
|
22
|
+
printWorkspaces();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
cmd
|
|
26
|
+
.command('list')
|
|
27
|
+
.description('list all workspaces')
|
|
28
|
+
.action(() => {
|
|
29
|
+
printWorkspaces();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
cmd
|
|
33
|
+
.command('create <name>')
|
|
34
|
+
.description('create a new workspace')
|
|
35
|
+
.option('--description <desc>', 'Workspace description')
|
|
36
|
+
.action((name, opts) => {
|
|
37
|
+
const workspaces = config.get('workspaces') || [];
|
|
38
|
+
const ws = {
|
|
39
|
+
id: genId(),
|
|
40
|
+
name,
|
|
41
|
+
description: opts.description || '',
|
|
42
|
+
shares: [],
|
|
43
|
+
paths: [],
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
workspaces.push(ws);
|
|
47
|
+
config.set('workspaces', workspaces);
|
|
48
|
+
console.log(chalk.green(`\u2714 Workspace created: ${ws.name}`));
|
|
49
|
+
console.log(` ID: ${chalk.white(ws.id)}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
cmd
|
|
53
|
+
.command('delete <id>')
|
|
54
|
+
.description('delete a workspace')
|
|
55
|
+
.action((id) => {
|
|
56
|
+
const workspaces = config.get('workspaces') || [];
|
|
57
|
+
const ws = workspaces.find(w => w.id === id);
|
|
58
|
+
if (!ws) {
|
|
59
|
+
console.log(chalk.red('Workspace not found.'));
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
config.set('workspaces', workspaces.filter(w => w.id !== id));
|
|
64
|
+
console.log(chalk.green(`\u2714 Workspace "${ws.name}" deleted.`));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
cmd
|
|
68
|
+
.command('add <workspaceId> <shareId>')
|
|
69
|
+
.description('add a share to a workspace')
|
|
70
|
+
.action((workspaceId, shareId) => {
|
|
71
|
+
const workspaces = config.get('workspaces') || [];
|
|
72
|
+
const ws = workspaces.find(w => w.id === workspaceId);
|
|
73
|
+
if (!ws) {
|
|
74
|
+
console.log(chalk.red('Workspace not found.'));
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (ws.shares.includes(shareId)) {
|
|
79
|
+
console.log(chalk.yellow('Share already in workspace.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
ws.shares.push(shareId);
|
|
83
|
+
config.set('workspaces', workspaces);
|
|
84
|
+
console.log(chalk.green(`\u2714 Share ${shareId} added to "${ws.name}".`));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
cmd
|
|
88
|
+
.command('remove <workspaceId> <shareId>')
|
|
89
|
+
.description('remove a share from a workspace')
|
|
90
|
+
.action((workspaceId, shareId) => {
|
|
91
|
+
const workspaces = config.get('workspaces') || [];
|
|
92
|
+
const ws = workspaces.find(w => w.id === workspaceId);
|
|
93
|
+
if (!ws) {
|
|
94
|
+
console.log(chalk.red('Workspace not found.'));
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!ws.shares.includes(shareId)) {
|
|
99
|
+
console.log(chalk.yellow('Share not in workspace.'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
ws.shares = ws.shares.filter(id => id !== shareId);
|
|
103
|
+
config.set('workspaces', workspaces);
|
|
104
|
+
console.log(chalk.green(`\u2714 Share ${shareId} removed from "${ws.name}".`));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printWorkspaces() {
|
|
109
|
+
const workspaces = config.get('workspaces') || [];
|
|
110
|
+
if (workspaces.length === 0) {
|
|
111
|
+
console.log(chalk.gray('No workspaces. Use `pe workspace create <name>` to create one.'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
console.log('');
|
|
115
|
+
console.log(chalk.cyan('Workspaces:'));
|
|
116
|
+
for (const ws of workspaces) {
|
|
117
|
+
console.log(` ${chalk.white(ws.id)} ${chalk.yellow(ws.name)}`);
|
|
118
|
+
if (ws.description) console.log(` ${chalk.gray(ws.description)}`);
|
|
119
|
+
console.log(` Shares: ${chalk.white(ws.shares.length)} Created: ${chalk.gray(ws.createdAt)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const LOG_DIR = join(homedir(), '.palexplorer');
|
|
6
|
+
const LOG_FILE = join(LOG_DIR, 'access-log.json');
|
|
7
|
+
const MAX_ENTRIES = 2000;
|
|
8
|
+
|
|
9
|
+
function readLog() {
|
|
10
|
+
try {
|
|
11
|
+
if (!existsSync(LOG_FILE)) return [];
|
|
12
|
+
return JSON.parse(readFileSync(LOG_FILE, 'utf8'));
|
|
13
|
+
} catch { return []; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeLog(entries) {
|
|
17
|
+
writeFileSync(LOG_FILE, JSON.stringify(entries, null, 2), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function logAccess({ shareId, shareName, action, peerPublicKey, peerHandle, ip, userAgent, bytes }) {
|
|
21
|
+
const entries = readLog();
|
|
22
|
+
entries.push({
|
|
23
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
24
|
+
shareId,
|
|
25
|
+
shareName: shareName || null,
|
|
26
|
+
action,
|
|
27
|
+
peerPublicKey: peerPublicKey || null,
|
|
28
|
+
peerHandle: peerHandle || null,
|
|
29
|
+
ip: ip || null,
|
|
30
|
+
userAgent: userAgent || null,
|
|
31
|
+
bytes: bytes || 0,
|
|
32
|
+
timestamp: new Date().toISOString(),
|
|
33
|
+
});
|
|
34
|
+
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES);
|
|
35
|
+
writeLog(entries);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
export function getAccessLog({ shareId, action, limit = 100, offset = 0 } = {}) {
|
|
41
|
+
let entries = readLog();
|
|
42
|
+
entries = entries.filter(e => !e.timestamp || (Date.now() - new Date(e.timestamp).getTime()) < THIRTY_DAYS_MS);
|
|
43
|
+
if (shareId) entries = entries.filter(e => e.shareId === shareId);
|
|
44
|
+
if (action) entries = entries.filter(e => e.action === action);
|
|
45
|
+
entries.reverse();
|
|
46
|
+
return {
|
|
47
|
+
total: entries.length,
|
|
48
|
+
entries: entries.slice(offset, offset + limit),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearAccessLog() {
|
|
53
|
+
writeLog([]);
|
|
54
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import config from '../utils/config.js';
|
|
3
|
+
|
|
4
|
+
const VERSION = '0.4.0';
|
|
5
|
+
|
|
6
|
+
let _getPrimaryServer;
|
|
7
|
+
async function loadServer() {
|
|
8
|
+
if (!_getPrimaryServer) {
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import('./discoveryClient.js');
|
|
11
|
+
_getPrimaryServer = mod.getPrimaryServer;
|
|
12
|
+
} catch {
|
|
13
|
+
_getPrimaryServer = () => null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return _getPrimaryServer();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getDeviceId() {
|
|
20
|
+
let id = config.get('analyticsDeviceId');
|
|
21
|
+
if (!id) {
|
|
22
|
+
id = crypto.randomUUID();
|
|
23
|
+
config.set('analyticsDeviceId', id);
|
|
24
|
+
}
|
|
25
|
+
return id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function baseProperties() {
|
|
29
|
+
return {
|
|
30
|
+
platform: process.platform,
|
|
31
|
+
version: VERSION,
|
|
32
|
+
arch: process.arch,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function reportError(error, context = {}) {
|
|
37
|
+
const settings = config.get('settings') || {};
|
|
38
|
+
if (settings.errorReportingEnabled === false) return;
|
|
39
|
+
|
|
40
|
+
const payload = {
|
|
41
|
+
message: error?.message || String(error),
|
|
42
|
+
stack: error?.stack?.split('\n').slice(0, 10).join('\n'),
|
|
43
|
+
code: error?.code,
|
|
44
|
+
...context,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
loadServer().then(server => {
|
|
48
|
+
if (!server) return;
|
|
49
|
+
fetch(`${server}/api/v1/analytics/event`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ event: 'error_report', properties: { ...baseProperties(), ...payload }, device_id: getDeviceId() }),
|
|
53
|
+
signal: AbortSignal.timeout(10_000),
|
|
54
|
+
}).catch(() => {});
|
|
55
|
+
}).catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reportCrash(error) {
|
|
59
|
+
const settings = config.get('settings') || {};
|
|
60
|
+
if (settings.errorReportingEnabled === false) return;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const server = _getPrimaryServer?.();
|
|
64
|
+
if (!server) return;
|
|
65
|
+
const body = JSON.stringify({
|
|
66
|
+
event: 'crash_report',
|
|
67
|
+
properties: {
|
|
68
|
+
...baseProperties(),
|
|
69
|
+
message: error?.message || String(error),
|
|
70
|
+
stack: error?.stack?.split('\n').slice(0, 20).join('\n'),
|
|
71
|
+
code: error?.code,
|
|
72
|
+
uptime: process.uptime(),
|
|
73
|
+
memoryMB: Math.round(process.memoryUsage().rss / 1048576),
|
|
74
|
+
},
|
|
75
|
+
device_id: getDeviceId(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
fetch(`${server}/api/v1/analytics/event`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body,
|
|
82
|
+
signal: AbortSignal.timeout(5000),
|
|
83
|
+
}).catch(() => {});
|
|
84
|
+
} catch {
|
|
85
|
+
// nothing we can do
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function installGlobalErrorHandler() {
|
|
90
|
+
const settings = config.get('settings') || {};
|
|
91
|
+
if (settings.errorReportingEnabled === false) return;
|
|
92
|
+
|
|
93
|
+
process.on('uncaughtException', (err) => {
|
|
94
|
+
reportCrash(err);
|
|
95
|
+
});
|
|
96
|
+
process.on('unhandledRejection', (reason) => {
|
|
97
|
+
reportError(reason instanceof Error ? reason : new Error(String(reason)), { type: 'unhandledRejection' });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import sodium from 'sodium-native';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.palexplorer');
|
|
7
|
+
const BACKUP_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export async function createBackup(password) {
|
|
10
|
+
const configFile = join(CONFIG_DIR, 'config.json');
|
|
11
|
+
const policiesFile = join(CONFIG_DIR, 'share-policies.json');
|
|
12
|
+
const accessLogFile = join(CONFIG_DIR, 'access-log.json');
|
|
13
|
+
|
|
14
|
+
const data = {
|
|
15
|
+
version: BACKUP_VERSION,
|
|
16
|
+
createdAt: new Date().toISOString(),
|
|
17
|
+
config: safeRead(configFile),
|
|
18
|
+
policies: safeRead(policiesFile),
|
|
19
|
+
accessLog: safeRead(accessLogFile),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const plaintext = Buffer.from(JSON.stringify(data), 'utf8');
|
|
23
|
+
|
|
24
|
+
const salt = Buffer.alloc(sodium.crypto_pwhash_SALTBYTES);
|
|
25
|
+
sodium.randombytes_buf(salt);
|
|
26
|
+
|
|
27
|
+
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES);
|
|
28
|
+
sodium.crypto_pwhash(
|
|
29
|
+
key, Buffer.from(password, 'utf8'), salt,
|
|
30
|
+
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
|
|
31
|
+
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
|
|
32
|
+
sodium.crypto_pwhash_ALG_ARGON2ID13
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
|
|
36
|
+
sodium.randombytes_buf(nonce);
|
|
37
|
+
|
|
38
|
+
const cipher = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES);
|
|
39
|
+
sodium.crypto_secretbox_easy(cipher, plaintext, nonce, key);
|
|
40
|
+
|
|
41
|
+
const backup = {
|
|
42
|
+
palexplorer_backup: true,
|
|
43
|
+
version: BACKUP_VERSION,
|
|
44
|
+
salt: salt.toString('hex'),
|
|
45
|
+
nonce: nonce.toString('hex'),
|
|
46
|
+
data: cipher.toString('base64'),
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return JSON.stringify(backup, null, 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function restoreBackup(backupJson, password) {
|
|
54
|
+
const backup = JSON.parse(backupJson);
|
|
55
|
+
if (!backup.palexplorer_backup) throw new Error('Invalid backup file');
|
|
56
|
+
|
|
57
|
+
const salt = Buffer.from(backup.salt, 'hex');
|
|
58
|
+
const nonce = Buffer.from(backup.nonce, 'hex');
|
|
59
|
+
const cipher = Buffer.from(backup.data, 'base64');
|
|
60
|
+
|
|
61
|
+
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES);
|
|
62
|
+
sodium.crypto_pwhash(
|
|
63
|
+
key, Buffer.from(password, 'utf8'), salt,
|
|
64
|
+
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
|
|
65
|
+
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
|
|
66
|
+
sodium.crypto_pwhash_ALG_ARGON2ID13
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const plaintext = Buffer.alloc(cipher.length - sodium.crypto_secretbox_MACBYTES);
|
|
70
|
+
const ok = sodium.crypto_secretbox_open_easy(plaintext, cipher, nonce, key);
|
|
71
|
+
if (!ok) throw new Error('Wrong password or corrupted backup');
|
|
72
|
+
|
|
73
|
+
const data = JSON.parse(plaintext.toString('utf8'));
|
|
74
|
+
|
|
75
|
+
if (data.config) writeFileSync(join(CONFIG_DIR, 'config.json'), JSON.stringify(data.config, null, 2));
|
|
76
|
+
if (data.policies) writeFileSync(join(CONFIG_DIR, 'share-policies.json'), JSON.stringify(data.policies, null, 2));
|
|
77
|
+
if (data.accessLog) writeFileSync(join(CONFIG_DIR, 'access-log.json'), JSON.stringify(data.accessLog, null, 2));
|
|
78
|
+
|
|
79
|
+
return { restored: true, createdAt: data.createdAt };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeRead(path) {
|
|
83
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
84
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import config from '../utils/config.js';
|
|
4
|
+
import keytar from 'keytar';
|
|
5
|
+
import { hooks } from './extensions.js';
|
|
6
|
+
|
|
7
|
+
const KEYTAR_SERVICE = 'pal-billing';
|
|
8
|
+
const KEYTAR_ACCOUNT = 'license-key';
|
|
9
|
+
|
|
10
|
+
const LEMON_STORE_ID = process.env.PAL_LEMON_STORE_ID || 'not-configured';
|
|
11
|
+
const LEMON_API_KEY = process.env.PAL_LEMON_API_KEY || '';
|
|
12
|
+
|
|
13
|
+
const VARIANT_IDS = {
|
|
14
|
+
pro_monthly: process.env.PAL_LEMON_PRO_MONTHLY || 'not-configured-pro-monthly',
|
|
15
|
+
pro_annual: process.env.PAL_LEMON_PRO_ANNUAL || 'not-configured-pro-annual',
|
|
16
|
+
enterprise: process.env.PAL_LEMON_ENTERPRISE || 'not-configured-enterprise',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const STORE_SLUG = 'palexplorer';
|
|
20
|
+
|
|
21
|
+
const REVALIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
22
|
+
const MAX_OFFLINE_DAYS = 7;
|
|
23
|
+
const MAX_OFFLINE_MS = MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
export const PLANS = {
|
|
26
|
+
free: {
|
|
27
|
+
name: 'Free', price: 0,
|
|
28
|
+
limits: {
|
|
29
|
+
maxShares: 5, maxRecipients: 3, maxGroups: 2, maxGroupMembers: 5,
|
|
30
|
+
maxCommentsPerShare: 5, maxFileSize: 2 * 1024 * 1024 * 1024,
|
|
31
|
+
apiKeys: 0, handles: 1, inbox: 200, favorites: 10,
|
|
32
|
+
},
|
|
33
|
+
features: {
|
|
34
|
+
remoteStreaming: false, watchParties: false, communityExtensions: false,
|
|
35
|
+
vfsMount: false, explorerMenu: false, autoSync: false, deltaSync: false,
|
|
36
|
+
batchTransfers: false, prioritySeeding: false, analytics90d: false,
|
|
37
|
+
encryptedBackup: false, priorityRelay: false, customThemes: false, vanityHandle: false,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
pro_monthly: {
|
|
41
|
+
name: 'Pro', price: 2.99, interval: 'monthly',
|
|
42
|
+
limits: {
|
|
43
|
+
maxShares: Infinity, maxRecipients: Infinity, maxGroups: Infinity, maxGroupMembers: Infinity,
|
|
44
|
+
maxCommentsPerShare: Infinity, maxFileSize: Infinity,
|
|
45
|
+
apiKeys: 3, handles: 3, inbox: Infinity, favorites: Infinity,
|
|
46
|
+
},
|
|
47
|
+
features: {
|
|
48
|
+
remoteStreaming: true, watchParties: true, communityExtensions: true,
|
|
49
|
+
vfsMount: true, explorerMenu: true, autoSync: true, deltaSync: true,
|
|
50
|
+
batchTransfers: true, prioritySeeding: true, analytics90d: true,
|
|
51
|
+
encryptedBackup: true, priorityRelay: true, customThemes: true, vanityHandle: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
pro_annual: {
|
|
55
|
+
name: 'Pro Annual', price: 29.99, interval: 'yearly',
|
|
56
|
+
limits: {
|
|
57
|
+
maxShares: Infinity, maxRecipients: Infinity, maxGroups: Infinity, maxGroupMembers: Infinity,
|
|
58
|
+
maxCommentsPerShare: Infinity, maxFileSize: Infinity,
|
|
59
|
+
apiKeys: 3, handles: 3, inbox: Infinity, favorites: Infinity,
|
|
60
|
+
},
|
|
61
|
+
features: {
|
|
62
|
+
remoteStreaming: true, watchParties: true, communityExtensions: true,
|
|
63
|
+
vfsMount: true, explorerMenu: true, autoSync: true, deltaSync: true,
|
|
64
|
+
batchTransfers: true, prioritySeeding: true, analytics90d: true,
|
|
65
|
+
encryptedBackup: true, priorityRelay: true, customThemes: true, vanityHandle: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
enterprise: {
|
|
69
|
+
name: 'Enterprise', price: 9.99, interval: 'monthly', perSeat: true,
|
|
70
|
+
limits: {
|
|
71
|
+
maxShares: Infinity, maxRecipients: Infinity, maxGroups: Infinity, maxGroupMembers: Infinity,
|
|
72
|
+
maxCommentsPerShare: Infinity, maxFileSize: Infinity,
|
|
73
|
+
apiKeys: 50, handles: 10, inbox: Infinity, favorites: Infinity,
|
|
74
|
+
},
|
|
75
|
+
features: {
|
|
76
|
+
remoteStreaming: true, watchParties: true, communityExtensions: true,
|
|
77
|
+
vfsMount: true, explorerMenu: true, autoSync: true, deltaSync: true,
|
|
78
|
+
batchTransfers: true, prioritySeeding: true, analytics90d: true,
|
|
79
|
+
encryptedBackup: true, priorityRelay: true, customThemes: true, vanityHandle: true,
|
|
80
|
+
oauth: true, sso: true, auditExport: true, dlpPolicies: true,
|
|
81
|
+
retentionRules: true, brandedThemes: true, webhooks: true,
|
|
82
|
+
dedicatedRelay: true, prioritySupport: true,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function getBillingData() {
|
|
88
|
+
return config.get('billing') || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function setBillingData(data) {
|
|
92
|
+
config.set('billing', data);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clearBillingData() {
|
|
96
|
+
config.delete('billing');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getStoredLicenseKey() {
|
|
100
|
+
try {
|
|
101
|
+
return await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getConfigLicenseKey() {
|
|
108
|
+
const stored = config.get('billing_license_key');
|
|
109
|
+
if (!stored) return null;
|
|
110
|
+
if (typeof stored === 'string') return stored; // legacy plaintext
|
|
111
|
+
try {
|
|
112
|
+
const machineKey = crypto.createHash('sha256').update(`pe:billing:${os.hostname()}:${os.userInfo().username}`).digest();
|
|
113
|
+
const iv = Buffer.from(stored.iv, 'hex');
|
|
114
|
+
const tag = Buffer.from(stored.tag, 'hex');
|
|
115
|
+
const data = Buffer.from(stored.data, 'hex');
|
|
116
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', machineKey, iv);
|
|
117
|
+
decipher.setAuthTag(tag);
|
|
118
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function storeLicenseKey(key) {
|
|
125
|
+
try {
|
|
126
|
+
await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, key);
|
|
127
|
+
} catch {
|
|
128
|
+
// keytar unavailable — encrypt with machine-derived key before storing
|
|
129
|
+
const machineKey = crypto.createHash('sha256').update(`pe:billing:${os.hostname()}:${os.userInfo().username}`).digest();
|
|
130
|
+
const iv = crypto.randomBytes(16);
|
|
131
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', machineKey, iv);
|
|
132
|
+
const encrypted = Buffer.concat([cipher.update(key, 'utf8'), cipher.final()]);
|
|
133
|
+
const tag = cipher.getAuthTag();
|
|
134
|
+
config.set('billing_license_key', { iv: iv.toString('hex'), tag: tag.toString('hex'), data: encrypted.toString('hex') });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function removeLicenseKey() {
|
|
139
|
+
try {
|
|
140
|
+
await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
141
|
+
} catch {}
|
|
142
|
+
config.delete('billing_license_key');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function validateLicenseKey(key) {
|
|
146
|
+
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
149
|
+
body: JSON.stringify({ license_key: key }),
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const text = await res.text();
|
|
153
|
+
throw new Error(`License validation failed: ${res.status} ${text}`);
|
|
154
|
+
}
|
|
155
|
+
return res.json();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function activateLicenseKey(key) {
|
|
159
|
+
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
162
|
+
body: JSON.stringify({ license_key: key, instance_name: `palexplorer-${Date.now()}` }),
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
const text = await res.text();
|
|
166
|
+
throw new Error(`License activation failed: ${res.status} ${text}`);
|
|
167
|
+
}
|
|
168
|
+
const data = await res.json();
|
|
169
|
+
|
|
170
|
+
if (!data.activated && !data.valid) {
|
|
171
|
+
throw new Error(data.error || 'License activation failed');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const meta = data.meta || {};
|
|
175
|
+
const variantId = String(meta.variant_id || '');
|
|
176
|
+
let planKey = 'pro_monthly';
|
|
177
|
+
if (variantId === VARIANT_IDS.pro_annual) planKey = 'pro_annual';
|
|
178
|
+
else if (variantId === VARIANT_IDS.enterprise) planKey = 'enterprise';
|
|
179
|
+
else if (variantId === VARIANT_IDS.pro_monthly) planKey = 'pro_monthly';
|
|
180
|
+
|
|
181
|
+
const expiresAt = meta.expires_at || null;
|
|
182
|
+
|
|
183
|
+
await storeLicenseKey(key);
|
|
184
|
+
const now = new Date().toISOString();
|
|
185
|
+
const billing = {
|
|
186
|
+
plan: planKey,
|
|
187
|
+
licenseKey: key.slice(0, 8) + '...',
|
|
188
|
+
activatedAt: now,
|
|
189
|
+
lastValidated: now,
|
|
190
|
+
expiresAt,
|
|
191
|
+
instanceId: data.instance?.id || null,
|
|
192
|
+
customerEmail: meta.customer_email || null,
|
|
193
|
+
};
|
|
194
|
+
setBillingData(billing);
|
|
195
|
+
|
|
196
|
+
hooks.emit('after:billing:upgrade', { plan: planKey }).catch(() => {});
|
|
197
|
+
|
|
198
|
+
return { plan: planKey, expiresAt, billing };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function deactivateLicenseKey() {
|
|
202
|
+
const key = await getStoredLicenseKey() || getConfigLicenseKey();
|
|
203
|
+
const billing = getBillingData();
|
|
204
|
+
|
|
205
|
+
if (key && billing?.instanceId) {
|
|
206
|
+
try {
|
|
207
|
+
await fetch('https://api.lemonsqueezy.com/v1/licenses/deactivate', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
210
|
+
body: JSON.stringify({ license_key: key, instance_id: billing.instanceId }),
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
// Best-effort deactivation
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await removeLicenseKey();
|
|
218
|
+
clearBillingData();
|
|
219
|
+
config.delete('proEntitlement');
|
|
220
|
+
return { success: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shouldRevalidate() {
|
|
224
|
+
const billing = getBillingData();
|
|
225
|
+
if (!billing || billing.plan === 'free' || !billing.plan) return false;
|
|
226
|
+
if (!billing.lastValidated) return true;
|
|
227
|
+
const elapsed = Date.now() - new Date(billing.lastValidated).getTime();
|
|
228
|
+
return elapsed >= REVALIDATION_INTERVAL_MS;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function revalidateLicense() {
|
|
232
|
+
const billing = getBillingData();
|
|
233
|
+
if (!billing || !billing.plan || billing.plan === 'free') return;
|
|
234
|
+
|
|
235
|
+
const key = await getStoredLicenseKey() || getConfigLicenseKey();
|
|
236
|
+
if (!key) {
|
|
237
|
+
downgradeToFree('no_license_key');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const result = await validateLicenseKey(key);
|
|
243
|
+
if (!result.valid) {
|
|
244
|
+
downgradeToFree('license_invalid');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
billing.lastValidated = new Date().toISOString();
|
|
248
|
+
setBillingData(billing);
|
|
249
|
+
} catch {
|
|
250
|
+
// Offline — check max offline period
|
|
251
|
+
if (billing.lastValidated) {
|
|
252
|
+
const elapsed = Date.now() - new Date(billing.lastValidated).getTime();
|
|
253
|
+
if (elapsed >= MAX_OFFLINE_MS) {
|
|
254
|
+
downgradeToFree('offline_expired');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// If no lastValidated but has billing, allow a grace period from activation
|
|
258
|
+
else if (billing.activatedAt) {
|
|
259
|
+
const elapsed = Date.now() - new Date(billing.activatedAt).getTime();
|
|
260
|
+
if (elapsed >= MAX_OFFLINE_MS) {
|
|
261
|
+
downgradeToFree('offline_expired');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function downgradeToFree(reason) {
|
|
268
|
+
clearBillingData();
|
|
269
|
+
config.delete('proEntitlement');
|
|
270
|
+
hooks.emit('after:billing:downgrade', { reason }).catch(() => {});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let _revalidationPromise = null;
|
|
274
|
+
|
|
275
|
+
export function getActivePlan() {
|
|
276
|
+
const billing = getBillingData();
|
|
277
|
+
if (!billing) {
|
|
278
|
+
return { key: 'free', ...PLANS.free, expiresAt: null, source: 'none' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (billing.expiresAt && new Date(billing.expiresAt) < new Date()) {
|
|
282
|
+
return { key: 'free', ...PLANS.free, expiresAt: null, source: 'expired' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Trigger non-blocking revalidation if needed
|
|
286
|
+
if (shouldRevalidate() && !_revalidationPromise) {
|
|
287
|
+
_revalidationPromise = revalidateLicense().finally(() => { _revalidationPromise = null; });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const planKey = billing.plan || 'pro_monthly';
|
|
291
|
+
const plan = PLANS[planKey] || PLANS.pro_monthly;
|
|
292
|
+
return {
|
|
293
|
+
key: planKey,
|
|
294
|
+
...plan,
|
|
295
|
+
expiresAt: billing.expiresAt,
|
|
296
|
+
source: 'license',
|
|
297
|
+
customerEmail: billing.customerEmail || null,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function getFeature(featureName) {
|
|
302
|
+
const plan = getActivePlan();
|
|
303
|
+
return !!plan.features[featureName];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function checkFeature(featureName) {
|
|
307
|
+
if (!getFeature(featureName)) {
|
|
308
|
+
throw new Error(`${featureName} requires a Pro subscription. Upgrade at https://palexplorer.com/pro`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function getPlanLimits() {
|
|
313
|
+
return getActivePlan().limits;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function getCheckoutUrl(plan, publicKey) {
|
|
317
|
+
const variantId = VARIANT_IDS[plan];
|
|
318
|
+
if (!variantId) throw new Error(`Unknown plan: ${plan}. Available: ${Object.keys(VARIANT_IDS).join(', ')}`);
|
|
319
|
+
let url = `https://${STORE_SLUG}.lemonsqueezy.com/checkout/buy/${variantId}`;
|
|
320
|
+
if (publicKey) {
|
|
321
|
+
url += `?checkout[custom][public_key]=${encodeURIComponent(publicKey)}`;
|
|
322
|
+
}
|
|
323
|
+
return url;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function verifyWebhookSignature(payload, signature, secret) {
|
|
327
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
328
|
+
hmac.update(payload);
|
|
329
|
+
const digest = hmac.digest('hex');
|
|
330
|
+
if (digest.length !== signature.length) return false;
|
|
331
|
+
try {
|
|
332
|
+
return crypto.timingSafeEqual(Buffer.from(digest, 'hex'), Buffer.from(signature, 'hex'));
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|