linkitylink 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 +238 -0
- package/Dockerfile +20 -0
- package/Dockerfile.local +24 -0
- package/LICENSE +674 -0
- package/MANUAL-TESTING.md +399 -0
- package/README.md +119 -0
- package/TEMPLATE-FEDERATION-GO-LIVE.md +269 -0
- package/USER-TESTING-GUIDE.md +420 -0
- package/docker-compose.standalone.yml +14 -0
- package/docker-compose.yml +42 -0
- package/lib/app-handoff.js +315 -0
- package/lib/relevant-bdos-middleware.js +381 -0
- package/package.json +33 -0
- package/public/create.html +1468 -0
- package/public/index.html +117 -0
- package/public/moderate.html +465 -0
- package/public/my-tapestries.html +351 -0
- package/public/relevant-bdos.js +267 -0
- package/public/styles.css +1004 -0
- package/server.js +2914 -0
package/server.js
ADDED
|
@@ -0,0 +1,2914 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Linkitylink - Privacy-First Link Page Service
|
|
5
|
+
*
|
|
6
|
+
* Creates beautiful SVG link pages from user-provided link data
|
|
7
|
+
* Public access via emojicode - no authentication required for viewers
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. User creates link page via POST /create or web interface
|
|
11
|
+
* 2. BDO made public ā generates shareable emojicode
|
|
12
|
+
* 3. Anyone can view: linkitylink?emojicode=šššš...
|
|
13
|
+
* 4. Linkitylink renders links into beautiful SVG layouts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import express from 'express';
|
|
17
|
+
import session from 'express-session';
|
|
18
|
+
import store from 'memorystore';
|
|
19
|
+
import fountLib from 'fount-js';
|
|
20
|
+
import bdoLib from 'bdo-js';
|
|
21
|
+
import addieLib from 'addie-js';
|
|
22
|
+
import sessionless from 'sessionless-node';
|
|
23
|
+
import fetch from 'node-fetch';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
import { dirname, join } from 'path';
|
|
26
|
+
|
|
27
|
+
// Import relevantBDOs middleware
|
|
28
|
+
import {
|
|
29
|
+
relevantBDOsMiddleware,
|
|
30
|
+
getRelevantBDOs,
|
|
31
|
+
setRelevantBDOs,
|
|
32
|
+
clearRelevantBDOs,
|
|
33
|
+
toStripeMetadata,
|
|
34
|
+
logRelevantBDOs,
|
|
35
|
+
configureBdoLib,
|
|
36
|
+
fetchAndExtractPayees,
|
|
37
|
+
logPayees
|
|
38
|
+
} from './lib/relevant-bdos-middleware.js';
|
|
39
|
+
|
|
40
|
+
// Import app handoff module
|
|
41
|
+
import {
|
|
42
|
+
createPendingHandoff,
|
|
43
|
+
getPendingHandoff,
|
|
44
|
+
verifyAuthSequence,
|
|
45
|
+
associateAppCredentials,
|
|
46
|
+
completeHandoff,
|
|
47
|
+
getHandoffForApp,
|
|
48
|
+
getHandoffStats
|
|
49
|
+
} from './lib/app-handoff.js';
|
|
50
|
+
|
|
51
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
52
|
+
const __dirname = dirname(__filename);
|
|
53
|
+
|
|
54
|
+
const MemoryStore = store(session);
|
|
55
|
+
|
|
56
|
+
// Stats file path
|
|
57
|
+
const STATS_FILE = join(__dirname, 'data', 'stats.json');
|
|
58
|
+
|
|
59
|
+
const app = express();
|
|
60
|
+
const PORT = process.env.PORT || 3010;
|
|
61
|
+
|
|
62
|
+
// Configuration (defaults to dev environment)
|
|
63
|
+
const FOUNT_BASE_URL = process.env.FOUNT_BASE_URL || 'https://dev.fount.allyabase.com/';
|
|
64
|
+
const BDO_BASE_URL = process.env.BDO_BASE_URL || 'https://dev.bdo.allyabase.com';
|
|
65
|
+
const ADDIE_BASE_URL = process.env.ADDIE_BASE_URL || 'https://dev.addie.allyabase.com';
|
|
66
|
+
const ENABLE_APP_PURCHASE = process.env.ENABLE_APP_PURCHASE === 'true';
|
|
67
|
+
|
|
68
|
+
// Configure SDKs
|
|
69
|
+
fountLib.baseURL = FOUNT_BASE_URL.endsWith('/') ? FOUNT_BASE_URL : `${FOUNT_BASE_URL}/`;
|
|
70
|
+
bdoLib.baseURL = BDO_BASE_URL.endsWith('/') ? BDO_BASE_URL : `${BDO_BASE_URL}/`;
|
|
71
|
+
addieLib.baseURL = ADDIE_BASE_URL.endsWith('/') ? ADDIE_BASE_URL : `${ADDIE_BASE_URL}/`;
|
|
72
|
+
|
|
73
|
+
// Configure relevantBDOs middleware with bdo-js instance
|
|
74
|
+
configureBdoLib(bdoLib);
|
|
75
|
+
|
|
76
|
+
console.log('š Linkitylink - Privacy-First Link Pages');
|
|
77
|
+
console.log('========================================');
|
|
78
|
+
console.log(`š Port: ${PORT}`);
|
|
79
|
+
console.log(`š Fount URL: ${fountLib.baseURL}`);
|
|
80
|
+
console.log(`š BDO URL: ${bdoLib.baseURL}`);
|
|
81
|
+
console.log(`š Addie URL: ${ADDIE_BASE_URL}`);
|
|
82
|
+
console.log(`š App Purchase: ${ENABLE_APP_PURCHASE ? 'Enabled' : 'Disabled'}`);
|
|
83
|
+
console.log('š Architecture: Server returns identifiers only (clients construct URLs)');
|
|
84
|
+
|
|
85
|
+
// Configuration endpoint - allows dynamic base URL configuration
|
|
86
|
+
app.post('/config', express.json(), (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const { fountURL, bdoURL, addieURL } = req.body;
|
|
89
|
+
|
|
90
|
+
if (fountURL) {
|
|
91
|
+
fountLib.baseURL = fountURL.endsWith('/') ? fountURL : `${fountURL}/`;
|
|
92
|
+
console.log(`š§ Fount URL updated: ${fountLib.baseURL}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (bdoURL) {
|
|
96
|
+
bdoLib.baseURL = bdoURL.endsWith('/') ? bdoURL : `${bdoURL}/`;
|
|
97
|
+
configureBdoLib(bdoLib);
|
|
98
|
+
console.log(`š§ BDO URL updated: ${bdoLib.baseURL}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (addieURL) {
|
|
102
|
+
addieLib.baseURL = addieURL.endsWith('/') ? addieURL : `${addieURL}/`;
|
|
103
|
+
console.log(`š§ Addie URL updated: ${addieLib.baseURL}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
res.json({
|
|
107
|
+
success: true,
|
|
108
|
+
config: {
|
|
109
|
+
fountURL: fountLib.baseURL,
|
|
110
|
+
bdoURL: bdoLib.baseURL,
|
|
111
|
+
addieURL: addieLib.baseURL
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('ā Configuration error:', error);
|
|
116
|
+
res.status(500).json({
|
|
117
|
+
success: false,
|
|
118
|
+
error: error.message
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Get current configuration
|
|
124
|
+
app.get('/config', (req, res) => {
|
|
125
|
+
res.json({
|
|
126
|
+
fountURL: fountLib.baseURL,
|
|
127
|
+
bdoURL: bdoLib.baseURL,
|
|
128
|
+
addieURL: addieLib.baseURL
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Session middleware - gives users persistent sessions
|
|
133
|
+
app.use(session({
|
|
134
|
+
store: new MemoryStore({
|
|
135
|
+
checkPeriod: 86400000 // prune expired entries every 24h
|
|
136
|
+
}),
|
|
137
|
+
resave: false,
|
|
138
|
+
saveUninitialized: false,
|
|
139
|
+
secret: 'linkitylink-privacy-first-links-2025',
|
|
140
|
+
cookie: {
|
|
141
|
+
maxAge: 31536000000, // 1 year (basically never expire)
|
|
142
|
+
httpOnly: true,
|
|
143
|
+
secure: process.env.NODE_ENV === 'production'
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
// Middleware
|
|
148
|
+
app.use(express.static(join(__dirname, 'public')));
|
|
149
|
+
app.use(express.json());
|
|
150
|
+
app.use(relevantBDOsMiddleware); // Extract relevantBDOs from requests and store in session
|
|
151
|
+
|
|
152
|
+
// ========== STATS TRACKING ==========
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Load stats from disk
|
|
156
|
+
*/
|
|
157
|
+
async function loadStats() {
|
|
158
|
+
try {
|
|
159
|
+
const fs = await import('fs/promises');
|
|
160
|
+
|
|
161
|
+
// Ensure data directory exists
|
|
162
|
+
const dataDir = join(__dirname, 'data');
|
|
163
|
+
try {
|
|
164
|
+
await fs.access(dataDir);
|
|
165
|
+
} catch {
|
|
166
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Try to read stats file
|
|
170
|
+
try {
|
|
171
|
+
const data = await fs.readFile(STATS_FILE, 'utf-8');
|
|
172
|
+
return JSON.parse(data);
|
|
173
|
+
} catch {
|
|
174
|
+
// File doesn't exist, return default stats
|
|
175
|
+
return {
|
|
176
|
+
totalSales: 0,
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
lastUpdated: new Date().toISOString()
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error('ā Error loading stats:', error);
|
|
183
|
+
return {
|
|
184
|
+
totalSales: 0,
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
lastUpdated: new Date().toISOString()
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Save stats to disk
|
|
193
|
+
*/
|
|
194
|
+
async function saveStats(stats) {
|
|
195
|
+
try {
|
|
196
|
+
const fs = await import('fs/promises');
|
|
197
|
+
const writeFileAtomic = (await import('write-file-atomic')).default;
|
|
198
|
+
|
|
199
|
+
stats.lastUpdated = new Date().toISOString();
|
|
200
|
+
|
|
201
|
+
await writeFileAtomic(STATS_FILE, JSON.stringify(stats, null, 2));
|
|
202
|
+
console.log(`š Stats saved: ${stats.totalSales} total sales`);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error('ā Error saving stats:', error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Increment total sales counter
|
|
210
|
+
*/
|
|
211
|
+
async function incrementSales() {
|
|
212
|
+
try {
|
|
213
|
+
const stats = await loadStats();
|
|
214
|
+
stats.totalSales += 1;
|
|
215
|
+
await saveStats(stats);
|
|
216
|
+
console.log(`š Sale #${stats.totalSales} recorded!`);
|
|
217
|
+
return stats.totalSales;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error('ā Error incrementing sales:', error);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* View linkitylink by emojicode
|
|
226
|
+
* Route: /view/:emojicode
|
|
227
|
+
*/
|
|
228
|
+
app.get('/view/:emojicode', async (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
const { emojicode } = req.params;
|
|
231
|
+
|
|
232
|
+
console.log(`š Fetching Linkitylink by emojicode: ${emojicode}`);
|
|
233
|
+
|
|
234
|
+
let links = [];
|
|
235
|
+
let userName = 'My Links';
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Fetch Linkitylink BDO by emojicode
|
|
239
|
+
const linkHubBDO = await bdoLib.getBDOByEmojicode(emojicode);
|
|
240
|
+
|
|
241
|
+
console.log('š¦ Linkitylink BDO fetched:', JSON.stringify(linkHubBDO).substring(0, 200));
|
|
242
|
+
|
|
243
|
+
// Extract links from BDO data
|
|
244
|
+
const bdoData = linkHubBDO.bdo || linkHubBDO;
|
|
245
|
+
if (bdoData.links && Array.isArray(bdoData.links)) {
|
|
246
|
+
links = bdoData.links;
|
|
247
|
+
console.log(`š Found ${links.length} links in Linkitylink BDO`);
|
|
248
|
+
} else {
|
|
249
|
+
console.log('ā ļø No links array found in Linkitylink BDO');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Get user name from BDO
|
|
253
|
+
userName = bdoData.title || bdoData.name || 'My Links';
|
|
254
|
+
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error('ā Failed to fetch Linkitylink BDO by emojicode:', error.message);
|
|
257
|
+
// Continue with empty links - will show demo
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// If no links, show demo links
|
|
261
|
+
if (links.length === 0) {
|
|
262
|
+
links = getDemoLinks();
|
|
263
|
+
userName = 'Demo Links';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Limit to 20 links
|
|
267
|
+
const displayLinks = links.slice(0, 20);
|
|
268
|
+
|
|
269
|
+
// Generate HTML page
|
|
270
|
+
const html = generateLinkitylinkPage(displayLinks, userName, false, null);
|
|
271
|
+
|
|
272
|
+
res.send(html);
|
|
273
|
+
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error('ā Server error:', error);
|
|
276
|
+
res.status(500).send('Error loading linkitylink');
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Main route - Landing page or tapestry display
|
|
282
|
+
*
|
|
283
|
+
* No query params: Show landing page
|
|
284
|
+
* Query params (Method 1 - Emojicode):
|
|
285
|
+
* - emojicode: 8-emoji identifier for Linkitylink BDO
|
|
286
|
+
*
|
|
287
|
+
* Query params (Method 2 - Legacy Authentication):
|
|
288
|
+
* - pubKey: User's public key
|
|
289
|
+
* - timestamp: Request timestamp
|
|
290
|
+
* - signature: Sessionless signature (timestamp + pubKey)
|
|
291
|
+
*/
|
|
292
|
+
app.get('/', async (req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
const { emojicode, pubKey, timestamp, signature } = req.query;
|
|
295
|
+
|
|
296
|
+
// Debug logging
|
|
297
|
+
console.log('š GET / query params:', { emojicode: emojicode ? emojicode.substring(0, 20) + '...' : 'none', pubKey: pubKey ? pubKey.substring(0, 16) + '...' : 'none' });
|
|
298
|
+
|
|
299
|
+
// If no query parameters, serve landing page
|
|
300
|
+
if (!emojicode && !pubKey && !timestamp && !signature) {
|
|
301
|
+
const fs = await import('fs/promises');
|
|
302
|
+
const landingPage = await fs.readFile(join(__dirname, 'public', 'index.html'), 'utf-8');
|
|
303
|
+
return res.send(landingPage);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let links = [];
|
|
307
|
+
let userName = 'Anonymous';
|
|
308
|
+
let authenticated = false;
|
|
309
|
+
|
|
310
|
+
// Method 1: Fetch by emojicode (PUBLIC - no auth required)
|
|
311
|
+
if (emojicode) {
|
|
312
|
+
console.log(`š Fetching Linkitylink by emojicode: ${emojicode}`);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
// Fetch Linkitylink BDO by emojicode
|
|
316
|
+
const linkHubBDO = await bdoLib.getBDOByEmojicode(emojicode);
|
|
317
|
+
|
|
318
|
+
console.log('š¦ Linkitylink BDO fetched:', JSON.stringify(linkHubBDO).substring(0, 200));
|
|
319
|
+
|
|
320
|
+
// Extract links from BDO data
|
|
321
|
+
const bdoData = linkHubBDO.bdo || linkHubBDO;
|
|
322
|
+
if (bdoData.links && Array.isArray(bdoData.links)) {
|
|
323
|
+
links = bdoData.links;
|
|
324
|
+
console.log(`š Found ${links.length} links in Linkitylink BDO`);
|
|
325
|
+
} else {
|
|
326
|
+
console.log('ā ļø No links array found in Linkitylink BDO');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Get user name from BDO
|
|
330
|
+
userName = bdoData.title || bdoData.name || 'My Links';
|
|
331
|
+
authenticated = false; // Public access via emojicode
|
|
332
|
+
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error('ā Failed to fetch Linkitylink BDO by emojicode:', error.message);
|
|
335
|
+
// Continue with empty links array
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Method 2: Legacy authentication (for backward compatibility)
|
|
339
|
+
else if (pubKey && timestamp && signature) {
|
|
340
|
+
console.log(`š Authenticating request for pubKey: ${pubKey.substring(0, 16)}...`);
|
|
341
|
+
|
|
342
|
+
// Verify signature
|
|
343
|
+
const message = timestamp + pubKey;
|
|
344
|
+
const isValid = sessionless.verifySignature(signature, message, pubKey);
|
|
345
|
+
|
|
346
|
+
if (isValid) {
|
|
347
|
+
console.log('ā
Signature valid, fetching user BDO...');
|
|
348
|
+
authenticated = true;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
// Fetch user's Fount BDO (which contains carrierBag)
|
|
352
|
+
const userBDO = await fountLib.getBDO(pubKey);
|
|
353
|
+
|
|
354
|
+
console.log('š¦ User BDO fetched:', JSON.stringify(userBDO).substring(0, 200));
|
|
355
|
+
|
|
356
|
+
// Extract carrierBag from BDO
|
|
357
|
+
const bdo = userBDO.bdo || userBDO;
|
|
358
|
+
const carrierBag = bdo.carrierBag || bdo.data?.carrierBag;
|
|
359
|
+
|
|
360
|
+
if (carrierBag && carrierBag.links) {
|
|
361
|
+
links = carrierBag.links;
|
|
362
|
+
console.log(`š Found ${links.length} links in carrierBag`);
|
|
363
|
+
} else {
|
|
364
|
+
console.log('ā ļø No links collection found in carrierBag');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Try to get user name from BDO
|
|
368
|
+
userName = bdo.name || bdo.title || 'My Links';
|
|
369
|
+
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error('ā Failed to fetch user BDO:', error.message);
|
|
372
|
+
// Continue with empty links array
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
console.log('ā Invalid signature');
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
console.log('ā¹ļø No emojicode or authentication provided, showing demo');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// If no links, show demo links
|
|
382
|
+
if (links.length === 0) {
|
|
383
|
+
links = getDemoLinks();
|
|
384
|
+
userName = 'Demo Links';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Limit to 20 links
|
|
388
|
+
const displayLinks = links.slice(0, 20);
|
|
389
|
+
|
|
390
|
+
// Generate HTML page
|
|
391
|
+
const html = generateLinkitylinkPage(displayLinks, userName, authenticated, pubKey);
|
|
392
|
+
|
|
393
|
+
res.send(html);
|
|
394
|
+
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error('ā Server error:', error);
|
|
397
|
+
res.status(500).send(`
|
|
398
|
+
<!DOCTYPE html>
|
|
399
|
+
<html>
|
|
400
|
+
<head>
|
|
401
|
+
<title>Linkitylink Error</title>
|
|
402
|
+
<meta charset="UTF-8">
|
|
403
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
404
|
+
<style>
|
|
405
|
+
body {
|
|
406
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
407
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
408
|
+
color: white;
|
|
409
|
+
display: flex;
|
|
410
|
+
align-items: center;
|
|
411
|
+
justify-content: center;
|
|
412
|
+
min-height: 100vh;
|
|
413
|
+
margin: 0;
|
|
414
|
+
padding: 20px;
|
|
415
|
+
}
|
|
416
|
+
.error {
|
|
417
|
+
background: rgba(255,255,255,0.1);
|
|
418
|
+
padding: 40px;
|
|
419
|
+
border-radius: 20px;
|
|
420
|
+
text-align: center;
|
|
421
|
+
}
|
|
422
|
+
</style>
|
|
423
|
+
</head>
|
|
424
|
+
<body>
|
|
425
|
+
<div class="error">
|
|
426
|
+
<h1>ā ļø Error</h1>
|
|
427
|
+
<p>${error.message}</p>
|
|
428
|
+
</div>
|
|
429
|
+
</body>
|
|
430
|
+
</html>
|
|
431
|
+
`);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// In-memory mapping of pubKey to metadata for alphanumeric URLs
|
|
436
|
+
const bdoMetadataMap = new Map();
|
|
437
|
+
|
|
438
|
+
// Persistence tracking
|
|
439
|
+
let mappingsDirty = false;
|
|
440
|
+
let mappingsCounter = 0;
|
|
441
|
+
let lastBDOBackup = Date.now();
|
|
442
|
+
|
|
443
|
+
// File paths
|
|
444
|
+
const MAPPINGS_FILE = join(__dirname, 'alphanumeric-mappings.json');
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Load alphanumeric mappings from filesystem on startup
|
|
448
|
+
*/
|
|
449
|
+
async function loadMappings() {
|
|
450
|
+
try {
|
|
451
|
+
const fs = await import('fs/promises');
|
|
452
|
+
const data = await fs.readFile(MAPPINGS_FILE, 'utf-8');
|
|
453
|
+
const mappings = JSON.parse(data);
|
|
454
|
+
|
|
455
|
+
for (const [pubKey, metadata] of Object.entries(mappings)) {
|
|
456
|
+
bdoMetadataMap.set(pubKey, metadata);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
console.log(`š Loaded ${bdoMetadataMap.size} alphanumeric mappings from filesystem`);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
if (err.code === 'ENOENT') {
|
|
462
|
+
console.log('š No existing mappings file, starting fresh');
|
|
463
|
+
} else {
|
|
464
|
+
console.error('ā Error loading mappings:', err.message);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Save alphanumeric mappings to filesystem (batched)
|
|
471
|
+
*/
|
|
472
|
+
async function saveMappingsToFilesystem() {
|
|
473
|
+
try {
|
|
474
|
+
const fs = await import('fs/promises');
|
|
475
|
+
const mappings = Object.fromEntries(bdoMetadataMap);
|
|
476
|
+
await fs.writeFile(MAPPINGS_FILE, JSON.stringify(mappings, null, 2), 'utf-8');
|
|
477
|
+
|
|
478
|
+
mappingsDirty = false;
|
|
479
|
+
console.log(`š¾ Saved ${bdoMetadataMap.size} alphanumeric mappings to filesystem`);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
console.error('ā Error saving mappings to filesystem:', err.message);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Backup alphanumeric mappings to BDO service (hourly)
|
|
487
|
+
*/
|
|
488
|
+
async function backupMappingsToBDO() {
|
|
489
|
+
try {
|
|
490
|
+
console.log('āļø Backing up alphanumeric mappings to BDO service...');
|
|
491
|
+
|
|
492
|
+
const mappings = Object.fromEntries(bdoMetadataMap);
|
|
493
|
+
|
|
494
|
+
// Generate temporary keys for backup BDO
|
|
495
|
+
const saveKeys = (keys) => { backupKeys = keys; };
|
|
496
|
+
const getKeys = () => backupKeys;
|
|
497
|
+
let backupKeys = null;
|
|
498
|
+
|
|
499
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
500
|
+
|
|
501
|
+
// Create backup BDO
|
|
502
|
+
const backupBDO = {
|
|
503
|
+
title: 'Linkitylink Alphanumeric Mappings Backup',
|
|
504
|
+
type: 'linkitylink-backup',
|
|
505
|
+
mappings: mappings,
|
|
506
|
+
mappingCount: bdoMetadataMap.size,
|
|
507
|
+
backedUpAt: new Date().toISOString()
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const hash = 'Linkitylink-System';
|
|
511
|
+
await bdoLib.createUser(hash, backupBDO, saveKeys, getKeys);
|
|
512
|
+
|
|
513
|
+
lastBDOBackup = Date.now();
|
|
514
|
+
console.log(`āļø Backed up ${bdoMetadataMap.size} mappings to BDO service`);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.error('ā Error backing up to BDO:', err.message);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Mark mappings as dirty and potentially trigger save
|
|
522
|
+
*/
|
|
523
|
+
function markMappingsDirty() {
|
|
524
|
+
mappingsDirty = true;
|
|
525
|
+
mappingsCounter++;
|
|
526
|
+
|
|
527
|
+
// Batch save every 10 creates
|
|
528
|
+
if (mappingsCounter >= 10) {
|
|
529
|
+
mappingsCounter = 0;
|
|
530
|
+
saveMappingsToFilesystem();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Periodic save timer (every 10 minutes if dirty)
|
|
535
|
+
setInterval(() => {
|
|
536
|
+
if (mappingsDirty) {
|
|
537
|
+
console.log('ā° 10-minute timer: Saving dirty mappings...');
|
|
538
|
+
saveMappingsToFilesystem();
|
|
539
|
+
}
|
|
540
|
+
}, 10 * 60 * 1000); // 10 minutes
|
|
541
|
+
|
|
542
|
+
// Hourly BDO backup timer
|
|
543
|
+
setInterval(() => {
|
|
544
|
+
const hoursSinceBackup = (Date.now() - lastBDOBackup) / (1000 * 60 * 60);
|
|
545
|
+
if (hoursSinceBackup >= 1 && bdoMetadataMap.size > 0) {
|
|
546
|
+
console.log('ā° 1-hour timer: Backing up to BDO...');
|
|
547
|
+
backupMappingsToBDO();
|
|
548
|
+
}
|
|
549
|
+
}, 60 * 60 * 1000); // 1 hour
|
|
550
|
+
|
|
551
|
+
// Load mappings on startup
|
|
552
|
+
loadMappings();
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Alphanumeric path route - /t/:identifier
|
|
556
|
+
* Provides shareable alphanumeric URLs using pubKey (first 16 chars)
|
|
557
|
+
*/
|
|
558
|
+
app.get('/t/:identifier', async (req, res) => {
|
|
559
|
+
try {
|
|
560
|
+
const { identifier } = req.params;
|
|
561
|
+
|
|
562
|
+
console.log(`š Fetching Linkitylink by identifier: ${identifier}`);
|
|
563
|
+
|
|
564
|
+
// Look up full pubKey from identifier
|
|
565
|
+
let pubKey = null;
|
|
566
|
+
for (const [key, metadata] of bdoMetadataMap.entries()) {
|
|
567
|
+
if (key.startsWith(identifier)) {
|
|
568
|
+
pubKey = key;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!pubKey) {
|
|
574
|
+
throw new Error('Tapestry not found. Identifier may have expired.');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Get emojicode from metadata
|
|
578
|
+
const metadata = bdoMetadataMap.get(pubKey);
|
|
579
|
+
const emojicode = metadata.emojicode;
|
|
580
|
+
|
|
581
|
+
console.log(`š Found emojicode: ${emojicode}`);
|
|
582
|
+
|
|
583
|
+
let links = [];
|
|
584
|
+
let userName = 'Anonymous';
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
// Fetch BDO by emojicode (same as emojicode route)
|
|
588
|
+
const linkHubBDO = await bdoLib.getBDOByEmojicode(emojicode);
|
|
589
|
+
|
|
590
|
+
console.log('š¦ Linkitylink BDO fetched:', JSON.stringify(linkHubBDO).substring(0, 200));
|
|
591
|
+
|
|
592
|
+
// Extract links from BDO data
|
|
593
|
+
const bdoData = linkHubBDO.bdo || linkHubBDO;
|
|
594
|
+
|
|
595
|
+
if (bdoData.links && Array.isArray(bdoData.links)) {
|
|
596
|
+
links = bdoData.links;
|
|
597
|
+
console.log(`š Found ${links.length} links in Linkitylink BDO`);
|
|
598
|
+
} else {
|
|
599
|
+
console.log('ā ļø No links array found in Linkitylink BDO');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Get user name from BDO
|
|
603
|
+
userName = bdoData.title || bdoData.name || 'My Links';
|
|
604
|
+
|
|
605
|
+
} catch (error) {
|
|
606
|
+
console.error('ā Failed to fetch Linkitylink BDO:', error.message);
|
|
607
|
+
// Continue with empty links array
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// If no links, show demo links
|
|
611
|
+
if (links.length === 0) {
|
|
612
|
+
links = getDemoLinks();
|
|
613
|
+
userName = 'Demo Links';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Limit to 20 links
|
|
617
|
+
const displayLinks = links.slice(0, 20);
|
|
618
|
+
|
|
619
|
+
// Generate HTML page
|
|
620
|
+
const html = generateLinkitylinkPage(displayLinks, userName, false, null);
|
|
621
|
+
|
|
622
|
+
res.send(html);
|
|
623
|
+
|
|
624
|
+
} catch (error) {
|
|
625
|
+
console.error('ā Server error:', error);
|
|
626
|
+
res.status(500).send(`
|
|
627
|
+
<!DOCTYPE html>
|
|
628
|
+
<html>
|
|
629
|
+
<head>
|
|
630
|
+
<title>Linkitylink Error</title>
|
|
631
|
+
<meta charset="UTF-8">
|
|
632
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
633
|
+
<style>
|
|
634
|
+
body {
|
|
635
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
636
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
637
|
+
color: white;
|
|
638
|
+
display: flex;
|
|
639
|
+
align-items: center;
|
|
640
|
+
justify-content: center;
|
|
641
|
+
min-height: 100vh;
|
|
642
|
+
margin: 0;
|
|
643
|
+
padding: 20px;
|
|
644
|
+
}
|
|
645
|
+
.error {
|
|
646
|
+
background: rgba(255,255,255,0.1);
|
|
647
|
+
padding: 40px;
|
|
648
|
+
border-radius: 20px;
|
|
649
|
+
text-align: center;
|
|
650
|
+
}
|
|
651
|
+
</style>
|
|
652
|
+
</head>
|
|
653
|
+
<body>
|
|
654
|
+
<div class="error">
|
|
655
|
+
<h1>ā ļø Error</h1>
|
|
656
|
+
<p>${error.message}</p>
|
|
657
|
+
</div>
|
|
658
|
+
</body>
|
|
659
|
+
</html>
|
|
660
|
+
`);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Generate the main Linkitylink HTML page
|
|
666
|
+
*/
|
|
667
|
+
function generateLinkitylinkPage(links, userName, authenticated, pubKey) {
|
|
668
|
+
const linkCount = links.length;
|
|
669
|
+
const svgTemplate = chooseSVGTemplate(linkCount);
|
|
670
|
+
|
|
671
|
+
return `<!DOCTYPE html>
|
|
672
|
+
<html lang="en">
|
|
673
|
+
<head>
|
|
674
|
+
<meta charset="UTF-8">
|
|
675
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
676
|
+
<title>${userName} - Linkitylink</title>
|
|
677
|
+
<style>
|
|
678
|
+
* {
|
|
679
|
+
margin: 0;
|
|
680
|
+
padding: 0;
|
|
681
|
+
box-sizing: border-box;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
body {
|
|
685
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
686
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
687
|
+
min-height: 100vh;
|
|
688
|
+
padding: 40px 20px;
|
|
689
|
+
display: flex;
|
|
690
|
+
flex-direction: column;
|
|
691
|
+
align-items: center;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.header {
|
|
695
|
+
text-align: center;
|
|
696
|
+
margin-bottom: 40px;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.header h1 {
|
|
700
|
+
color: white;
|
|
701
|
+
font-size: 2.5rem;
|
|
702
|
+
margin-bottom: 10px;
|
|
703
|
+
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.header .badge {
|
|
707
|
+
display: inline-block;
|
|
708
|
+
background: rgba(255,255,255,0.2);
|
|
709
|
+
color: white;
|
|
710
|
+
padding: 5px 15px;
|
|
711
|
+
border-radius: 20px;
|
|
712
|
+
font-size: 0.9rem;
|
|
713
|
+
margin-top: 10px;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.links-container {
|
|
717
|
+
max-width: 600px;
|
|
718
|
+
width: 100%;
|
|
719
|
+
margin-bottom: 40px;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.link-card {
|
|
723
|
+
background: white;
|
|
724
|
+
border-radius: 12px;
|
|
725
|
+
padding: 20px;
|
|
726
|
+
margin-bottom: 15px;
|
|
727
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
728
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
729
|
+
cursor: pointer;
|
|
730
|
+
text-decoration: none;
|
|
731
|
+
display: block;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.link-card:hover {
|
|
735
|
+
transform: translateY(-2px);
|
|
736
|
+
box-shadow: 0 6px 30px rgba(0,0,0,0.15);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.link-card .title {
|
|
740
|
+
font-size: 1.2rem;
|
|
741
|
+
font-weight: 600;
|
|
742
|
+
color: #333;
|
|
743
|
+
margin-bottom: 5px;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
.link-card .url {
|
|
747
|
+
font-size: 0.9rem;
|
|
748
|
+
color: #666;
|
|
749
|
+
word-break: break-all;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.svg-container {
|
|
753
|
+
max-width: 800px;
|
|
754
|
+
width: 100%;
|
|
755
|
+
margin-bottom: 40px;
|
|
756
|
+
background: white;
|
|
757
|
+
border-radius: 20px;
|
|
758
|
+
padding: 30px;
|
|
759
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.cta-container {
|
|
763
|
+
text-align: center;
|
|
764
|
+
background: rgba(255,255,255,0.1);
|
|
765
|
+
border-radius: 20px;
|
|
766
|
+
padding: 40px;
|
|
767
|
+
max-width: 600px;
|
|
768
|
+
width: 100%;
|
|
769
|
+
backdrop-filter: blur(10px);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.cta-container h2 {
|
|
773
|
+
color: white;
|
|
774
|
+
margin-bottom: 20px;
|
|
775
|
+
font-size: 1.8rem;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.cta-container p {
|
|
779
|
+
color: rgba(255,255,255,0.9);
|
|
780
|
+
margin-bottom: 25px;
|
|
781
|
+
font-size: 1.1rem;
|
|
782
|
+
line-height: 1.6;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.cta-button {
|
|
786
|
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
787
|
+
color: white;
|
|
788
|
+
padding: 15px 40px;
|
|
789
|
+
border-radius: 30px;
|
|
790
|
+
text-decoration: none;
|
|
791
|
+
font-weight: 600;
|
|
792
|
+
font-size: 1.1rem;
|
|
793
|
+
display: inline-block;
|
|
794
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
795
|
+
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.3);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.cta-button:hover {
|
|
799
|
+
transform: translateY(-2px);
|
|
800
|
+
box-shadow: 0 6px 30px rgba(16, 185, 129, 0.4);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
.footer {
|
|
804
|
+
margin-top: 40px;
|
|
805
|
+
text-align: center;
|
|
806
|
+
color: rgba(255,255,255,0.7);
|
|
807
|
+
font-size: 0.9rem;
|
|
808
|
+
}
|
|
809
|
+
</style>
|
|
810
|
+
</head>
|
|
811
|
+
<body>
|
|
812
|
+
<div class="header">
|
|
813
|
+
<h1>${userName}</h1>
|
|
814
|
+
${authenticated ? '<div class="badge">š Authenticated</div>' : '<div class="badge">šļø Demo Mode</div>'}
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
<div class="svg-container">
|
|
818
|
+
${svgTemplate(links)}
|
|
819
|
+
</div>
|
|
820
|
+
|
|
821
|
+
<div class="cta-container">
|
|
822
|
+
<h2>⨠Weave Your Own Linkitylink</h2>
|
|
823
|
+
<p>Cast the Linkitylink enchantment to create your mystical link tapestry. Visit The Enchantment Emporium in The Advancement app.</p>
|
|
824
|
+
<a href="#purchase" class="cta-button" onclick="handlePurchase()">
|
|
825
|
+
Visit The Enchantment Emporium
|
|
826
|
+
</a>
|
|
827
|
+
<p style="font-size: 0.9rem; margin-top: 20px; opacity: 0.8;">
|
|
828
|
+
⨠Privacy-first ⢠š Cryptographically secure ⢠šØ Mystically beautiful
|
|
829
|
+
</p>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<div class="footer">
|
|
833
|
+
<p>Woven by <strong>Planet Nine</strong></p>
|
|
834
|
+
<p style="margin-top: 5px;">The Enchantment Emporium ⢠Linkitylink Tapestries</p>
|
|
835
|
+
</div>
|
|
836
|
+
|
|
837
|
+
<script>
|
|
838
|
+
function handlePurchase() {
|
|
839
|
+
// TODO: Implement Enchantment Emporium integration
|
|
840
|
+
alert('Visit The Enchantment Emporium in The Advancement app to cast the Linkitylink enchantment!');
|
|
841
|
+
console.log('Redirecting to Enchantment Emporium');
|
|
842
|
+
|
|
843
|
+
// Future implementation:
|
|
844
|
+
// 1. Deep link to The Advancement app
|
|
845
|
+
// 2. Open Enchantment Emporium
|
|
846
|
+
// 3. Show Linkitylink enchantment
|
|
847
|
+
// 4. Guide user through enchantment casting
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Make links clickable
|
|
851
|
+
document.querySelectorAll('.link-card').forEach(card => {
|
|
852
|
+
card.addEventListener('click', function(e) {
|
|
853
|
+
const url = this.dataset.url;
|
|
854
|
+
if (url) {
|
|
855
|
+
window.open(url, '_blank');
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
</script>
|
|
860
|
+
</body>
|
|
861
|
+
</html>`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Get social media icon path for SVG
|
|
866
|
+
*/
|
|
867
|
+
function getSocialIcon(type) {
|
|
868
|
+
const iconType = type.toUpperCase();
|
|
869
|
+
const icons = {
|
|
870
|
+
INSTAGRAM: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z',
|
|
871
|
+
TIKTOK: 'M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-5.2 1.74 2.89 2.89 0 012.31-4.64 2.93 2.93 0 01.88.13V9.4a6.84 6.84 0 00-1-.05A6.33 6.33 0 005 20.1a6.34 6.34 0 0010.86-4.43v-7a8.16 8.16 0 004.77 1.52v-3.4a4.85 4.85 0 01-1-.1z',
|
|
872
|
+
YOUTUBE: 'M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z',
|
|
873
|
+
TWITTER: 'M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z',
|
|
874
|
+
FACEBOOK: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z',
|
|
875
|
+
LINKEDIN: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z',
|
|
876
|
+
GITHUB: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'
|
|
877
|
+
};
|
|
878
|
+
return icons[iconType] || icons.INSTAGRAM; // Default to Instagram if unknown
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Generate SoMa (Social Media) section with icons
|
|
883
|
+
*/
|
|
884
|
+
function generateSoMaSection(socialLinks, yPosition) {
|
|
885
|
+
if (!socialLinks || socialLinks.length === 0) return '';
|
|
886
|
+
|
|
887
|
+
const iconSize = 32;
|
|
888
|
+
const iconSpacing = 50;
|
|
889
|
+
const startX = 350 - ((socialLinks.length * iconSpacing) / 2);
|
|
890
|
+
|
|
891
|
+
const socialIcons = socialLinks.map((link, index) => {
|
|
892
|
+
const x = startX + (index * iconSpacing);
|
|
893
|
+
const iconPath = getSocialIcon(link.title);
|
|
894
|
+
const url = escapeXML(link.url || '#');
|
|
895
|
+
|
|
896
|
+
return `
|
|
897
|
+
<a href="${url}" target="_blank">
|
|
898
|
+
<g transform="translate(${x}, ${yPosition})">
|
|
899
|
+
<circle cx="16" cy="16" r="18" fill="rgba(167, 139, 250, 0.1)"
|
|
900
|
+
stroke="#a78bfa" stroke-width="1" opacity="0.6"/>
|
|
901
|
+
<path d="${iconPath}" fill="#a78bfa" opacity="0.8"
|
|
902
|
+
transform="scale(0.65) translate(4, 4)"
|
|
903
|
+
style="filter: drop-shadow(0 0 4px #a78bfa);"/>
|
|
904
|
+
</g>
|
|
905
|
+
</a>`;
|
|
906
|
+
}).join('\n');
|
|
907
|
+
|
|
908
|
+
return `
|
|
909
|
+
<text x="350" y="${yPosition - 15}" fill="#a78bfa" font-size="16" font-weight="bold"
|
|
910
|
+
text-anchor="middle" opacity="0.7"
|
|
911
|
+
style="filter: drop-shadow(0 0 6px #a78bfa);">
|
|
912
|
+
SoMa:
|
|
913
|
+
</text>
|
|
914
|
+
${socialIcons}`;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Choose SVG template based on link count (regular links only)
|
|
919
|
+
*/
|
|
920
|
+
function chooseSVGTemplate(linkCount) {
|
|
921
|
+
if (linkCount <= 6) {
|
|
922
|
+
return generateCompactSVG;
|
|
923
|
+
} else if (linkCount <= 13) {
|
|
924
|
+
return generateGridSVG;
|
|
925
|
+
} else {
|
|
926
|
+
return generateDenseSVG;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Template 1: Compact layout (1-6 links)
|
|
932
|
+
* Large cards, vertical stack - DARK MODE WITH GLOW
|
|
933
|
+
*/
|
|
934
|
+
function generateCompactSVG(links) {
|
|
935
|
+
// Separate regular links from social links
|
|
936
|
+
const regularLinks = links.filter(link => !link.isSocial);
|
|
937
|
+
const socialLinks = links.filter(link => link.isSocial);
|
|
938
|
+
|
|
939
|
+
const baseLinkHeight = regularLinks.length * 110 + 60;
|
|
940
|
+
const somaHeight = socialLinks.length > 0 ? 100 : 0;
|
|
941
|
+
const height = Math.max(400, baseLinkHeight + somaHeight);
|
|
942
|
+
|
|
943
|
+
const linkElements = regularLinks.map((link, index) => {
|
|
944
|
+
const y = 60 + (index * 110);
|
|
945
|
+
const title = escapeXML(link.title || 'Untitled');
|
|
946
|
+
const url = escapeXML(link.url || '#');
|
|
947
|
+
const truncatedTitle = title.length > 30 ? title.substring(0, 30) + '...' : title;
|
|
948
|
+
|
|
949
|
+
// Magical glowing gradients
|
|
950
|
+
const gradients = [
|
|
951
|
+
['#10b981', '#059669'], // Emerald glow
|
|
952
|
+
['#3b82f6', '#2563eb'], // Sapphire glow
|
|
953
|
+
['#8b5cf6', '#7c3aed'], // Amethyst glow
|
|
954
|
+
['#ec4899', '#db2777'], // Ruby glow
|
|
955
|
+
['#fbbf24', '#f59e0b'], // Topaz glow
|
|
956
|
+
['#06b6d4', '#0891b2'] // Aquamarine glow
|
|
957
|
+
];
|
|
958
|
+
const gradient = gradients[index % gradients.length];
|
|
959
|
+
const gradId = `grad${index}`;
|
|
960
|
+
const glowId = `glow${index}`;
|
|
961
|
+
|
|
962
|
+
return `
|
|
963
|
+
<defs>
|
|
964
|
+
<linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
965
|
+
<stop offset="0%" style="stop-color:${gradient[0]};stop-opacity:1" />
|
|
966
|
+
<stop offset="100%" style="stop-color:${gradient[1]};stop-opacity:1" />
|
|
967
|
+
</linearGradient>
|
|
968
|
+
<filter id="${glowId}" x="-50%" y="-50%" width="200%" height="200%">
|
|
969
|
+
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
|
970
|
+
<feMerge>
|
|
971
|
+
<feMergeNode in="coloredBlur"/>
|
|
972
|
+
<feMergeNode in="SourceGraphic"/>
|
|
973
|
+
</feMerge>
|
|
974
|
+
</filter>
|
|
975
|
+
</defs>
|
|
976
|
+
|
|
977
|
+
<a href="${url}" target="_blank">
|
|
978
|
+
<g filter="url(#${glowId})">
|
|
979
|
+
<rect x="50" y="${y}" width="600" height="90" rx="15"
|
|
980
|
+
fill="url(#${gradId})" opacity="0.15"/>
|
|
981
|
+
<rect x="50" y="${y}" width="600" height="90" rx="15"
|
|
982
|
+
fill="none" stroke="url(#${gradId})" stroke-width="2" opacity="0.8"/>
|
|
983
|
+
</g>
|
|
984
|
+
<text x="90" y="${y + 40}" fill="${gradient[0]}" font-size="20" font-weight="bold"
|
|
985
|
+
style="filter: drop-shadow(0 0 8px ${gradient[0]});">${truncatedTitle}</text>
|
|
986
|
+
<text x="90" y="${y + 65}" fill="rgba(167, 139, 250, 0.7)" font-size="14">⨠Tap to open</text>
|
|
987
|
+
<text x="600" y="${y + 50}" fill="${gradient[0]}" font-size="30"
|
|
988
|
+
style="filter: drop-shadow(0 0 6px ${gradient[0]});">ā</text>
|
|
989
|
+
</a>`;
|
|
990
|
+
}).join('\n');
|
|
991
|
+
|
|
992
|
+
return `
|
|
993
|
+
<svg width="700" height="${height}" viewBox="0 0 700 ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
994
|
+
<defs>
|
|
995
|
+
<radialGradient id="bgGrad" cx="50%" cy="50%">
|
|
996
|
+
<stop offset="0%" style="stop-color:#1a0033;stop-opacity:1" />
|
|
997
|
+
<stop offset="100%" style="stop-color:#0a001a;stop-opacity:1" />
|
|
998
|
+
</radialGradient>
|
|
999
|
+
</defs>
|
|
1000
|
+
|
|
1001
|
+
<rect width="700" height="${height}" fill="url(#bgGrad)"/>
|
|
1002
|
+
|
|
1003
|
+
<!-- Magical particles -->
|
|
1004
|
+
<circle cx="100" cy="20" r="2" fill="#fbbf24" opacity="0.6">
|
|
1005
|
+
<animate attributeName="opacity" values="0.3;0.8;0.3" dur="3s" repeatCount="indefinite"/>
|
|
1006
|
+
</circle>
|
|
1007
|
+
<circle cx="600" cy="30" r="1.5" fill="#a78bfa" opacity="0.5">
|
|
1008
|
+
<animate attributeName="opacity" values="0.2;0.7;0.2" dur="4s" repeatCount="indefinite"/>
|
|
1009
|
+
</circle>
|
|
1010
|
+
<circle cx="350" cy="15" r="1" fill="#10b981" opacity="0.4">
|
|
1011
|
+
<animate attributeName="opacity" values="0.2;0.6;0.2" dur="5s" repeatCount="indefinite"/>
|
|
1012
|
+
</circle>
|
|
1013
|
+
|
|
1014
|
+
<text x="350" y="35" fill="#fbbf24" font-size="24" font-weight="bold" text-anchor="middle"
|
|
1015
|
+
style="filter: drop-shadow(0 0 10px #fbbf24);">
|
|
1016
|
+
⨠My Links āØ
|
|
1017
|
+
</text>
|
|
1018
|
+
|
|
1019
|
+
${linkElements}
|
|
1020
|
+
|
|
1021
|
+
<!-- Social Media Section (SoMa) -->
|
|
1022
|
+
${socialLinks.length > 0 ? generateSoMaSection(socialLinks, baseLinkHeight + 50) : ''}
|
|
1023
|
+
</svg>`;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Template 2: Grid layout (7-13 links)
|
|
1028
|
+
* 2-column grid with medium cards - DARK MODE WITH GLOW
|
|
1029
|
+
*/
|
|
1030
|
+
function generateGridSVG(links) {
|
|
1031
|
+
// Separate regular links from social links
|
|
1032
|
+
const regularLinks = links.filter(link => !link.isSocial);
|
|
1033
|
+
const socialLinks = links.filter(link => link.isSocial);
|
|
1034
|
+
|
|
1035
|
+
const rows = Math.ceil(regularLinks.length / 2);
|
|
1036
|
+
const baseLinkHeight = rows * 100 + 100;
|
|
1037
|
+
const somaHeight = socialLinks.length > 0 ? 100 : 0;
|
|
1038
|
+
const height = Math.max(400, baseLinkHeight + somaHeight);
|
|
1039
|
+
|
|
1040
|
+
const linkElements = regularLinks.map((link, index) => {
|
|
1041
|
+
const col = index % 2;
|
|
1042
|
+
const row = Math.floor(index / 2);
|
|
1043
|
+
const x = col === 0 ? 40 : 370;
|
|
1044
|
+
const y = 80 + (row * 100);
|
|
1045
|
+
|
|
1046
|
+
const title = escapeXML(link.title || 'Untitled');
|
|
1047
|
+
const url = escapeXML(link.url || '#');
|
|
1048
|
+
const truncatedTitle = title.length > 15 ? title.substring(0, 15) + '...' : title;
|
|
1049
|
+
|
|
1050
|
+
const gradients = [
|
|
1051
|
+
['#10b981', '#059669'], // Emerald
|
|
1052
|
+
['#3b82f6', '#2563eb'], // Sapphire
|
|
1053
|
+
['#8b5cf6', '#7c3aed'], // Amethyst
|
|
1054
|
+
['#ec4899', '#db2777'], // Ruby
|
|
1055
|
+
['#fbbf24', '#f59e0b'], // Topaz
|
|
1056
|
+
['#06b6d4', '#0891b2'] // Aquamarine
|
|
1057
|
+
];
|
|
1058
|
+
const gradient = gradients[index % gradients.length];
|
|
1059
|
+
const gradId = `grad${index}`;
|
|
1060
|
+
const glowId = `glow${index}`;
|
|
1061
|
+
|
|
1062
|
+
return `
|
|
1063
|
+
<defs>
|
|
1064
|
+
<linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
1065
|
+
<stop offset="0%" style="stop-color:${gradient[0]};stop-opacity:1" />
|
|
1066
|
+
<stop offset="100%" style="stop-color:${gradient[1]};stop-opacity:1" />
|
|
1067
|
+
</linearGradient>
|
|
1068
|
+
<filter id="${glowId}" x="-50%" y="-50%" width="200%" height="200%">
|
|
1069
|
+
<feGaussianBlur stdDeviation="6" result="coloredBlur"/>
|
|
1070
|
+
<feMerge>
|
|
1071
|
+
<feMergeNode in="coloredBlur"/>
|
|
1072
|
+
<feMergeNode in="SourceGraphic"/>
|
|
1073
|
+
</feMerge>
|
|
1074
|
+
</filter>
|
|
1075
|
+
</defs>
|
|
1076
|
+
|
|
1077
|
+
<a href="${url}" target="_blank">
|
|
1078
|
+
<g filter="url(#${glowId})">
|
|
1079
|
+
<rect x="${x}" y="${y}" width="290" height="80" rx="12"
|
|
1080
|
+
fill="url(#${gradId})" opacity="0.15"/>
|
|
1081
|
+
<rect x="${x}" y="${y}" width="290" height="80" rx="12"
|
|
1082
|
+
fill="none" stroke="url(#${gradId})" stroke-width="2" opacity="0.8"/>
|
|
1083
|
+
</g>
|
|
1084
|
+
<text x="${x + 20}" y="${y + 35}" fill="${gradient[0]}" font-size="16" font-weight="bold"
|
|
1085
|
+
style="filter: drop-shadow(0 0 6px ${gradient[0]});">${truncatedTitle}</text>
|
|
1086
|
+
<text x="${x + 20}" y="${y + 55}" fill="rgba(167, 139, 250, 0.7)" font-size="12">⨠Click</text>
|
|
1087
|
+
</a>`;
|
|
1088
|
+
}).join('\n');
|
|
1089
|
+
|
|
1090
|
+
return `
|
|
1091
|
+
<svg width="700" height="${height}" viewBox="0 0 700 ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
1092
|
+
<defs>
|
|
1093
|
+
<radialGradient id="bgGrad" cx="50%" cy="50%">
|
|
1094
|
+
<stop offset="0%" style="stop-color:#1a0033;stop-opacity:1" />
|
|
1095
|
+
<stop offset="100%" style="stop-color:#0a001a;stop-opacity:1" />
|
|
1096
|
+
</radialGradient>
|
|
1097
|
+
</defs>
|
|
1098
|
+
|
|
1099
|
+
<rect width="700" height="${height}" fill="url(#bgGrad)"/>
|
|
1100
|
+
|
|
1101
|
+
<!-- Magical particles -->
|
|
1102
|
+
<circle cx="120" cy="25" r="2" fill="#fbbf24" opacity="0.6">
|
|
1103
|
+
<animate attributeName="opacity" values="0.3;0.8;0.3" dur="3s" repeatCount="indefinite"/>
|
|
1104
|
+
</circle>
|
|
1105
|
+
<circle cx="580" cy="35" r="1.5" fill="#a78bfa" opacity="0.5">
|
|
1106
|
+
<animate attributeName="opacity" values="0.2;0.7;0.2" dur="4s" repeatCount="indefinite"/>
|
|
1107
|
+
</circle>
|
|
1108
|
+
<circle cx="350" cy="20" r="1" fill="#10b981" opacity="0.4">
|
|
1109
|
+
<animate attributeName="opacity" values="0.2;0.6;0.2" dur="5s" repeatCount="indefinite"/>
|
|
1110
|
+
</circle>
|
|
1111
|
+
<circle cx="200" cy="30" r="1.5" fill="#ec4899" opacity="0.5">
|
|
1112
|
+
<animate attributeName="opacity" values="0.3;0.7;0.3" dur="3.5s" repeatCount="indefinite"/>
|
|
1113
|
+
</circle>
|
|
1114
|
+
|
|
1115
|
+
<text x="350" y="40" fill="#fbbf24" font-size="24" font-weight="bold" text-anchor="middle"
|
|
1116
|
+
style="filter: drop-shadow(0 0 10px #fbbf24);">
|
|
1117
|
+
⨠My Links āØ
|
|
1118
|
+
</text>
|
|
1119
|
+
|
|
1120
|
+
${linkElements}
|
|
1121
|
+
|
|
1122
|
+
<!-- Social Media Section (SoMa) -->
|
|
1123
|
+
${socialLinks.length > 0 ? generateSoMaSection(socialLinks, baseLinkHeight + 20) : ''}
|
|
1124
|
+
</svg>`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Template 3: Dense layout (14-20 links)
|
|
1129
|
+
* 3-column grid with compact cards - DARK MODE WITH GLOW
|
|
1130
|
+
*/
|
|
1131
|
+
function generateDenseSVG(links) {
|
|
1132
|
+
// Separate regular links from social links
|
|
1133
|
+
const regularLinks = links.filter(link => !link.isSocial);
|
|
1134
|
+
const socialLinks = links.filter(link => link.isSocial);
|
|
1135
|
+
|
|
1136
|
+
const rows = Math.ceil(regularLinks.length / 3);
|
|
1137
|
+
const baseLinkHeight = rows * 80 + 100;
|
|
1138
|
+
const somaHeight = socialLinks.length > 0 ? 100 : 0;
|
|
1139
|
+
const height = Math.max(400, baseLinkHeight + somaHeight);
|
|
1140
|
+
|
|
1141
|
+
const linkElements = regularLinks.map((link, index) => {
|
|
1142
|
+
const col = index % 3;
|
|
1143
|
+
const row = Math.floor(index / 3);
|
|
1144
|
+
const x = 30 + (col * 220);
|
|
1145
|
+
const y = 80 + (row * 80);
|
|
1146
|
+
|
|
1147
|
+
const title = escapeXML(link.title || 'Untitled');
|
|
1148
|
+
const url = escapeXML(link.url || '#');
|
|
1149
|
+
const truncatedTitle = title.length > 12 ? title.substring(0, 12) + '...' : title;
|
|
1150
|
+
|
|
1151
|
+
const gradients = [
|
|
1152
|
+
['#10b981', '#059669'], // Emerald
|
|
1153
|
+
['#3b82f6', '#2563eb'], // Sapphire
|
|
1154
|
+
['#8b5cf6', '#7c3aed'], // Amethyst
|
|
1155
|
+
['#ec4899', '#db2777'], // Ruby
|
|
1156
|
+
['#fbbf24', '#f59e0b'], // Topaz
|
|
1157
|
+
['#06b6d4', '#0891b2'] // Aquamarine
|
|
1158
|
+
];
|
|
1159
|
+
const gradient = gradients[index % gradients.length];
|
|
1160
|
+
const gradId = `grad${index}`;
|
|
1161
|
+
const glowId = `glow${index}`;
|
|
1162
|
+
|
|
1163
|
+
return `
|
|
1164
|
+
<defs>
|
|
1165
|
+
<linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
1166
|
+
<stop offset="0%" style="stop-color:${gradient[0]};stop-opacity:1" />
|
|
1167
|
+
<stop offset="100%" style="stop-color:${gradient[1]};stop-opacity:1" />
|
|
1168
|
+
</linearGradient>
|
|
1169
|
+
<filter id="${glowId}" x="-50%" y="-50%" width="200%" height="200%">
|
|
1170
|
+
<feGaussianBlur stdDeviation="5" result="coloredBlur"/>
|
|
1171
|
+
<feMerge>
|
|
1172
|
+
<feMergeNode in="coloredBlur"/>
|
|
1173
|
+
<feMergeNode in="SourceGraphic"/>
|
|
1174
|
+
</feMerge>
|
|
1175
|
+
</filter>
|
|
1176
|
+
</defs>
|
|
1177
|
+
|
|
1178
|
+
<a href="${url}" target="_blank">
|
|
1179
|
+
<g filter="url(#${glowId})">
|
|
1180
|
+
<rect x="${x}" y="${y}" width="190" height="65" rx="10"
|
|
1181
|
+
fill="url(#${gradId})" opacity="0.15"/>
|
|
1182
|
+
<rect x="${x}" y="${y}" width="190" height="65" rx="10"
|
|
1183
|
+
fill="none" stroke="url(#${gradId})" stroke-width="2" opacity="0.8"/>
|
|
1184
|
+
</g>
|
|
1185
|
+
<text x="${x + 15}" y="${y + 30}" fill="${gradient[0]}" font-size="14" font-weight="bold"
|
|
1186
|
+
style="filter: drop-shadow(0 0 5px ${gradient[0]});">${truncatedTitle}</text>
|
|
1187
|
+
<text x="${x + 15}" y="${y + 48}" fill="rgba(167, 139, 250, 0.7)" font-size="11">āØ</text>
|
|
1188
|
+
</a>`;
|
|
1189
|
+
}).join('\n');
|
|
1190
|
+
|
|
1191
|
+
return `
|
|
1192
|
+
<svg width="700" height="${height}" viewBox="0 0 700 ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
1193
|
+
<defs>
|
|
1194
|
+
<radialGradient id="bgGrad" cx="50%" cy="50%">
|
|
1195
|
+
<stop offset="0%" style="stop-color:#1a0033;stop-opacity:1" />
|
|
1196
|
+
<stop offset="100%" style="stop-color:#0a001a;stop-opacity:1" />
|
|
1197
|
+
</radialGradient>
|
|
1198
|
+
</defs>
|
|
1199
|
+
|
|
1200
|
+
<rect width="700" height="${height}" fill="url(#bgGrad)"/>
|
|
1201
|
+
|
|
1202
|
+
<!-- Magical particles -->
|
|
1203
|
+
<circle cx="100" cy="25" r="2" fill="#fbbf24" opacity="0.6">
|
|
1204
|
+
<animate attributeName="opacity" values="0.3;0.8;0.3" dur="3s" repeatCount="indefinite"/>
|
|
1205
|
+
</circle>
|
|
1206
|
+
<circle cx="350" cy="20" r="1.5" fill="#a78bfa" opacity="0.5">
|
|
1207
|
+
<animate attributeName="opacity" values="0.2;0.7;0.2" dur="4s" repeatCount="indefinite"/>
|
|
1208
|
+
</circle>
|
|
1209
|
+
<circle cx="600" cy="30" r="1" fill="#10b981" opacity="0.4">
|
|
1210
|
+
<animate attributeName="opacity" values="0.2;0.6;0.2" dur="5s" repeatCount="indefinite"/>
|
|
1211
|
+
</circle>
|
|
1212
|
+
<circle cx="200" cy="35" r="1.5" fill="#ec4899" opacity="0.5">
|
|
1213
|
+
<animate attributeName="opacity" values="0.3;0.7;0.3" dur="3.5s" repeatCount="indefinite"/>
|
|
1214
|
+
</circle>
|
|
1215
|
+
<circle cx="500" cy="28" r="1" fill="#06b6d4" opacity="0.4">
|
|
1216
|
+
<animate attributeName="opacity" values="0.2;0.6;0.2" dur="4.5s" repeatCount="indefinite"/>
|
|
1217
|
+
</circle>
|
|
1218
|
+
|
|
1219
|
+
<text x="350" y="40" fill="#fbbf24" font-size="22" font-weight="bold" text-anchor="middle"
|
|
1220
|
+
style="filter: drop-shadow(0 0 10px #fbbf24);">
|
|
1221
|
+
⨠My Links āØ
|
|
1222
|
+
</text>
|
|
1223
|
+
|
|
1224
|
+
${linkElements}
|
|
1225
|
+
|
|
1226
|
+
<!-- Social Media Section (SoMa) -->
|
|
1227
|
+
${socialLinks.length > 0 ? generateSoMaSection(socialLinks, baseLinkHeight + 10) : ''}
|
|
1228
|
+
</svg>`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Get demo links for unauthenticated users
|
|
1233
|
+
*/
|
|
1234
|
+
function getDemoLinks() {
|
|
1235
|
+
return [
|
|
1236
|
+
{ title: 'GitHub', url: 'https://github.com/planet-nine-app' },
|
|
1237
|
+
{ title: 'Planet Nine', url: 'https://planetnine.app' },
|
|
1238
|
+
{ title: 'Documentation', url: 'https://docs.planetnine.app' },
|
|
1239
|
+
{ title: 'Twitter', url: 'https://twitter.com/planetnine' },
|
|
1240
|
+
{ title: 'Discord', url: 'https://discord.gg/planetnine' },
|
|
1241
|
+
{ title: 'Blog', url: 'https://blog.planetnine.app' }
|
|
1242
|
+
];
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Escape XML special characters
|
|
1247
|
+
*/
|
|
1248
|
+
function escapeXML(str) {
|
|
1249
|
+
return String(str)
|
|
1250
|
+
.replace(/&/g, '&')
|
|
1251
|
+
.replace(/</g, '<')
|
|
1252
|
+
.replace(/>/g, '>')
|
|
1253
|
+
.replace(/"/g, '"')
|
|
1254
|
+
.replace(/'/g, ''');
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Get or create user account
|
|
1259
|
+
* Returns: { uuid, pubKey, keys, carrierBag }
|
|
1260
|
+
*/
|
|
1261
|
+
async function getOrCreateUser(req) {
|
|
1262
|
+
// Check if user already has account in session
|
|
1263
|
+
if (req.session.userUUID && req.session.userPubKey && req.session.userKeys && req.session.carrierBag) {
|
|
1264
|
+
console.log(`ā
Existing user session: ${req.session.userUUID}`);
|
|
1265
|
+
return {
|
|
1266
|
+
uuid: req.session.userUUID,
|
|
1267
|
+
pubKey: req.session.userPubKey,
|
|
1268
|
+
keys: req.session.userKeys,
|
|
1269
|
+
carrierBag: req.session.carrierBag
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Create new user (session-based for now, Fount integration later)
|
|
1274
|
+
console.log('š Creating new user session...');
|
|
1275
|
+
|
|
1276
|
+
// Generate sessionless keys for user
|
|
1277
|
+
let userKeys;
|
|
1278
|
+
const saveKeys = (keys) => { userKeys = keys; };
|
|
1279
|
+
const getKeys = () => userKeys;
|
|
1280
|
+
|
|
1281
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
1282
|
+
const pubKey = keys.pubKey;
|
|
1283
|
+
|
|
1284
|
+
console.log(`š Generated user keys: ${pubKey.substring(0, 16)}...`);
|
|
1285
|
+
|
|
1286
|
+
// Generate a simple UUID for the user
|
|
1287
|
+
const userUUID = `user_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
1288
|
+
|
|
1289
|
+
// Initialize empty carrierBag
|
|
1290
|
+
const carrierBag = {
|
|
1291
|
+
linkitylink: [] // Will store tapestry references
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
console.log(`ā
User session created: ${userUUID}`);
|
|
1295
|
+
|
|
1296
|
+
// Store in session
|
|
1297
|
+
req.session.userUUID = userUUID;
|
|
1298
|
+
req.session.userPubKey = pubKey;
|
|
1299
|
+
req.session.userKeys = userKeys;
|
|
1300
|
+
req.session.carrierBag = carrierBag;
|
|
1301
|
+
|
|
1302
|
+
// Save session
|
|
1303
|
+
await new Promise((resolve, reject) => {
|
|
1304
|
+
req.session.save((err) => {
|
|
1305
|
+
if (err) reject(err);
|
|
1306
|
+
else resolve();
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
console.log(`ā
User session saved`);
|
|
1311
|
+
|
|
1312
|
+
return { uuid: userUUID, pubKey, keys: userKeys, carrierBag };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Add tapestry reference to user's session carrierBag
|
|
1317
|
+
*/
|
|
1318
|
+
async function addTapestryToUser(req, tapestryData) {
|
|
1319
|
+
try {
|
|
1320
|
+
console.log(`š¼ Adding tapestry to user session carrierBag...`);
|
|
1321
|
+
|
|
1322
|
+
// Get current carrierBag from session
|
|
1323
|
+
const carrierBag = req.session.carrierBag || { linkitylink: [] };
|
|
1324
|
+
|
|
1325
|
+
// Add tapestry reference
|
|
1326
|
+
if (!carrierBag.linkitylink) {
|
|
1327
|
+
carrierBag.linkitylink = [];
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
carrierBag.linkitylink.unshift({ // Add to beginning
|
|
1331
|
+
bdoUUID: tapestryData.bdoUUID,
|
|
1332
|
+
emojicode: tapestryData.emojicode,
|
|
1333
|
+
pubKey: tapestryData.pubKey,
|
|
1334
|
+
title: tapestryData.title,
|
|
1335
|
+
linkCount: tapestryData.linkCount,
|
|
1336
|
+
createdAt: tapestryData.createdAt
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
// Update session
|
|
1340
|
+
req.session.carrierBag = carrierBag;
|
|
1341
|
+
|
|
1342
|
+
// Save session
|
|
1343
|
+
await new Promise((resolve, reject) => {
|
|
1344
|
+
req.session.save((err) => {
|
|
1345
|
+
if (err) reject(err);
|
|
1346
|
+
else resolve();
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
console.log(`ā
Tapestry added to carrierBag (${carrierBag.linkitylink.length} total)`);
|
|
1351
|
+
return { success: true };
|
|
1352
|
+
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
console.error('ā Failed to add tapestry to carrierBag:', error);
|
|
1355
|
+
return { success: false, error: error.message };
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* GET /create - Serve create page
|
|
1361
|
+
*/
|
|
1362
|
+
app.get('/create', async (req, res) => {
|
|
1363
|
+
const fs = await import('fs/promises');
|
|
1364
|
+
let createPage = await fs.readFile(join(__dirname, 'public', 'create.html'), 'utf-8');
|
|
1365
|
+
|
|
1366
|
+
// Inject app purchase configuration
|
|
1367
|
+
const configScript = `
|
|
1368
|
+
<script>
|
|
1369
|
+
window.LINKITYLINK_CONFIG = {
|
|
1370
|
+
enableAppPurchase: ${ENABLE_APP_PURCHASE}
|
|
1371
|
+
};
|
|
1372
|
+
</script>
|
|
1373
|
+
`;
|
|
1374
|
+
|
|
1375
|
+
// Insert config script before closing </head> tag
|
|
1376
|
+
createPage = createPage.replace('</head>', `${configScript}</head>`);
|
|
1377
|
+
|
|
1378
|
+
res.send(createPage);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* POST /create - Create Linkitylink BDO
|
|
1383
|
+
*
|
|
1384
|
+
* Flow:
|
|
1385
|
+
* 1. Get or create user account (Fount user with session)
|
|
1386
|
+
* 2. Receive raw BDO data with links from client
|
|
1387
|
+
* 3. Generate composite SVG based on link count
|
|
1388
|
+
* 4. Add svgContent to BDO
|
|
1389
|
+
* 5. Create tapestry BDO in BDO service
|
|
1390
|
+
* 6. Add tapestry reference to user's carrierBag
|
|
1391
|
+
* 7. Return emojicode to client
|
|
1392
|
+
*
|
|
1393
|
+
* Body:
|
|
1394
|
+
* {
|
|
1395
|
+
* "title": "My Links",
|
|
1396
|
+
* "links": [{"title": "...", "url": "..."}, ...],
|
|
1397
|
+
* "source": "linktree" | "manual" (optional),
|
|
1398
|
+
* "sourceUrl": "https://..." (optional),
|
|
1399
|
+
* "style": "stunning" | "dazzling" | ... (optional),
|
|
1400
|
+
* "template": "Sunset" | "Ocean" | ... (optional)
|
|
1401
|
+
* }
|
|
1402
|
+
*/
|
|
1403
|
+
app.post('/create', async (req, res) => {
|
|
1404
|
+
try {
|
|
1405
|
+
console.log('šØ Creating Linkitylink BDO...');
|
|
1406
|
+
|
|
1407
|
+
// Get or create user account
|
|
1408
|
+
const user = await getOrCreateUser(req);
|
|
1409
|
+
|
|
1410
|
+
const { title, links, source, sourceUrl, style, template } = req.body;
|
|
1411
|
+
|
|
1412
|
+
// Validate input
|
|
1413
|
+
if (!links || !Array.isArray(links) || links.length === 0) {
|
|
1414
|
+
return res.status(400).json({
|
|
1415
|
+
error: 'Missing or invalid links array'
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
console.log(`š Received ${links.length} links`);
|
|
1420
|
+
console.log(`š Title: ${title || 'My Links'}`);
|
|
1421
|
+
|
|
1422
|
+
// Generate composite SVG
|
|
1423
|
+
const linkCount = links.length;
|
|
1424
|
+
const svgTemplate = chooseSVGTemplate(linkCount);
|
|
1425
|
+
const svgContent = svgTemplate(links);
|
|
1426
|
+
|
|
1427
|
+
console.log(`ā
Generated SVG (${svgContent.length} characters)`);
|
|
1428
|
+
|
|
1429
|
+
// Build complete BDO with svgContent
|
|
1430
|
+
const linkitylinkBDO = {
|
|
1431
|
+
title: title || 'My Links',
|
|
1432
|
+
type: 'linkitylink',
|
|
1433
|
+
svgContent: svgContent, // Added by Linkitylink!
|
|
1434
|
+
links: links,
|
|
1435
|
+
createdAt: new Date().toISOString()
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// Add optional metadata
|
|
1439
|
+
if (source) linkitylinkBDO.source = source;
|
|
1440
|
+
if (sourceUrl) linkitylinkBDO.sourceUrl = sourceUrl;
|
|
1441
|
+
|
|
1442
|
+
// Generate temporary keys for BDO
|
|
1443
|
+
const saveKeys = (keys) => { tempKeys = keys; };
|
|
1444
|
+
const getKeys = () => tempKeys;
|
|
1445
|
+
let tempKeys = null;
|
|
1446
|
+
|
|
1447
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
1448
|
+
const pubKey = keys.pubKey;
|
|
1449
|
+
|
|
1450
|
+
console.log(`š Generated keys: ${pubKey.substring(0, 16)}...`);
|
|
1451
|
+
|
|
1452
|
+
// Create BDO via bdo-js (handles signing automatically)
|
|
1453
|
+
const hash = 'Linkitylink';
|
|
1454
|
+
console.log(`š Creating BDO with hash: ${hash}`);
|
|
1455
|
+
|
|
1456
|
+
const bdoUUID = await bdoLib.createUser(hash, linkitylinkBDO, saveKeys, getKeys);
|
|
1457
|
+
console.log(`ā
BDO created: ${bdoUUID}`);
|
|
1458
|
+
|
|
1459
|
+
// Make BDO public to get emojicode (using bdo-js)
|
|
1460
|
+
console.log(`š Making BDO public...`);
|
|
1461
|
+
const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, linkitylinkBDO, true);
|
|
1462
|
+
const emojicode = updatedBDO.emojiShortcode;
|
|
1463
|
+
|
|
1464
|
+
console.log(`ā
Emojicode generated: ${emojicode}`);
|
|
1465
|
+
|
|
1466
|
+
// Store pubKey metadata for alphanumeric URL lookup
|
|
1467
|
+
bdoMetadataMap.set(pubKey, {
|
|
1468
|
+
uuid: bdoUUID,
|
|
1469
|
+
emojicode: emojicode,
|
|
1470
|
+
createdAt: new Date()
|
|
1471
|
+
});
|
|
1472
|
+
markMappingsDirty();
|
|
1473
|
+
|
|
1474
|
+
// Add tapestry to user's carrierBag
|
|
1475
|
+
await addTapestryToUser(req, {
|
|
1476
|
+
bdoUUID: bdoUUID,
|
|
1477
|
+
emojicode: emojicode,
|
|
1478
|
+
pubKey: pubKey,
|
|
1479
|
+
title: title || 'My Tapestry',
|
|
1480
|
+
linkCount: links.length,
|
|
1481
|
+
createdAt: new Date().toISOString()
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
// Increment sales counter
|
|
1485
|
+
await incrementSales();
|
|
1486
|
+
|
|
1487
|
+
// Return identifiers only - let client construct URLs
|
|
1488
|
+
res.json({
|
|
1489
|
+
success: true,
|
|
1490
|
+
uuid: bdoUUID,
|
|
1491
|
+
pubKey: pubKey,
|
|
1492
|
+
emojicode: emojicode,
|
|
1493
|
+
userUUID: user.uuid // Include user UUID for reference
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
console.error('ā Error creating Linkitylink:', error);
|
|
1498
|
+
res.status(500).json({
|
|
1499
|
+
error: error.message
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* GET /stats - Get server statistics
|
|
1506
|
+
*
|
|
1507
|
+
* Returns total sales and other stats
|
|
1508
|
+
*/
|
|
1509
|
+
app.get('/stats', async (req, res) => {
|
|
1510
|
+
try {
|
|
1511
|
+
const stats = await loadStats();
|
|
1512
|
+
res.json({
|
|
1513
|
+
success: true,
|
|
1514
|
+
totalSales: stats.totalSales,
|
|
1515
|
+
createdAt: stats.createdAt,
|
|
1516
|
+
lastUpdated: stats.lastUpdated
|
|
1517
|
+
});
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
console.error('ā Error loading stats:', error);
|
|
1520
|
+
res.status(500).json({
|
|
1521
|
+
success: false,
|
|
1522
|
+
error: 'Failed to load statistics'
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* GET /my-tapestries - Get user's tapestries
|
|
1529
|
+
*
|
|
1530
|
+
* Returns all tapestries created by the current user from session
|
|
1531
|
+
*/
|
|
1532
|
+
app.get('/my-tapestries', async (req, res) => {
|
|
1533
|
+
try {
|
|
1534
|
+
// Check if user has session
|
|
1535
|
+
if (!req.session.userUUID) {
|
|
1536
|
+
return res.json({
|
|
1537
|
+
success: true,
|
|
1538
|
+
tapestries: [],
|
|
1539
|
+
message: 'No user session found'
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
console.log(`š Fetching tapestries for user ${req.session.userUUID}`);
|
|
1544
|
+
|
|
1545
|
+
// Get carrierBag from session
|
|
1546
|
+
const carrierBag = req.session.carrierBag || {};
|
|
1547
|
+
const tapestries = carrierBag.linkitylink || [];
|
|
1548
|
+
|
|
1549
|
+
console.log(`ā
Found ${tapestries.length} tapestries`);
|
|
1550
|
+
|
|
1551
|
+
res.json({
|
|
1552
|
+
success: true,
|
|
1553
|
+
tapestries: tapestries,
|
|
1554
|
+
userUUID: req.session.userUUID
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
console.error('ā Error fetching tapestries:', error);
|
|
1559
|
+
res.status(500).json({
|
|
1560
|
+
success: false,
|
|
1561
|
+
error: error.message
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* POST /parse-linktree - Parse links from a Linktree URL using lightweight HTTP fetch
|
|
1568
|
+
*
|
|
1569
|
+
* Linktree embeds all data in __NEXT_DATA__ script tag in initial HTML response
|
|
1570
|
+
* No browser needed - just fetch HTML and parse the JSON
|
|
1571
|
+
* Returns just the links array without creating any BDOs
|
|
1572
|
+
*/
|
|
1573
|
+
app.post('/parse-linktree', async (req, res) => {
|
|
1574
|
+
try {
|
|
1575
|
+
const { url } = req.body;
|
|
1576
|
+
|
|
1577
|
+
console.log(`š Parsing Linktree URL: ${url}`);
|
|
1578
|
+
|
|
1579
|
+
// Validate URL
|
|
1580
|
+
if (!url || !url.includes('linktr.ee')) {
|
|
1581
|
+
return res.status(400).json({
|
|
1582
|
+
success: false,
|
|
1583
|
+
error: 'Invalid Linktree URL. Please enter a linktr.ee URL.'
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
console.log('š Fetching Linktree page...');
|
|
1588
|
+
|
|
1589
|
+
// Fetch HTML with realistic User-Agent
|
|
1590
|
+
const response = await fetch(url, {
|
|
1591
|
+
headers: {
|
|
1592
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
if (!response.ok) {
|
|
1597
|
+
return res.status(400).json({
|
|
1598
|
+
success: false,
|
|
1599
|
+
error: `Failed to fetch Linktree page: ${response.statusText}`
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const html = await response.text();
|
|
1604
|
+
|
|
1605
|
+
console.log('š Extracting __NEXT_DATA__ from HTML...');
|
|
1606
|
+
|
|
1607
|
+
// Parse __NEXT_DATA__ from HTML (Linktree embeds data in script tag)
|
|
1608
|
+
const nextDataMatch = html.match(/<script id="__NEXT_DATA__"[^>]*>(.*?)<\/script>/s);
|
|
1609
|
+
|
|
1610
|
+
if (!nextDataMatch) {
|
|
1611
|
+
return res.status(400).json({
|
|
1612
|
+
success: false,
|
|
1613
|
+
error: 'Could not find __NEXT_DATA__ in Linktree page. The page structure may have changed.'
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const nextData = JSON.parse(nextDataMatch[1]);
|
|
1618
|
+
const pageProps = nextData.props?.pageProps?.account;
|
|
1619
|
+
|
|
1620
|
+
if (!pageProps || !pageProps.links) {
|
|
1621
|
+
return res.status(400).json({
|
|
1622
|
+
success: false,
|
|
1623
|
+
error: 'No links found on this Linktree page.'
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Extract regular links
|
|
1628
|
+
const links = pageProps.links.map(link => ({
|
|
1629
|
+
title: link.title,
|
|
1630
|
+
url: link.url
|
|
1631
|
+
}));
|
|
1632
|
+
|
|
1633
|
+
// Extract social links (Instagram, TikTok, YouTube, etc.)
|
|
1634
|
+
const socialLinks = (pageProps.socialLinks || []).map(social => ({
|
|
1635
|
+
title: social.type.charAt(0) + social.type.slice(1).toLowerCase(), // Capitalize type
|
|
1636
|
+
url: social.url,
|
|
1637
|
+
isSocial: true // Mark as social link
|
|
1638
|
+
}));
|
|
1639
|
+
|
|
1640
|
+
const username = pageProps.username || 'Unknown';
|
|
1641
|
+
|
|
1642
|
+
console.log(`ā
Extracted ${links.length} links + ${socialLinks.length} social links from @${username}'s Linktree`);
|
|
1643
|
+
|
|
1644
|
+
res.json({
|
|
1645
|
+
success: true,
|
|
1646
|
+
links: [...links, ...socialLinks], // Combine regular and social links
|
|
1647
|
+
username: username,
|
|
1648
|
+
source: 'linktree'
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
console.error('ā Error parsing Linktree:', error);
|
|
1653
|
+
|
|
1654
|
+
// Make sure browser is closed
|
|
1655
|
+
if (browser) {
|
|
1656
|
+
try {
|
|
1657
|
+
await browser.close();
|
|
1658
|
+
} catch (closeError) {
|
|
1659
|
+
console.error('Error closing browser:', closeError);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
res.status(500).json({
|
|
1664
|
+
success: false,
|
|
1665
|
+
error: 'Failed to parse Linktree page. Please try again.'
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* POST /create-payment-intent - Create Stripe payment intent via Addie
|
|
1672
|
+
*
|
|
1673
|
+
* Creates a $20 payment intent for Linkitylink tapestry purchase.
|
|
1674
|
+
* Fetches relevantBDOs and extracts their payees for payment splits.
|
|
1675
|
+
*/
|
|
1676
|
+
app.post('/create-payment-intent', async (req, res) => {
|
|
1677
|
+
try {
|
|
1678
|
+
console.log('š³ Creating payment intent via Addie...');
|
|
1679
|
+
|
|
1680
|
+
// Get relevantBDOs using middleware helper (handles body + session)
|
|
1681
|
+
const relevantBDOs = getRelevantBDOs(req);
|
|
1682
|
+
logRelevantBDOs(relevantBDOs, 'š¦ relevantBDOs for payment');
|
|
1683
|
+
|
|
1684
|
+
// Fetch BDOs and extract payees
|
|
1685
|
+
const payees = await fetchAndExtractPayees(relevantBDOs);
|
|
1686
|
+
logPayees(payees, 'š° Payees from relevantBDOs');
|
|
1687
|
+
|
|
1688
|
+
// Get or create user session
|
|
1689
|
+
const user = await getOrCreateUser(req);
|
|
1690
|
+
|
|
1691
|
+
// Set up saveKeys/getKeys for addie-js (same pattern as bdo-js)
|
|
1692
|
+
const saveKeys = (keys) => { user.keys = keys; };
|
|
1693
|
+
const getKeys = () => user.keys;
|
|
1694
|
+
|
|
1695
|
+
// Create/get Addie user if needed
|
|
1696
|
+
if (!user.addieUUID) {
|
|
1697
|
+
console.log('š Creating Addie user...');
|
|
1698
|
+
|
|
1699
|
+
// Create Addie user via addie-js SDK
|
|
1700
|
+
const addieUUID = await addieLib.createUser(saveKeys, getKeys);
|
|
1701
|
+
user.addieUUID = addieUUID;
|
|
1702
|
+
|
|
1703
|
+
// Save to session
|
|
1704
|
+
req.session.addieUUID = addieUUID;
|
|
1705
|
+
await new Promise((resolve, reject) => {
|
|
1706
|
+
req.session.save((err) => err ? reject(err) : resolve());
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
console.log(`ā
Addie user created: ${user.addieUUID}`);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Create payment intent via addie-js SDK
|
|
1713
|
+
const amount = 2000; // $20.00
|
|
1714
|
+
const currency = 'usd';
|
|
1715
|
+
|
|
1716
|
+
console.log(`š° Creating payment intent for $${amount/100}...`);
|
|
1717
|
+
|
|
1718
|
+
// Set up sessionless.getKeys for addie-js to use for signing
|
|
1719
|
+
sessionless.getKeys = getKeys;
|
|
1720
|
+
|
|
1721
|
+
// Convert relevantBDOs to Stripe metadata format (for record keeping)
|
|
1722
|
+
const stripeMetadata = toStripeMetadata(relevantBDOs);
|
|
1723
|
+
|
|
1724
|
+
let intentData;
|
|
1725
|
+
|
|
1726
|
+
// Use getPaymentIntent with payees if we have any, otherwise use without splits
|
|
1727
|
+
if (payees.length > 0) {
|
|
1728
|
+
console.log(`š° Creating payment intent WITH ${payees.length} payees...`);
|
|
1729
|
+
intentData = await addieLib.getPaymentIntent(
|
|
1730
|
+
user.addieUUID,
|
|
1731
|
+
'stripe',
|
|
1732
|
+
amount,
|
|
1733
|
+
currency,
|
|
1734
|
+
payees
|
|
1735
|
+
);
|
|
1736
|
+
} else {
|
|
1737
|
+
console.log('š° Creating payment intent WITHOUT payees...');
|
|
1738
|
+
intentData = await addieLib.getPaymentIntentWithoutSplits(
|
|
1739
|
+
user.addieUUID,
|
|
1740
|
+
'stripe',
|
|
1741
|
+
amount,
|
|
1742
|
+
currency
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
console.log(`ā
Payment intent created`);
|
|
1747
|
+
if (Object.keys(stripeMetadata).length > 0) {
|
|
1748
|
+
console.log('š¦ Stripe metadata prepared:', Object.keys(stripeMetadata).length, 'keys');
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
res.json({
|
|
1752
|
+
success: true,
|
|
1753
|
+
clientSecret: intentData.paymentIntent, // This is the client_secret
|
|
1754
|
+
publishableKey: intentData.publishableKey,
|
|
1755
|
+
customer: intentData.customer,
|
|
1756
|
+
ephemeralKey: intentData.ephemeralKey,
|
|
1757
|
+
payeesIncluded: payees.length // Let client know how many payees were included
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
console.error('ā Error creating payment intent:', error);
|
|
1762
|
+
res.status(500).json({
|
|
1763
|
+
success: false,
|
|
1764
|
+
error: error.message
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* POST /magic/spell/:spellName - MAGIC Protocol Endpoint
|
|
1771
|
+
*
|
|
1772
|
+
* Handles spell casting for Linkitylink creation with integrated payment processing.
|
|
1773
|
+
*
|
|
1774
|
+
* Available Spells:
|
|
1775
|
+
* - linkitylink: Create tapestry from carrierBag links
|
|
1776
|
+
* - glyphtree: Create tapestry from Linktree URL
|
|
1777
|
+
*/
|
|
1778
|
+
app.post('/magic/spell/:spellName', async (req, res) => {
|
|
1779
|
+
try {
|
|
1780
|
+
const { spellName } = req.params;
|
|
1781
|
+
const { caster, payload } = req.body;
|
|
1782
|
+
|
|
1783
|
+
console.log(`⨠MAGIC: Casting spell "${spellName}"`);
|
|
1784
|
+
|
|
1785
|
+
// Validate caster authentication
|
|
1786
|
+
if (!caster || !caster.pubKey || !caster.timestamp || !caster.signature) {
|
|
1787
|
+
return res.status(403).json({
|
|
1788
|
+
success: false,
|
|
1789
|
+
error: 'Missing caster authentication'
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Verify caster signature (timestamp + pubKey)
|
|
1794
|
+
const message = caster.timestamp + caster.pubKey;
|
|
1795
|
+
const isValid = sessionless.verifySignature(caster.signature, message, caster.pubKey);
|
|
1796
|
+
|
|
1797
|
+
if (!isValid) {
|
|
1798
|
+
return res.status(403).json({
|
|
1799
|
+
success: false,
|
|
1800
|
+
error: 'Invalid caster signature'
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Route to spell resolver
|
|
1805
|
+
let result;
|
|
1806
|
+
if (spellName === 'linkitylink') {
|
|
1807
|
+
result = await resolveLinkitylinkSpell(caster, payload);
|
|
1808
|
+
} else if (spellName === 'glyphtree') {
|
|
1809
|
+
result = await resolveGlyphtreeSpell(caster, payload);
|
|
1810
|
+
} else if (spellName === 'submitLinkitylinkTemplate') {
|
|
1811
|
+
result = await resolveSubmitTemplateSpell(caster, payload);
|
|
1812
|
+
} else {
|
|
1813
|
+
return res.status(404).json({
|
|
1814
|
+
success: false,
|
|
1815
|
+
error: `Unknown spell: ${spellName}`
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
res.json(result);
|
|
1820
|
+
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
console.error('ā MAGIC spell error:', error);
|
|
1823
|
+
res.status(500).json({
|
|
1824
|
+
success: false,
|
|
1825
|
+
error: error.message
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Resolve linkitylink spell
|
|
1832
|
+
* Creates tapestry from carrierBag links with payment processing
|
|
1833
|
+
*/
|
|
1834
|
+
async function resolveLinkitylinkSpell(caster, payload) {
|
|
1835
|
+
console.log('šØ Resolving linkitylink spell...');
|
|
1836
|
+
|
|
1837
|
+
const { paymentMethod, links, title } = payload;
|
|
1838
|
+
|
|
1839
|
+
// Validate required spell components
|
|
1840
|
+
if (!links || !Array.isArray(links) || links.length === 0) {
|
|
1841
|
+
return { success: false, error: 'Missing or invalid links array' };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (!paymentMethod || (paymentMethod !== 'mp' && paymentMethod !== 'money')) {
|
|
1845
|
+
return { success: false, error: 'Invalid payment method (must be mp or money)' };
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Process payment
|
|
1849
|
+
const paymentResult = await processSpellPayment(caster, paymentMethod, 100); // $1.00
|
|
1850
|
+
if (!paymentResult.success) {
|
|
1851
|
+
return paymentResult;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// Generate SVG using existing template logic
|
|
1855
|
+
const linkCount = links.length;
|
|
1856
|
+
const svgTemplate = chooseSVGTemplate(linkCount);
|
|
1857
|
+
const svgContent = svgTemplate(links);
|
|
1858
|
+
|
|
1859
|
+
console.log(`ā
Generated SVG (${svgContent.length} characters)`);
|
|
1860
|
+
|
|
1861
|
+
// Build complete BDO with svgContent
|
|
1862
|
+
const linkitylinkBDO = {
|
|
1863
|
+
title: title || 'My Linkitylink',
|
|
1864
|
+
type: 'linkitylink',
|
|
1865
|
+
svgContent: svgContent,
|
|
1866
|
+
links: links,
|
|
1867
|
+
source: 'emporium-spell',
|
|
1868
|
+
createdAt: new Date().toISOString()
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// Generate temporary keys for BDO
|
|
1872
|
+
const saveKeys = (keys) => { tempKeys = keys; };
|
|
1873
|
+
const getKeys = () => tempKeys;
|
|
1874
|
+
let tempKeys = null;
|
|
1875
|
+
|
|
1876
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
1877
|
+
const pubKey = keys.pubKey;
|
|
1878
|
+
|
|
1879
|
+
console.log(`š Generated BDO keys: ${pubKey.substring(0, 16)}...`);
|
|
1880
|
+
|
|
1881
|
+
// Create BDO via bdo-js (handles signing automatically)
|
|
1882
|
+
const hash = 'Linkitylink';
|
|
1883
|
+
console.log(`š Creating BDO with hash: ${hash}`);
|
|
1884
|
+
|
|
1885
|
+
const bdoUUID = await bdoLib.createUser(hash, linkitylinkBDO, saveKeys, getKeys);
|
|
1886
|
+
console.log(`ā
BDO created: ${bdoUUID}`);
|
|
1887
|
+
|
|
1888
|
+
// Make BDO public to get emojicode
|
|
1889
|
+
console.log(`š Making BDO public...`);
|
|
1890
|
+
const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, linkitylinkBDO, true);
|
|
1891
|
+
const emojicode = updatedBDO.emojiShortcode;
|
|
1892
|
+
|
|
1893
|
+
console.log(`ā
Emojicode generated: ${emojicode}`);
|
|
1894
|
+
|
|
1895
|
+
// Save to carrierBag "store" collection
|
|
1896
|
+
const carrierBagResult = await saveToCarrierBag(caster.pubKey, 'store', {
|
|
1897
|
+
title: linkitylinkBDO.title,
|
|
1898
|
+
type: 'linkitylink',
|
|
1899
|
+
emojicode: emojicode,
|
|
1900
|
+
bdoPubKey: pubKey,
|
|
1901
|
+
createdAt: linkitylinkBDO.createdAt
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
if (!carrierBagResult.success) {
|
|
1905
|
+
console.warn('ā ļø Failed to save to carrierBag, but spell succeeded');
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Store pubKey metadata for alphanumeric URL lookup
|
|
1909
|
+
bdoMetadataMap.set(pubKey, {
|
|
1910
|
+
uuid: bdoUUID,
|
|
1911
|
+
emojicode: emojicode,
|
|
1912
|
+
createdAt: new Date()
|
|
1913
|
+
});
|
|
1914
|
+
markMappingsDirty();
|
|
1915
|
+
|
|
1916
|
+
// Return identifiers only - let client construct URLs
|
|
1917
|
+
return {
|
|
1918
|
+
success: true,
|
|
1919
|
+
uuid: bdoUUID,
|
|
1920
|
+
pubKey: pubKey,
|
|
1921
|
+
emojicode: emojicode,
|
|
1922
|
+
payment: paymentResult.payment
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Resolve glyphtree spell
|
|
1928
|
+
* Creates tapestry from Linktree URL with payment processing
|
|
1929
|
+
*/
|
|
1930
|
+
async function resolveGlyphtreeSpell(caster, payload) {
|
|
1931
|
+
console.log('š³ Resolving glyphtree spell...');
|
|
1932
|
+
|
|
1933
|
+
const { paymentMethod, linktreeUrl } = payload;
|
|
1934
|
+
|
|
1935
|
+
// Validate required spell components
|
|
1936
|
+
if (!linktreeUrl || !linktreeUrl.includes('linktr.ee')) {
|
|
1937
|
+
return { success: false, error: 'Invalid Linktree URL' };
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (!paymentMethod || (paymentMethod !== 'mp' && paymentMethod !== 'money')) {
|
|
1941
|
+
return { success: false, error: 'Invalid payment method (must be mp or money)' };
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Fetch and parse Linktree page
|
|
1945
|
+
console.log(`š Fetching Linktree page: ${linktreeUrl}`);
|
|
1946
|
+
|
|
1947
|
+
const response = await fetch(linktreeUrl, {
|
|
1948
|
+
headers: {
|
|
1949
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
if (!response.ok) {
|
|
1954
|
+
return { success: false, error: `Failed to fetch Linktree page: ${response.statusText}` };
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const html = await response.text();
|
|
1958
|
+
|
|
1959
|
+
// Extract __NEXT_DATA__ from page
|
|
1960
|
+
const nextDataMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/);
|
|
1961
|
+
if (!nextDataMatch) {
|
|
1962
|
+
return { success: false, error: 'Could not find __NEXT_DATA__ in Linktree page' };
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
const nextData = JSON.parse(nextDataMatch[1]);
|
|
1966
|
+
const pageProps = nextData.props?.pageProps?.account;
|
|
1967
|
+
|
|
1968
|
+
if (!pageProps || !pageProps.links) {
|
|
1969
|
+
return { success: false, error: 'Invalid Linktree page structure' };
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// Extract regular links
|
|
1973
|
+
const links = pageProps.links.map(link => ({
|
|
1974
|
+
title: link.title,
|
|
1975
|
+
url: link.url
|
|
1976
|
+
}));
|
|
1977
|
+
|
|
1978
|
+
// Extract social links (Instagram, TikTok, YouTube, etc.)
|
|
1979
|
+
const socialLinks = (pageProps.socialLinks || []).map(social => ({
|
|
1980
|
+
title: social.type.charAt(0) + social.type.slice(1).toLowerCase(),
|
|
1981
|
+
url: social.url,
|
|
1982
|
+
isSocial: true
|
|
1983
|
+
}));
|
|
1984
|
+
|
|
1985
|
+
// Combine all links
|
|
1986
|
+
const allLinks = [...links, ...socialLinks];
|
|
1987
|
+
|
|
1988
|
+
const title = `${pageProps.username}'s Links` || 'Linktree Import';
|
|
1989
|
+
|
|
1990
|
+
console.log(`ā
Extracted ${links.length} links + ${socialLinks.length} social links from Linktree`);
|
|
1991
|
+
|
|
1992
|
+
// Process payment
|
|
1993
|
+
const paymentResult = await processSpellPayment(caster, paymentMethod, 100); // $1.00
|
|
1994
|
+
if (!paymentResult.success) {
|
|
1995
|
+
return paymentResult;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Generate SVG using existing template logic
|
|
1999
|
+
const linkCount = allLinks.length;
|
|
2000
|
+
const svgTemplate = chooseSVGTemplate(linkCount);
|
|
2001
|
+
const svgContent = svgTemplate(allLinks);
|
|
2002
|
+
|
|
2003
|
+
console.log(`ā
Generated SVG (${svgContent.length} characters)`);
|
|
2004
|
+
|
|
2005
|
+
// Build complete BDO with svgContent
|
|
2006
|
+
const linkitylinkBDO = {
|
|
2007
|
+
title: title,
|
|
2008
|
+
type: 'linkitylink',
|
|
2009
|
+
svgContent: svgContent,
|
|
2010
|
+
links: allLinks, // Include both regular and social links
|
|
2011
|
+
source: 'linktree',
|
|
2012
|
+
sourceUrl: linktreeUrl,
|
|
2013
|
+
createdAt: new Date().toISOString()
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// Generate temporary keys for BDO
|
|
2017
|
+
const saveKeys = (keys) => { tempKeys = keys; };
|
|
2018
|
+
const getKeys = () => tempKeys;
|
|
2019
|
+
let tempKeys = null;
|
|
2020
|
+
|
|
2021
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
2022
|
+
const pubKey = keys.pubKey;
|
|
2023
|
+
|
|
2024
|
+
console.log(`š Generated BDO keys: ${pubKey.substring(0, 16)}...`);
|
|
2025
|
+
|
|
2026
|
+
// Create BDO via bdo-js (handles signing automatically)
|
|
2027
|
+
const hash = 'Linkitylink';
|
|
2028
|
+
console.log(`š Creating BDO with hash: ${hash}`);
|
|
2029
|
+
|
|
2030
|
+
const bdoUUID = await bdoLib.createUser(hash, linkitylinkBDO, saveKeys, getKeys);
|
|
2031
|
+
console.log(`ā
BDO created: ${bdoUUID}`);
|
|
2032
|
+
|
|
2033
|
+
// Make BDO public to get emojicode
|
|
2034
|
+
console.log(`š Making BDO public...`);
|
|
2035
|
+
const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, linkitylinkBDO, true);
|
|
2036
|
+
const emojicode = updatedBDO.emojiShortcode;
|
|
2037
|
+
|
|
2038
|
+
console.log(`ā
Emojicode generated: ${emojicode}`);
|
|
2039
|
+
|
|
2040
|
+
// Save to carrierBag "store" collection
|
|
2041
|
+
const carrierBagResult = await saveToCarrierBag(caster.pubKey, 'store', {
|
|
2042
|
+
title: linkitylinkBDO.title,
|
|
2043
|
+
type: 'linkitylink',
|
|
2044
|
+
emojicode: emojicode,
|
|
2045
|
+
bdoPubKey: pubKey,
|
|
2046
|
+
sourceUrl: linktreeUrl,
|
|
2047
|
+
createdAt: linkitylinkBDO.createdAt
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
if (!carrierBagResult.success) {
|
|
2051
|
+
console.warn('ā ļø Failed to save to carrierBag, but spell succeeded');
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// Store pubKey metadata for alphanumeric URL lookup
|
|
2055
|
+
bdoMetadataMap.set(pubKey, {
|
|
2056
|
+
uuid: bdoUUID,
|
|
2057
|
+
emojicode: emojicode,
|
|
2058
|
+
createdAt: new Date()
|
|
2059
|
+
});
|
|
2060
|
+
markMappingsDirty();
|
|
2061
|
+
|
|
2062
|
+
// Return identifiers only - let client construct URLs
|
|
2063
|
+
return {
|
|
2064
|
+
success: true,
|
|
2065
|
+
uuid: bdoUUID,
|
|
2066
|
+
pubKey: pubKey,
|
|
2067
|
+
emojicode: emojicode,
|
|
2068
|
+
linkCount: links.length,
|
|
2069
|
+
payment: paymentResult.payment
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Process payment for spell casting
|
|
2075
|
+
*
|
|
2076
|
+
* Note: Word of power validation happens CLIENT-SIDE using SHA256 hash comparison.
|
|
2077
|
+
* This function does not validate or require word of power - that check is done
|
|
2078
|
+
* in the browser before the spell is cast.
|
|
2079
|
+
*/
|
|
2080
|
+
async function processSpellPayment(caster, paymentMethod, amountCents) {
|
|
2081
|
+
console.log(`š° Processing ${paymentMethod} payment...`);
|
|
2082
|
+
|
|
2083
|
+
if (paymentMethod === 'mp') {
|
|
2084
|
+
// MP payment through Fount
|
|
2085
|
+
// TODO: Call Fount /resolve with deductMP spell
|
|
2086
|
+
// For now, return simulated success
|
|
2087
|
+
return {
|
|
2088
|
+
success: true,
|
|
2089
|
+
payment: {
|
|
2090
|
+
method: 'mp',
|
|
2091
|
+
amount: amountCents / 100,
|
|
2092
|
+
message: 'MP payment simulated (TODO: integrate with Fount)'
|
|
2093
|
+
}
|
|
2094
|
+
};
|
|
2095
|
+
|
|
2096
|
+
} else if (paymentMethod === 'money') {
|
|
2097
|
+
// Money payment through Addie
|
|
2098
|
+
// TODO: Call Addie /charge-with-saved-method
|
|
2099
|
+
// For now, return simulated success
|
|
2100
|
+
return {
|
|
2101
|
+
success: true,
|
|
2102
|
+
payment: {
|
|
2103
|
+
method: 'money',
|
|
2104
|
+
amount: amountCents / 100,
|
|
2105
|
+
message: 'Money payment simulated (TODO: integrate with Addie)'
|
|
2106
|
+
}
|
|
2107
|
+
};
|
|
2108
|
+
|
|
2109
|
+
} else {
|
|
2110
|
+
return {
|
|
2111
|
+
success: false,
|
|
2112
|
+
error: 'Unknown payment method'
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* Save item to user's carrierBag collection
|
|
2119
|
+
*/
|
|
2120
|
+
async function saveToCarrierBag(userPubKey, collection, item) {
|
|
2121
|
+
console.log(`š¼ Saving to carrierBag collection: ${collection}`);
|
|
2122
|
+
|
|
2123
|
+
try {
|
|
2124
|
+
// Fetch user's Fount BDO (which contains carrierBag)
|
|
2125
|
+
const userBDO = await fountLib.getBDO(userPubKey);
|
|
2126
|
+
const bdo = userBDO.bdo || userBDO;
|
|
2127
|
+
const carrierBag = bdo.carrierBag || bdo.data?.carrierBag || {};
|
|
2128
|
+
|
|
2129
|
+
// Add item to collection
|
|
2130
|
+
if (!carrierBag[collection]) {
|
|
2131
|
+
carrierBag[collection] = [];
|
|
2132
|
+
}
|
|
2133
|
+
carrierBag[collection].push(item);
|
|
2134
|
+
|
|
2135
|
+
// Update carrierBag
|
|
2136
|
+
// TODO: This requires authentication - need to handle signing
|
|
2137
|
+
// For now, log success but don't actually update
|
|
2138
|
+
console.log(`ā
Would save to carrierBag ${collection} collection`);
|
|
2139
|
+
console.log(` Item: ${JSON.stringify(item).substring(0, 100)}...`);
|
|
2140
|
+
|
|
2141
|
+
return { success: true };
|
|
2142
|
+
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
console.error('ā Failed to save to carrierBag:', error);
|
|
2145
|
+
return { success: false, error: error.message };
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Check if a user has admin nineum in the connected base's Fount instance
|
|
2151
|
+
*
|
|
2152
|
+
* @param {string} pubKey - User's public key
|
|
2153
|
+
* @returns {Promise<boolean>} - True if user has admin nineum
|
|
2154
|
+
*/
|
|
2155
|
+
async function checkIsAdmin(pubKey) {
|
|
2156
|
+
try {
|
|
2157
|
+
console.log(`š Checking admin status for pubKey: ${pubKey.substring(0, 16)}...`);
|
|
2158
|
+
|
|
2159
|
+
// Get user UUID from Fount using pubKey
|
|
2160
|
+
const userResponse = await fetch(`${FOUNT_BASE_URL}user/pubKey/${pubKey}`, {
|
|
2161
|
+
method: 'GET',
|
|
2162
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
if (!userResponse.ok) {
|
|
2166
|
+
console.log(`ā User not found in Fount: ${userResponse.status}`);
|
|
2167
|
+
return false;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const user = await userResponse.json();
|
|
2171
|
+
if (!user || !user.uuid) {
|
|
2172
|
+
console.log(`ā User response missing UUID`);
|
|
2173
|
+
return false;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
console.log(`ā
Found user UUID: ${user.uuid}`);
|
|
2177
|
+
|
|
2178
|
+
// Check if user has admin nineum
|
|
2179
|
+
const nineumResponse = await fetch(`${FOUNT_BASE_URL}user/${user.uuid}/nineum/admin`, {
|
|
2180
|
+
method: 'GET',
|
|
2181
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
if (!nineumResponse.ok) {
|
|
2185
|
+
console.log(`ā Failed to check admin nineum: ${nineumResponse.status}`);
|
|
2186
|
+
return false;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
const nineumData = await nineumResponse.json();
|
|
2190
|
+
const hasAdminNineum = nineumData && nineumData.count > 0;
|
|
2191
|
+
|
|
2192
|
+
console.log(`ā
Admin check result: ${hasAdminNineum} (count: ${nineumData?.count || 0})`);
|
|
2193
|
+
return hasAdminNineum;
|
|
2194
|
+
|
|
2195
|
+
} catch (error) {
|
|
2196
|
+
console.error('ā Error checking admin status:', error);
|
|
2197
|
+
return false;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Resolve submitLinkitylinkTemplate spell
|
|
2203
|
+
* Allows users to submit custom templates and earn when they're used
|
|
2204
|
+
*
|
|
2205
|
+
* Cost: 600 MP
|
|
2206
|
+
*
|
|
2207
|
+
* Payload:
|
|
2208
|
+
* {
|
|
2209
|
+
* paymentMethod: 'mp' | 'money',
|
|
2210
|
+
* template: {
|
|
2211
|
+
* name: 'Sunset Gradient',
|
|
2212
|
+
* colors: ['#ff6b6b', '#ee5a6f', '#feca57'],
|
|
2213
|
+
* linkColors: ['#10b981', '#3b82f6', '#8b5cf6', '#ec4899']
|
|
2214
|
+
* },
|
|
2215
|
+
* payeeQuadEmojicode: 'ššššØšššš'
|
|
2216
|
+
* }
|
|
2217
|
+
*/
|
|
2218
|
+
async function resolveSubmitTemplateSpell(caster, payload) {
|
|
2219
|
+
console.log('šØ Resolving submitLinkitylinkTemplate spell...');
|
|
2220
|
+
|
|
2221
|
+
const { paymentMethod, template, payeeQuadEmojicode } = payload;
|
|
2222
|
+
|
|
2223
|
+
// Validate required components
|
|
2224
|
+
if (!template || !template.name || !template.colors || !template.linkColors) {
|
|
2225
|
+
return { success: false, error: 'Invalid template structure' };
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
if (!Array.isArray(template.colors) || template.colors.length === 0) {
|
|
2229
|
+
return { success: false, error: 'Template colors must be a non-empty array' };
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
if (!Array.isArray(template.linkColors) || template.linkColors.length === 0) {
|
|
2233
|
+
return { success: false, error: 'Template linkColors must be a non-empty array' };
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
if (!payeeQuadEmojicode || payeeQuadEmojicode.length !== 8) {
|
|
2237
|
+
return { success: false, error: 'Invalid payeeQuadEmojicode (must be 8 emojis)' };
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
if (!paymentMethod || (paymentMethod !== 'mp' && paymentMethod !== 'money')) {
|
|
2241
|
+
return { success: false, error: 'Invalid payment method (must be mp or money)' };
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// Process payment (600 MP)
|
|
2245
|
+
const paymentResult = await processSpellPayment(caster, paymentMethod, 600); // 600 MP
|
|
2246
|
+
if (!paymentResult.success) {
|
|
2247
|
+
return paymentResult;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
console.log(`ā
Payment processed (600 MP)`);
|
|
2251
|
+
|
|
2252
|
+
// Build template BDO
|
|
2253
|
+
const templateBDO = {
|
|
2254
|
+
type: 'linkitylink-template',
|
|
2255
|
+
name: template.name,
|
|
2256
|
+
colors: template.colors,
|
|
2257
|
+
linkColors: template.linkColors,
|
|
2258
|
+
payeeEmojicode: payeeQuadEmojicode,
|
|
2259
|
+
creatorPubKey: caster.pubKey,
|
|
2260
|
+
submittedAt: new Date().toISOString(),
|
|
2261
|
+
status: 'pending' // Requires admin approval before showing to users
|
|
2262
|
+
};
|
|
2263
|
+
|
|
2264
|
+
// Generate temporary keys for template BDO
|
|
2265
|
+
const saveKeys = (keys) => { tempKeys = keys; };
|
|
2266
|
+
const getKeys = () => tempKeys;
|
|
2267
|
+
let tempKeys = null;
|
|
2268
|
+
|
|
2269
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
2270
|
+
const pubKey = keys.pubKey;
|
|
2271
|
+
|
|
2272
|
+
console.log(`š Generated template BDO keys: ${pubKey.substring(0, 16)}...`);
|
|
2273
|
+
|
|
2274
|
+
// Create template BDO
|
|
2275
|
+
const hash = 'Linkitylink-Template';
|
|
2276
|
+
console.log(`š Creating template BDO with hash: ${hash}`);
|
|
2277
|
+
|
|
2278
|
+
const bdoUUID = await bdoLib.createUser(hash, templateBDO, saveKeys, getKeys);
|
|
2279
|
+
console.log(`ā
Template BDO created: ${bdoUUID}`);
|
|
2280
|
+
|
|
2281
|
+
// Make BDO public to get emojicode
|
|
2282
|
+
console.log(`š Making template BDO public...`);
|
|
2283
|
+
const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, templateBDO, true);
|
|
2284
|
+
const emojicode = updatedBDO.emojiShortcode;
|
|
2285
|
+
|
|
2286
|
+
console.log(`ā
Template emojicode: ${emojicode}`);
|
|
2287
|
+
|
|
2288
|
+
// Add template to BDO service index for querying
|
|
2289
|
+
try {
|
|
2290
|
+
const addToIndexURL = `${BDO_BASE_URL}/templates/${hash}/add`;
|
|
2291
|
+
const indexResponse = await fetch(addToIndexURL, {
|
|
2292
|
+
method: 'POST',
|
|
2293
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2294
|
+
body: JSON.stringify({ emojicode })
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
if (indexResponse.ok) {
|
|
2298
|
+
console.log(`ā
Added template ${emojicode} to BDO index`);
|
|
2299
|
+
} else {
|
|
2300
|
+
console.warn(`ā ļø Failed to add template to index: ${indexResponse.status}`);
|
|
2301
|
+
}
|
|
2302
|
+
} catch (err) {
|
|
2303
|
+
console.warn(`ā ļø Failed to add template to index:`, err.message);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// Save to creator's carrierBag
|
|
2307
|
+
const carrierBagResult = await saveToCarrierBag(caster.pubKey, 'linkitylinkTemplates', {
|
|
2308
|
+
title: template.name,
|
|
2309
|
+
type: 'linkitylink-template',
|
|
2310
|
+
emojicode: emojicode,
|
|
2311
|
+
bdoPubKey: pubKey,
|
|
2312
|
+
payeeQuadEmojicode: payeeQuadEmojicode,
|
|
2313
|
+
createdAt: templateBDO.submittedAt
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
if (!carrierBagResult.success) {
|
|
2317
|
+
console.warn('ā ļø Failed to save template to carrierBag, but spell succeeded');
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Return success
|
|
2321
|
+
return {
|
|
2322
|
+
success: true,
|
|
2323
|
+
uuid: bdoUUID,
|
|
2324
|
+
pubKey: pubKey,
|
|
2325
|
+
emojicode: emojicode,
|
|
2326
|
+
templateName: template.name,
|
|
2327
|
+
payment: paymentResult.payment,
|
|
2328
|
+
message: 'Template submitted successfully! You will earn a share when users purchase linkitylinks with your template.'
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Template cache with 5-minute TTL
|
|
2333
|
+
let templateCache = {
|
|
2334
|
+
templates: [],
|
|
2335
|
+
lastFetched: null,
|
|
2336
|
+
ttl: 5 * 60 * 1000 // 5 minutes
|
|
2337
|
+
};
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* GET /templates - Get all user-submitted templates
|
|
2341
|
+
*
|
|
2342
|
+
* Fetches all linkitylink-template BDOs from the BDO service.
|
|
2343
|
+
* Returns active templates with their payee information for revenue sharing.
|
|
2344
|
+
* Caches results for 5 minutes to reduce BDO service load.
|
|
2345
|
+
*/
|
|
2346
|
+
app.get('/templates', async (req, res) => {
|
|
2347
|
+
try {
|
|
2348
|
+
console.log('šØ Fetching user-submitted templates...');
|
|
2349
|
+
|
|
2350
|
+
// Check cache
|
|
2351
|
+
const now = Date.now();
|
|
2352
|
+
if (templateCache.lastFetched && (now - templateCache.lastFetched) < templateCache.ttl) {
|
|
2353
|
+
console.log(`ā
Returning ${templateCache.templates.length} cached templates`);
|
|
2354
|
+
return res.json({
|
|
2355
|
+
success: true,
|
|
2356
|
+
templates: templateCache.templates,
|
|
2357
|
+
cached: true
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Query BDO service for all templates with hash 'Linkitylink-Template'
|
|
2362
|
+
const hash = 'Linkitylink-Template';
|
|
2363
|
+
const templatesURL = `${BDO_BASE_URL}/templates/${hash}`;
|
|
2364
|
+
|
|
2365
|
+
console.log(`š” Querying BDO service: ${templatesURL}`);
|
|
2366
|
+
|
|
2367
|
+
const response = await fetch(templatesURL);
|
|
2368
|
+
|
|
2369
|
+
if (!response.ok) {
|
|
2370
|
+
throw new Error(`BDO service returned ${response.status}`);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
const data = await response.json();
|
|
2374
|
+
|
|
2375
|
+
console.log(`ā
Received ${data.count} templates from BDO service`);
|
|
2376
|
+
|
|
2377
|
+
// Filter for approved templates only (pending/rejected templates are hidden)
|
|
2378
|
+
const templates = data.templates
|
|
2379
|
+
.filter(t => t.status === 'approved')
|
|
2380
|
+
.map(t => ({
|
|
2381
|
+
name: t.name,
|
|
2382
|
+
colors: t.colors,
|
|
2383
|
+
linkColors: t.linkColors,
|
|
2384
|
+
emojicode: t.emojicode,
|
|
2385
|
+
payeeEmojicode: t.payeeEmojicode,
|
|
2386
|
+
creatorPubKey: t.creatorPubKey
|
|
2387
|
+
}));
|
|
2388
|
+
|
|
2389
|
+
// Update cache
|
|
2390
|
+
templateCache.templates = templates;
|
|
2391
|
+
templateCache.lastFetched = now;
|
|
2392
|
+
|
|
2393
|
+
res.json({
|
|
2394
|
+
success: true,
|
|
2395
|
+
templates: templates,
|
|
2396
|
+
count: templates.length
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
} catch (error) {
|
|
2400
|
+
console.error('ā Error fetching templates:', error);
|
|
2401
|
+
res.status(500).json({
|
|
2402
|
+
success: false,
|
|
2403
|
+
error: error.message
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
/**
|
|
2409
|
+
* GET /templates/pending - Get pending templates for admin moderation
|
|
2410
|
+
*
|
|
2411
|
+
* Admin-only endpoint. Returns all templates with status 'pending' awaiting approval.
|
|
2412
|
+
*/
|
|
2413
|
+
app.get('/templates/pending', async (req, res) => {
|
|
2414
|
+
try {
|
|
2415
|
+
// Verify admin status
|
|
2416
|
+
const pubKey = req.query.pubKey;
|
|
2417
|
+
if (!pubKey) {
|
|
2418
|
+
return res.status(401).json({
|
|
2419
|
+
success: false,
|
|
2420
|
+
error: 'Missing pubKey parameter'
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const isAdmin = await checkIsAdmin(pubKey);
|
|
2425
|
+
if (!isAdmin) {
|
|
2426
|
+
return res.status(403).json({
|
|
2427
|
+
success: false,
|
|
2428
|
+
error: 'Unauthorized - admin nineum required'
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
console.log('šØ Fetching pending templates for admin review...');
|
|
2433
|
+
|
|
2434
|
+
// Query BDO service for all templates
|
|
2435
|
+
const hash = 'Linkitylink-Template';
|
|
2436
|
+
const templatesURL = `${BDO_BASE_URL}/templates/${hash}`;
|
|
2437
|
+
|
|
2438
|
+
console.log(`š” Querying BDO service: ${templatesURL}`);
|
|
2439
|
+
|
|
2440
|
+
const response = await fetch(templatesURL);
|
|
2441
|
+
|
|
2442
|
+
if (!response.ok) {
|
|
2443
|
+
throw new Error(`BDO service returned ${response.status}`);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
const data = await response.json();
|
|
2447
|
+
|
|
2448
|
+
console.log(`ā
Received ${data.count} total templates from BDO service`);
|
|
2449
|
+
|
|
2450
|
+
// Filter for pending templates only
|
|
2451
|
+
const pendingTemplates = data.templates
|
|
2452
|
+
.filter(t => t.status === 'pending')
|
|
2453
|
+
.map(t => ({
|
|
2454
|
+
name: t.name,
|
|
2455
|
+
colors: t.colors,
|
|
2456
|
+
linkColors: t.linkColors,
|
|
2457
|
+
emojicode: t.emojicode,
|
|
2458
|
+
payeeEmojicode: t.payeeEmojicode,
|
|
2459
|
+
creatorPubKey: t.creatorPubKey,
|
|
2460
|
+
submittedAt: t.submittedAt
|
|
2461
|
+
}));
|
|
2462
|
+
|
|
2463
|
+
console.log(`š Returning ${pendingTemplates.length} pending templates`);
|
|
2464
|
+
|
|
2465
|
+
res.json({
|
|
2466
|
+
success: true,
|
|
2467
|
+
templates: pendingTemplates,
|
|
2468
|
+
count: pendingTemplates.length
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
} catch (error) {
|
|
2472
|
+
console.error('ā Error fetching pending templates:', error);
|
|
2473
|
+
res.status(500).json({
|
|
2474
|
+
success: false,
|
|
2475
|
+
error: error.message
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
/**
|
|
2481
|
+
* PUT /template/:emojicode/moderate - Approve or reject a template
|
|
2482
|
+
*
|
|
2483
|
+
* Admin-only endpoint. Updates template status to 'approved' or 'rejected'.
|
|
2484
|
+
*
|
|
2485
|
+
* Body: { pubKey: string, action: 'approve' | 'reject' }
|
|
2486
|
+
*/
|
|
2487
|
+
app.put('/template/:emojicode/moderate', async (req, res) => {
|
|
2488
|
+
try {
|
|
2489
|
+
const { emojicode } = req.params;
|
|
2490
|
+
const { pubKey, action } = req.body;
|
|
2491
|
+
|
|
2492
|
+
// Validate inputs
|
|
2493
|
+
if (!pubKey) {
|
|
2494
|
+
return res.status(400).json({
|
|
2495
|
+
success: false,
|
|
2496
|
+
error: 'Missing pubKey in request body'
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
if (!action || !['approve', 'reject'].includes(action)) {
|
|
2501
|
+
return res.status(400).json({
|
|
2502
|
+
success: false,
|
|
2503
|
+
error: 'Invalid action - must be "approve" or "reject"'
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// Verify admin status
|
|
2508
|
+
const isAdmin = await checkIsAdmin(pubKey);
|
|
2509
|
+
if (!isAdmin) {
|
|
2510
|
+
return res.status(403).json({
|
|
2511
|
+
success: false,
|
|
2512
|
+
error: 'Unauthorized - admin nineum required'
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
console.log(`šØ Moderating template ${emojicode}: ${action}`);
|
|
2517
|
+
|
|
2518
|
+
// Fetch template BDO via emojicode
|
|
2519
|
+
const templateBDO = await bdoLib.getBDOByEmojicode(emojicode);
|
|
2520
|
+
|
|
2521
|
+
if (!templateBDO || !templateBDO.bdo) {
|
|
2522
|
+
return res.status(404).json({
|
|
2523
|
+
success: false,
|
|
2524
|
+
error: 'Template not found'
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
const bdo = templateBDO.bdo;
|
|
2529
|
+
|
|
2530
|
+
// Verify it's a template
|
|
2531
|
+
if (bdo.type !== 'linkitylink-template') {
|
|
2532
|
+
return res.status(400).json({
|
|
2533
|
+
success: false,
|
|
2534
|
+
error: 'BDO is not a linkitylink template'
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// Update status
|
|
2539
|
+
const newStatus = action === 'approve' ? 'approved' : 'rejected';
|
|
2540
|
+
bdo.status = newStatus;
|
|
2541
|
+
bdo.moderatedAt = new Date().toISOString();
|
|
2542
|
+
bdo.moderatedBy = pubKey;
|
|
2543
|
+
|
|
2544
|
+
// Update the BDO
|
|
2545
|
+
const hash = 'Linkitylink-Template';
|
|
2546
|
+
await bdoLib.updateBDO(templateBDO.uuid, hash, bdo, true); // Keep it public
|
|
2547
|
+
|
|
2548
|
+
console.log(`ā
Template ${emojicode} ${newStatus}`);
|
|
2549
|
+
|
|
2550
|
+
// Clear cache so next request gets updated data
|
|
2551
|
+
templateCache.lastFetched = 0;
|
|
2552
|
+
|
|
2553
|
+
res.json({
|
|
2554
|
+
success: true,
|
|
2555
|
+
emojicode: emojicode,
|
|
2556
|
+
status: newStatus,
|
|
2557
|
+
message: `Template ${action}d successfully`
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
} catch (error) {
|
|
2561
|
+
console.error('ā Error moderating template:', error);
|
|
2562
|
+
res.status(500).json({
|
|
2563
|
+
success: false,
|
|
2564
|
+
error: error.message
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
// ============================================================================
|
|
2570
|
+
// App Handoff Endpoints - Web-to-App purchase flow
|
|
2571
|
+
// ============================================================================
|
|
2572
|
+
|
|
2573
|
+
/**
|
|
2574
|
+
* POST /handoff/create - Create a pending handoff for app purchase
|
|
2575
|
+
*
|
|
2576
|
+
* Creates a BDO (unpurchased) and initiates handoff to The Advancement app.
|
|
2577
|
+
* Returns a token and auth sequence for the color game.
|
|
2578
|
+
*
|
|
2579
|
+
* Body: {
|
|
2580
|
+
* title: "My Links",
|
|
2581
|
+
* links: [...],
|
|
2582
|
+
* relevantBDOs: { emojicodes: [...], pubKeys: [...] }
|
|
2583
|
+
* }
|
|
2584
|
+
*/
|
|
2585
|
+
app.post('/handoff/create', async (req, res) => {
|
|
2586
|
+
try {
|
|
2587
|
+
console.log('š± Creating app handoff...');
|
|
2588
|
+
|
|
2589
|
+
const { bdoData, relevantBDOs, productType } = req.body;
|
|
2590
|
+
|
|
2591
|
+
// Extract links from bdoData or directly from body (backward compat)
|
|
2592
|
+
const links = bdoData?.links || req.body.links;
|
|
2593
|
+
const title = bdoData?.title || req.body.title;
|
|
2594
|
+
|
|
2595
|
+
// Validate
|
|
2596
|
+
if (!links || !Array.isArray(links) || links.length === 0) {
|
|
2597
|
+
return res.status(400).json({
|
|
2598
|
+
success: false,
|
|
2599
|
+
error: 'Missing or invalid links array'
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// Generate SVG for the BDO
|
|
2604
|
+
const svgTemplate = chooseSVGTemplate(links.length);
|
|
2605
|
+
const svgContent = svgTemplate(links);
|
|
2606
|
+
|
|
2607
|
+
// Build BDO data (not yet saved to BDO service)
|
|
2608
|
+
const finalBdoData = {
|
|
2609
|
+
title: title || 'My Links',
|
|
2610
|
+
type: 'linkitylink',
|
|
2611
|
+
svgContent: svgContent,
|
|
2612
|
+
links: links,
|
|
2613
|
+
source: bdoData?.source || 'create-page',
|
|
2614
|
+
style: bdoData?.style,
|
|
2615
|
+
template: bdoData?.template,
|
|
2616
|
+
createdAt: new Date().toISOString(),
|
|
2617
|
+
status: 'pending_purchase'
|
|
2618
|
+
};
|
|
2619
|
+
|
|
2620
|
+
// Generate keys for the BDO (we'll need these for association later)
|
|
2621
|
+
let bdoKeys;
|
|
2622
|
+
const saveKeys = (keys) => { bdoKeys = keys; };
|
|
2623
|
+
const getKeys = () => bdoKeys;
|
|
2624
|
+
|
|
2625
|
+
const keys = await sessionless.generateKeys(saveKeys, getKeys);
|
|
2626
|
+
const bdoPubKey = keys.pubKey;
|
|
2627
|
+
|
|
2628
|
+
console.log(`š Generated BDO keys: ${bdoPubKey.substring(0, 16)}...`);
|
|
2629
|
+
|
|
2630
|
+
// Store the keys in session for later use
|
|
2631
|
+
req.session.pendingBdoKeys = bdoKeys;
|
|
2632
|
+
await new Promise((resolve, reject) => {
|
|
2633
|
+
req.session.save((err) => err ? reject(err) : resolve());
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
// Create pending handoff
|
|
2637
|
+
const handoff = createPendingHandoff({
|
|
2638
|
+
bdoData: finalBdoData,
|
|
2639
|
+
bdoPubKey,
|
|
2640
|
+
bdoEmojicode: null, // Not yet created
|
|
2641
|
+
relevantBDOs: relevantBDOs || { emojicodes: [], pubKeys: [] },
|
|
2642
|
+
productType: productType || 'linkitylink',
|
|
2643
|
+
webPrice: 2000, // $20.00
|
|
2644
|
+
appPrice: 1500 // $15.00 (25% discount)
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
console.log(`ā
Handoff created: ${handoff.token.substring(0, 8)}...`);
|
|
2648
|
+
|
|
2649
|
+
res.json({
|
|
2650
|
+
success: true,
|
|
2651
|
+
token: handoff.token,
|
|
2652
|
+
sequence: handoff.sequence,
|
|
2653
|
+
expiresAt: handoff.expiresAt,
|
|
2654
|
+
webPrice: 2000,
|
|
2655
|
+
appPrice: 1500,
|
|
2656
|
+
discount: 500,
|
|
2657
|
+
discountPercent: 25
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
} catch (error) {
|
|
2661
|
+
console.error('ā Error creating handoff:', error);
|
|
2662
|
+
res.status(500).json({
|
|
2663
|
+
success: false,
|
|
2664
|
+
error: error.message
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
/**
|
|
2670
|
+
* POST /handoff/:token/verify - Verify the auth sequence
|
|
2671
|
+
*
|
|
2672
|
+
* User has completed the color sequence game. Verify it.
|
|
2673
|
+
*
|
|
2674
|
+
* Body: { sequence: ['red', 'blue', 'green', 'yellow', 'purple'] }
|
|
2675
|
+
*/
|
|
2676
|
+
app.post('/handoff/:token/verify', async (req, res) => {
|
|
2677
|
+
try {
|
|
2678
|
+
const { token } = req.params;
|
|
2679
|
+
const { sequence } = req.body;
|
|
2680
|
+
|
|
2681
|
+
console.log(`š± Verifying handoff sequence: ${token.substring(0, 8)}...`);
|
|
2682
|
+
|
|
2683
|
+
const result = verifyAuthSequence(token, sequence);
|
|
2684
|
+
|
|
2685
|
+
if (result.success) {
|
|
2686
|
+
console.log(`ā
Sequence verified for: ${token.substring(0, 8)}...`);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
res.json(result);
|
|
2690
|
+
|
|
2691
|
+
} catch (error) {
|
|
2692
|
+
console.error('ā Error verifying sequence:', error);
|
|
2693
|
+
res.status(500).json({
|
|
2694
|
+
success: false,
|
|
2695
|
+
error: error.message
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
/**
|
|
2701
|
+
* POST /handoff/:token/associate - Associate app credentials with handoff
|
|
2702
|
+
*
|
|
2703
|
+
* Called by The Advancement app after auth sequence is completed.
|
|
2704
|
+
* Links the app's pubKey to this handoff.
|
|
2705
|
+
*
|
|
2706
|
+
* Body: { pubKey, uuid, timestamp, signature }
|
|
2707
|
+
*/
|
|
2708
|
+
app.post('/handoff/:token/associate', async (req, res) => {
|
|
2709
|
+
try {
|
|
2710
|
+
const { token } = req.params;
|
|
2711
|
+
const appCredentials = req.body;
|
|
2712
|
+
|
|
2713
|
+
console.log(`š± Associating app with handoff: ${token.substring(0, 8)}...`);
|
|
2714
|
+
|
|
2715
|
+
const result = associateAppCredentials(token, appCredentials);
|
|
2716
|
+
|
|
2717
|
+
if (result.success) {
|
|
2718
|
+
console.log(`ā
App associated: ${appCredentials.pubKey?.substring(0, 16)}...`);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
res.json(result);
|
|
2722
|
+
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
console.error('ā Error associating app:', error);
|
|
2725
|
+
res.status(500).json({
|
|
2726
|
+
success: false,
|
|
2727
|
+
error: error.message
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
});
|
|
2731
|
+
|
|
2732
|
+
/**
|
|
2733
|
+
* GET /handoff/:token/status - Get handoff status for polling
|
|
2734
|
+
*
|
|
2735
|
+
* Called by the web page to check if the app has completed the sequence.
|
|
2736
|
+
* Returns status flags for UI updates.
|
|
2737
|
+
*/
|
|
2738
|
+
app.get('/handoff/:token/status', async (req, res) => {
|
|
2739
|
+
try {
|
|
2740
|
+
const { token } = req.params;
|
|
2741
|
+
|
|
2742
|
+
const handoff = getPendingHandoff(token);
|
|
2743
|
+
|
|
2744
|
+
if (!handoff) {
|
|
2745
|
+
return res.status(404).json({
|
|
2746
|
+
success: false,
|
|
2747
|
+
error: 'Handoff not found or expired'
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
res.json({
|
|
2752
|
+
success: true,
|
|
2753
|
+
sequenceCompleted: handoff.sequenceCompleted,
|
|
2754
|
+
appPubKey: handoff.appPubKey ? handoff.appPubKey.substring(0, 16) + '...' : null,
|
|
2755
|
+
completedAt: handoff.completedAt,
|
|
2756
|
+
emojicode: handoff.completedAt ? handoff.bdoEmojicode : null,
|
|
2757
|
+
bdoPubKey: handoff.completedAt ? handoff.bdoPubKey : null
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
} catch (error) {
|
|
2761
|
+
console.error('ā Error getting handoff status:', error);
|
|
2762
|
+
res.status(500).json({
|
|
2763
|
+
success: false,
|
|
2764
|
+
error: error.message
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
/**
|
|
2770
|
+
* GET /handoff/:token - Get handoff data for the app
|
|
2771
|
+
*
|
|
2772
|
+
* Called by The Advancement app to get BDO data for display.
|
|
2773
|
+
* Requires appPubKey query param for verification.
|
|
2774
|
+
*/
|
|
2775
|
+
app.get('/handoff/:token', async (req, res) => {
|
|
2776
|
+
try {
|
|
2777
|
+
const { token } = req.params;
|
|
2778
|
+
const { appPubKey } = req.query;
|
|
2779
|
+
|
|
2780
|
+
console.log(`š± Getting handoff: ${token.substring(0, 8)}...`);
|
|
2781
|
+
|
|
2782
|
+
const result = getHandoffForApp(token, appPubKey);
|
|
2783
|
+
|
|
2784
|
+
res.json(result);
|
|
2785
|
+
|
|
2786
|
+
} catch (error) {
|
|
2787
|
+
console.error('ā Error getting handoff:', error);
|
|
2788
|
+
res.status(500).json({
|
|
2789
|
+
success: false,
|
|
2790
|
+
error: error.message
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
});
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* POST /handoff/:token/complete - Complete the handoff after purchase
|
|
2797
|
+
*
|
|
2798
|
+
* Called after successful payment in the app.
|
|
2799
|
+
* Creates the actual BDO and adds to carrierBag.
|
|
2800
|
+
*
|
|
2801
|
+
* Body: { appPubKey, paymentConfirmation }
|
|
2802
|
+
*/
|
|
2803
|
+
app.post('/handoff/:token/complete', async (req, res) => {
|
|
2804
|
+
try {
|
|
2805
|
+
const { token } = req.params;
|
|
2806
|
+
const { appPubKey } = req.body;
|
|
2807
|
+
|
|
2808
|
+
console.log(`š± Completing handoff: ${token.substring(0, 8)}...`);
|
|
2809
|
+
|
|
2810
|
+
const handoff = getPendingHandoff(token);
|
|
2811
|
+
|
|
2812
|
+
if (!handoff) {
|
|
2813
|
+
return res.status(404).json({
|
|
2814
|
+
success: false,
|
|
2815
|
+
error: 'Handoff not found or expired'
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
if (handoff.appPubKey !== appPubKey) {
|
|
2820
|
+
return res.status(403).json({
|
|
2821
|
+
success: false,
|
|
2822
|
+
error: 'App not authorized for this handoff'
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// Now actually create the BDO
|
|
2827
|
+
console.log('šØ Creating actual BDO...');
|
|
2828
|
+
|
|
2829
|
+
// Get keys from session if available, or generate new ones
|
|
2830
|
+
let bdoKeys = req.session.pendingBdoKeys;
|
|
2831
|
+
if (!bdoKeys) {
|
|
2832
|
+
const saveKeys = (keys) => { bdoKeys = keys; };
|
|
2833
|
+
const getKeys = () => bdoKeys;
|
|
2834
|
+
await sessionless.generateKeys(saveKeys, getKeys);
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
const saveKeys = (keys) => { bdoKeys = keys; };
|
|
2838
|
+
const getKeys = () => bdoKeys;
|
|
2839
|
+
sessionless.getKeys = getKeys;
|
|
2840
|
+
|
|
2841
|
+
// Create BDO in BDO service
|
|
2842
|
+
const hash = 'Linkitylink';
|
|
2843
|
+
const bdoUUID = await bdoLib.createUser(hash, handoff.bdoData, saveKeys, getKeys);
|
|
2844
|
+
console.log(`ā
BDO created: ${bdoUUID}`);
|
|
2845
|
+
|
|
2846
|
+
// Make BDO public
|
|
2847
|
+
const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, handoff.bdoData, true);
|
|
2848
|
+
const emojicode = updatedBDO.emojiShortcode;
|
|
2849
|
+
console.log(`ā
Emojicode: ${emojicode}`);
|
|
2850
|
+
|
|
2851
|
+
// Store pubKey metadata for alphanumeric URL lookup
|
|
2852
|
+
bdoMetadataMap.set(handoff.bdoPubKey, {
|
|
2853
|
+
uuid: bdoUUID,
|
|
2854
|
+
emojicode: emojicode,
|
|
2855
|
+
createdAt: new Date(),
|
|
2856
|
+
purchasedVia: 'app-handoff',
|
|
2857
|
+
appPubKey: appPubKey
|
|
2858
|
+
});
|
|
2859
|
+
markMappingsDirty();
|
|
2860
|
+
|
|
2861
|
+
// Mark handoff as complete
|
|
2862
|
+
completeHandoff(token);
|
|
2863
|
+
|
|
2864
|
+
// Clean up session
|
|
2865
|
+
delete req.session.pendingBdoKeys;
|
|
2866
|
+
req.session.save(() => {});
|
|
2867
|
+
|
|
2868
|
+
res.json({
|
|
2869
|
+
success: true,
|
|
2870
|
+
uuid: bdoUUID,
|
|
2871
|
+
pubKey: handoff.bdoPubKey,
|
|
2872
|
+
emojicode: emojicode,
|
|
2873
|
+
message: 'BDO created and added to carrierBag'
|
|
2874
|
+
});
|
|
2875
|
+
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
console.error('ā Error completing handoff:', error);
|
|
2878
|
+
res.status(500).json({
|
|
2879
|
+
success: false,
|
|
2880
|
+
error: error.message
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
/**
|
|
2886
|
+
* GET /handoff/stats - Get handoff statistics (debug)
|
|
2887
|
+
*/
|
|
2888
|
+
app.get('/handoff-stats', async (req, res) => {
|
|
2889
|
+
res.json(getHandoffStats());
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
// ============================================================================
|
|
2893
|
+
|
|
2894
|
+
// Start server
|
|
2895
|
+
app.listen(PORT, () => {
|
|
2896
|
+
console.log(`\nā
Linkitylink tapestry weaver active on port ${PORT}`);
|
|
2897
|
+
console.log(`š View demo: http://localhost:${PORT}`);
|
|
2898
|
+
console.log(`\nš Viewing Modes:`);
|
|
2899
|
+
console.log(` Demo tapestry: http://localhost:${PORT}`);
|
|
2900
|
+
console.log(` By emojicode: http://localhost:${PORT}/view/šššš...`);
|
|
2901
|
+
console.log(` By alphanumeric: http://localhost:${PORT}/t/abc123...`);
|
|
2902
|
+
console.log(` Legacy auth: http://localhost:${PORT}?pubKey=YOUR_PUBKEY×tamp=TIMESTAMP&signature=SIGNATURE`);
|
|
2903
|
+
console.log(`\nš Creation Endpoints:`);
|
|
2904
|
+
console.log(` POST /create - Create new Linkitylink with auto-generated SVG`);
|
|
2905
|
+
console.log(` POST /magic/spell/linkitylink - Cast linkitylink spell (carrierBag links)`);
|
|
2906
|
+
console.log(` POST /magic/spell/glyphtree - Cast glyphtree spell (Linktree URL)`);
|
|
2907
|
+
console.log(`\nš± App Handoff Endpoints:`);
|
|
2908
|
+
console.log(` POST /handoff/create - Start web-to-app handoff`);
|
|
2909
|
+
console.log(` POST /handoff/:token/verify - Verify auth sequence`);
|
|
2910
|
+
console.log(` POST /handoff/:token/associate - Associate app credentials`);
|
|
2911
|
+
console.log(` GET /handoff/:token - Get handoff data for app`);
|
|
2912
|
+
console.log(` POST /handoff/:token/complete - Complete purchase`);
|
|
2913
|
+
console.log('');
|
|
2914
|
+
});
|