wiki-plugin-lucille 0.0.1
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/CLAUDE.md +201 -0
- package/client/lucille.js +198 -0
- package/factory.json +7 -0
- package/index.js +4 -0
- package/package.json +16 -0
- package/server/server.js +1294 -0
package/server/server.js
ADDED
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const httpProxy = require('http-proxy');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fetch = require('node-fetch');
|
|
7
|
+
const sessionless = require('sessionless-node');
|
|
8
|
+
const { spawn, exec } = require('child_process');
|
|
9
|
+
|
|
10
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const LUCILLE_PORT = parseInt(process.env.LUCILLE_PORT) || 5444;
|
|
13
|
+
const LUCILLE_PATH = process.env.LUCILLE_PATH || path.dirname(require.resolve('lucille/package.json'));
|
|
14
|
+
const LUCILLE_PID_FILE = path.join(__dirname, `lucille-${LUCILLE_PORT}.pid`);
|
|
15
|
+
const CONFIG_FILE = path.join(process.env.HOME || '/root', '.lucille', 'config.json');
|
|
16
|
+
|
|
17
|
+
let lucilleProcess = null;
|
|
18
|
+
|
|
19
|
+
// ── Default config ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG = {
|
|
22
|
+
spacesKey: '',
|
|
23
|
+
spacesSecret: '',
|
|
24
|
+
spacesRegion: 'nyc3',
|
|
25
|
+
spacesBucket: '',
|
|
26
|
+
spacesCdnEndpoint: '',
|
|
27
|
+
trackerPort: 8000,
|
|
28
|
+
sanoraUrl: '',
|
|
29
|
+
tiers: [
|
|
30
|
+
{ id: 'free', name: 'Free', price: 0, storageLimit: 524288000, videoLimit: 5, durationLimit: 600 },
|
|
31
|
+
{ id: 'creator', name: 'Creator', price: 500, storageLimit: 10737418240, videoLimit: 50, durationLimit: 3600 },
|
|
32
|
+
{ id: 'pro', name: 'Pro', price: 2000, storageLimit: 0, videoLimit: 0, durationLimit: 0 }
|
|
33
|
+
],
|
|
34
|
+
federationPeers: []
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ── Config helpers ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function loadConfig() {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
42
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) };
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn('[wiki-plugin-lucille] Could not read config:', err.message);
|
|
46
|
+
}
|
|
47
|
+
return { ...DEFAULT_CONFIG };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveConfig(config) {
|
|
51
|
+
try {
|
|
52
|
+
const dir = path.dirname(CONFIG_FILE);
|
|
53
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('[wiki-plugin-lucille] Could not save config:', err.message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isConfigured(config) {
|
|
61
|
+
return !!(config.spacesKey && config.spacesSecret && config.spacesBucket);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Addie helpers ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function getAddieUrl() {
|
|
67
|
+
const cfg = loadConfig();
|
|
68
|
+
if (cfg.allyabaseUrl) return cfg.allyabaseUrl.replace(/\/$/, '') + '/plugin/allyabase/addie';
|
|
69
|
+
if (cfg.sanoraUrl) {
|
|
70
|
+
try { return new URL(cfg.sanoraUrl).origin + '/plugin/allyabase/addie'; } catch (e) {}
|
|
71
|
+
}
|
|
72
|
+
return 'https://dev.allyabase.com/plugin/allyabase/addie';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function generateAddieKeys() {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
sessionless.generateKeys(
|
|
78
|
+
(k) => { resolve(k); return k; },
|
|
79
|
+
() => null
|
|
80
|
+
);
|
|
81
|
+
setTimeout(() => reject(new Error('generateKeys timed out')), 5000);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function addieCreateUser(addieUrl, addieKeys) {
|
|
86
|
+
sessionless.getKeys = () => addieKeys;
|
|
87
|
+
const timestamp = Date.now().toString();
|
|
88
|
+
const message = timestamp + addieKeys.pubKey;
|
|
89
|
+
const signature = await sessionless.sign(message);
|
|
90
|
+
const resp = await fetch(`${addieUrl}/user/create`, {
|
|
91
|
+
method: 'PUT',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ timestamp, pubKey: addieKeys.pubKey, signature })
|
|
94
|
+
});
|
|
95
|
+
if (!resp.ok) throw new Error(`Addie create failed: ${resp.status}`);
|
|
96
|
+
return resp.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function addieGetStripeConnectUrl(addieUrl, addieKeys, addieUuid, returnUrl) {
|
|
100
|
+
sessionless.getKeys = () => addieKeys;
|
|
101
|
+
const timestamp = Date.now().toString();
|
|
102
|
+
const message = timestamp + addieUuid;
|
|
103
|
+
const signature = await sessionless.sign(message);
|
|
104
|
+
const resp = await fetch(
|
|
105
|
+
`${addieUrl}/user/${addieUuid}/processor/stripe/connect?timestamp=${timestamp}&signature=${signature}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
|
106
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
107
|
+
);
|
|
108
|
+
if (!resp.ok) throw new Error(`Addie Stripe connect failed: ${resp.status}`);
|
|
109
|
+
const data = await resp.json();
|
|
110
|
+
return data.url || data.connectUrl || data.onboardingUrl;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function addieCreatePaymentIntent(addieUrl, buyerKeys, buyerUuid, amount, payees) {
|
|
114
|
+
sessionless.getKeys = () => buyerKeys;
|
|
115
|
+
const timestamp = Date.now().toString();
|
|
116
|
+
const message = timestamp + buyerUuid + amount + 'USD';
|
|
117
|
+
const signature = await sessionless.sign(message);
|
|
118
|
+
const resp = await fetch(`${addieUrl}/user/${buyerUuid}/processor/stripe/intent`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ timestamp, amount, currency: 'USD', payees, signature })
|
|
122
|
+
});
|
|
123
|
+
if (!resp.ok) throw new Error(`Addie intent failed: ${resp.status}`);
|
|
124
|
+
return resp.json();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function ensureServerAddieUser() {
|
|
128
|
+
const config = loadConfig();
|
|
129
|
+
if (config.serverAddie && config.serverAddie.uuid) return config.serverAddie;
|
|
130
|
+
const addieUrl = getAddieUrl();
|
|
131
|
+
const addieKeys = await generateAddieKeys();
|
|
132
|
+
const addieUser = await addieCreateUser(addieUrl, addieKeys);
|
|
133
|
+
if (addieUser.error) throw new Error(`Addie: ${addieUser.error}`);
|
|
134
|
+
const serverAddie = { uuid: addieUser.uuid, pubKey: addieKeys.pubKey, privateKey: addieKeys.privateKey };
|
|
135
|
+
config.serverAddie = serverAddie;
|
|
136
|
+
saveConfig(config);
|
|
137
|
+
return serverAddie;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Process lifecycle ─────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function killProcessByPid(pid) {
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 'SIGTERM');
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch {}
|
|
147
|
+
}, 2000);
|
|
148
|
+
return true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code !== 'ESRCH') console.error(`[wiki-plugin-lucille] Error killing ${pid}:`, err.message);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function killProcessByPort(port) {
|
|
156
|
+
return new Promise(resolve => {
|
|
157
|
+
exec(`lsof -ti tcp:${port}`, (err, stdout) => {
|
|
158
|
+
if (err || !stdout.trim()) return resolve(false);
|
|
159
|
+
const pid = parseInt(stdout.trim(), 10);
|
|
160
|
+
resolve(killProcessByPid(pid));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function cleanupOrphanedProcess() {
|
|
166
|
+
if (fs.existsSync(LUCILLE_PID_FILE)) {
|
|
167
|
+
try {
|
|
168
|
+
const pid = parseInt(fs.readFileSync(LUCILLE_PID_FILE, 'utf8').trim(), 10);
|
|
169
|
+
killProcessByPid(pid);
|
|
170
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
171
|
+
fs.unlinkSync(LUCILLE_PID_FILE);
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
const killed = await killProcessByPort(LUCILLE_PORT);
|
|
175
|
+
if (killed) await new Promise(r => setTimeout(r, 2500));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writePidFile(pid) {
|
|
179
|
+
try { fs.writeFileSync(LUCILLE_PID_FILE, pid.toString(), 'utf8'); } catch {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cleanupPidFile() {
|
|
183
|
+
try { if (fs.existsSync(LUCILLE_PID_FILE)) fs.unlinkSync(LUCILLE_PID_FILE); } catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function shutdownLucille() {
|
|
187
|
+
if (lucilleProcess && !lucilleProcess.killed) {
|
|
188
|
+
try {
|
|
189
|
+
lucilleProcess.kill('SIGTERM');
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
if (lucilleProcess && !lucilleProcess.killed) lucilleProcess.kill('SIGKILL');
|
|
192
|
+
}, 2000);
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
cleanupPidFile();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function checkLucilleRunning() {
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(`http://127.0.0.1:${LUCILLE_PORT}/health`, { timeout: 2000 });
|
|
201
|
+
return res.ok;
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function launchLucille(config) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
console.log('[wiki-plugin-lucille] Launching lucille service…');
|
|
210
|
+
|
|
211
|
+
if (!fs.existsSync(LUCILLE_PATH)) {
|
|
212
|
+
return reject(new Error(`Lucille not found at ${LUCILLE_PATH}. Set LUCILLE_PATH.`));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const serverPath = path.join(LUCILLE_PATH, 'lucille.js');
|
|
216
|
+
if (!fs.existsSync(serverPath)) {
|
|
217
|
+
return reject(new Error(`lucille.js not found at ${serverPath}`));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const trackerPort = config.trackerPort || 8000;
|
|
221
|
+
const trackerUrl = config.trackerUrl ||
|
|
222
|
+
`ws://${config.trackerHost || 'localhost'}:${trackerPort}`;
|
|
223
|
+
|
|
224
|
+
const env = {
|
|
225
|
+
...process.env,
|
|
226
|
+
PORT: LUCILLE_PORT.toString(),
|
|
227
|
+
LUCILLE_MODE: 'all',
|
|
228
|
+
DO_SPACES_KEY: config.spacesKey || '',
|
|
229
|
+
DO_SPACES_SECRET: config.spacesSecret || '',
|
|
230
|
+
DO_SPACES_REGION: config.spacesRegion || 'nyc3',
|
|
231
|
+
DO_SPACES_BUCKET: config.spacesBucket || '',
|
|
232
|
+
DO_SPACES_CDN_ENDPOINT: config.spacesCdnEndpoint || '',
|
|
233
|
+
TRACKER_PORT: trackerPort.toString(),
|
|
234
|
+
TRACKER_URL: trackerUrl,
|
|
235
|
+
SANORA_URL: config.sanoraUrl || ''
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
console.log('[wiki-plugin-lucille] Env:');
|
|
239
|
+
console.log(` PORT: ${env.PORT}`);
|
|
240
|
+
console.log(` TRACKER_PORT: ${env.TRACKER_PORT}`);
|
|
241
|
+
console.log(` DO_SPACES_BUCKET: ${env.DO_SPACES_BUCKET || '(not set)'}`);
|
|
242
|
+
|
|
243
|
+
lucilleProcess = spawn('node', ['lucille.js'], {
|
|
244
|
+
cwd: LUCILLE_PATH,
|
|
245
|
+
env,
|
|
246
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
writePidFile(lucilleProcess.pid);
|
|
250
|
+
|
|
251
|
+
lucilleProcess.stdout.on('data', d =>
|
|
252
|
+
d.toString().split('\n').filter(l => l.trim())
|
|
253
|
+
.forEach(l => console.log(`[lucille:${LUCILLE_PORT}] ${l}`))
|
|
254
|
+
);
|
|
255
|
+
lucilleProcess.stderr.on('data', d =>
|
|
256
|
+
console.error(`[lucille:${LUCILLE_PORT}] ERR: ${d.toString().trim()}`)
|
|
257
|
+
);
|
|
258
|
+
lucilleProcess.on('exit', (code, signal) => {
|
|
259
|
+
if (code !== 0 && code !== null) {
|
|
260
|
+
console.error(`[wiki-plugin-lucille] Process exited (code ${code}, signal ${signal})`);
|
|
261
|
+
}
|
|
262
|
+
cleanupPidFile();
|
|
263
|
+
lucilleProcess = null;
|
|
264
|
+
});
|
|
265
|
+
lucilleProcess.on('error', err => reject(err));
|
|
266
|
+
|
|
267
|
+
console.log('[wiki-plugin-lucille] Waiting 4s for lucille to start…');
|
|
268
|
+
setTimeout(async () => {
|
|
269
|
+
const running = await checkLucilleRunning();
|
|
270
|
+
if (running) {
|
|
271
|
+
console.log('[wiki-plugin-lucille] ✅ Lucille started');
|
|
272
|
+
resolve();
|
|
273
|
+
} else {
|
|
274
|
+
console.warn('[wiki-plugin-lucille] ⚠️ Lucille did not respond to health check — continuing anyway');
|
|
275
|
+
resolve();
|
|
276
|
+
}
|
|
277
|
+
}, 4000);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function configureLucille(config) {
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch(`http://127.0.0.1:${LUCILLE_PORT}/config`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'Content-Type': 'application/json' },
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
tiers: config.tiers,
|
|
288
|
+
federationPeers: config.federationPeers,
|
|
289
|
+
sanoraUrl: config.sanoraUrl
|
|
290
|
+
})
|
|
291
|
+
});
|
|
292
|
+
const data = await res.json();
|
|
293
|
+
if (data.success) {
|
|
294
|
+
console.log(`[wiki-plugin-lucille] ✅ Lucille configured (${data.tiers?.length || 0} tiers, ${data.federationPeers?.length || 0} peers)`);
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.warn('[wiki-plugin-lucille] Could not configure lucille:', err.message);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Freyja federation page ────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
function generateFederationPage(currentId) {
|
|
304
|
+
const PLUGINS = {
|
|
305
|
+
agora: { id: 'agora', color: '#00cc00', icon: '🛍️', name: 'Agora', tagline: 'Digital marketplace', desc: 'A federated marketplace for independent creators. Buy and sell books, music, posts, and more — commerce the way it was supposed to work.', path: '/plugin/agora/directory', ping: '/plugin/agora/directory', fed: '/plugin/agora/federation' },
|
|
306
|
+
lucille: { id: 'lucille', color: '#ee22ee', icon: '🎬', name: 'Lucille', tagline: 'P2P video hosting', desc: 'Upload and stream video peer-to-peer. No corporate infrastructure, no surveillance — your wiki hosts your content directly.', path: '/plugin/lucille/setup', ping: '/plugin/lucille/setup/status', fed: '/plugin/lucille/federation' },
|
|
307
|
+
linkitylink: { id: 'linkitylink', color: '#9922cc', icon: '🔗', name: 'Linkitylink', tagline: 'Privacy-first link pages', desc: 'Create beautiful tapestries of links. No tracking, no algorithms — just your links, shared your way, on your terms.', path: '/plugin/linkitylink', ping: '/plugin/linkitylink/config', fed: '/plugin/linkitylink/federation' },
|
|
308
|
+
salon: { id: 'salon', color: '#ffdd00', icon: '🏛️', name: 'Salon', tagline: 'Community gathering space', desc: 'A gathering place for your wiki community. Members register, connect, and receive updates from the wider Freyja ecosystem.', path: '/plugin/salon', ping: '/plugin/salon/config', fed: '/plugin/salon/federation' },
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const current = PLUGINS[currentId];
|
|
312
|
+
const others = Object.values(PLUGINS).filter(p => p.id !== currentId);
|
|
313
|
+
|
|
314
|
+
const navDotsHtml = Object.values(PLUGINS).map(p => `
|
|
315
|
+
<a href="${p.fed}" class="fnav-dot" style="--dot-color:${p.color};" title="${p.name}">
|
|
316
|
+
<span class="fnav-dot-inner"></span>
|
|
317
|
+
</a>`).join('');
|
|
318
|
+
|
|
319
|
+
const cardsHtml = others.map(p => `
|
|
320
|
+
<a href="${p.fed}" class="fed-card" style="--card-color:${p.color};">
|
|
321
|
+
<div class="fed-card-top">
|
|
322
|
+
<span class="fed-card-icon">${p.icon}</span>
|
|
323
|
+
<div class="fed-card-meta">
|
|
324
|
+
<div class="fed-card-name">
|
|
325
|
+
<span class="fed-status-dot" id="dot-${p.id}"></span>
|
|
326
|
+
${p.name}
|
|
327
|
+
</div>
|
|
328
|
+
<div class="fed-card-tagline">${p.tagline}</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="fed-card-desc">${p.desc}</div>
|
|
332
|
+
<div class="fed-card-cta">Explore ${p.name} →</div>
|
|
333
|
+
</a>`).join('');
|
|
334
|
+
|
|
335
|
+
return `<!DOCTYPE html>
|
|
336
|
+
<html lang="en">
|
|
337
|
+
<head>
|
|
338
|
+
<meta charset="UTF-8">
|
|
339
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
340
|
+
<title>Freyja — ${current.name}</title>
|
|
341
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
342
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
343
|
+
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
|
344
|
+
<style>
|
|
345
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
346
|
+
|
|
347
|
+
:root {
|
|
348
|
+
--bg: #04040f;
|
|
349
|
+
--surface: rgba(12, 12, 30, 0.75);
|
|
350
|
+
--border: rgba(100, 120, 200, 0.18);
|
|
351
|
+
--text: #ffffff;
|
|
352
|
+
--text-muted: rgba(220, 225, 255, 0.88);
|
|
353
|
+
--text-dim: rgba(200, 210, 255, 0.65);
|
|
354
|
+
--radius-card: 1.25rem;
|
|
355
|
+
--radius-pill: 9999px;
|
|
356
|
+
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
|
357
|
+
--current-color: ${current.color};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
html, body { height: 100%; }
|
|
361
|
+
|
|
362
|
+
body {
|
|
363
|
+
font-family: 'Inter', system-ui, sans-serif;
|
|
364
|
+
font-weight: 300;
|
|
365
|
+
background: var(--bg);
|
|
366
|
+
color: var(--text-muted);
|
|
367
|
+
min-height: 100vh;
|
|
368
|
+
overflow-x: hidden;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#starfield {
|
|
372
|
+
position: fixed;
|
|
373
|
+
inset: 0;
|
|
374
|
+
z-index: 0;
|
|
375
|
+
pointer-events: none;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.fnav {
|
|
379
|
+
position: fixed;
|
|
380
|
+
top: 0; left: 0; right: 0;
|
|
381
|
+
z-index: 100;
|
|
382
|
+
display: flex;
|
|
383
|
+
align-items: center;
|
|
384
|
+
justify-content: space-between;
|
|
385
|
+
padding: 0 2rem;
|
|
386
|
+
height: 56px;
|
|
387
|
+
background: rgba(4, 4, 15, 0.8);
|
|
388
|
+
backdrop-filter: blur(16px);
|
|
389
|
+
border-bottom: 1px solid var(--border);
|
|
390
|
+
}
|
|
391
|
+
.fnav-brand {
|
|
392
|
+
font-family: 'Orbitron', sans-serif;
|
|
393
|
+
font-weight: 700;
|
|
394
|
+
font-size: 1rem;
|
|
395
|
+
color: var(--current-color);
|
|
396
|
+
text-decoration: none;
|
|
397
|
+
filter: drop-shadow(0 0 8px var(--current-color));
|
|
398
|
+
letter-spacing: 0.06em;
|
|
399
|
+
}
|
|
400
|
+
.fnav-dots { display: flex; align-items: center; gap: 0.75rem; }
|
|
401
|
+
.fnav-dot {
|
|
402
|
+
display: flex;
|
|
403
|
+
align-items: center;
|
|
404
|
+
justify-content: center;
|
|
405
|
+
width: 28px;
|
|
406
|
+
height: 28px;
|
|
407
|
+
border-radius: 50%;
|
|
408
|
+
text-decoration: none;
|
|
409
|
+
transition: transform 0.2s var(--ease);
|
|
410
|
+
}
|
|
411
|
+
.fnav-dot:hover { transform: scale(1.25); }
|
|
412
|
+
.fnav-dot-inner {
|
|
413
|
+
width: 12px;
|
|
414
|
+
height: 12px;
|
|
415
|
+
border-radius: 50%;
|
|
416
|
+
background: var(--dot-color);
|
|
417
|
+
filter: drop-shadow(0 0 6px var(--dot-color));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.page { position: relative; z-index: 1; }
|
|
421
|
+
|
|
422
|
+
.hero {
|
|
423
|
+
min-height: 100vh;
|
|
424
|
+
display: flex;
|
|
425
|
+
flex-direction: column;
|
|
426
|
+
align-items: center;
|
|
427
|
+
justify-content: center;
|
|
428
|
+
text-align: center;
|
|
429
|
+
padding: 7rem 2rem 4rem;
|
|
430
|
+
gap: 1.25rem;
|
|
431
|
+
opacity: 0;
|
|
432
|
+
transform: translateY(24px);
|
|
433
|
+
transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
|
|
434
|
+
}
|
|
435
|
+
.hero.visible { opacity: 1; transform: none; }
|
|
436
|
+
|
|
437
|
+
.hero-eyebrow {
|
|
438
|
+
font-family: 'Orbitron', sans-serif;
|
|
439
|
+
font-size: 0.7rem;
|
|
440
|
+
font-weight: 600;
|
|
441
|
+
letter-spacing: 0.2em;
|
|
442
|
+
text-transform: uppercase;
|
|
443
|
+
color: var(--current-color);
|
|
444
|
+
}
|
|
445
|
+
.hero-icon {
|
|
446
|
+
font-size: 5rem;
|
|
447
|
+
line-height: 1;
|
|
448
|
+
filter: drop-shadow(0 0 24px ${current.color});
|
|
449
|
+
}
|
|
450
|
+
.hero-title {
|
|
451
|
+
font-family: 'Orbitron', sans-serif;
|
|
452
|
+
font-size: clamp(3rem, 10vw, 6rem);
|
|
453
|
+
font-weight: 900;
|
|
454
|
+
color: var(--current-color);
|
|
455
|
+
filter: drop-shadow(0 0 30px var(--current-color));
|
|
456
|
+
line-height: 1;
|
|
457
|
+
letter-spacing: -0.02em;
|
|
458
|
+
}
|
|
459
|
+
.hero-tagline {
|
|
460
|
+
font-family: 'Orbitron', sans-serif;
|
|
461
|
+
font-size: 0.75rem;
|
|
462
|
+
font-weight: 400;
|
|
463
|
+
letter-spacing: 0.18em;
|
|
464
|
+
text-transform: uppercase;
|
|
465
|
+
color: var(--text-dim);
|
|
466
|
+
}
|
|
467
|
+
.hero-desc {
|
|
468
|
+
max-width: 520px;
|
|
469
|
+
font-size: 1rem;
|
|
470
|
+
font-weight: 300;
|
|
471
|
+
color: var(--text-muted);
|
|
472
|
+
line-height: 1.7;
|
|
473
|
+
}
|
|
474
|
+
.hero-btn {
|
|
475
|
+
display: inline-block;
|
|
476
|
+
margin-top: 0.5rem;
|
|
477
|
+
padding: 0.75rem 2rem;
|
|
478
|
+
border-radius: var(--radius-pill);
|
|
479
|
+
background: var(--current-color);
|
|
480
|
+
color: #000;
|
|
481
|
+
font-family: 'Inter', sans-serif;
|
|
482
|
+
font-weight: 600;
|
|
483
|
+
font-size: 0.9rem;
|
|
484
|
+
text-decoration: none;
|
|
485
|
+
transition: transform 0.2s var(--ease), filter 0.2s;
|
|
486
|
+
}
|
|
487
|
+
.hero-btn:hover { transform: scale(1.04); filter: brightness(1.15); }
|
|
488
|
+
|
|
489
|
+
.cards-section {
|
|
490
|
+
max-width: 1000px;
|
|
491
|
+
margin: 0 auto;
|
|
492
|
+
padding: 4rem 2rem 6rem;
|
|
493
|
+
opacity: 0;
|
|
494
|
+
transform: translateY(32px);
|
|
495
|
+
transition: opacity 0.8s var(--ease) 0.15s, transform 0.8s var(--ease) 0.15s;
|
|
496
|
+
}
|
|
497
|
+
.cards-section.visible { opacity: 1; transform: none; }
|
|
498
|
+
|
|
499
|
+
.section-label {
|
|
500
|
+
font-family: 'Orbitron', sans-serif;
|
|
501
|
+
font-size: 0.65rem;
|
|
502
|
+
font-weight: 600;
|
|
503
|
+
letter-spacing: 0.22em;
|
|
504
|
+
text-transform: uppercase;
|
|
505
|
+
color: var(--text-dim);
|
|
506
|
+
margin-bottom: 1.5rem;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.fed-grid {
|
|
510
|
+
display: grid;
|
|
511
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
512
|
+
gap: 1.25rem;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.fed-card {
|
|
516
|
+
--card-color: #ffffff;
|
|
517
|
+
background: var(--surface);
|
|
518
|
+
backdrop-filter: blur(10px);
|
|
519
|
+
border: 1px solid var(--border);
|
|
520
|
+
border-radius: var(--radius-card);
|
|
521
|
+
padding: 1.5rem;
|
|
522
|
+
display: flex;
|
|
523
|
+
flex-direction: column;
|
|
524
|
+
gap: 1rem;
|
|
525
|
+
text-decoration: none;
|
|
526
|
+
color: var(--text-muted);
|
|
527
|
+
transition: border-color 0.25s var(--ease), filter 0.25s var(--ease), transform 0.25s var(--ease);
|
|
528
|
+
}
|
|
529
|
+
.fed-card:hover {
|
|
530
|
+
border-color: var(--card-color);
|
|
531
|
+
filter: drop-shadow(0 0 14px var(--card-color));
|
|
532
|
+
transform: translateY(-4px);
|
|
533
|
+
}
|
|
534
|
+
.fed-card-top { display: flex; align-items: flex-start; gap: 1rem; }
|
|
535
|
+
.fed-card-icon { font-size: 2rem; line-height: 1; flex-shrink: 0; }
|
|
536
|
+
.fed-card-meta { flex: 1; }
|
|
537
|
+
.fed-card-name {
|
|
538
|
+
font-family: 'Orbitron', sans-serif;
|
|
539
|
+
font-size: 0.9rem;
|
|
540
|
+
font-weight: 600;
|
|
541
|
+
color: var(--text);
|
|
542
|
+
display: flex;
|
|
543
|
+
align-items: center;
|
|
544
|
+
gap: 0.5rem;
|
|
545
|
+
margin-bottom: 0.25rem;
|
|
546
|
+
}
|
|
547
|
+
.fed-status-dot {
|
|
548
|
+
width: 8px;
|
|
549
|
+
height: 8px;
|
|
550
|
+
border-radius: 50%;
|
|
551
|
+
background: rgba(200, 210, 255, 0.3);
|
|
552
|
+
flex-shrink: 0;
|
|
553
|
+
transition: background 0.4s, filter 0.4s;
|
|
554
|
+
}
|
|
555
|
+
.fed-card-tagline { font-size: 0.75rem; color: var(--card-color); font-weight: 500; }
|
|
556
|
+
.fed-card-desc { font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); flex: 1; }
|
|
557
|
+
.fed-card-cta { font-size: 0.8rem; font-weight: 600; color: var(--card-color); align-self: flex-start; }
|
|
558
|
+
|
|
559
|
+
.fed-footer {
|
|
560
|
+
text-align: center;
|
|
561
|
+
padding: 2rem;
|
|
562
|
+
font-size: 0.78rem;
|
|
563
|
+
color: var(--text-dim);
|
|
564
|
+
border-top: 1px solid var(--border);
|
|
565
|
+
position: relative;
|
|
566
|
+
z-index: 1;
|
|
567
|
+
}
|
|
568
|
+
.fed-footer strong { font-family: 'Orbitron', sans-serif; color: var(--text-muted); }
|
|
569
|
+
</style>
|
|
570
|
+
</head>
|
|
571
|
+
<body>
|
|
572
|
+
<canvas id="starfield"></canvas>
|
|
573
|
+
|
|
574
|
+
<nav class="fnav">
|
|
575
|
+
<a href="${current.fed}" class="fnav-brand">✦ FREYJA</a>
|
|
576
|
+
<div class="fnav-dots">${navDotsHtml}
|
|
577
|
+
</div>
|
|
578
|
+
</nav>
|
|
579
|
+
|
|
580
|
+
<div class="page">
|
|
581
|
+
<section class="hero" id="hero">
|
|
582
|
+
<div class="hero-eyebrow">Freyja Ecosystem</div>
|
|
583
|
+
<div class="hero-icon">${current.icon}</div>
|
|
584
|
+
<div class="hero-title">${current.name}</div>
|
|
585
|
+
<div class="hero-tagline">${current.tagline}</div>
|
|
586
|
+
<div class="hero-desc">${current.desc}</div>
|
|
587
|
+
<a href="${current.path}" class="hero-btn">Open ${current.name} →</a>
|
|
588
|
+
</section>
|
|
589
|
+
|
|
590
|
+
<section class="cards-section" id="cards">
|
|
591
|
+
<div class="section-label">Also on this wiki</div>
|
|
592
|
+
<div class="fed-grid">${cardsHtml}
|
|
593
|
+
</div>
|
|
594
|
+
</section>
|
|
595
|
+
|
|
596
|
+
<footer class="fed-footer">
|
|
597
|
+
<strong>Freyja</strong> — open, federated, and owned by you.
|
|
598
|
+
</footer>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<script>
|
|
602
|
+
(function() {
|
|
603
|
+
var canvas = document.getElementById('starfield');
|
|
604
|
+
var ctx = canvas.getContext('2d');
|
|
605
|
+
var stars = [];
|
|
606
|
+
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
|
607
|
+
resize();
|
|
608
|
+
window.addEventListener('resize', resize);
|
|
609
|
+
for (var i = 0; i < 180; i++) {
|
|
610
|
+
stars.push({ x: Math.random(), y: Math.random(), r: Math.random() * 1.2 + 0.2, a: Math.random(), da: (Math.random() - 0.5) * 0.008 });
|
|
611
|
+
}
|
|
612
|
+
function drawStars() {
|
|
613
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
614
|
+
for (var i = 0; i < stars.length; i++) {
|
|
615
|
+
var s = stars[i];
|
|
616
|
+
s.a += s.da;
|
|
617
|
+
if (s.a <= 0 || s.a >= 1) s.da = -s.da;
|
|
618
|
+
ctx.beginPath();
|
|
619
|
+
ctx.arc(s.x * canvas.width, s.y * canvas.height, s.r, 0, Math.PI * 2);
|
|
620
|
+
ctx.fillStyle = 'rgba(200,210,255,' + s.a.toFixed(2) + ')';
|
|
621
|
+
ctx.fill();
|
|
622
|
+
}
|
|
623
|
+
requestAnimationFrame(drawStars);
|
|
624
|
+
}
|
|
625
|
+
drawStars();
|
|
626
|
+
|
|
627
|
+
var obs = new IntersectionObserver(function(entries) {
|
|
628
|
+
entries.forEach(function(e) { if (e.isIntersecting) e.target.classList.add('visible'); });
|
|
629
|
+
}, { threshold: 0.1 });
|
|
630
|
+
obs.observe(document.getElementById('hero'));
|
|
631
|
+
obs.observe(document.getElementById('cards'));
|
|
632
|
+
|
|
633
|
+
var pluginColors = { agora: '#00cc00', lucille: '#ee22ee', linkitylink: '#9922cc', salon: '#ffdd00' };
|
|
634
|
+
var pings = [
|
|
635
|
+
{ id: 'agora', url: '/plugin/agora/directory' },
|
|
636
|
+
{ id: 'lucille', url: '/plugin/lucille/setup/status' },
|
|
637
|
+
{ id: 'linkitylink', url: '/plugin/linkitylink/config' },
|
|
638
|
+
{ id: 'salon', url: '/plugin/salon/config' }
|
|
639
|
+
];
|
|
640
|
+
pings.forEach(function(p) {
|
|
641
|
+
fetch(p.url, { signal: AbortSignal.timeout(3000) })
|
|
642
|
+
.then(function(r) {
|
|
643
|
+
var d = document.getElementById('dot-' + p.id);
|
|
644
|
+
if (d) {
|
|
645
|
+
d.style.background = r.ok ? pluginColors[p.id] : 'rgba(200,210,255,0.3)';
|
|
646
|
+
if (r.ok) d.style.filter = 'drop-shadow(0 0 5px ' + pluginColors[p.id] + ')';
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
.catch(function() {});
|
|
650
|
+
});
|
|
651
|
+
})();
|
|
652
|
+
</script>
|
|
653
|
+
</body>
|
|
654
|
+
</html>`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Main startServer ──────────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
async function startServer(params) {
|
|
660
|
+
const app = params.app;
|
|
661
|
+
const config = loadConfig();
|
|
662
|
+
|
|
663
|
+
console.log('[wiki-plugin-lucille] Starting…');
|
|
664
|
+
console.log(`[wiki-plugin-lucille] Config: ${CONFIG_FILE}`);
|
|
665
|
+
console.log(`[wiki-plugin-lucille] Configured: ${isConfigured(config)}`);
|
|
666
|
+
|
|
667
|
+
await cleanupOrphanedProcess();
|
|
668
|
+
|
|
669
|
+
if (isConfigured(config)) {
|
|
670
|
+
const running = await checkLucilleRunning();
|
|
671
|
+
if (!running) {
|
|
672
|
+
try {
|
|
673
|
+
await launchLucille(config);
|
|
674
|
+
await configureLucille(config);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.error('[wiki-plugin-lucille] Failed to launch lucille:', err.message);
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
console.log('[wiki-plugin-lucille] Lucille already running, reconfiguring…');
|
|
680
|
+
await configureLucille(config);
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
console.log('[wiki-plugin-lucille] DO Spaces not configured — lucille in offline mode');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Proxy setup ─────────────────────────────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
const proxy = httpProxy.createProxyServer({});
|
|
689
|
+
|
|
690
|
+
proxy.on('error', (err, req, res) => {
|
|
691
|
+
console.error('[lucille proxy]', err.message);
|
|
692
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
693
|
+
res.end(JSON.stringify({ error: 'Lucille service not available', hint: 'Complete setup at /plugin/lucille/setup' }));
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
proxy.on('proxyReq', (proxyReq, req) => {
|
|
697
|
+
if (req.body && Object.keys(req.body).length > 0) {
|
|
698
|
+
const body = JSON.stringify(req.body);
|
|
699
|
+
proxyReq.setHeader('Content-Type', 'application/json');
|
|
700
|
+
proxyReq.setHeader('Content-Length', Buffer.byteLength(body));
|
|
701
|
+
proxyReq.write(body);
|
|
702
|
+
proxyReq.end();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ── Setup / onboarding endpoints ────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
// GET /plugin/lucille/setup — serve setup page
|
|
709
|
+
app.get('/plugin/lucille/setup', (req, res) => {
|
|
710
|
+
const cfg = loadConfig();
|
|
711
|
+
res.send(generateSetupPage(cfg));
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// POST /plugin/lucille/setup — save config and (re)launch lucille
|
|
715
|
+
app.post('/plugin/lucille/setup', express_json(), async (req, res) => {
|
|
716
|
+
const {
|
|
717
|
+
spacesKey, spacesSecret, spacesRegion, spacesBucket, spacesCdnEndpoint,
|
|
718
|
+
trackerPort, trackerHost, sanoraUrl, allyabaseUrl, tiers, federationPeers
|
|
719
|
+
} = req.body;
|
|
720
|
+
|
|
721
|
+
const existing = loadConfig();
|
|
722
|
+
const newConfig = {
|
|
723
|
+
...existing,
|
|
724
|
+
...(spacesKey && { spacesKey }),
|
|
725
|
+
...(spacesSecret && { spacesSecret }),
|
|
726
|
+
...(spacesRegion && { spacesRegion }),
|
|
727
|
+
...(spacesBucket && { spacesBucket }),
|
|
728
|
+
...(spacesCdnEndpoint && { spacesCdnEndpoint }),
|
|
729
|
+
...(trackerPort && { trackerPort: parseInt(trackerPort) }),
|
|
730
|
+
...(trackerHost && { trackerHost }),
|
|
731
|
+
...(sanoraUrl && { sanoraUrl }),
|
|
732
|
+
...(allyabaseUrl && { allyabaseUrl, sanoraUrl: allyabaseUrl.replace(/\/$/, '') + '/plugin/allyabase/sanora' }),
|
|
733
|
+
...(tiers && { tiers }),
|
|
734
|
+
...(federationPeers && { federationPeers })
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
saveConfig(newConfig);
|
|
738
|
+
|
|
739
|
+
// Auto-create server Addie user if allyabaseUrl was just set
|
|
740
|
+
if (allyabaseUrl && !newConfig.serverAddie) {
|
|
741
|
+
try {
|
|
742
|
+
await ensureServerAddieUser();
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.warn('[wiki-plugin-lucille] Could not create server Addie user:', err.message);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Restart lucille with new config
|
|
749
|
+
if (lucilleProcess) shutdownLucille();
|
|
750
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
751
|
+
|
|
752
|
+
if (isConfigured(newConfig)) {
|
|
753
|
+
try {
|
|
754
|
+
await launchLucille(newConfig);
|
|
755
|
+
await configureLucille(newConfig);
|
|
756
|
+
res.json({ success: true, message: 'Lucille configured and started' });
|
|
757
|
+
} catch (err) {
|
|
758
|
+
res.json({ success: false, error: err.message });
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
res.json({ success: true, message: 'Config saved — provide DO Spaces credentials to start lucille' });
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// GET /plugin/lucille/setup/status
|
|
766
|
+
app.get('/plugin/lucille/setup/status', async (req, res) => {
|
|
767
|
+
const cfg = loadConfig();
|
|
768
|
+
const running = await checkLucilleRunning();
|
|
769
|
+
const isOwner = !!(app.securityhandler && app.securityhandler.isAuthorized(req));
|
|
770
|
+
const allyabaseUrl = cfg.allyabaseUrl ||
|
|
771
|
+
(cfg.sanoraUrl ? cfg.sanoraUrl.replace(/\/plugin\/allyabase\/sanora$/, '') : '');
|
|
772
|
+
res.json({
|
|
773
|
+
configured: isConfigured(cfg),
|
|
774
|
+
running,
|
|
775
|
+
tiers: cfg.tiers,
|
|
776
|
+
federationPeers: cfg.federationPeers,
|
|
777
|
+
isOwner,
|
|
778
|
+
allyabaseUrl: isOwner ? allyabaseUrl : undefined,
|
|
779
|
+
serverAddieReady: isOwner ? !!(cfg.serverAddie && cfg.serverAddie.uuid) : undefined,
|
|
780
|
+
stripeOnboarded: isOwner ? !!cfg.stripeOnboarded : undefined
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// ── Owner Stripe Connect ──────────────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
app.get('/plugin/lucille/setup/stripe', async (req, res) => {
|
|
787
|
+
const isOwnerReq = !!(app.securityhandler && app.securityhandler.isAuthorized(req));
|
|
788
|
+
if (!isOwnerReq) return res.status(403).send('Owner only');
|
|
789
|
+
const cfg = loadConfig();
|
|
790
|
+
if (!cfg.serverAddie || !cfg.serverAddie.uuid) {
|
|
791
|
+
return res.status(503).send('Save your allyabase URL first to set up a server Addie account');
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
const addieUrl = getAddieUrl();
|
|
795
|
+
const returnUrl = `${req.protocol}://${req.get('host')}/plugin/lucille/setup/stripe/return`;
|
|
796
|
+
const connectUrl = await addieGetStripeConnectUrl(addieUrl, cfg.serverAddie, cfg.serverAddie.uuid, returnUrl);
|
|
797
|
+
if (!connectUrl) return res.status(502).send('Addie did not return a Stripe Connect URL');
|
|
798
|
+
res.redirect(connectUrl);
|
|
799
|
+
} catch (err) {
|
|
800
|
+
res.status(502).send('Could not start Stripe onboarding: ' + err.message);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
app.get('/plugin/lucille/setup/stripe/return', (req, res) => {
|
|
805
|
+
const cfg = loadConfig();
|
|
806
|
+
cfg.stripeOnboarded = true;
|
|
807
|
+
saveConfig(cfg);
|
|
808
|
+
res.send(`<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
809
|
+
<title>Stripe Connect complete</title>
|
|
810
|
+
<style>body{font-family:sans-serif;background:#0d0d1a;color:#e0d0ff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
|
|
811
|
+
.card{background:rgba(20,0,40,.7);border:1px solid rgba(167,139,250,.25);border-radius:12px;padding:2rem;max-width:400px;text-align:center;}
|
|
812
|
+
h1{color:#0e0;font-size:1.5rem;margin-bottom:.75rem;}p{color:#a78bfa;font-size:.9rem;}</style>
|
|
813
|
+
</head><body><div class="card">
|
|
814
|
+
<h1>✅ Stripe payouts enabled</h1>
|
|
815
|
+
<p>Your wiki is now connected to Stripe and will receive tier upgrade payments.</p>
|
|
816
|
+
<p style="margin-top:1rem"><a href="/plugin/lucille/setup" style="color:#a78bfa;">Back to setup</a></p>
|
|
817
|
+
</div></body></html>`);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// ── Tier upgrade payment ──────────────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
const express_json_tier = require('express').json({ limit: '64kb' });
|
|
823
|
+
|
|
824
|
+
// Tier upgrade page — customer pays here
|
|
825
|
+
app.get('/plugin/lucille/tier/:tierId', (req, res) => {
|
|
826
|
+
const { tierId } = req.params;
|
|
827
|
+
const cfg = loadConfig();
|
|
828
|
+
const tier = (cfg.tiers || []).find(t => t.id === tierId);
|
|
829
|
+
if (!tier) return res.status(404).send('Tier not found');
|
|
830
|
+
if (tier.price === 0) return res.status(400).send('This tier is free — no payment required');
|
|
831
|
+
|
|
832
|
+
res.send(`<!DOCTYPE html>
|
|
833
|
+
<html lang="en">
|
|
834
|
+
<head>
|
|
835
|
+
<meta charset="UTF-8">
|
|
836
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
837
|
+
<title>Upgrade to ${tier.name} — Lucille</title>
|
|
838
|
+
<script src="https://js.stripe.com/v3/"></script>
|
|
839
|
+
<style>
|
|
840
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
841
|
+
body { min-height: 100vh; background: linear-gradient(135deg, #0d0d1a 0%, #1a0033 100%);
|
|
842
|
+
color: #e0d0ff; font-family: system-ui, sans-serif; display: flex;
|
|
843
|
+
align-items: center; justify-content: center; padding: 2rem; }
|
|
844
|
+
.card { background: rgba(20,0,40,.7); border: 1px solid rgba(167,139,250,.25);
|
|
845
|
+
border-radius: 12px; padding: 2rem; max-width: 440px; width: 100%; }
|
|
846
|
+
h1 { font-size: 1.4rem; color: #a78bfa; margin-bottom: .5rem; }
|
|
847
|
+
.price { font-size: 2rem; font-weight: 700; color: #e0d0ff; margin-bottom: 1.5rem; }
|
|
848
|
+
.field { margin-bottom: 1rem; }
|
|
849
|
+
label { display: block; font-size: .8rem; color: #a78bfa; margin-bottom: 4px; }
|
|
850
|
+
input { width: 100%; padding: 8px 10px; background: #0d001a;
|
|
851
|
+
border: 1px solid rgba(167,139,250,.3); border-radius: 6px;
|
|
852
|
+
color: #e0d0ff; font-size: .875rem; }
|
|
853
|
+
#payment-element { margin: 1rem 0; }
|
|
854
|
+
.btn { width: 100%; padding: 12px; background: #7c3aed; border: none;
|
|
855
|
+
border-radius: 8px; color: white; font-size: 1rem; cursor: pointer;
|
|
856
|
+
font-weight: 600; margin-top: .5rem; }
|
|
857
|
+
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
858
|
+
#status { margin-top: 1rem; font-size: .875rem; min-height: 1.2em; }
|
|
859
|
+
.err { color: #f55; } .ok { color: #0e0; }
|
|
860
|
+
</style>
|
|
861
|
+
</head>
|
|
862
|
+
<body>
|
|
863
|
+
<div class="card">
|
|
864
|
+
<h1>Upgrade to ${tier.name}</h1>
|
|
865
|
+
<div class="price">$${(tier.price / 100).toFixed(2)}</div>
|
|
866
|
+
|
|
867
|
+
<div class="field" id="uuid-section">
|
|
868
|
+
<label>Your Lucille UUID</label>
|
|
869
|
+
<input id="user-uuid" placeholder="Paste your UUID here">
|
|
870
|
+
<div style="font-size:.75rem;color:#7060a0;margin-top:4px;">
|
|
871
|
+
Find it in your Lucille account settings.
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
|
|
875
|
+
<div id="stripe-section" style="display:none">
|
|
876
|
+
<div id="payment-element"></div>
|
|
877
|
+
<button class="btn" id="pay-btn">Pay $${(tier.price / 100).toFixed(2)}</button>
|
|
878
|
+
</div>
|
|
879
|
+
<button class="btn" id="intent-btn">Continue to payment →</button>
|
|
880
|
+
<div id="status"></div>
|
|
881
|
+
</div>
|
|
882
|
+
|
|
883
|
+
<script>
|
|
884
|
+
(function() {
|
|
885
|
+
var TIER_ID = ${JSON.stringify(tierId)};
|
|
886
|
+
var status = document.getElementById('status');
|
|
887
|
+
var intentBtn = document.getElementById('intent-btn');
|
|
888
|
+
var stripeSection = document.getElementById('stripe-section');
|
|
889
|
+
var uuidSection = document.getElementById('uuid-section');
|
|
890
|
+
var stripe, elements;
|
|
891
|
+
|
|
892
|
+
// Read window.params payees from URL
|
|
893
|
+
var searchParams = new URLSearchParams(window.location.search);
|
|
894
|
+
var clientPayees = [];
|
|
895
|
+
var payeeKey = searchParams.get('payee');
|
|
896
|
+
var payeeAmt = parseInt(searchParams.get('payeeAmount') || '0');
|
|
897
|
+
if (payeeKey && payeeAmt > 0) clientPayees.push({ pubKey: payeeKey, amount: payeeAmt });
|
|
898
|
+
|
|
899
|
+
intentBtn.addEventListener('click', function() {
|
|
900
|
+
var uuid = document.getElementById('user-uuid').value.trim();
|
|
901
|
+
if (!uuid) { status.textContent = 'Enter your UUID first.'; return; }
|
|
902
|
+
intentBtn.disabled = true; intentBtn.textContent = 'Setting up payment…';
|
|
903
|
+
fetch('/plugin/lucille/tier/intent', {
|
|
904
|
+
method: 'POST',
|
|
905
|
+
headers: { 'Content-Type': 'application/json' },
|
|
906
|
+
body: JSON.stringify({ tierId: TIER_ID, userUuid: uuid, clientPayees: clientPayees })
|
|
907
|
+
})
|
|
908
|
+
.then(function(r) { return r.json(); })
|
|
909
|
+
.then(function(d) {
|
|
910
|
+
if (d.error) { status.innerHTML = '<span class="err">' + d.error + '</span>'; intentBtn.disabled = false; intentBtn.textContent = 'Continue to payment →'; return; }
|
|
911
|
+
stripe = Stripe(d.publishableKey);
|
|
912
|
+
elements = stripe.elements({ clientSecret: d.clientSecret });
|
|
913
|
+
var paymentEl = elements.create('payment');
|
|
914
|
+
paymentEl.mount('#payment-element');
|
|
915
|
+
uuidSection.style.display = 'none';
|
|
916
|
+
intentBtn.style.display = 'none';
|
|
917
|
+
stripeSection.style.display = 'block';
|
|
918
|
+
})
|
|
919
|
+
.catch(function(e) { status.innerHTML = '<span class="err">' + e.message + '</span>'; intentBtn.disabled = false; intentBtn.textContent = 'Continue to payment →'; });
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
document.getElementById('pay-btn').addEventListener('click', async function() {
|
|
923
|
+
var payBtn = document.getElementById('pay-btn');
|
|
924
|
+
payBtn.disabled = true; payBtn.textContent = 'Processing…';
|
|
925
|
+
var uuid = document.getElementById('user-uuid').value.trim() ||
|
|
926
|
+
(window._lucilleUuid || '');
|
|
927
|
+
var result = await stripe.confirmPayment({
|
|
928
|
+
elements,
|
|
929
|
+
confirmParams: { return_url: window.location.href },
|
|
930
|
+
redirect: 'if_required'
|
|
931
|
+
});
|
|
932
|
+
if (result.error) {
|
|
933
|
+
status.innerHTML = '<span class="err">' + result.error.message + '</span>';
|
|
934
|
+
payBtn.disabled = false; payBtn.textContent = 'Pay';
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
var piId = result.paymentIntent && result.paymentIntent.id;
|
|
938
|
+
fetch('/plugin/lucille/tier/complete', {
|
|
939
|
+
method: 'POST',
|
|
940
|
+
headers: { 'Content-Type': 'application/json' },
|
|
941
|
+
body: JSON.stringify({ paymentIntentId: piId, tierId: TIER_ID, userUuid: uuid })
|
|
942
|
+
})
|
|
943
|
+
.then(function(r) { return r.json(); })
|
|
944
|
+
.then(function(d) {
|
|
945
|
+
if (d.error) { status.innerHTML = '<span class="err">' + d.error + '</span>'; payBtn.disabled = false; return; }
|
|
946
|
+
status.innerHTML = '<span class="ok">✅ Upgrade complete! Your account has been upgraded to ${tier.name}.</span>';
|
|
947
|
+
stripeSection.style.display = 'none';
|
|
948
|
+
})
|
|
949
|
+
.catch(function(e) { status.innerHTML = '<span class="err">' + e.message + '</span>'; payBtn.disabled = false; });
|
|
950
|
+
});
|
|
951
|
+
})();
|
|
952
|
+
</script>
|
|
953
|
+
</body>
|
|
954
|
+
</html>`);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// Create payment intent for tier upgrade
|
|
958
|
+
app.post('/plugin/lucille/tier/intent', express_json_tier, async (req, res) => {
|
|
959
|
+
const { tierId, userUuid, clientPayees = [] } = req.body || {};
|
|
960
|
+
if (!tierId || !userUuid) return res.status(400).json({ error: 'tierId and userUuid required' });
|
|
961
|
+
|
|
962
|
+
const cfg = loadConfig();
|
|
963
|
+
const tier = (cfg.tiers || []).find(t => t.id === tierId);
|
|
964
|
+
if (!tier) return res.status(404).json({ error: 'Tier not found' });
|
|
965
|
+
if (tier.price === 0) return res.status(400).json({ error: 'This tier is free' });
|
|
966
|
+
if (!cfg.serverAddie || !cfg.serverAddie.uuid) {
|
|
967
|
+
return res.status(503).json({ error: 'Payment not configured — wiki owner has not set up Stripe' });
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const addieUrl = getAddieUrl();
|
|
971
|
+
const amount = tier.price; // in cents
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
// Create buyer Addie account
|
|
975
|
+
const buyerKeys = await generateAddieKeys();
|
|
976
|
+
const buyerUser = await addieCreateUser(addieUrl, buyerKeys);
|
|
977
|
+
if (buyerUser.error) return res.status(502).json({ error: 'Could not create buyer account' });
|
|
978
|
+
|
|
979
|
+
// Build payees — wiki owner receives full amount
|
|
980
|
+
let payees = [{ pubKey: cfg.serverAddie.pubKey, amount }];
|
|
981
|
+
|
|
982
|
+
// Subtract client payees (capped at 5% each) from owner's share
|
|
983
|
+
const maxPayeeAmount = Math.floor(amount * 0.05);
|
|
984
|
+
for (const p of clientPayees) {
|
|
985
|
+
if (!p.pubKey) continue;
|
|
986
|
+
const pAmount = Math.min(parseInt(p.amount) || 0, maxPayeeAmount);
|
|
987
|
+
if (pAmount <= 0) continue;
|
|
988
|
+
const ownerIdx = payees.findIndex(x => x.pubKey === cfg.serverAddie.pubKey);
|
|
989
|
+
if (ownerIdx >= 0 && payees[ownerIdx].amount - pAmount > 0) {
|
|
990
|
+
payees[ownerIdx].amount -= pAmount;
|
|
991
|
+
payees.push({ pubKey: p.pubKey, amount: pAmount });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const intentData = await addieCreatePaymentIntent(addieUrl, buyerKeys, buyerUser.uuid, amount, payees);
|
|
996
|
+
if (!intentData.clientSecret) return res.status(502).json({ error: 'Could not create payment intent' });
|
|
997
|
+
|
|
998
|
+
res.json({ clientSecret: intentData.clientSecret, publishableKey: intentData.publishableKey });
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
console.error('[lucille] tier/intent error:', err.message);
|
|
1001
|
+
res.status(500).json({ error: err.message });
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// Payment complete — trigger transfers + upgrade user tier via lucille service
|
|
1006
|
+
app.post('/plugin/lucille/tier/complete', express_json_tier, async (req, res) => {
|
|
1007
|
+
const { paymentIntentId, tierId, userUuid } = req.body || {};
|
|
1008
|
+
if (!paymentIntentId || !tierId || !userUuid) {
|
|
1009
|
+
return res.status(400).json({ error: 'paymentIntentId, tierId, and userUuid required' });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const cfg = loadConfig();
|
|
1013
|
+
const addieUrl = getAddieUrl();
|
|
1014
|
+
|
|
1015
|
+
// Fire-and-forget transfer trigger
|
|
1016
|
+
fetch(`${addieUrl}/payment/${paymentIntentId}/process-transfers`, { method: 'POST' })
|
|
1017
|
+
.catch(err => console.warn('[lucille] process-transfers error:', err.message));
|
|
1018
|
+
|
|
1019
|
+
// Upgrade user tier via lucille service (internal call)
|
|
1020
|
+
try {
|
|
1021
|
+
await fetch(`http://127.0.0.1:${LUCILLE_PORT}/user/${userUuid}/tier`, {
|
|
1022
|
+
method: 'PUT',
|
|
1023
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1024
|
+
body: JSON.stringify({ tierId, paymentIntentId })
|
|
1025
|
+
});
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
console.warn('[lucille] tier upgrade call to service failed:', err.message);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
res.json({ success: true });
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// ── Version management ──────────────────────────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
app.get('/plugin/lucille/version-status', async (req, res) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const pkgPath = path.join(LUCILLE_PATH, 'package.json');
|
|
1038
|
+
const installed = fs.existsSync(pkgPath)
|
|
1039
|
+
? JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
|
1040
|
+
: null;
|
|
1041
|
+
|
|
1042
|
+
let published = null;
|
|
1043
|
+
try {
|
|
1044
|
+
const npmRes = await fetch('https://registry.npmjs.org/lucille/latest');
|
|
1045
|
+
published = (await npmRes.json()).version;
|
|
1046
|
+
} catch {}
|
|
1047
|
+
|
|
1048
|
+
res.json({ installed, published, updateAvailable: installed && published && installed !== published });
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
res.status(500).json({ error: err.message });
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
app.post('/plugin/lucille/update', (req, res) => {
|
|
1055
|
+
const installDir = path.join(LUCILLE_PATH, '../..');
|
|
1056
|
+
exec('npm install lucille@latest', { cwd: installDir }, (err, stdout, stderr) => {
|
|
1057
|
+
if (err) return res.json({ success: false, error: stderr || err.message });
|
|
1058
|
+
const pkgPath = path.join(LUCILLE_PATH, 'package.json');
|
|
1059
|
+
const version = fs.existsSync(pkgPath)
|
|
1060
|
+
? JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
|
1061
|
+
: 'unknown';
|
|
1062
|
+
res.json({ success: true, version });
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// ── Freyja federation page ──────────────────────────────────────────────────
|
|
1067
|
+
|
|
1068
|
+
app.get('/plugin/lucille/federation', (req, res) => {
|
|
1069
|
+
res.send(generateFederationPage('lucille'));
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// ── Proxy all other lucille routes ──────────────────────────────────────────
|
|
1073
|
+
|
|
1074
|
+
app.all(/^\/plugin\/lucille\//, (req, res) => {
|
|
1075
|
+
req.url = req.url.replace('/plugin/lucille', '');
|
|
1076
|
+
proxy.web(req, res, { target: `http://127.0.0.1:${LUCILLE_PORT}`, changeOrigin: true });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
app.all('/plugin/lucille', (req, res) => {
|
|
1080
|
+
req.url = '/';
|
|
1081
|
+
proxy.web(req, res, { target: `http://127.0.0.1:${LUCILLE_PORT}`, changeOrigin: true });
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// ── Shutdown hooks ──────────────────────────────────────────────────────────
|
|
1085
|
+
|
|
1086
|
+
let isShuttingDown = false;
|
|
1087
|
+
const handleShutdown = (signal) => {
|
|
1088
|
+
if (isShuttingDown) return;
|
|
1089
|
+
isShuttingDown = true;
|
|
1090
|
+
console.log(`[wiki-plugin-lucille] ${signal} — shutting down lucille…`);
|
|
1091
|
+
shutdownLucille();
|
|
1092
|
+
setTimeout(() => console.log('[wiki-plugin-lucille] Shutdown complete'), 3000);
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
if (!process.lucilleShutdownRegistered) {
|
|
1096
|
+
process.lucilleShutdownRegistered = true;
|
|
1097
|
+
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
1098
|
+
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
1099
|
+
process.on('exit', () => { if (!isShuttingDown) shutdownLucille(); });
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
console.log('✅ wiki-plugin-lucille ready');
|
|
1103
|
+
console.log(` /plugin/lucille/* → http://127.0.0.1:${LUCILLE_PORT}/*`);
|
|
1104
|
+
console.log(` /plugin/lucille/setup → onboarding UI`);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ── Minimal JSON body parser (avoids pulling in express) ─────────────────────
|
|
1108
|
+
|
|
1109
|
+
function express_json() {
|
|
1110
|
+
return (req, res, next) => {
|
|
1111
|
+
if (req.headers['content-type'] !== 'application/json') return next();
|
|
1112
|
+
let data = '';
|
|
1113
|
+
req.on('data', chunk => { data += chunk; });
|
|
1114
|
+
req.on('end', () => {
|
|
1115
|
+
try { req.body = JSON.parse(data); } catch { req.body = {}; }
|
|
1116
|
+
next();
|
|
1117
|
+
});
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ── Setup page HTML ───────────────────────────────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
function generateSetupPage(config) {
|
|
1124
|
+
const configured = isConfigured(config);
|
|
1125
|
+
const tiersJson = JSON.stringify(config.tiers || [], null, 2);
|
|
1126
|
+
const peersJson = (config.federationPeers || []).join('\n');
|
|
1127
|
+
|
|
1128
|
+
return `<!DOCTYPE html>
|
|
1129
|
+
<html lang="en">
|
|
1130
|
+
<head>
|
|
1131
|
+
<meta charset="UTF-8">
|
|
1132
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1133
|
+
<title>Lucille Setup</title>
|
|
1134
|
+
<style>
|
|
1135
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1136
|
+
body { font-family: system-ui, sans-serif; background: #0f0f0f; color: #e0e0e0; padding: 40px 24px; }
|
|
1137
|
+
.container { max-width: 760px; margin: 0 auto; }
|
|
1138
|
+
h1 { font-size: 1.8rem; color: #a78bfa; margin-bottom: 8px; }
|
|
1139
|
+
.subtitle { color: #888; margin-bottom: 32px; font-size: 0.95rem; }
|
|
1140
|
+
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; margin-bottom: 32px; }
|
|
1141
|
+
.status-badge.ok { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
|
|
1142
|
+
.status-badge.bad { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
|
|
1143
|
+
.section { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
|
1144
|
+
.section h2 { font-size: 1rem; font-weight: 600; color: #a78bfa; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
1145
|
+
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
|
1146
|
+
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
|
1147
|
+
label { font-size: 0.82rem; color: #999; }
|
|
1148
|
+
input, textarea { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; color: #e0e0e0; padding: 8px 10px; font-size: 0.9rem; width: 100%; outline: none; transition: border-color 0.2s; }
|
|
1149
|
+
input:focus, textarea:focus { border-color: #a78bfa; }
|
|
1150
|
+
textarea { font-family: monospace; font-size: 0.82rem; resize: vertical; }
|
|
1151
|
+
.hint { font-size: 0.78rem; color: #666; margin-top: 4px; }
|
|
1152
|
+
.save-btn { background: linear-gradient(135deg, #a78bfa, #7c3aed); color: white; border: none; border-radius: 8px; padding: 12px 32px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
|
|
1153
|
+
.save-btn:hover { opacity: 0.85; }
|
|
1154
|
+
.result { margin-top: 16px; padding: 12px 16px; border-radius: 8px; font-size: 0.9rem; display: none; }
|
|
1155
|
+
.result.ok { background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
|
|
1156
|
+
.result.err { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; }
|
|
1157
|
+
</style>
|
|
1158
|
+
</head>
|
|
1159
|
+
<body>
|
|
1160
|
+
<div class="container">
|
|
1161
|
+
<h1>🎬 Lucille Setup</h1>
|
|
1162
|
+
<p class="subtitle">P2P video hosting for your federated wiki</p>
|
|
1163
|
+
<div class="status-badge ${configured ? 'ok' : 'bad'}">
|
|
1164
|
+
${configured ? '● Configured' : '○ Not configured — complete setup below'}
|
|
1165
|
+
</div>
|
|
1166
|
+
|
|
1167
|
+
<form id="setup-form">
|
|
1168
|
+
|
|
1169
|
+
<div class="section">
|
|
1170
|
+
<h2>DigitalOcean Spaces</h2>
|
|
1171
|
+
<div class="form-row">
|
|
1172
|
+
<div class="form-group">
|
|
1173
|
+
<label>Access Key</label>
|
|
1174
|
+
<input name="spacesKey" type="password" value="${configured ? '••••••••' : ''}" placeholder="DO Spaces access key">
|
|
1175
|
+
</div>
|
|
1176
|
+
<div class="form-group">
|
|
1177
|
+
<label>Secret Key</label>
|
|
1178
|
+
<input name="spacesSecret" type="password" value="${configured ? '••••••••' : ''}" placeholder="DO Spaces secret key">
|
|
1179
|
+
</div>
|
|
1180
|
+
</div>
|
|
1181
|
+
<div class="form-row">
|
|
1182
|
+
<div class="form-group">
|
|
1183
|
+
<label>Region</label>
|
|
1184
|
+
<input name="spacesRegion" value="${config.spacesRegion || 'nyc3'}" placeholder="nyc3">
|
|
1185
|
+
</div>
|
|
1186
|
+
<div class="form-group">
|
|
1187
|
+
<label>Bucket Name</label>
|
|
1188
|
+
<input name="spacesBucket" value="${config.spacesBucket || ''}" placeholder="my-wiki-videos">
|
|
1189
|
+
</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
<div class="form-group">
|
|
1192
|
+
<label>CDN Endpoint (optional)</label>
|
|
1193
|
+
<input name="spacesCdnEndpoint" value="${config.spacesCdnEndpoint || ''}" placeholder="https://my-wiki-videos.nyc3.cdn.digitaloceanspaces.com">
|
|
1194
|
+
<div class="hint">Enable CDN in your Spaces settings for faster initial delivery.</div>
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
|
|
1198
|
+
<div class="section">
|
|
1199
|
+
<h2>Tracker</h2>
|
|
1200
|
+
<div class="form-row">
|
|
1201
|
+
<div class="form-group">
|
|
1202
|
+
<label>Tracker Port</label>
|
|
1203
|
+
<input name="trackerPort" type="number" value="${config.trackerPort || 8000}" placeholder="8000">
|
|
1204
|
+
<div class="hint">Must be open in your firewall for federation to work.</div>
|
|
1205
|
+
</div>
|
|
1206
|
+
<div class="form-group">
|
|
1207
|
+
<label>Public Hostname (for federation)</label>
|
|
1208
|
+
<input name="trackerHost" value="${config.trackerHost || ''}" placeholder="mywiki.example.com">
|
|
1209
|
+
<div class="hint">How other wikis reach your tracker. Leave blank for localhost-only.</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
</div>
|
|
1213
|
+
|
|
1214
|
+
<div class="section">
|
|
1215
|
+
<h2>Tiers</h2>
|
|
1216
|
+
<p class="hint" style="margin-bottom:12px">Define storage and video limits per tier. Price is in MAGIC Points (MP). Set storageLimit/videoLimit/durationLimit to 0 for unlimited.</p>
|
|
1217
|
+
<div class="form-group">
|
|
1218
|
+
<label>Tier definitions (JSON)</label>
|
|
1219
|
+
<textarea name="tiers" rows="18">${tiersJson}</textarea>
|
|
1220
|
+
</div>
|
|
1221
|
+
</div>
|
|
1222
|
+
|
|
1223
|
+
<div class="section">
|
|
1224
|
+
<h2>Federation Peers</h2>
|
|
1225
|
+
<p class="hint" style="margin-bottom:12px">Enter the base URL of each peer lucille instance (one per line). Your tracker will be added to their announce lists and vice versa.</p>
|
|
1226
|
+
<div class="form-group">
|
|
1227
|
+
<label>Peer lucille URLs</label>
|
|
1228
|
+
<textarea name="federationPeers" rows="4" placeholder="https://otherwiki.example.com/plugin/lucille https://anotherwiki.example.com/plugin/lucille">${peersJson}</textarea>
|
|
1229
|
+
</div>
|
|
1230
|
+
</div>
|
|
1231
|
+
|
|
1232
|
+
<div class="section">
|
|
1233
|
+
<h2>Allyabase</h2>
|
|
1234
|
+
<div class="form-group">
|
|
1235
|
+
<label>Allyabase URL (for tier upgrade payments)</label>
|
|
1236
|
+
<input name="allyabaseUrl" value="${config.allyabaseUrl || (config.sanoraUrl ? config.sanoraUrl.replace(/\/plugin\/allyabase\/sanora$/, '') : '')}" placeholder="https://dev.allyabase.com">
|
|
1237
|
+
</div>
|
|
1238
|
+
</div>
|
|
1239
|
+
|
|
1240
|
+
<button type="submit" class="save-btn">Save & Start Lucille</button>
|
|
1241
|
+
<div class="result" id="result"></div>
|
|
1242
|
+
|
|
1243
|
+
</form>
|
|
1244
|
+
</div>
|
|
1245
|
+
|
|
1246
|
+
<script>
|
|
1247
|
+
document.getElementById('setup-form').addEventListener('submit', async (e) => {
|
|
1248
|
+
e.preventDefault();
|
|
1249
|
+
const btn = e.target.querySelector('.save-btn');
|
|
1250
|
+
const resultEl = document.getElementById('result');
|
|
1251
|
+
btn.textContent = 'Saving…';
|
|
1252
|
+
btn.disabled = true;
|
|
1253
|
+
|
|
1254
|
+
const data = Object.fromEntries(new FormData(e.target));
|
|
1255
|
+
|
|
1256
|
+
// Parse tiers JSON
|
|
1257
|
+
try { data.tiers = JSON.parse(data.tiers); } catch {
|
|
1258
|
+
resultEl.className = 'result err';
|
|
1259
|
+
resultEl.style.display = 'block';
|
|
1260
|
+
resultEl.textContent = 'Invalid JSON in Tiers field';
|
|
1261
|
+
btn.textContent = 'Save & Start Lucille';
|
|
1262
|
+
btn.disabled = false;
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Parse federation peers (one per line)
|
|
1267
|
+
data.federationPeers = (data.federationPeers || '').split('\\n').map(s => s.trim()).filter(Boolean);
|
|
1268
|
+
|
|
1269
|
+
try {
|
|
1270
|
+
const res = await fetch('/plugin/lucille/setup', {
|
|
1271
|
+
method: 'POST',
|
|
1272
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1273
|
+
body: JSON.stringify(data)
|
|
1274
|
+
});
|
|
1275
|
+
const result = await res.json();
|
|
1276
|
+
resultEl.className = 'result ' + (result.success ? 'ok' : 'err');
|
|
1277
|
+
resultEl.style.display = 'block';
|
|
1278
|
+
resultEl.textContent = result.message || result.error || JSON.stringify(result);
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
resultEl.className = 'result err';
|
|
1281
|
+
resultEl.style.display = 'block';
|
|
1282
|
+
resultEl.textContent = err.message;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
btn.textContent = 'Save & Start Lucille';
|
|
1286
|
+
btn.disabled = false;
|
|
1287
|
+
});
|
|
1288
|
+
</script>
|
|
1289
|
+
</body>
|
|
1290
|
+
</html>`;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
module.exports = { startServer };
|
|
1294
|
+
}).call(this);
|