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,81 @@
1
+ import config from '../utils/config.js';
2
+ import { getIdentity } from './identity.js';
3
+ import { getPrimaryServer } from './discoveryClient.js';
4
+
5
+ async function authHeaders() {
6
+ const identity = await getIdentity();
7
+ return {
8
+ 'Content-Type': 'application/json',
9
+ 'X-Public-Key': identity?.publicKey || '',
10
+ 'X-Handle': identity?.handle || '',
11
+ };
12
+ }
13
+
14
+ function serverUrl() {
15
+ return getPrimaryServer();
16
+ }
17
+
18
+ async function request(method, path, body) {
19
+ const headers = await authHeaders();
20
+ const opts = { method, headers, signal: AbortSignal.timeout(10000) };
21
+ if (body) opts.body = JSON.stringify(body);
22
+ const res = await fetch(`${serverUrl()}${path}`, opts);
23
+ if (!res.ok) {
24
+ const err = await res.json().catch(() => ({}));
25
+ throw new Error(err.error || res.statusText);
26
+ }
27
+ const text = await res.text();
28
+ return text ? JSON.parse(text) : null;
29
+ }
30
+
31
+ export async function createNetwork(name, slug, description) {
32
+ return request('POST', '/api/v1/networks', { name, slug, description });
33
+ }
34
+
35
+ export async function listNetworks() {
36
+ return request('GET', '/api/v1/networks');
37
+ }
38
+
39
+ export async function getNetwork(id) {
40
+ return request('GET', `/api/v1/networks/${encodeURIComponent(id)}`);
41
+ }
42
+
43
+ export async function updateNetwork(id, updates) {
44
+ return request('PATCH', `/api/v1/networks/${encodeURIComponent(id)}`, updates);
45
+ }
46
+
47
+ export async function deleteNetwork(id) {
48
+ return request('DELETE', `/api/v1/networks/${encodeURIComponent(id)}`);
49
+ }
50
+
51
+ export async function listMembers(networkId) {
52
+ return request('GET', `/api/v1/networks/${encodeURIComponent(networkId)}/members`);
53
+ }
54
+
55
+ export async function createInvite(networkId, role) {
56
+ return request('POST', `/api/v1/networks/${encodeURIComponent(networkId)}/invite`, { role });
57
+ }
58
+
59
+ export async function joinNetwork(code) {
60
+ return request('POST', `/api/v1/networks/join/${encodeURIComponent(code)}`);
61
+ }
62
+
63
+ export async function updateMemberRole(networkId, userId, role) {
64
+ return request('PATCH', `/api/v1/networks/${encodeURIComponent(networkId)}/members/${encodeURIComponent(userId)}`, { role });
65
+ }
66
+
67
+ export async function removeMember(networkId, userId) {
68
+ return request('DELETE', `/api/v1/networks/${encodeURIComponent(networkId)}/members/${encodeURIComponent(userId)}`);
69
+ }
70
+
71
+ export async function createGroup(networkId, name) {
72
+ return request('POST', `/api/v1/networks/${encodeURIComponent(networkId)}/groups`, { name });
73
+ }
74
+
75
+ export async function listGroups(networkId) {
76
+ return request('GET', `/api/v1/networks/${encodeURIComponent(networkId)}/groups`);
77
+ }
78
+
79
+ export async function deleteGroup(networkId, groupId) {
80
+ return request('DELETE', `/api/v1/networks/${encodeURIComponent(networkId)}/groups/${encodeURIComponent(groupId)}`);
81
+ }
@@ -0,0 +1,109 @@
1
+ import config from '../utils/config.js';
2
+ import { verifyConfigKey } from '../utils/configIntegrity.js';
3
+ import { getIdentity } from './identity.js';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ const AUDIT_MAX = 500;
8
+
9
+ export function requireIdentity() {
10
+ const identity = config.get('identity');
11
+ if (!identity || !identity.publicKey) {
12
+ throw new Error('No identity found. Run `pe init` first.');
13
+ }
14
+ return identity;
15
+ }
16
+
17
+ export function getPermissions(sharePath) {
18
+ const shares = config.get('shares') || [];
19
+ const share = shares.find(s => s.path === sharePath);
20
+ if (!share) return { owned: false, canRead: true, canWrite: false, canDelete: false, canShare: false };
21
+ return {
22
+ owned: true,
23
+ canRead: true,
24
+ canWrite: true,
25
+ canDelete: true,
26
+ canShare: true,
27
+ visibility: share.visibility || 'global',
28
+ recipients: share.recipients || [],
29
+ };
30
+ }
31
+
32
+ export async function getSharePermissionsForPal(shareId, palPublicKey) {
33
+ const valid = await verifyConfigKey('shares');
34
+ if (!valid) throw new Error('Shares config integrity check failed — possible tampering');
35
+ const shares = config.get('shares') || [];
36
+ const share = shares.find(s => s.id === shareId || s.magnet === shareId);
37
+ if (!share) return { canRead: false, canWrite: false, canDownload: false, drillDown: false };
38
+
39
+ if (share.visibility === 'global') {
40
+ return { canRead: true, canWrite: false, canDownload: true, drillDown: true };
41
+ }
42
+
43
+ const recipients = share.recipients || [];
44
+ const isRecipient = recipients.some(r =>
45
+ r.publicKey === palPublicKey || r.id === palPublicKey || r.handle === palPublicKey
46
+ );
47
+
48
+ if (!isRecipient) return { canRead: false, canWrite: false, canDownload: false, drillDown: false };
49
+
50
+ const perms = share.palPermissions?.[palPublicKey] || {};
51
+ return {
52
+ canRead: true,
53
+ canWrite: perms.canWrite || false,
54
+ canDownload: perms.canDownload !== false,
55
+ drillDown: perms.drillDown !== false,
56
+ };
57
+ }
58
+
59
+ function safeRealpath(p) {
60
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
61
+ }
62
+
63
+ export function checkPathAccess(targetPath, operation = 'read') {
64
+ requireIdentity();
65
+
66
+ const resolved = safeRealpath(targetPath);
67
+ const shares = config.get('shares') || [];
68
+
69
+ const isSharedPath = shares.some(s => {
70
+ const sharePath = safeRealpath(s.path);
71
+ return resolved === sharePath || resolved.startsWith(sharePath + path.sep);
72
+ });
73
+
74
+ if (isSharedPath && (operation === 'delete' || operation === 'move')) {
75
+ const share = shares.find(s => {
76
+ const sp = safeRealpath(s.path);
77
+ return resolved === sp || resolved.startsWith(sp + path.sep);
78
+ });
79
+ if (share && resolved === safeRealpath(share.path)) {
80
+ throw new Error(`Cannot ${operation} a shared root directory. Revoke the share first.`);
81
+ }
82
+ }
83
+
84
+ return { allowed: true, isShared: isSharedPath };
85
+ }
86
+
87
+ export function auditLog(action, details = {}) {
88
+ const log = config.get('auditLog') || [];
89
+ const identity = config.get('identity');
90
+ const { timestamp: _t, action: _a, user: _u, deviceId: _d, ...safeDetails } = details;
91
+ log.push({
92
+ timestamp: new Date().toISOString(),
93
+ action,
94
+ user: identity?.handle || identity?.name || 'unknown',
95
+ deviceId: config.get('device')?.id || 'unknown',
96
+ ...safeDetails,
97
+ });
98
+ if (log.length > AUDIT_MAX) log.splice(0, log.length - AUDIT_MAX);
99
+ config.set('auditLog', log);
100
+ }
101
+
102
+ export function getAuditLog(limit = 50) {
103
+ const log = config.get('auditLog') || [];
104
+ return log.slice(-limit);
105
+ }
106
+
107
+ export function clearAuditLog() {
108
+ config.set('auditLog', []);
109
+ }
@@ -0,0 +1,27 @@
1
+ import { PLANS, getActivePlan as billingGetActivePlan, getFeature, checkFeature, getPlanLimits } from './billing.js';
2
+
3
+ export const FREE_LIMITS = {
4
+ ...PLANS.free.limits,
5
+ maxShareRecipients: PLANS.free.limits.maxRecipients,
6
+ };
7
+
8
+ export function isPro() {
9
+ const plan = billingGetActivePlan();
10
+ return plan.key !== 'free';
11
+ }
12
+
13
+ export function isEnterprise() {
14
+ const plan = billingGetActivePlan();
15
+ return plan.key === 'enterprise';
16
+ }
17
+
18
+ export function checkLimit(feature, currentCount) {
19
+ if (isPro()) return;
20
+ const limit = FREE_LIMITS[feature];
21
+ if (limit == null) return;
22
+ if (currentCount > limit) {
23
+ throw new Error(`Free tier limit reached: ${feature} (max ${limit}). Upgrade to Pro for unlimited access.`);
24
+ }
25
+ }
26
+
27
+ export { getFeature, checkFeature, getPlanLimits };
@@ -0,0 +1,74 @@
1
+ import config from '../utils/config.js';
2
+ import { getServers, parseHandle, resolveFromServer } from './discoveryClient.js';
3
+
4
+ const CACHE_TTL_MS = Number(process.env.PAL_CACHE_TTL_MS) || 3600000; // 1 hour
5
+ const DHT_TIMEOUT_MS = 10000;
6
+
7
+ export async function resolveHandle(handle, opts = {}) {
8
+ const { name, server } = parseHandle(handle);
9
+ const order = opts.order || ['cache', 'server', 'dht'];
10
+
11
+ const cache = config.get('handleCache') || {};
12
+ const cacheKey = handle; // full handle including @server for uniqueness
13
+
14
+ for (const source of order) {
15
+ try {
16
+ const result = await resolveFrom(source, name, cache, cacheKey, server);
17
+ if (result) {
18
+ if (source !== 'cache') {
19
+ cache[cacheKey] = { ...result, timestamp: Date.now(), source };
20
+ config.set('handleCache', cache);
21
+ }
22
+ return result;
23
+ }
24
+ } catch {
25
+ // Source failed, try next
26
+ }
27
+ }
28
+
29
+ return null;
30
+ }
31
+
32
+ async function resolveFrom(source, handle, cache, cacheKey, targetServer) {
33
+ switch (source) {
34
+ case 'cache': {
35
+ const cached = cache[cacheKey];
36
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
37
+ return cached;
38
+ }
39
+ return null;
40
+ }
41
+ case 'server': {
42
+ // Federated: if handle has @server, query that server directly
43
+ if (targetServer) {
44
+ return await resolveFromServer(targetServer, handle);
45
+ }
46
+
47
+ // Local: query servers with 'discovery' role
48
+ const servers = getServers('discovery');
49
+ const results = await Promise.allSettled(
50
+ servers.map(server => resolveFromServer(server, handle))
51
+ );
52
+ for (const r of results) {
53
+ if (r.status === 'fulfilled' && r.value) return r.value;
54
+ }
55
+ return null;
56
+ }
57
+ case 'dht': {
58
+ const { DHTDiscovery } = await import('./dhtDiscovery.js');
59
+ const dht = new DHTDiscovery();
60
+ try {
61
+ const knownPk = cache[cacheKey]?.publicKey || cache[cacheKey]?.dhtHash;
62
+ const result = await Promise.race([
63
+ dht.resolve(handle, knownPk),
64
+ new Promise((_, reject) => setTimeout(() => reject(new Error('DHT timeout')), DHT_TIMEOUT_MS))
65
+ ]);
66
+ return result || null;
67
+ } finally {
68
+ dht.destroy();
69
+ }
70
+ }
71
+ default:
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,224 @@
1
+ import config from '../utils/config.js';
2
+
3
+ export const SERVER_ROLES = ['discovery', 'signaling', 'turn', 'stun', 'relay', 'billing'];
4
+
5
+ export const TRUST_LEVELS = { bootstrap: 4, user: 3, federation: 2, gossip: 1 };
6
+ const WRITE_TRUST_THRESHOLD = 3; // only bootstrap and user-trusted servers can receive writes
7
+
8
+ // Bootstrap servers are injected by extensions (e.g. discovery).
9
+ // Core is pure P2P — no hardcoded server dependencies.
10
+ const BOOTSTRAP_SERVERS = [];
11
+
12
+ let cachedServers = null;
13
+ let healthSortedServers = null; // sorted by health + latency, updated by health checks
14
+ let serverRolesCache = new Map(); // url -> { roles, trusted, addedBy }
15
+
16
+ export function getBootstrapServers() {
17
+ return [...BOOTSTRAP_SERVERS];
18
+ }
19
+
20
+ export function addBootstrapServer(url) {
21
+ const normalized = url.replace(/\/+$/, '');
22
+ if (!BOOTSTRAP_SERVERS.includes(normalized)) {
23
+ BOOTSTRAP_SERVERS.push(normalized);
24
+ if (!serverRolesCache.has(normalized)) {
25
+ serverRolesCache.set(normalized, { roles: [...SERVER_ROLES], trusted: true, addedBy: 'bootstrap' });
26
+ }
27
+ }
28
+ }
29
+
30
+ function getServerMeta(url) {
31
+ return serverRolesCache.get(url) || { roles: [...SERVER_ROLES], trusted: true, addedBy: 'bootstrap' };
32
+ }
33
+
34
+ function setServerMeta(url, meta) {
35
+ serverRolesCache.set(url, meta);
36
+ }
37
+
38
+ export function getServersForRole(role) {
39
+ const servers = getCachedServers();
40
+ return servers.filter(url => {
41
+ const meta = getServerMeta(url);
42
+ return meta.roles.includes(role);
43
+ });
44
+ }
45
+
46
+ export async function addServerWithRoles(url, roles = [], { trustWrites = false } = {}) {
47
+ const normalized = url.replace(/\/+$/, '');
48
+ let verifiedRoles = roles.length > 0 ? roles.filter(r => SERVER_ROLES.includes(r)) : [...SERVER_ROLES];
49
+
50
+ try {
51
+ const res = await fetch(`${normalized}/status`, { signal: AbortSignal.timeout(5000) });
52
+ if (res.ok) {
53
+ const data = await res.json();
54
+ if (Array.isArray(data.roles)) {
55
+ verifiedRoles = roles.length > 0
56
+ ? roles.filter(r => data.roles.includes(r))
57
+ : data.roles.filter(r => SERVER_ROLES.includes(r));
58
+ }
59
+ }
60
+ } catch {
61
+ // keep claimed roles if server unreachable
62
+ }
63
+
64
+ const addedBy = trustWrites ? 'user' : 'federation';
65
+ setServerMeta(normalized, { roles: verifiedRoles, trusted: trustWrites, addedBy });
66
+
67
+ const settings = config.get('settings') || {};
68
+ const serverRoles = settings.server_roles || {};
69
+ serverRoles[normalized] = { roles: verifiedRoles, trusted: trustWrites, addedBy };
70
+ settings.server_roles = serverRoles;
71
+ config.set('settings', settings);
72
+
73
+ return { url: normalized, roles: verifiedRoles, trustWrites };
74
+ }
75
+
76
+ function loadServerRolesFromConfig() {
77
+ const settings = config.get('settings') || {};
78
+ const stored = settings.server_roles || {};
79
+ for (const [url, meta] of Object.entries(stored)) {
80
+ serverRolesCache.set(url, meta);
81
+ }
82
+ for (const url of BOOTSTRAP_SERVERS) {
83
+ if (!serverRolesCache.has(url)) {
84
+ serverRolesCache.set(url, { roles: [...SERVER_ROLES], trusted: true, addedBy: 'bootstrap' });
85
+ }
86
+ }
87
+ }
88
+
89
+ loadServerRolesFromConfig();
90
+
91
+ export function getServerRoles(url) {
92
+ return getServerMeta(url);
93
+ }
94
+
95
+ export async function fetchServerList(serverUrl) {
96
+ try {
97
+ const res = await fetch(`${serverUrl}/api/v1/servers`, {
98
+ signal: AbortSignal.timeout(5000),
99
+ });
100
+ if (!res.ok) return [];
101
+ const data = await res.json();
102
+ return Array.isArray(data.servers) ? data.servers : [];
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ export async function refreshServerList(onProgress) {
109
+ const bootstraps = getBootstrapServers();
110
+ const settings = config.get('settings') || {};
111
+ const configured = Array.isArray(settings.discovery_servers) ? settings.discovery_servers : [];
112
+
113
+ const allKnown = [...new Set([...bootstraps, ...configured])];
114
+ const fetched = new Set();
115
+
116
+ for (let i = 0; i < allKnown.length; i++) {
117
+ const server = allKnown[i];
118
+ onProgress?.(`Fetching server list from ${server}...`, Math.round((i / allKnown.length) * 100));
119
+ const list = await fetchServerList(server);
120
+ for (const entry of list) {
121
+ const url = typeof entry === 'string' ? entry : entry?.url;
122
+ if (!url) continue;
123
+ fetched.add(url);
124
+ if (typeof entry === 'object' && Array.isArray(entry.roles) && !serverRolesCache.has(url)) {
125
+ setServerMeta(url, {
126
+ roles: entry.roles.filter(r => SERVER_ROLES.includes(r)),
127
+ trusted: false,
128
+ addedBy: 'federation',
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ const merged = [...new Set([...allKnown, ...fetched])];
135
+ cachedServers = merged;
136
+ return merged;
137
+ }
138
+
139
+ export async function healthCheckServers(servers) {
140
+ const results = await Promise.all(
141
+ servers.map(async (url) => {
142
+ const start = Date.now();
143
+ try {
144
+ const res = await fetch(`${url}/status`, { signal: AbortSignal.timeout(3000) });
145
+ const latencyMs = Date.now() - start;
146
+ let roles = null;
147
+ if (res.ok) {
148
+ try {
149
+ const data = await res.json();
150
+ if (Array.isArray(data.roles)) {
151
+ roles = data.roles;
152
+ const existing = getServerMeta(url);
153
+ setServerMeta(url, { ...existing, roles: data.roles });
154
+ }
155
+ } catch {}
156
+ }
157
+ return { url, reachable: res.ok, latencyMs, roles };
158
+ } catch {
159
+ return { url, reachable: false, latencyMs: Infinity, roles: null };
160
+ }
161
+ })
162
+ );
163
+ return results;
164
+ }
165
+
166
+ export async function getSortedServers() {
167
+ const settings = config.get('settings') || {};
168
+ const configured = Array.isArray(settings.discovery_servers) ? settings.discovery_servers : [];
169
+ const servers = configured.length > 0 ? configured : BOOTSTRAP_SERVERS;
170
+
171
+ const checked = await healthCheckServers(servers);
172
+ checked.sort((a, b) => {
173
+ if (a.reachable && !b.reachable) return -1;
174
+ if (!a.reachable && b.reachable) return 1;
175
+ return a.latencyMs - b.latencyMs;
176
+ });
177
+
178
+ cachedServers = checked.map(s => s.url);
179
+ return checked;
180
+ }
181
+
182
+ export function getCachedServers() {
183
+ return cachedServers ? [...cachedServers] : getBootstrapServers();
184
+ }
185
+
186
+ export function getHealthSortedServers() {
187
+ return healthSortedServers ? [...healthSortedServers] : null;
188
+ }
189
+
190
+ export function setHealthSortedServers(sorted) {
191
+ healthSortedServers = sorted;
192
+ }
193
+
194
+ export function getTrustLevel(url) {
195
+ const meta = getServerMeta(url);
196
+ return TRUST_LEVELS[meta.addedBy] || 0;
197
+ }
198
+
199
+ export function isWriteTrusted(url) {
200
+ return getTrustLevel(url) >= WRITE_TRUST_THRESHOLD;
201
+ }
202
+
203
+ export function getWriteServers() {
204
+ return getCachedServers().filter(url => isWriteTrusted(url));
205
+ }
206
+
207
+ export function addGossipServer(url, publicKey) {
208
+ const normalized = url.replace(/\/+$/, '');
209
+ if (serverRolesCache.has(normalized)) return false;
210
+
211
+ const pinned = config.get('pinnedServerKeys') || {};
212
+ if (publicKey) {
213
+ pinned[normalized] = publicKey;
214
+ config.set('pinnedServerKeys', pinned);
215
+ }
216
+
217
+ setServerMeta(normalized, {
218
+ roles: [...SERVER_ROLES],
219
+ trusted: false,
220
+ addedBy: 'gossip',
221
+ publicKey,
222
+ });
223
+ return true;
224
+ }
@@ -0,0 +1,69 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const POLICY_FILE = join(homedir(), '.palexplorer', 'share-policies.json');
6
+
7
+ function readPolicies() {
8
+ try {
9
+ if (!existsSync(POLICY_FILE)) return {};
10
+ return JSON.parse(readFileSync(POLICY_FILE, 'utf8'));
11
+ } catch { return {}; }
12
+ }
13
+
14
+ function writePolicies(policies) {
15
+ writeFileSync(POLICY_FILE, JSON.stringify(policies, null, 2), 'utf8');
16
+ }
17
+
18
+ export function setSharePolicy(shareId, policy) {
19
+ const policies = readPolicies();
20
+ policies[shareId] = {
21
+ ...policies[shareId],
22
+ ...policy,
23
+ updatedAt: new Date().toISOString(),
24
+ };
25
+ writePolicies(policies);
26
+ return policies[shareId];
27
+ }
28
+
29
+ export function getSharePolicy(shareId) {
30
+ const policies = readPolicies();
31
+ return policies[shareId] || null;
32
+ }
33
+
34
+ export function checkShareAccess(shareId) {
35
+ const policy = getSharePolicy(shareId);
36
+ if (!policy) return { allowed: true };
37
+
38
+ if (policy.expiresAt && new Date(policy.expiresAt) < new Date()) {
39
+ return { allowed: false, reason: 'expired', message: 'This share link has expired' };
40
+ }
41
+
42
+ if (policy.maxDownloads && policy.downloadCount >= policy.maxDownloads) {
43
+ return { allowed: false, reason: 'download_limit', message: `Download limit reached (${policy.maxDownloads})` };
44
+ }
45
+
46
+ if (policy.allowedIPs && policy.allowedIPs.length > 0) {
47
+ return { allowed: true, requireIPCheck: true, allowedIPs: policy.allowedIPs };
48
+ }
49
+
50
+ return { allowed: true };
51
+ }
52
+
53
+ export function incrementDownloadCount(shareId) {
54
+ const policies = readPolicies();
55
+ if (!policies[shareId]) return;
56
+ policies[shareId].downloadCount = (policies[shareId].downloadCount || 0) + 1;
57
+ writePolicies(policies);
58
+ return policies[shareId].downloadCount;
59
+ }
60
+
61
+ export function removeSharePolicy(shareId) {
62
+ const policies = readPolicies();
63
+ delete policies[shareId];
64
+ writePolicies(policies);
65
+ }
66
+
67
+ export function listPolicies() {
68
+ return readPolicies();
69
+ }