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.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. 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
+ }