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,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "analytics",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Anonymous usage analytics via PostHog — opt-in, no PII, privacy-safe",
|
|
5
|
+
"author": "Palexplorer Team",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"hooks": [
|
|
9
|
+
"on:app:ready",
|
|
10
|
+
"on:app:shutdown",
|
|
11
|
+
"after:share:create",
|
|
12
|
+
"after:share:revoke",
|
|
13
|
+
"after:download:complete",
|
|
14
|
+
"on:config:change",
|
|
15
|
+
"after:billing:upgrade",
|
|
16
|
+
"after:billing:downgrade"
|
|
17
|
+
],
|
|
18
|
+
"permissions": ["config:read", "config:write", "net:http"],
|
|
19
|
+
"config": {
|
|
20
|
+
"enabled": { "type": "boolean", "default": false, "description": "Enable anonymous usage analytics (opt-in)" },
|
|
21
|
+
"posthogKey": { "type": "string", "default": "", "description": "PostHog project API key (leave empty for default)" },
|
|
22
|
+
"posthogHost": { "type": "string", "default": "https://us.i.posthog.com", "description": "PostHog ingestion host" },
|
|
23
|
+
"sessionTracking": { "type": "boolean", "default": true, "description": "Track session duration" }
|
|
24
|
+
},
|
|
25
|
+
"pro": false,
|
|
26
|
+
"minAppVersion": "0.4.0"
|
|
27
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_POSTHOG_KEY = 'phc_PSslPgpRuFzQf6s5qbN9atXFGUDHzCvMlmpBTtdTkte';
|
|
5
|
+
const FLUSH_INTERVAL = 30_000;
|
|
6
|
+
const MAX_QUEUE = 100;
|
|
7
|
+
|
|
8
|
+
let ctx = null;
|
|
9
|
+
let posthog = null;
|
|
10
|
+
let sessionStart = null;
|
|
11
|
+
let queue = [];
|
|
12
|
+
let flushTimer = null;
|
|
13
|
+
let online = true;
|
|
14
|
+
let initialized = false;
|
|
15
|
+
|
|
16
|
+
function getDeviceId() {
|
|
17
|
+
let id = ctx.store.get('deviceId');
|
|
18
|
+
if (!id) {
|
|
19
|
+
id = crypto.randomUUID();
|
|
20
|
+
ctx.store.set('deviceId', id);
|
|
21
|
+
}
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isEnabled() {
|
|
26
|
+
return ctx.config.get('enabled') === true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function baseProperties() {
|
|
30
|
+
return {
|
|
31
|
+
platform: process.platform,
|
|
32
|
+
version: ctx.app.version,
|
|
33
|
+
arch: process.arch,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function initPostHog() {
|
|
38
|
+
if (posthog) return;
|
|
39
|
+
try {
|
|
40
|
+
// Resolve posthog-node from the app's node_modules (deployed extensions can't resolve from their own path)
|
|
41
|
+
let PostHog;
|
|
42
|
+
try {
|
|
43
|
+
const appRoot = ctx.app.appRoot || process.cwd();
|
|
44
|
+
const require = createRequire(appRoot + '/package.json');
|
|
45
|
+
PostHog = require('posthog-node').PostHog;
|
|
46
|
+
} catch {
|
|
47
|
+
PostHog = (await import('posthog-node')).PostHog;
|
|
48
|
+
}
|
|
49
|
+
const key = ctx.config.get('posthogKey') || DEFAULT_POSTHOG_KEY;
|
|
50
|
+
const host = ctx.config.get('posthogHost') || 'https://us.i.posthog.com';
|
|
51
|
+
posthog = new PostHog(key, { host, flushAt: 20, flushInterval: FLUSH_INTERVAL });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
ctx.logger.warn('PostHog not available:', err.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function send(event, properties = {}) {
|
|
58
|
+
if (!isEnabled() || !posthog) return;
|
|
59
|
+
|
|
60
|
+
const props = { ...baseProperties(), ...properties };
|
|
61
|
+
const deviceId = getDeviceId();
|
|
62
|
+
|
|
63
|
+
posthog.capture({ distinctId: deviceId, event, properties: props });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function track(event, properties = {}) {
|
|
67
|
+
if (!isEnabled()) return;
|
|
68
|
+
|
|
69
|
+
if (!online) {
|
|
70
|
+
if (queue.length < MAX_QUEUE) {
|
|
71
|
+
queue.push({ event, properties });
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
send(event, properties);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flushQueue() {
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const { event, properties } = queue.shift();
|
|
82
|
+
send(event, properties);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function startPeriodicFlush() {
|
|
87
|
+
if (flushTimer) return;
|
|
88
|
+
flushTimer = setInterval(() => {
|
|
89
|
+
if (online && queue.length > 0) flushQueue();
|
|
90
|
+
}, FLUSH_INTERVAL);
|
|
91
|
+
flushTimer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function startTracking() {
|
|
95
|
+
if (initialized) return;
|
|
96
|
+
await initPostHog();
|
|
97
|
+
startPeriodicFlush();
|
|
98
|
+
sessionStart = Date.now();
|
|
99
|
+
initialized = true;
|
|
100
|
+
track('app_open');
|
|
101
|
+
ctx.logger.info('Analytics enabled — PostHog tracking started');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function activate(context) {
|
|
105
|
+
ctx = context;
|
|
106
|
+
|
|
107
|
+
// Register hooks regardless of enabled state — they check isEnabled() internally
|
|
108
|
+
context.hooks.on('on:app:ready', async () => {
|
|
109
|
+
context.logger.info('Analytics extension ready');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
context.hooks.on('on:app:shutdown', async () => {
|
|
113
|
+
const duration = sessionStart ? Math.round((Date.now() - sessionStart) / 1000) : 0;
|
|
114
|
+
if (ctx.config.get('sessionTracking') !== false) {
|
|
115
|
+
track('app_close', { sessionDurationSec: duration });
|
|
116
|
+
}
|
|
117
|
+
if (posthog) {
|
|
118
|
+
try { await posthog.flush(); } catch {}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
context.hooks.on('after:share:create', async ({ shareId, name, ...rest }) => {
|
|
123
|
+
track('share_created', {
|
|
124
|
+
visibility: rest.visibility,
|
|
125
|
+
recipientCount: rest.recipients?.length || 0,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
context.hooks.on('after:share:revoke', async () => {
|
|
130
|
+
track('share_revoked');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
context.hooks.on('after:download:complete', async ({ size, duration, speed }) => {
|
|
134
|
+
track('transfer_complete', {
|
|
135
|
+
size: size || 0,
|
|
136
|
+
duration: duration || 0,
|
|
137
|
+
speed: speed || 0,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
context.hooks.on('on:config:change', async ({ key, value }) => {
|
|
142
|
+
// Hot-enable: if analytics was just turned on, initialize PostHog
|
|
143
|
+
if (key === 'ext.analytics' || key === 'enabled') {
|
|
144
|
+
if (isEnabled() && !initialized) {
|
|
145
|
+
await startTracking();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
track('settings_changed', { key });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
context.hooks.on('after:billing:upgrade', async ({ plan }) => {
|
|
152
|
+
track('pro_upgrade', { plan });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
context.hooks.on('after:billing:downgrade', async ({ reason }) => {
|
|
156
|
+
track('pro_downgrade', { reason });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// If already enabled at startup, start immediately
|
|
160
|
+
if (isEnabled()) {
|
|
161
|
+
await startTracking();
|
|
162
|
+
} else {
|
|
163
|
+
context.logger.info('Analytics disabled (opt-in required) — will start when enabled');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function deactivate() {
|
|
168
|
+
if (flushTimer) {
|
|
169
|
+
clearInterval(flushTimer);
|
|
170
|
+
flushTimer = null;
|
|
171
|
+
}
|
|
172
|
+
if (posthog) {
|
|
173
|
+
try { await posthog.flush(); } catch {}
|
|
174
|
+
await posthog.shutdown();
|
|
175
|
+
posthog = null;
|
|
176
|
+
}
|
|
177
|
+
queue = [];
|
|
178
|
+
sessionStart = null;
|
|
179
|
+
initialized = false;
|
|
180
|
+
ctx = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function setOnline(isOnline) {
|
|
184
|
+
online = isOnline;
|
|
185
|
+
if (isOnline && queue.length > 0) flushQueue();
|
|
186
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
function createMockContext(overrides = {}) {
|
|
5
|
+
const config = { enabled: true, posthogKey: '', posthogHost: '', sessionTracking: true };
|
|
6
|
+
return {
|
|
7
|
+
hooks: { on: mock.fn() },
|
|
8
|
+
config: {
|
|
9
|
+
get: mock.fn((key) => config[key]),
|
|
10
|
+
set: mock.fn((key, val) => { config[key] = val; }),
|
|
11
|
+
},
|
|
12
|
+
store: {
|
|
13
|
+
get: mock.fn(() => undefined),
|
|
14
|
+
set: mock.fn(),
|
|
15
|
+
delete: mock.fn(),
|
|
16
|
+
},
|
|
17
|
+
logger: { info: mock.fn(), warn: mock.fn(), error: mock.fn() },
|
|
18
|
+
app: { version: '0.5.0', platform: 'linux', dataDir: '/tmp/test', appRoot: '/tmp/test' },
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('analytics extension', () => {
|
|
24
|
+
let ext;
|
|
25
|
+
let ctx;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
ext = await import('../index.js');
|
|
29
|
+
ctx = createMockContext();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await ext.deactivate();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should register all expected hooks when enabled', async () => {
|
|
37
|
+
await ext.activate(ctx);
|
|
38
|
+
const hookNames = ctx.hooks.on.mock.calls.map(c => c.arguments[0]);
|
|
39
|
+
assert.ok(hookNames.includes('on:app:ready'));
|
|
40
|
+
assert.ok(hookNames.includes('on:app:shutdown'));
|
|
41
|
+
assert.ok(hookNames.includes('after:share:create'));
|
|
42
|
+
assert.ok(hookNames.includes('after:download:complete'));
|
|
43
|
+
assert.ok(hookNames.includes('on:config:change'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should still register hooks when disabled (for hot-enable support)', async () => {
|
|
47
|
+
ctx.config.get = mock.fn((key) => key === 'enabled' ? false : undefined);
|
|
48
|
+
await ext.activate(ctx);
|
|
49
|
+
// Hooks are always registered so analytics can be hot-enabled via config change
|
|
50
|
+
const hookNames = ctx.hooks.on.mock.calls.map(c => c.arguments[0]);
|
|
51
|
+
assert.ok(hookNames.includes('on:config:change'));
|
|
52
|
+
assert.ok(hookNames.includes('on:app:shutdown'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should deactivate cleanly', async () => {
|
|
56
|
+
await ext.activate(ctx);
|
|
57
|
+
await ext.deactivate();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should generate and persist device ID', async () => {
|
|
61
|
+
await ext.activate(ctx);
|
|
62
|
+
// Device ID is generated on first track call — trigger via hook
|
|
63
|
+
// The store.set should be called with deviceId
|
|
64
|
+
const setCalls = ctx.store.set.mock.calls;
|
|
65
|
+
const deviceIdCall = setCalls.find(c => c.arguments[0] === 'deviceId');
|
|
66
|
+
if (deviceIdCall) {
|
|
67
|
+
assert.ok(deviceIdCall.arguments[1].length > 0);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should reuse existing device ID', async () => {
|
|
72
|
+
const existingId = 'test-uuid-1234';
|
|
73
|
+
ctx.store.get = mock.fn((key) => key === 'deviceId' ? existingId : undefined);
|
|
74
|
+
await ext.activate(ctx);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should queue events when offline', async () => {
|
|
78
|
+
await ext.activate(ctx);
|
|
79
|
+
ext.setOnline(false);
|
|
80
|
+
ext.setOnline(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "audit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Activity log and audit trail",
|
|
5
|
+
"author": "Palexplorer Team",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"hooks": ["on:app:ready"],
|
|
9
|
+
"permissions": ["audit:read"],
|
|
10
|
+
"contributes": {
|
|
11
|
+
"pages": [
|
|
12
|
+
{ "id": "activity", "label": "activity", "icon": "Activity", "section": "system", "minLevel": "owner" }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"config": { "enabled": { "type": "boolean", "default": false } },
|
|
16
|
+
"pro": false
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "auth-email",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Link your email to your Palexplorer handle for verification and recovery",
|
|
5
|
+
"author": "Palexplorer Team",
|
|
6
|
+
"license": "Proprietary",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"hooks": ["on:app:ready"],
|
|
9
|
+
"permissions": ["identity:read", "identity:write", "config:read", "config:write", "net:http", "notifications"],
|
|
10
|
+
"config": {
|
|
11
|
+
"server": { "type": "string", "default": "https://discovery.palexplorer.com", "description": "Discovery server for email auth" },
|
|
12
|
+
"verifiedEmail": { "type": "string", "default": "", "description": "Currently verified email" },
|
|
13
|
+
"verifiedToken": { "type": "string", "default": "", "description": "Verified JWT token" }
|
|
14
|
+
},
|
|
15
|
+
"tier": "free",
|
|
16
|
+
"minAppVersion": "0.5.0"
|
|
17
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export function activate(ctx) {
|
|
2
|
+
const { hooks, config, logger, http } = ctx;
|
|
3
|
+
|
|
4
|
+
// Verification flow
|
|
5
|
+
ctx.auth = {
|
|
6
|
+
async requestVerificationCode(email) {
|
|
7
|
+
const server = config.get('server');
|
|
8
|
+
logger.info(`Requesting verification code for ${email} from ${server}`);
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const res = await http(`${server}/api/v1/auth/email`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
body: JSON.stringify({ email })
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
throw new Error(data.error || `Server returned ${res.status}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return true;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
logger.error(`Failed to request code: ${err.message}`);
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async confirmCode(email, code) {
|
|
30
|
+
const server = config.get('server');
|
|
31
|
+
logger.info(`Confirming code for ${email}`);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const res = await http(`${server}/api/v1/auth/email/verify`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ email, code })
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
throw new Error(data.error || `Server returned ${res.status}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
config.set('verifiedEmail', email);
|
|
47
|
+
config.set('verifiedToken', data.token);
|
|
48
|
+
|
|
49
|
+
return data;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logger.error(`Failed to confirm code: ${err.message}`);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async linkIdentity() {
|
|
57
|
+
const token = config.get('verifiedToken');
|
|
58
|
+
const email = config.get('verifiedEmail');
|
|
59
|
+
const server = config.get('server');
|
|
60
|
+
|
|
61
|
+
if (!token) throw new Error('Not logged in with email. Run verify first.');
|
|
62
|
+
|
|
63
|
+
const identity = ctx.identity.get();
|
|
64
|
+
if (!identity) throw new Error('No local identity found.');
|
|
65
|
+
|
|
66
|
+
logger.info(`Linking identity ${identity.publicKey} to ${email}`);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const res = await http(`${server}/api/v1/auth/link`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'Authorization': `Bearer ${token}`
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({ publicKey: identity.publicKey })
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
throw new Error(data.error || `Server returned ${res.status}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.notify.show('Verification Success', `Your Palexplorer handle is now linked to ${email}.`);
|
|
84
|
+
return true;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logger.error(`Failed to link identity: ${err.message}`);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
hooks.on('on:app:ready', async ({ identity }) => {
|
|
93
|
+
const verifiedEmail = config.get('verifiedEmail');
|
|
94
|
+
if (verifiedEmail) {
|
|
95
|
+
logger.info(`Running as verified user: ${verifiedEmail}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function deactivate() {
|
|
101
|
+
// Cleanup if needed
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "auth-oauth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OAuth 2.0 social login with Google, GitHub, and Microsoft via PKCE flow",
|
|
5
|
+
"author": "Palexplorer Team",
|
|
6
|
+
"license": "Proprietary",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"hooks": ["on:app:ready"],
|
|
9
|
+
"permissions": ["identity:read", "identity:write", "config:read", "config:write", "net:http"],
|
|
10
|
+
"config": {
|
|
11
|
+
"providers": { "type": "array", "default": ["google", "github", "microsoft"], "description": "Enabled OAuth providers" },
|
|
12
|
+
"autoLink": { "type": "boolean", "default": true, "description": "Automatically link OAuth identity to Palexplorer identity" }
|
|
13
|
+
},
|
|
14
|
+
"tier": "free",
|
|
15
|
+
"minAppVersion": "0.5.0"
|
|
16
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const PROVIDER_CONFIG = {
|
|
4
|
+
google: {
|
|
5
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
6
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
7
|
+
userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
8
|
+
scopes: 'openid email profile',
|
|
9
|
+
},
|
|
10
|
+
github: {
|
|
11
|
+
authUrl: 'https://github.com/login/oauth/authorize',
|
|
12
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
13
|
+
userInfoUrl: 'https://api.github.com/user',
|
|
14
|
+
scopes: 'read:user user:email',
|
|
15
|
+
},
|
|
16
|
+
microsoft: {
|
|
17
|
+
authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
18
|
+
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
19
|
+
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
20
|
+
scopes: 'openid email profile',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let ctx = null;
|
|
25
|
+
let pendingAuth = null;
|
|
26
|
+
|
|
27
|
+
export function activate(context) {
|
|
28
|
+
ctx = context;
|
|
29
|
+
const { hooks, config, logger } = context;
|
|
30
|
+
|
|
31
|
+
context.oauth = {
|
|
32
|
+
getProviders() {
|
|
33
|
+
return config.get('providers') || [];
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async initiateLogin(provider) {
|
|
37
|
+
return startOAuthFlow(provider, context);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async handleCallback(callbackUrl) {
|
|
41
|
+
return processCallback(callbackUrl, context);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
getLinkedAccounts() {
|
|
45
|
+
return context.store.get('linkedAccounts') || [];
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async unlinkAccount(provider) {
|
|
49
|
+
const accounts = context.store.get('linkedAccounts') || [];
|
|
50
|
+
const filtered = accounts.filter(a => a.provider !== provider);
|
|
51
|
+
context.store.set('linkedAccounts', filtered);
|
|
52
|
+
logger.info(`Unlinked ${provider} account`);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
hooks.on('on:app:ready', async () => {
|
|
57
|
+
const accounts = context.store.get('linkedAccounts') || [];
|
|
58
|
+
if (accounts.length > 0) {
|
|
59
|
+
logger.info(`OAuth: ${accounts.length} linked account(s): ${accounts.map(a => a.provider).join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function deactivate() {
|
|
65
|
+
ctx = null;
|
|
66
|
+
pendingAuth = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function generatePKCE() {
|
|
70
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
71
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
72
|
+
return { verifier, challenge };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function startOAuthFlow(provider, ctx) {
|
|
76
|
+
const providerCfg = PROVIDER_CONFIG[provider];
|
|
77
|
+
if (!providerCfg) throw new Error(`Unsupported OAuth provider: ${provider}`);
|
|
78
|
+
|
|
79
|
+
const enabled = ctx.config.get('providers') || [];
|
|
80
|
+
if (!enabled.includes(provider)) throw new Error(`Provider ${provider} is not enabled`);
|
|
81
|
+
|
|
82
|
+
const { verifier, challenge } = generatePKCE();
|
|
83
|
+
const state = randomBytes(16).toString('hex');
|
|
84
|
+
|
|
85
|
+
pendingAuth = { provider, verifier, state, startedAt: Date.now() };
|
|
86
|
+
|
|
87
|
+
const params = new URLSearchParams({
|
|
88
|
+
response_type: 'code',
|
|
89
|
+
scope: providerCfg.scopes,
|
|
90
|
+
redirect_uri: 'palexplorer://oauth/callback',
|
|
91
|
+
state,
|
|
92
|
+
code_challenge: challenge,
|
|
93
|
+
code_challenge_method: 'S256',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const authUrl = `${providerCfg.authUrl}?${params}`;
|
|
97
|
+
ctx.logger.info(`OAuth: opening browser for ${provider} login`);
|
|
98
|
+
|
|
99
|
+
return { url: authUrl, state, provider };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function processCallback(callbackUrl, ctx) {
|
|
103
|
+
if (!pendingAuth) throw new Error('No pending OAuth flow');
|
|
104
|
+
|
|
105
|
+
const url = new URL(callbackUrl);
|
|
106
|
+
const code = url.searchParams.get('code');
|
|
107
|
+
const state = url.searchParams.get('state');
|
|
108
|
+
const error = url.searchParams.get('error');
|
|
109
|
+
|
|
110
|
+
if (error) {
|
|
111
|
+
pendingAuth = null;
|
|
112
|
+
throw new Error(`OAuth error: ${error}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (state !== pendingAuth.state) {
|
|
116
|
+
pendingAuth = null;
|
|
117
|
+
throw new Error('OAuth state mismatch — possible CSRF attack');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { provider, verifier } = pendingAuth;
|
|
121
|
+
const providerCfg = PROVIDER_CONFIG[provider];
|
|
122
|
+
pendingAuth = null;
|
|
123
|
+
|
|
124
|
+
// Exchange code for tokens
|
|
125
|
+
const tokenRes = await ctx.http(providerCfg.tokenUrl, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
128
|
+
body: new URLSearchParams({
|
|
129
|
+
grant_type: 'authorization_code',
|
|
130
|
+
code,
|
|
131
|
+
redirect_uri: 'palexplorer://oauth/callback',
|
|
132
|
+
code_verifier: verifier,
|
|
133
|
+
}).toString(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status}`);
|
|
137
|
+
const tokens = await tokenRes.json();
|
|
138
|
+
|
|
139
|
+
// Fetch user info
|
|
140
|
+
const userRes = await ctx.http(providerCfg.userInfoUrl, {
|
|
141
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, Accept: 'application/json' },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!userRes.ok) throw new Error(`Failed to fetch user info: ${userRes.status}`);
|
|
145
|
+
const userInfo = await userRes.json();
|
|
146
|
+
|
|
147
|
+
const profile = normalizeProfile(provider, userInfo);
|
|
148
|
+
|
|
149
|
+
// Link to local identity
|
|
150
|
+
if (ctx.config.get('autoLink')) {
|
|
151
|
+
await linkToIdentity(ctx, provider, profile);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return profile;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeProfile(provider, raw) {
|
|
158
|
+
switch (provider) {
|
|
159
|
+
case 'google':
|
|
160
|
+
return { provider, email: raw.email, name: raw.name, avatar: raw.picture };
|
|
161
|
+
case 'github':
|
|
162
|
+
return { provider, email: raw.email, name: raw.name || raw.login, avatar: raw.avatar_url };
|
|
163
|
+
case 'microsoft':
|
|
164
|
+
return { provider, email: raw.mail || raw.userPrincipalName, name: raw.displayName, avatar: null };
|
|
165
|
+
default:
|
|
166
|
+
return { provider, email: raw.email, name: raw.name, avatar: null };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function linkToIdentity(ctx, provider, profile) {
|
|
171
|
+
const identity = ctx.identity?.get?.() || ctx.identity;
|
|
172
|
+
if (!identity?.publicKey) {
|
|
173
|
+
ctx.logger.warn('OAuth: no local identity to link');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const accounts = ctx.store.get('linkedAccounts') || [];
|
|
178
|
+
const existing = accounts.findIndex(a => a.provider === provider);
|
|
179
|
+
const entry = {
|
|
180
|
+
provider,
|
|
181
|
+
email: profile.email,
|
|
182
|
+
name: profile.name,
|
|
183
|
+
publicKey: identity.publicKey,
|
|
184
|
+
linkedAt: new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (existing >= 0) {
|
|
188
|
+
accounts[existing] = entry;
|
|
189
|
+
} else {
|
|
190
|
+
accounts.push(entry);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
ctx.store.set('linkedAccounts', accounts);
|
|
194
|
+
ctx.logger.info(`OAuth: linked ${provider} (${profile.email}) to identity ${identity.publicKey.slice(0, 16)}`);
|
|
195
|
+
|
|
196
|
+
if (ctx.notify?.show) {
|
|
197
|
+
ctx.notify.show('Account Linked', `${profile.name} (${provider}) linked to your Palexplorer identity.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chat",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "1:1 messaging with pals",
|
|
5
|
+
"author": "Palexplorer Team",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"hooks": ["on:app:ready"],
|
|
9
|
+
"permissions": [],
|
|
10
|
+
"contributes": {
|
|
11
|
+
"pages": [
|
|
12
|
+
{ "id": "chat", "label": "chat", "icon": "MessagesSquare", "section": "social" }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"config": { "enabled": { "type": "boolean", "default": true } },
|
|
16
|
+
"pro": false
|
|
17
|
+
}
|