wiki-plugin-shoppe 0.0.21 → 0.0.23
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 +53 -9
- package/client/shoppe.js +40 -12
- package/package.json +1 -1
- package/server/scripts/shoppe-sign.js +369 -0
- package/server/server.js +789 -62
- package/server/templates/appointment-booking.html +23 -2
- package/server/templates/ebook-download.html +1 -0
- package/server/templates/generic-address-stripe.html +23 -2
- package/server/templates/generic-recover-stripe.html +23 -2
- package/server/templates/subscription-membership.html +7 -7
- package/server/templates/subscription-subscribe.html +30 -9
- package/test/test.js +3 -3
package/server/server.js
CHANGED
|
@@ -48,6 +48,12 @@ function saveConfig(config) {
|
|
|
48
48
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Derive the public-facing protocol from the request, respecting reverse-proxy headers.
|
|
52
|
+
// Behind HTTPS proxies req.protocol is 'http'; X-Forwarded-Proto carries the real value.
|
|
53
|
+
function reqProto(req) {
|
|
54
|
+
return (req.get('x-forwarded-proto') || req.protocol || 'https').split(',')[0].trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
51
57
|
function getSanoraUrl() {
|
|
52
58
|
const config = loadConfig();
|
|
53
59
|
if (config.sanoraUrl) return config.sanoraUrl.replace(/\/$/, '');
|
|
@@ -59,6 +65,12 @@ function getAddieUrl() {
|
|
|
59
65
|
return `http://localhost:${process.env.ADDIE_PORT || 3005}`;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
function getLucilleUrl() {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
if (config.lucilleUrl) return config.lucilleUrl.replace(/\/$/, '');
|
|
71
|
+
return `http://localhost:${process.env.LUCILLE_PORT || 5444}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
function loadBuyers() {
|
|
63
75
|
if (!fs.existsSync(BUYERS_FILE)) return {};
|
|
64
76
|
try { return JSON.parse(fs.readFileSync(BUYERS_FILE, 'utf8')); } catch { return {}; }
|
|
@@ -111,6 +123,7 @@ const EMOJI_PALETTE = [
|
|
|
111
123
|
const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
|
|
112
124
|
const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
|
|
113
125
|
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
|
|
126
|
+
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.mkv', '.webm', '.avi']);
|
|
114
127
|
|
|
115
128
|
// ============================================================
|
|
116
129
|
// MARKDOWN / FRONT MATTER UTILITIES
|
|
@@ -135,6 +148,147 @@ function escHtml(str) {
|
|
|
135
148
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
// Extract kw:-prefixed entries from a Sanora product's tags string into a comma-separated keyword list.
|
|
152
|
+
function extractKeywords(product) {
|
|
153
|
+
return (product.tags || '').split(',')
|
|
154
|
+
.filter(t => t.startsWith('kw:'))
|
|
155
|
+
.map(t => t.slice(3).trim())
|
|
156
|
+
.join(', ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Append keyword tags (as kw:word entries) to a base tags string.
|
|
160
|
+
function buildTags(baseTags, keywords) {
|
|
161
|
+
const kwTags = (Array.isArray(keywords) ? keywords : [])
|
|
162
|
+
.map(kw => `kw:${kw.trim()}`).filter(Boolean);
|
|
163
|
+
if (!kwTags.length) return baseTags;
|
|
164
|
+
return baseTags + ',' + kwTags.join(',');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Owner key pair (secp256k1 via sessionless) ───────────────────────────────
|
|
168
|
+
|
|
169
|
+
async function generateOwnerKeyPair() {
|
|
170
|
+
const keys = await sessionless.generateKeys(() => {}, () => null);
|
|
171
|
+
return { pubKey: keys.pubKey, privateKey: keys.privateKey };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate owner signature embedded in a manifest.
|
|
175
|
+
// If the tenant has no ownerPubKey (registered before this feature), validation is skipped.
|
|
176
|
+
function validateOwnerSignature(manifest, tenant) {
|
|
177
|
+
if (!tenant.ownerPubKey) return; // legacy tenant — no signature required
|
|
178
|
+
|
|
179
|
+
if (!manifest.ownerPubKey || !manifest.timestamp || !manifest.signature) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
'Archive is missing owner signature fields. Sign it first:\n' +
|
|
182
|
+
' node shoppe-sign.js'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (manifest.ownerPubKey !== tenant.ownerPubKey) {
|
|
186
|
+
throw new Error('Owner public key does not match the registered key for this shoppe');
|
|
187
|
+
}
|
|
188
|
+
const age = Date.now() - parseInt(manifest.timestamp, 10);
|
|
189
|
+
if (isNaN(age) || age < 0 || age > 10 * 60 * 1000) {
|
|
190
|
+
throw new Error('Signature timestamp is invalid or expired — re-run: node shoppe-sign.js');
|
|
191
|
+
}
|
|
192
|
+
const message = manifest.timestamp + manifest.uuid;
|
|
193
|
+
if (!sessionless.verifySignature(manifest.signature, message, manifest.ownerPubKey)) {
|
|
194
|
+
throw new Error('Owner signature verification failed');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Single-use bundle tokens: token → { uuid, expiresAt }
|
|
199
|
+
const bundleTokens = new Map();
|
|
200
|
+
|
|
201
|
+
// Build the starter bundle zip for a newly registered tenant.
|
|
202
|
+
function generateBundleBuffer(tenant, ownerPrivateKey, ownerPubKey, wikiOrigin) {
|
|
203
|
+
const SIGN_SCRIPT = fs.readFileSync(
|
|
204
|
+
path.join(__dirname, 'scripts', 'shoppe-sign.js')
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const manifest = {
|
|
208
|
+
uuid: tenant.uuid,
|
|
209
|
+
emojicode: tenant.emojicode,
|
|
210
|
+
name: tenant.name,
|
|
211
|
+
wikiUrl: `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const keyData = { privateKey: ownerPrivateKey, pubKey: ownerPubKey };
|
|
215
|
+
|
|
216
|
+
const packageJson = JSON.stringify({
|
|
217
|
+
name: 'shoppe',
|
|
218
|
+
version: '1.0.0',
|
|
219
|
+
private: true,
|
|
220
|
+
description: 'Shoppe content folder',
|
|
221
|
+
dependencies: {
|
|
222
|
+
'sessionless-node': '^0.9.12'
|
|
223
|
+
}
|
|
224
|
+
}, null, 2);
|
|
225
|
+
|
|
226
|
+
const readme = [
|
|
227
|
+
`# ${tenant.name} — Shoppe Starter`,
|
|
228
|
+
'',
|
|
229
|
+
'## First-time setup',
|
|
230
|
+
'',
|
|
231
|
+
'1. Install Node.js if needed: https://nodejs.org',
|
|
232
|
+
'2. Run: `npm install` (installs sessionless-node — one time only)',
|
|
233
|
+
'3. Run: `node shoppe-sign.js init`',
|
|
234
|
+
' This moves your private key to ~/.shoppe/keys/ and removes it from this folder.',
|
|
235
|
+
'',
|
|
236
|
+
'## Adding content',
|
|
237
|
+
'',
|
|
238
|
+
'Add your goods to the appropriate folders:',
|
|
239
|
+
'',
|
|
240
|
+
' books/ → .epub / .pdf / .mobi (+ cover.jpg + info.json)',
|
|
241
|
+
' music/ → album subfolders or standalone .mp3 files',
|
|
242
|
+
' posts/ → numbered subfolders with post.md',
|
|
243
|
+
' albums/ → photo album subfolders',
|
|
244
|
+
' products/ → physical products with info.json',
|
|
245
|
+
' videos/ → numbered subfolders with .mp4/.mov/.mkv + cover.jpg + info.json',
|
|
246
|
+
' appointments/ → bookable services with info.json',
|
|
247
|
+
' subscriptions/ → membership tiers with info.json',
|
|
248
|
+
'',
|
|
249
|
+
'Each content folder can have an optional info.json:',
|
|
250
|
+
' { "title": "…", "description": "…", "price": 0, "keywords": ["tag1","tag2"] }',
|
|
251
|
+
'',
|
|
252
|
+
'## Uploading',
|
|
253
|
+
'',
|
|
254
|
+
'Run: `node shoppe-sign.js`',
|
|
255
|
+
'',
|
|
256
|
+
'This signs your manifest and creates a ready-to-upload zip next to this folder.',
|
|
257
|
+
'Drag that zip onto your wiki\'s shoppe plugin.',
|
|
258
|
+
'',
|
|
259
|
+
'## Re-uploading',
|
|
260
|
+
'',
|
|
261
|
+
'Add or update content, then run `node shoppe-sign.js` again.',
|
|
262
|
+
'Each upload overwrites existing items and adds new ones.',
|
|
263
|
+
'',
|
|
264
|
+
'## Viewing orders',
|
|
265
|
+
'',
|
|
266
|
+
'Run: `node shoppe-sign.js orders`',
|
|
267
|
+
'',
|
|
268
|
+
'Opens a signed link to your order dashboard (valid for 5 minutes).',
|
|
269
|
+
'',
|
|
270
|
+
'## Setting up payouts (Stripe)',
|
|
271
|
+
'',
|
|
272
|
+
'Run: `node shoppe-sign.js payouts`',
|
|
273
|
+
'',
|
|
274
|
+
'Opens Stripe Connect onboarding so you can receive payments.',
|
|
275
|
+
'Do this once before your first sale.',
|
|
276
|
+
].join('\n');
|
|
277
|
+
|
|
278
|
+
const zip = new AdmZip();
|
|
279
|
+
zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2)));
|
|
280
|
+
zip.addFile('shoppe-key.json', Buffer.from(JSON.stringify(keyData, null, 2)));
|
|
281
|
+
zip.addFile('shoppe-sign.js', SIGN_SCRIPT);
|
|
282
|
+
zip.addFile('package.json', Buffer.from(packageJson));
|
|
283
|
+
zip.addFile('README.md', Buffer.from(readme));
|
|
284
|
+
|
|
285
|
+
for (const dir of ['books', 'music', 'posts', 'albums', 'products', 'appointments', 'subscriptions']) {
|
|
286
|
+
zip.addFile(`${dir}/.gitkeep`, Buffer.from(''));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return zip.toBuffer();
|
|
290
|
+
}
|
|
291
|
+
|
|
138
292
|
function renderMarkdown(md) {
|
|
139
293
|
// Process code blocks first to avoid mangling their contents
|
|
140
294
|
const codeBlocks = [];
|
|
@@ -247,6 +401,16 @@ async function registerTenant(name) {
|
|
|
247
401
|
console.warn('[shoppe] Could not create addie user (payouts unavailable):', err.message);
|
|
248
402
|
}
|
|
249
403
|
|
|
404
|
+
// Create a dedicated Lucille user for video uploads
|
|
405
|
+
let lucilleKeys = null;
|
|
406
|
+
try {
|
|
407
|
+
lucilleKeys = await lucilleCreateUser();
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.warn('[shoppe] Could not create lucille user (video uploads unavailable):', err.message);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const ownerKeys = await generateOwnerKeyPair();
|
|
413
|
+
|
|
250
414
|
const tenant = {
|
|
251
415
|
uuid: sanoraUser.uuid,
|
|
252
416
|
emojicode,
|
|
@@ -254,6 +418,8 @@ async function registerTenant(name) {
|
|
|
254
418
|
keys,
|
|
255
419
|
sanoraUser,
|
|
256
420
|
addieKeys,
|
|
421
|
+
lucilleKeys,
|
|
422
|
+
ownerPubKey: ownerKeys.pubKey,
|
|
257
423
|
createdAt: Date.now()
|
|
258
424
|
};
|
|
259
425
|
|
|
@@ -261,7 +427,15 @@ async function registerTenant(name) {
|
|
|
261
427
|
saveTenants(tenants);
|
|
262
428
|
|
|
263
429
|
console.log(`[shoppe] Registered tenant: "${name}" ${emojicode} (${sanoraUser.uuid})`);
|
|
264
|
-
|
|
430
|
+
// ownerPrivateKey is returned once so the caller can include it in the starter bundle.
|
|
431
|
+
// It is NOT persisted server-side.
|
|
432
|
+
return {
|
|
433
|
+
uuid: sanoraUser.uuid,
|
|
434
|
+
emojicode,
|
|
435
|
+
name: tenant.name,
|
|
436
|
+
ownerPrivateKey: ownerKeys.privateKey,
|
|
437
|
+
ownerPubKey: ownerKeys.pubKey
|
|
438
|
+
};
|
|
265
439
|
}
|
|
266
440
|
|
|
267
441
|
function getTenantByIdentifier(identifier) {
|
|
@@ -384,6 +558,91 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
|
384
558
|
return result;
|
|
385
559
|
}
|
|
386
560
|
|
|
561
|
+
// ============================================================
|
|
562
|
+
// LUCILLE HELPERS
|
|
563
|
+
// ============================================================
|
|
564
|
+
|
|
565
|
+
async function lucilleCreateUser(lucilleUrl) {
|
|
566
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
567
|
+
const keys = await sessionless.generateKeys(() => {}, () => null);
|
|
568
|
+
sessionless.getKeys = () => keys;
|
|
569
|
+
const timestamp = Date.now().toString();
|
|
570
|
+
const message = timestamp + keys.pubKey;
|
|
571
|
+
const signature = await sessionless.sign(message);
|
|
572
|
+
|
|
573
|
+
const resp = await fetch(`${url}/user/create`, {
|
|
574
|
+
method: 'PUT',
|
|
575
|
+
headers: { 'Content-Type': 'application/json' },
|
|
576
|
+
body: JSON.stringify({ timestamp, pubKey: keys.pubKey, signature })
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const lucilleUser = await resp.json();
|
|
580
|
+
if (lucilleUser.error) throw new Error(`Lucille: ${lucilleUser.error}`);
|
|
581
|
+
return { uuid: lucilleUser.uuid, pubKey: keys.pubKey, privateKey: keys.privateKey };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function lucilleGetVideos(lucilleUuid, lucilleUrl) {
|
|
585
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
586
|
+
try {
|
|
587
|
+
const resp = await fetch(`${url}/videos/${lucilleUuid}`);
|
|
588
|
+
if (!resp.ok) return {};
|
|
589
|
+
return await resp.json();
|
|
590
|
+
} catch (err) {
|
|
591
|
+
return {};
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function lucilleRegisterVideo(tenant, title, description, tags, lucilleUrl) {
|
|
596
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
597
|
+
const { lucilleKeys } = tenant;
|
|
598
|
+
if (!lucilleKeys) throw new Error('Tenant has no Lucille user — re-register to enable video uploads');
|
|
599
|
+
const timestamp = Date.now().toString();
|
|
600
|
+
sessionless.getKeys = () => lucilleKeys;
|
|
601
|
+
const signature = await sessionless.sign(timestamp + lucilleKeys.pubKey);
|
|
602
|
+
|
|
603
|
+
const resp = await fetch(
|
|
604
|
+
`${url}/user/${lucilleKeys.uuid}/video/${encodeURIComponent(title)}`,
|
|
605
|
+
{
|
|
606
|
+
method: 'PUT',
|
|
607
|
+
headers: { 'Content-Type': 'application/json' },
|
|
608
|
+
body: JSON.stringify({ timestamp, signature, description: description || '', tags: tags || [] })
|
|
609
|
+
}
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const result = await resp.json();
|
|
613
|
+
if (result.error) throw new Error(`Lucille register video failed: ${result.error}`);
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUrl) {
|
|
618
|
+
const url = lucilleUrl || getLucilleUrl();
|
|
619
|
+
const { lucilleKeys } = tenant;
|
|
620
|
+
if (!lucilleKeys) throw new Error('Tenant has no Lucille user');
|
|
621
|
+
const timestamp = Date.now().toString();
|
|
622
|
+
sessionless.getKeys = () => lucilleKeys;
|
|
623
|
+
const signature = await sessionless.sign(timestamp + lucilleKeys.pubKey);
|
|
624
|
+
|
|
625
|
+
const form = new FormData();
|
|
626
|
+
form.append('video', fileBuffer, { filename, contentType: getMimeType(filename) });
|
|
627
|
+
|
|
628
|
+
const resp = await fetch(
|
|
629
|
+
`${url}/user/${lucilleKeys.uuid}/video/${encodeURIComponent(title)}/file`,
|
|
630
|
+
{
|
|
631
|
+
method: 'PUT',
|
|
632
|
+
headers: {
|
|
633
|
+
'x-pn-timestamp': timestamp,
|
|
634
|
+
'x-pn-signature': signature,
|
|
635
|
+
...form.getHeaders()
|
|
636
|
+
},
|
|
637
|
+
body: form
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
const result = await resp.json();
|
|
642
|
+
if (result.error) throw new Error(`Lucille video upload failed: ${result.error}`);
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
387
646
|
// ============================================================
|
|
388
647
|
// ARCHIVE PROCESSING
|
|
389
648
|
// ============================================================
|
|
@@ -427,7 +686,25 @@ async function processArchive(zipPath) {
|
|
|
427
686
|
throw new Error('emojicode does not match registered tenant');
|
|
428
687
|
}
|
|
429
688
|
|
|
430
|
-
|
|
689
|
+
// Verify owner signature (required for tenants registered after signing support was added).
|
|
690
|
+
validateOwnerSignature(manifest, tenant);
|
|
691
|
+
|
|
692
|
+
// Store manifest-level keywords and per-category redirect URLs in the tenant record.
|
|
693
|
+
const tenantUpdates = {};
|
|
694
|
+
if (Array.isArray(manifest.keywords) && manifest.keywords.length > 0) {
|
|
695
|
+
tenantUpdates.keywords = manifest.keywords.join(', ');
|
|
696
|
+
}
|
|
697
|
+
if (manifest.redirects && typeof manifest.redirects === 'object') {
|
|
698
|
+
tenantUpdates.redirects = manifest.redirects;
|
|
699
|
+
}
|
|
700
|
+
if (Object.keys(tenantUpdates).length > 0) {
|
|
701
|
+
const tenants = loadTenants();
|
|
702
|
+
Object.assign(tenants[tenant.uuid], tenantUpdates);
|
|
703
|
+
saveTenants(tenants);
|
|
704
|
+
Object.assign(tenant, tenantUpdates);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
|
|
431
708
|
|
|
432
709
|
function readInfo(entryPath) {
|
|
433
710
|
const infoPath = path.join(entryPath, 'info.json');
|
|
@@ -455,7 +732,7 @@ async function processArchive(zipPath) {
|
|
|
455
732
|
const description = info.description || '';
|
|
456
733
|
const price = info.price || 0;
|
|
457
734
|
|
|
458
|
-
await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
|
|
735
|
+
await sanoraCreateProduct(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
|
|
459
736
|
|
|
460
737
|
// Cover image — use info.cover to pin a specific file, else first image found
|
|
461
738
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -497,7 +774,7 @@ async function processArchive(zipPath) {
|
|
|
497
774
|
try {
|
|
498
775
|
const description = info.description || `Album: ${albumTitle}`;
|
|
499
776
|
const price = info.price || 0;
|
|
500
|
-
await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, 'music,album');
|
|
777
|
+
await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
|
|
501
778
|
const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
|
|
502
779
|
if (coverFile) {
|
|
503
780
|
const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
|
|
@@ -526,7 +803,7 @@ async function processArchive(zipPath) {
|
|
|
526
803
|
const buf = fs.readFileSync(entryPath);
|
|
527
804
|
const description = trackInfo.description || `Track: ${title}`;
|
|
528
805
|
const price = trackInfo.price || 0;
|
|
529
|
-
await sanoraCreateProduct(tenant, title, 'music', description, price, 0, 'music,track');
|
|
806
|
+
await sanoraCreateProduct(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
|
|
530
807
|
await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
|
|
531
808
|
results.music.push({ title, type: 'track' });
|
|
532
809
|
console.log(`[shoppe] 🎵 track: ${title}`);
|
|
@@ -566,7 +843,7 @@ async function processArchive(zipPath) {
|
|
|
566
843
|
// Register the series itself as a parent product
|
|
567
844
|
try {
|
|
568
845
|
const description = info.description || `A ${subDirs.length}-part series`;
|
|
569
|
-
await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, `post,series,order:${order}
|
|
846
|
+
await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
|
|
570
847
|
|
|
571
848
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
572
849
|
if (covers.length > 0) {
|
|
@@ -607,7 +884,7 @@ async function processArchive(zipPath) {
|
|
|
607
884
|
const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
|
|
608
885
|
|
|
609
886
|
await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
|
|
610
|
-
`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}
|
|
887
|
+
buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
|
|
611
888
|
|
|
612
889
|
await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
|
|
613
890
|
|
|
@@ -648,7 +925,7 @@ async function processArchive(zipPath) {
|
|
|
648
925
|
const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
|
|
649
926
|
const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
|
|
650
927
|
|
|
651
|
-
await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}
|
|
928
|
+
await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
|
|
652
929
|
await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
|
|
653
930
|
|
|
654
931
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -722,7 +999,7 @@ async function processArchive(zipPath) {
|
|
|
722
999
|
const price = info.price || 0;
|
|
723
1000
|
const shipping = info.shipping || 0;
|
|
724
1001
|
|
|
725
|
-
await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, `product,physical,order:${order}
|
|
1002
|
+
await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
|
|
726
1003
|
|
|
727
1004
|
// Hero image: prefer hero.jpg / hero.png, fall back to first image
|
|
728
1005
|
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -763,7 +1040,7 @@ async function processArchive(zipPath) {
|
|
|
763
1040
|
renewalDays: info.renewalDays || 30
|
|
764
1041
|
};
|
|
765
1042
|
|
|
766
|
-
await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, 'subscription');
|
|
1043
|
+
await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
|
|
767
1044
|
|
|
768
1045
|
// Upload tier metadata (benefits list, renewal period) as a JSON artifact
|
|
769
1046
|
const tierBuf = Buffer.from(JSON.stringify(tierMeta));
|
|
@@ -794,6 +1071,87 @@ async function processArchive(zipPath) {
|
|
|
794
1071
|
}
|
|
795
1072
|
}
|
|
796
1073
|
|
|
1074
|
+
// ---- videos/ ----
|
|
1075
|
+
// Each subfolder is a video. Contains the video file, optional cover/poster image, and info.json.
|
|
1076
|
+
// info.json: { title, description, price, tags[] }
|
|
1077
|
+
// Video is uploaded to Lucille (DO Spaces + WebTorrent seeder); Sanora holds the catalog entry.
|
|
1078
|
+
//
|
|
1079
|
+
// The manifest may specify a lucilleUrl to override the plugin's global config — this lets
|
|
1080
|
+
// different shoppe tenants point to different Lucille instances.
|
|
1081
|
+
//
|
|
1082
|
+
// Deduplication: Lucille stores a SHA-256 contentHash for each uploaded file. Before uploading,
|
|
1083
|
+
// shoppe computes the local file's hash and skips the upload if it matches what Lucille has.
|
|
1084
|
+
const videosDir = path.join(root, 'videos');
|
|
1085
|
+
if (fs.existsSync(videosDir)) {
|
|
1086
|
+
const effectiveLucilleUrl = (manifest.lucilleUrl || '').replace(/\/$/, '') || null;
|
|
1087
|
+
|
|
1088
|
+
const videoFolders = fs.readdirSync(videosDir)
|
|
1089
|
+
.filter(f => fs.statSync(path.join(videosDir, f)).isDirectory())
|
|
1090
|
+
.sort();
|
|
1091
|
+
|
|
1092
|
+
// Fetch existing Lucille videos once for this tenant so we can dedup
|
|
1093
|
+
let existingLucilleVideos = {};
|
|
1094
|
+
if (tenant.lucilleKeys) {
|
|
1095
|
+
existingLucilleVideos = await lucilleGetVideos(tenant.lucilleKeys.uuid, effectiveLucilleUrl);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
for (const entry of videoFolders) {
|
|
1099
|
+
const entryPath = path.join(videosDir, entry);
|
|
1100
|
+
const folderTitle = entry.replace(/^\d+-/, '');
|
|
1101
|
+
try {
|
|
1102
|
+
const info = readInfo(entryPath);
|
|
1103
|
+
const title = info.title || folderTitle;
|
|
1104
|
+
const description = info.description || '';
|
|
1105
|
+
const price = info.price || 0;
|
|
1106
|
+
const tags = info.tags || [];
|
|
1107
|
+
|
|
1108
|
+
// Compute Lucille videoId deterministically (sha256(lucilleUuid + title))
|
|
1109
|
+
// so we can embed it in the Sanora product tags before calling Lucille.
|
|
1110
|
+
const lucilleBase = (effectiveLucilleUrl || getLucilleUrl()).replace(/\/$/, '');
|
|
1111
|
+
const lucilleVideoId = tenant.lucilleKeys
|
|
1112
|
+
? crypto.createHash('sha256').update(tenant.lucilleKeys.uuid + title).digest('hex')
|
|
1113
|
+
: null;
|
|
1114
|
+
const videoTags = buildTags('video', info.keywords) +
|
|
1115
|
+
(lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
|
|
1116
|
+
|
|
1117
|
+
// Sanora catalog entry (for discovery / storefront)
|
|
1118
|
+
await sanoraCreateProduct(tenant, title, 'video', description, price, 0, videoTags);
|
|
1119
|
+
|
|
1120
|
+
// Cover / poster image (optional)
|
|
1121
|
+
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
1122
|
+
const coverFile = images.find(f => /^(cover|poster|hero|thumbnail)\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
|
|
1123
|
+
if (coverFile) {
|
|
1124
|
+
const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
|
|
1125
|
+
await sanoraUploadImage(tenant, title, coverBuf, coverFile);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Video file → Lucille (with content-hash deduplication)
|
|
1129
|
+
const videoFiles = fs.readdirSync(entryPath).filter(f => VIDEO_EXTS.has(path.extname(f).toLowerCase()));
|
|
1130
|
+
if (videoFiles.length > 0) {
|
|
1131
|
+
const videoFilename = videoFiles[0];
|
|
1132
|
+
const videoBuf = fs.readFileSync(path.join(entryPath, videoFilename));
|
|
1133
|
+
const localHash = crypto.createHash('sha256').update(videoBuf).digest('hex');
|
|
1134
|
+
|
|
1135
|
+
const existing = existingLucilleVideos[title];
|
|
1136
|
+
if (existing && existing.contentHash && existing.contentHash === localHash) {
|
|
1137
|
+
console.log(`[shoppe] ⏩ video unchanged, skipping upload: ${title}`);
|
|
1138
|
+
results.videos.push({ title, price, skipped: true });
|
|
1139
|
+
} else {
|
|
1140
|
+
await lucilleRegisterVideo(tenant, title, description, tags, effectiveLucilleUrl);
|
|
1141
|
+
await lucilleUploadVideo(tenant, title, videoBuf, videoFilename, effectiveLucilleUrl);
|
|
1142
|
+
results.videos.push({ title, price });
|
|
1143
|
+
console.log(`[shoppe] 🎬 video: ${title}`);
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
results.warnings.push(`video "${title}": no video file found (expected .mp4/.mov/.mkv/.webm/.avi)`);
|
|
1147
|
+
}
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
console.warn(`[shoppe] ⚠️ video ${entry}: ${err.message}`);
|
|
1150
|
+
results.warnings.push(`video "${entry}": ${err.message}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
797
1155
|
// ---- appointments/ ----
|
|
798
1156
|
// Each subfolder is a bookable appointment type.
|
|
799
1157
|
// info.json: { title, description, price, duration (mins), timezone, availability[], advanceDays }
|
|
@@ -819,7 +1177,7 @@ async function processArchive(zipPath) {
|
|
|
819
1177
|
advanceDays: info.advanceDays || 30
|
|
820
1178
|
};
|
|
821
1179
|
|
|
822
|
-
await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, 'appointment');
|
|
1180
|
+
await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
|
|
823
1181
|
|
|
824
1182
|
// Upload schedule as a JSON artifact so the booking page can retrieve it
|
|
825
1183
|
const scheduleBuf = Buffer.from(JSON.stringify(schedule));
|
|
@@ -858,33 +1216,55 @@ async function processArchive(zipPath) {
|
|
|
858
1216
|
async function getShoppeGoods(tenant) {
|
|
859
1217
|
const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
|
|
860
1218
|
const products = await resp.json();
|
|
1219
|
+
const redirects = tenant.redirects || {};
|
|
1220
|
+
|
|
1221
|
+
const goods = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [] };
|
|
861
1222
|
|
|
862
|
-
const
|
|
1223
|
+
const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', video: 'videos', appointment: 'appointments', subscription: 'subscriptions' };
|
|
863
1224
|
|
|
864
1225
|
for (const [title, product] of Object.entries(products)) {
|
|
865
1226
|
const isPost = product.category === 'post' || product.category === 'post-series';
|
|
1227
|
+
const bucketName = CATEGORY_BUCKET[product.category];
|
|
1228
|
+
|
|
1229
|
+
// Extract lucille-id and lucille-url from tags for video products
|
|
1230
|
+
let lucillePlayerUrl = null;
|
|
1231
|
+
if (product.category === 'video' && product.tags) {
|
|
1232
|
+
const tagParts = product.tags.split(',');
|
|
1233
|
+
const idTag = tagParts.find(t => t.startsWith('lucille-id:'));
|
|
1234
|
+
const urlTag = tagParts.find(t => t.startsWith('lucille-url:'));
|
|
1235
|
+
if (idTag && urlTag) {
|
|
1236
|
+
const videoId = idTag.slice('lucille-id:'.length);
|
|
1237
|
+
const lucilleBase = urlTag.slice('lucille-url:'.length);
|
|
1238
|
+
lucillePlayerUrl = `${lucilleBase}/watch/${videoId}`;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const defaultUrl = isPost
|
|
1243
|
+
? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
|
|
1244
|
+
: product.category === 'book'
|
|
1245
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
1246
|
+
: product.category === 'subscription'
|
|
1247
|
+
? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
|
|
1248
|
+
: product.category === 'appointment'
|
|
1249
|
+
? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
|
|
1250
|
+
: product.category === 'product' && product.shipping > 0
|
|
1251
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
1252
|
+
: product.category === 'product'
|
|
1253
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
1254
|
+
: product.category === 'video' && lucillePlayerUrl
|
|
1255
|
+
? lucillePlayerUrl
|
|
1256
|
+
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`;
|
|
1257
|
+
|
|
866
1258
|
const item = {
|
|
867
1259
|
title: product.title || title,
|
|
868
1260
|
description: product.description || '',
|
|
869
1261
|
price: product.price || 0,
|
|
870
1262
|
shipping: product.shipping || 0,
|
|
871
1263
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
872
|
-
url:
|
|
873
|
-
|
|
874
|
-
: product.category === 'book'
|
|
875
|
-
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
876
|
-
: product.category === 'subscription'
|
|
877
|
-
? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
|
|
878
|
-
: product.category === 'appointment'
|
|
879
|
-
? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
|
|
880
|
-
: product.category === 'product' && product.shipping > 0
|
|
881
|
-
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
882
|
-
: product.category === 'product'
|
|
883
|
-
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
884
|
-
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
|
|
1264
|
+
url: (bucketName && redirects[bucketName]) || defaultUrl,
|
|
1265
|
+
...(lucillePlayerUrl && { lucillePlayerUrl })
|
|
885
1266
|
};
|
|
886
|
-
const
|
|
887
|
-
const bucket = goods[CATEGORY_BUCKET[product.category]];
|
|
1267
|
+
const bucket = goods[bucketName];
|
|
888
1268
|
if (bucket) bucket.push(item);
|
|
889
1269
|
}
|
|
890
1270
|
|
|
@@ -898,7 +1278,7 @@ async function getShoppeGoods(tenant) {
|
|
|
898
1278
|
// Fetch and parse the schedule JSON artifact for an appointment product.
|
|
899
1279
|
async function getAppointmentSchedule(tenant, product) {
|
|
900
1280
|
const sanoraUrl = getSanoraUrl();
|
|
901
|
-
const scheduleArtifact = (product.artifacts || []).find(a => a.
|
|
1281
|
+
const scheduleArtifact = (product.artifacts || []).find(a => a.endsWith('.json'));
|
|
902
1282
|
if (!scheduleArtifact) return null;
|
|
903
1283
|
const resp = await fetch(`${sanoraUrl}/artifacts/${scheduleArtifact}`);
|
|
904
1284
|
if (!resp.ok) return null;
|
|
@@ -948,20 +1328,15 @@ function generateAvailableSlots(schedule, bookedSlots) {
|
|
|
948
1328
|
const dateStr = dateFmt.format(date);
|
|
949
1329
|
const dayName = weekdayFmt.format(date).toLowerCase();
|
|
950
1330
|
const rule = (schedule.availability || []).find(a => a.day.toLowerCase() === dayName);
|
|
951
|
-
if (!rule) continue;
|
|
952
|
-
|
|
953
|
-
const [startH, startM] = rule.start.split(':').map(Number);
|
|
954
|
-
const [endH, endM] = rule.end.split(':').map(Number);
|
|
955
|
-
const startMins = startH * 60 + startM;
|
|
956
|
-
const endMins = endH * 60 + endM;
|
|
1331
|
+
if (!rule || !rule.slots || !rule.slots.length) continue;
|
|
957
1332
|
|
|
958
1333
|
const slots = [];
|
|
959
|
-
for (
|
|
1334
|
+
for (const slotTime of rule.slots) {
|
|
1335
|
+
const [h, m] = slotTime.split(':').map(Number);
|
|
1336
|
+
const slotMins = h * 60 + m;
|
|
960
1337
|
// For today, skip slots within the next hour
|
|
961
|
-
if (d === 0 &&
|
|
962
|
-
const
|
|
963
|
-
const min = (m % 60).toString().padStart(2, '0');
|
|
964
|
-
const slotStr = `${dateStr}T${h}:${min}`;
|
|
1338
|
+
if (d === 0 && slotMins <= nowMins + 60) continue;
|
|
1339
|
+
const slotStr = `${dateStr}T${slotTime}`;
|
|
965
1340
|
if (!bookedSet.has(slotStr)) slots.push(slotStr);
|
|
966
1341
|
}
|
|
967
1342
|
|
|
@@ -979,7 +1354,7 @@ function generateAvailableSlots(schedule, bookedSlots) {
|
|
|
979
1354
|
// Fetch tier metadata (benefits list, renewalDays) from the tier-info artifact.
|
|
980
1355
|
async function getTierInfo(tenant, product) {
|
|
981
1356
|
const sanoraUrl = getSanoraUrl();
|
|
982
|
-
const tierArtifact = (product.artifacts || []).find(a => a.
|
|
1357
|
+
const tierArtifact = (product.artifacts || []).find(a => a.endsWith('.json'));
|
|
983
1358
|
if (!tierArtifact) return null;
|
|
984
1359
|
const resp = await fetch(`${sanoraUrl}/artifacts/${tierArtifact}`);
|
|
985
1360
|
if (!resp.ok) return null;
|
|
@@ -1014,21 +1389,167 @@ async function getSubscriptionStatus(tenant, productId, recoveryKey) {
|
|
|
1014
1389
|
} catch { return { active: false }; }
|
|
1015
1390
|
}
|
|
1016
1391
|
|
|
1017
|
-
const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁' };
|
|
1392
|
+
const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁', video: '🎬' };
|
|
1393
|
+
|
|
1394
|
+
// ============================================================
|
|
1395
|
+
// OWNER ORDERS
|
|
1396
|
+
// ============================================================
|
|
1397
|
+
|
|
1398
|
+
// Validate an owner-signed request (used for browser-facing owner routes).
|
|
1399
|
+
// Expects req.query.timestamp and req.query.signature.
|
|
1400
|
+
// Returns an error string if invalid, null if valid.
|
|
1401
|
+
function checkOwnerSignature(req, tenant) {
|
|
1402
|
+
if (!tenant.ownerPubKey) return 'This shoppe was registered before owner signing was added';
|
|
1403
|
+
const { timestamp, signature } = req.query;
|
|
1404
|
+
if (!timestamp || !signature) return 'Missing timestamp or signature — generate a fresh URL with: node shoppe-sign.js orders';
|
|
1405
|
+
const age = Date.now() - parseInt(timestamp, 10);
|
|
1406
|
+
if (isNaN(age) || age < 0 || age > 5 * 60 * 1000) return 'URL has expired — generate a new one with: node shoppe-sign.js orders';
|
|
1407
|
+
const message = timestamp + tenant.uuid;
|
|
1408
|
+
if (!sessionless.verifySignature(signature, message, tenant.ownerPubKey)) return 'Signature invalid';
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Fetch all orders for every product belonging to a tenant.
|
|
1413
|
+
// Returns an array of { product, orders } objects.
|
|
1414
|
+
async function getAllOrders(tenant) {
|
|
1415
|
+
const sanoraUrl = getSanoraUrl();
|
|
1416
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1417
|
+
const products = await productsResp.json();
|
|
1418
|
+
|
|
1419
|
+
sessionless.getKeys = () => tenant.keys;
|
|
1420
|
+
|
|
1421
|
+
const results = [];
|
|
1422
|
+
for (const [title, product] of Object.entries(products)) {
|
|
1423
|
+
const timestamp = Date.now().toString();
|
|
1424
|
+
const signature = await sessionless.sign(timestamp + tenant.uuid);
|
|
1425
|
+
try {
|
|
1426
|
+
const resp = await fetch(
|
|
1427
|
+
`${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(product.productId)}` +
|
|
1428
|
+
`?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
|
|
1429
|
+
);
|
|
1430
|
+
if (!resp.ok) continue;
|
|
1431
|
+
const data = await resp.json();
|
|
1432
|
+
const orders = data.orders || [];
|
|
1433
|
+
if (orders.length > 0) results.push({ product, orders });
|
|
1434
|
+
} catch { /* skip products with no order data */ }
|
|
1435
|
+
}
|
|
1436
|
+
return results;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function fmtDate(ts) {
|
|
1440
|
+
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function generateOrdersHTML(tenant, orderData) {
|
|
1444
|
+
const totalOrders = orderData.reduce((n, p) => n + p.orders.length, 0);
|
|
1445
|
+
const totalRevenue = orderData.reduce((n, p) =>
|
|
1446
|
+
n + p.orders.reduce((m, o) => m + (o.amount || p.product.price || 0), 0), 0);
|
|
1447
|
+
|
|
1448
|
+
const sections = orderData.map(({ product, orders }) => {
|
|
1449
|
+
const emoji = CATEGORY_EMOJI[product.category] || '🛍️';
|
|
1450
|
+
const rows = orders.map(o => {
|
|
1451
|
+
const date = fmtDate(o.paidAt || o.createdAt || Date.now());
|
|
1452
|
+
const amount = o.amount != null ? `$${(o.amount / 100).toFixed(2)}` : `$${((product.price || 0) / 100).toFixed(2)}`;
|
|
1453
|
+
const detail = o.slot
|
|
1454
|
+
? `<span class="tag">📅 ${o.slot}</span>`
|
|
1455
|
+
: o.renewalDays
|
|
1456
|
+
? `<span class="tag">🔄 ${o.renewalDays}d renewal</span>`
|
|
1457
|
+
: '';
|
|
1458
|
+
const keyHint = o.orderKey
|
|
1459
|
+
? `<span class="hash" title="sha256(recoveryKey+productId)">${o.orderKey.slice(0, 12)}…</span>`
|
|
1460
|
+
: '—';
|
|
1461
|
+
return `<tr><td>${date}</td><td>${amount}</td><td>${keyHint}</td><td>${detail}</td></tr>`;
|
|
1462
|
+
}).join('');
|
|
1463
|
+
|
|
1464
|
+
return `
|
|
1465
|
+
<div class="product-section">
|
|
1466
|
+
<div class="product-header">
|
|
1467
|
+
<span class="product-emoji">${emoji}</span>
|
|
1468
|
+
<span class="product-title">${escHtml(product.title || 'Untitled')}</span>
|
|
1469
|
+
<span class="order-count">${orders.length} order${orders.length !== 1 ? 's' : ''}</span>
|
|
1470
|
+
</div>
|
|
1471
|
+
<table>
|
|
1472
|
+
<thead><tr><th>Date</th><th>Amount</th><th>Key hash</th><th>Details</th></tr></thead>
|
|
1473
|
+
<tbody>${rows}</tbody>
|
|
1474
|
+
</table>
|
|
1475
|
+
</div>`;
|
|
1476
|
+
}).join('');
|
|
1477
|
+
|
|
1478
|
+
const empty = totalOrders === 0
|
|
1479
|
+
? '<p class="empty">No orders yet. Share your shoppe link to get started!</p>'
|
|
1480
|
+
: '';
|
|
1481
|
+
|
|
1482
|
+
return `<!DOCTYPE html>
|
|
1483
|
+
<html lang="en">
|
|
1484
|
+
<head>
|
|
1485
|
+
<meta charset="UTF-8">
|
|
1486
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1487
|
+
<title>Orders — ${escHtml(tenant.name)}</title>
|
|
1488
|
+
<style>
|
|
1489
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1490
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: #e0e0e0; min-height: 100vh; }
|
|
1491
|
+
header { background: linear-gradient(135deg, #1a1a2e, #0f3460); padding: 36px 32px 28px; }
|
|
1492
|
+
header h1 { font-size: 26px; font-weight: 700; margin-bottom: 4px; }
|
|
1493
|
+
header p { font-size: 14px; color: #aaa; }
|
|
1494
|
+
.stats { display: flex; gap: 20px; padding: 24px 32px; border-bottom: 1px solid #222; }
|
|
1495
|
+
.stat { background: #18181c; border: 1px solid #333; border-radius: 12px; padding: 16px 24px; }
|
|
1496
|
+
.stat-val { font-size: 28px; font-weight: 800; color: #7ec8e3; }
|
|
1497
|
+
.stat-lbl { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
|
|
1498
|
+
.main { max-width: 900px; margin: 0 auto; padding: 28px 24px 60px; }
|
|
1499
|
+
.product-section { margin-bottom: 32px; }
|
|
1500
|
+
.product-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
1501
|
+
.product-emoji { font-size: 20px; }
|
|
1502
|
+
.product-title { font-size: 17px; font-weight: 600; flex: 1; }
|
|
1503
|
+
.order-count { font-size: 12px; color: #888; background: #222; border-radius: 10px; padding: 3px 10px; }
|
|
1504
|
+
table { width: 100%; border-collapse: collapse; background: #18181c; border: 1px solid #2a2a2e; border-radius: 12px; overflow: hidden; }
|
|
1505
|
+
thead { background: #222; }
|
|
1506
|
+
th { padding: 10px 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #888; text-align: left; }
|
|
1507
|
+
td { padding: 11px 14px; font-size: 13px; border-top: 1px solid #222; }
|
|
1508
|
+
.hash { font-family: monospace; font-size: 12px; color: #7ec8e3; }
|
|
1509
|
+
.tag { background: #2a2a2e; border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #ccc; }
|
|
1510
|
+
.empty { color: #555; font-size: 15px; text-align: center; padding: 60px 0; }
|
|
1511
|
+
.back { display: inline-block; margin-bottom: 20px; color: #7ec8e3; text-decoration: none; font-size: 13px; }
|
|
1512
|
+
.back:hover { text-decoration: underline; }
|
|
1513
|
+
.warning { background: #2a1f0a; border: 1px solid #665; border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #cc9; margin-bottom: 24px; }
|
|
1514
|
+
</style>
|
|
1515
|
+
</head>
|
|
1516
|
+
<body>
|
|
1517
|
+
<header>
|
|
1518
|
+
<h1>${escHtml(tenant.emojicode)} ${escHtml(tenant.name)}</h1>
|
|
1519
|
+
<p>Order history — this URL is valid for 5 minutes</p>
|
|
1520
|
+
</header>
|
|
1521
|
+
<div class="stats">
|
|
1522
|
+
<div class="stat"><div class="stat-val">${totalOrders}</div><div class="stat-lbl">Total orders</div></div>
|
|
1523
|
+
<div class="stat"><div class="stat-val">$${(totalRevenue / 100).toFixed(2)}</div><div class="stat-lbl">Total revenue</div></div>
|
|
1524
|
+
<div class="stat"><div class="stat-val">${orderData.length}</div><div class="stat-lbl">Products sold</div></div>
|
|
1525
|
+
</div>
|
|
1526
|
+
<div class="main">
|
|
1527
|
+
<a class="back" href="/plugin/shoppe/${tenant.uuid}">← Back to shoppe</a>
|
|
1528
|
+
<div class="warning">🔑 Key hashes are shown (not recovery keys — those never reach this server). Revenue totals are approximate when order amounts aren't stored.</div>
|
|
1529
|
+
${empty}
|
|
1530
|
+
${sections}
|
|
1531
|
+
</div>
|
|
1532
|
+
</body>
|
|
1533
|
+
</html>`;
|
|
1534
|
+
}
|
|
1018
1535
|
|
|
1019
1536
|
function renderCards(items, category) {
|
|
1020
1537
|
if (items.length === 0) {
|
|
1021
1538
|
return '<p class="empty">Nothing here yet.</p>';
|
|
1022
1539
|
}
|
|
1023
1540
|
return items.map(item => {
|
|
1541
|
+
const isVideo = !!item.lucillePlayerUrl;
|
|
1024
1542
|
const imgHtml = item.image
|
|
1025
|
-
? `<div class="card-img"><img src="${item.image}" alt="" loading="lazy"></div>`
|
|
1543
|
+
? `<div class="card-img${isVideo ? ' card-video-play' : ''}"><img src="${item.image}" alt="" loading="lazy"></div>`
|
|
1026
1544
|
: `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
|
|
1027
1545
|
const priceHtml = (item.price > 0 || category === 'product')
|
|
1028
1546
|
? `<div class="price">$${(item.price / 100).toFixed(2)}${item.shipping ? ` <span class="shipping">+ $${(item.shipping / 100).toFixed(2)} shipping</span>` : ''}</div>`
|
|
1029
1547
|
: '';
|
|
1548
|
+
const clickHandler = isVideo
|
|
1549
|
+
? `playVideo('${item.lucillePlayerUrl}')`
|
|
1550
|
+
: `window.open('${item.url}','_blank')`;
|
|
1030
1551
|
return `
|
|
1031
|
-
<div class="card" onclick="
|
|
1552
|
+
<div class="card" onclick="${clickHandler}">
|
|
1032
1553
|
${imgHtml}
|
|
1033
1554
|
<div class="card-body">
|
|
1034
1555
|
<div class="card-title">${item.title}</div>
|
|
@@ -1048,14 +1569,15 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1048
1569
|
{ id: 'posts', label: '📝 Posts', count: goods.posts.length },
|
|
1049
1570
|
{ id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
|
|
1050
1571
|
{ id: 'products', label: '📦 Products', count: goods.products.length },
|
|
1572
|
+
{ id: 'videos', label: '🎬 Videos', count: goods.videos.length },
|
|
1051
1573
|
{ id: 'appointments', label: '📅 Appointments', count: goods.appointments.length },
|
|
1052
|
-
{ id: 'subscriptions', label: '🎁
|
|
1574
|
+
{ id: 'subscriptions', label: '🎁 Infuse', count: goods.subscriptions.length }
|
|
1053
1575
|
]
|
|
1054
1576
|
.filter(t => t.always || t.count > 0)
|
|
1055
1577
|
.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
|
|
1056
1578
|
.join('');
|
|
1057
1579
|
|
|
1058
|
-
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.appointments, ...goods.subscriptions];
|
|
1580
|
+
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.videos, ...goods.appointments, ...goods.subscriptions];
|
|
1059
1581
|
|
|
1060
1582
|
return `<!DOCTYPE html>
|
|
1061
1583
|
<html lang="en">
|
|
@@ -1063,6 +1585,7 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1063
1585
|
<meta charset="UTF-8">
|
|
1064
1586
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1065
1587
|
<title>${tenant.name}</title>
|
|
1588
|
+
${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
|
|
1066
1589
|
<style>
|
|
1067
1590
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1068
1591
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
|
|
@@ -1089,6 +1612,16 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1089
1612
|
.price { font-size: 15px; font-weight: 700; color: #0066cc; }
|
|
1090
1613
|
.shipping { font-size: 12px; font-weight: 400; color: #888; }
|
|
1091
1614
|
.empty { color: #999; text-align: center; padding: 60px 0; font-size: 15px; }
|
|
1615
|
+
.card-video-play { position: relative; }
|
|
1616
|
+
.card-video-play::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 36px; color: rgba(255,255,255,0.9); background: rgba(0,0,0,0.35); opacity: 0; transition: opacity 0.2s; pointer-events: none; }
|
|
1617
|
+
.card:hover .card-video-play::after { opacity: 1; }
|
|
1618
|
+
.video-modal { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; }
|
|
1619
|
+
.video-modal.open { display: flex; }
|
|
1620
|
+
.video-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.85); }
|
|
1621
|
+
.video-modal-content { position: relative; z-index: 1; width: 90vw; max-width: 960px; aspect-ratio: 16/9; background: #000; border-radius: 10px; overflow: hidden; box-shadow: 0 24px 80px rgba(0,0,0,0.6); }
|
|
1622
|
+
.video-modal-content iframe { width: 100%; height: 100%; border: none; display: block; }
|
|
1623
|
+
.video-modal-close { position: absolute; top: 10px; right: 12px; z-index: 2; background: rgba(0,0,0,0.5); border: none; color: #fff; font-size: 20px; line-height: 1; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
|
|
1624
|
+
.video-modal-close:hover { background: rgba(0,0,0,0.8); }
|
|
1092
1625
|
</style>
|
|
1093
1626
|
</head>
|
|
1094
1627
|
<body>
|
|
@@ -1105,12 +1638,20 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1105
1638
|
<div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
|
|
1106
1639
|
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
1107
1640
|
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
1641
|
+
<div id="videos" class="section"><div class="grid">${renderCards(goods.videos, 'video')}</div></div>
|
|
1108
1642
|
<div id="appointments" class="section"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
|
|
1109
1643
|
<div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
|
|
1110
1644
|
<div style="text-align:center;padding:24px 0 8px;font-size:14px;color:#888;">
|
|
1111
|
-
Already
|
|
1645
|
+
Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
|
|
1112
1646
|
</div>
|
|
1113
1647
|
</main>
|
|
1648
|
+
<div id="video-modal" class="video-modal">
|
|
1649
|
+
<div class="video-modal-backdrop" onclick="closeVideo()"></div>
|
|
1650
|
+
<div class="video-modal-content">
|
|
1651
|
+
<button class="video-modal-close" onclick="closeVideo()">✕</button>
|
|
1652
|
+
<iframe id="video-iframe" src="" allowfullscreen allow="autoplay"></iframe>
|
|
1653
|
+
</div>
|
|
1654
|
+
</div>
|
|
1114
1655
|
<script>
|
|
1115
1656
|
function show(id, tab) {
|
|
1116
1657
|
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
@@ -1118,6 +1659,15 @@ function generateShoppeHTML(tenant, goods) {
|
|
|
1118
1659
|
document.getElementById(id).classList.add('active');
|
|
1119
1660
|
tab.classList.add('active');
|
|
1120
1661
|
}
|
|
1662
|
+
function playVideo(url) {
|
|
1663
|
+
document.getElementById('video-iframe').src = url;
|
|
1664
|
+
document.getElementById('video-modal').classList.add('open');
|
|
1665
|
+
}
|
|
1666
|
+
function closeVideo() {
|
|
1667
|
+
document.getElementById('video-modal').classList.remove('open');
|
|
1668
|
+
document.getElementById('video-iframe').src = '';
|
|
1669
|
+
}
|
|
1670
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
|
|
1121
1671
|
</script>
|
|
1122
1672
|
</body>
|
|
1123
1673
|
</html>`;
|
|
@@ -1192,14 +1742,54 @@ async function startServer(params) {
|
|
|
1192
1742
|
// Register a new tenant (owner only)
|
|
1193
1743
|
app.post('/plugin/shoppe/register', owner, async (req, res) => {
|
|
1194
1744
|
try {
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1745
|
+
const { uuid, emojicode, name, ownerPrivateKey, ownerPubKey } = await registerTenant(req.body.name);
|
|
1746
|
+
|
|
1747
|
+
// Generate a single-use, short-lived token for the starter bundle download.
|
|
1748
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1749
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
1750
|
+
bundleTokens.set(token, { uuid, ownerPrivateKey, ownerPubKey, wikiOrigin, expiresAt: Date.now() + 15 * 60 * 1000 });
|
|
1751
|
+
|
|
1752
|
+
// Expire tokens automatically after 15 minutes.
|
|
1753
|
+
setTimeout(() => bundleTokens.delete(token), 15 * 60 * 1000);
|
|
1754
|
+
|
|
1755
|
+
res.json({ success: true, tenant: { uuid, emojicode, name }, bundleToken: token });
|
|
1197
1756
|
} catch (err) {
|
|
1198
1757
|
console.error('[shoppe] register error:', err);
|
|
1199
1758
|
res.status(500).json({ success: false, error: err.message });
|
|
1200
1759
|
}
|
|
1201
1760
|
});
|
|
1202
1761
|
|
|
1762
|
+
// Starter bundle download — single-use token acts as the credential.
|
|
1763
|
+
// The zip contains manifest.json, shoppe-key.json (private key), shoppe-sign.js, and empty content folders.
|
|
1764
|
+
app.get('/plugin/shoppe/bundle/:token', (req, res) => {
|
|
1765
|
+
const entry = bundleTokens.get(req.params.token);
|
|
1766
|
+
if (!entry) {
|
|
1767
|
+
return res.status(404).send('<h1>Bundle link expired or invalid</h1><p>Re-register to get a new link.</p>');
|
|
1768
|
+
}
|
|
1769
|
+
if (Date.now() > entry.expiresAt) {
|
|
1770
|
+
bundleTokens.delete(req.params.token);
|
|
1771
|
+
return res.status(410).send('<h1>Bundle link expired</h1><p>Re-register to get a new link.</p>');
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Invalidate immediately — single use
|
|
1775
|
+
bundleTokens.delete(req.params.token);
|
|
1776
|
+
|
|
1777
|
+
const tenant = getTenantByIdentifier(entry.uuid);
|
|
1778
|
+
if (!tenant) return res.status(404).send('<h1>Tenant not found</h1>');
|
|
1779
|
+
|
|
1780
|
+
try {
|
|
1781
|
+
const buf = generateBundleBuffer(tenant, entry.ownerPrivateKey, entry.ownerPubKey, entry.wikiOrigin);
|
|
1782
|
+
const filename = `${tenant.name.replace(/[^a-z0-9-]/gi, '-').toLowerCase()}-shoppe-starter.zip`;
|
|
1783
|
+
res.set('Content-Type', 'application/zip');
|
|
1784
|
+
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
|
1785
|
+
res.send(buf);
|
|
1786
|
+
console.log(`[shoppe] Starter bundle downloaded for "${tenant.name}" (${tenant.uuid})`);
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
console.error('[shoppe] bundle error:', err);
|
|
1789
|
+
res.status(500).send('<h1>Error generating bundle</h1><p>' + err.message + '</p>');
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1203
1793
|
// List all tenants (owner only — includes uuid for management)
|
|
1204
1794
|
app.get('/plugin/shoppe/tenants', owner, (req, res) => {
|
|
1205
1795
|
const tenants = loadTenants();
|
|
@@ -1242,16 +1832,17 @@ async function startServer(params) {
|
|
|
1242
1832
|
// Get config (owner only)
|
|
1243
1833
|
app.get('/plugin/shoppe/config', owner, (req, res) => {
|
|
1244
1834
|
const config = loadConfig();
|
|
1245
|
-
res.json({ success: true, sanoraUrl: config.sanoraUrl || '' });
|
|
1835
|
+
res.json({ success: true, sanoraUrl: config.sanoraUrl || '', lucilleUrl: config.lucilleUrl || '' });
|
|
1246
1836
|
});
|
|
1247
1837
|
|
|
1248
1838
|
// Save config (owner only)
|
|
1249
1839
|
app.post('/plugin/shoppe/config', owner, (req, res) => {
|
|
1250
|
-
const { sanoraUrl, addieUrl } = req.body;
|
|
1840
|
+
const { sanoraUrl, addieUrl, lucilleUrl } = req.body;
|
|
1251
1841
|
if (!sanoraUrl) return res.status(400).json({ success: false, error: 'sanoraUrl required' });
|
|
1252
1842
|
const config = loadConfig();
|
|
1253
1843
|
config.sanoraUrl = sanoraUrl;
|
|
1254
1844
|
if (addieUrl) config.addieUrl = addieUrl;
|
|
1845
|
+
if (lucilleUrl) config.lucilleUrl = lucilleUrl;
|
|
1255
1846
|
saveConfig(config);
|
|
1256
1847
|
console.log('[shoppe] Sanora URL set to:', sanoraUrl);
|
|
1257
1848
|
res.json({ success: true });
|
|
@@ -1265,7 +1856,7 @@ async function startServer(params) {
|
|
|
1265
1856
|
|
|
1266
1857
|
const title = decodeURIComponent(req.params.title);
|
|
1267
1858
|
const sanoraUrlInternal = getSanoraUrl();
|
|
1268
|
-
const wikiOrigin = `${req
|
|
1859
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1269
1860
|
const sanoraUrl = `${wikiOrigin}/plugin/allyabase/sanora`;
|
|
1270
1861
|
const productsResp = await fetch(`${sanoraUrlInternal}/products/${tenant.uuid}`);
|
|
1271
1862
|
const products = await productsResp.json();
|
|
@@ -1293,7 +1884,8 @@ async function startServer(params) {
|
|
|
1293
1884
|
ebookUrl,
|
|
1294
1885
|
shoppeUrl,
|
|
1295
1886
|
payees,
|
|
1296
|
-
tenantUuid: tenant.uuid
|
|
1887
|
+
tenantUuid: tenant.uuid,
|
|
1888
|
+
keywords: extractKeywords(product)
|
|
1297
1889
|
});
|
|
1298
1890
|
|
|
1299
1891
|
res.set('Content-Type', 'text/html');
|
|
@@ -1326,7 +1918,7 @@ async function startServer(params) {
|
|
|
1326
1918
|
if (!product) return res.status(404).send('<h1>Appointment not found</h1>');
|
|
1327
1919
|
|
|
1328
1920
|
const schedule = await getAppointmentSchedule(tenant, product);
|
|
1329
|
-
const wikiOrigin = `${req
|
|
1921
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1330
1922
|
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1331
1923
|
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
1332
1924
|
|
|
@@ -1342,7 +1934,8 @@ async function startServer(params) {
|
|
|
1342
1934
|
duration: String(schedule ? schedule.duration : 60),
|
|
1343
1935
|
proceedLabel: price === 0 ? 'Confirm Booking →' : 'Continue to Payment →',
|
|
1344
1936
|
shoppeUrl,
|
|
1345
|
-
tenantUuid: tenant.uuid
|
|
1937
|
+
tenantUuid: tenant.uuid,
|
|
1938
|
+
keywords: extractKeywords(product)
|
|
1346
1939
|
});
|
|
1347
1940
|
|
|
1348
1941
|
res.set('Content-Type', 'text/html');
|
|
@@ -1393,7 +1986,7 @@ async function startServer(params) {
|
|
|
1393
1986
|
if (!product) return res.status(404).send('<h1>Tier not found</h1>');
|
|
1394
1987
|
|
|
1395
1988
|
const tierInfo = await getTierInfo(tenant, product);
|
|
1396
|
-
const wikiOrigin = `${req
|
|
1989
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1397
1990
|
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1398
1991
|
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
1399
1992
|
const benefits = tierInfo && tierInfo.benefits
|
|
@@ -1410,7 +2003,8 @@ async function startServer(params) {
|
|
|
1410
2003
|
benefits,
|
|
1411
2004
|
renewalDays: String(tierInfo ? (tierInfo.renewalDays || 30) : 30),
|
|
1412
2005
|
shoppeUrl,
|
|
1413
|
-
tenantUuid: tenant.uuid
|
|
2006
|
+
tenantUuid: tenant.uuid,
|
|
2007
|
+
keywords: extractKeywords(product)
|
|
1414
2008
|
});
|
|
1415
2009
|
|
|
1416
2010
|
res.set('Content-Type', 'text/html');
|
|
@@ -1421,11 +2015,119 @@ async function startServer(params) {
|
|
|
1421
2015
|
}
|
|
1422
2016
|
});
|
|
1423
2017
|
|
|
2018
|
+
// Owner orders page — authenticated via signed URL from shoppe-sign.js
|
|
2019
|
+
app.get('/plugin/shoppe/:uuid/orders', async (req, res) => {
|
|
2020
|
+
try {
|
|
2021
|
+
const tenant = getTenantByIdentifier(req.params.uuid);
|
|
2022
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
2023
|
+
|
|
2024
|
+
const err = checkOwnerSignature(req, tenant);
|
|
2025
|
+
if (err) {
|
|
2026
|
+
return res.status(403).send(
|
|
2027
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2028
|
+
`<h2>Access denied</h2><p style="color:#f66;margin-top:12px">${escHtml(err)}</p></body></html>`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const orderData = await getAllOrders(tenant);
|
|
2033
|
+
res.set('Content-Type', 'text/html');
|
|
2034
|
+
res.send(generateOrdersHTML(tenant, orderData));
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
console.error('[shoppe] orders page error:', err);
|
|
2037
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
// Owner payouts setup — validates owner sig, redirects to Stripe Connect Express onboarding
|
|
2042
|
+
app.get('/plugin/shoppe/:uuid/payouts', async (req, res) => {
|
|
2043
|
+
try {
|
|
2044
|
+
const tenant = getTenantByIdentifier(req.params.uuid);
|
|
2045
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
2046
|
+
|
|
2047
|
+
const err = checkOwnerSignature(req, tenant);
|
|
2048
|
+
if (err) {
|
|
2049
|
+
return res.status(403).send(
|
|
2050
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2051
|
+
`<h2>Access denied</h2><p style="color:#f66;margin-top:12px">${escHtml(err)}</p></body></html>`
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!tenant.addieKeys) {
|
|
2056
|
+
return res.status(500).send(
|
|
2057
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2058
|
+
`<h2>Payment account not configured</h2><p>This shoppe has no Addie user. Re-register to get one.</p></body></html>`
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
const addieKeys = { pubKey: tenant.addieKeys.pubKey, privateKey: tenant.addieKeys.privateKey };
|
|
2063
|
+
sessionless.getKeys = () => addieKeys;
|
|
2064
|
+
const timestamp = Date.now().toString();
|
|
2065
|
+
const message = timestamp + tenant.addieKeys.uuid;
|
|
2066
|
+
const signature = await sessionless.sign(message);
|
|
2067
|
+
|
|
2068
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
2069
|
+
const returnUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}/payouts/return`;
|
|
2070
|
+
|
|
2071
|
+
const resp = await fetch(`${getAddieUrl()}/user/${tenant.addieKeys.uuid}/processor/stripe/express`, {
|
|
2072
|
+
method: 'PUT',
|
|
2073
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2074
|
+
body: JSON.stringify({ timestamp, pubKey: tenant.addieKeys.pubKey, signature, returnUrl })
|
|
2075
|
+
});
|
|
2076
|
+
const json = await resp.json();
|
|
2077
|
+
|
|
2078
|
+
if (json.error) {
|
|
2079
|
+
return res.status(500).send(
|
|
2080
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
|
|
2081
|
+
`<h2>Error setting up payouts</h2><p style="color:#f66;margin-top:12px">${escHtml(json.error)}</p></body></html>`
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
res.redirect(json.onboardingUrl);
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
console.error('[shoppe] payouts error:', err);
|
|
2088
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
// Stripe Connect Express return page — no auth, Stripe redirects here after onboarding
|
|
2093
|
+
app.get('/plugin/shoppe/:uuid/payouts/return', (req, res) => {
|
|
2094
|
+
const tenant = getTenantByIdentifier(req.params.uuid);
|
|
2095
|
+
const name = tenant ? escHtml(tenant.name) : 'your shoppe';
|
|
2096
|
+
const shoppeUrl = tenant ? `/plugin/shoppe/${tenant.uuid}` : '/';
|
|
2097
|
+
res.set('Content-Type', 'text/html');
|
|
2098
|
+
res.send(`<!DOCTYPE html>
|
|
2099
|
+
<html lang="en">
|
|
2100
|
+
<head>
|
|
2101
|
+
<meta charset="UTF-8">
|
|
2102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2103
|
+
<title>Payouts connected — ${name}</title>
|
|
2104
|
+
<style>
|
|
2105
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2106
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
2107
|
+
.card { background: #18181c; border: 1px solid #333; border-radius: 16px; padding: 48px 40px; max-width: 480px; text-align: center; }
|
|
2108
|
+
h1 { font-size: 28px; font-weight: 700; margin-bottom: 12px; }
|
|
2109
|
+
p { color: #aaa; font-size: 15px; line-height: 1.6; margin-top: 10px; }
|
|
2110
|
+
a { display: inline-block; margin-top: 28px; color: #7ec8e3; text-decoration: none; font-size: 14px; }
|
|
2111
|
+
a:hover { text-decoration: underline; }
|
|
2112
|
+
</style>
|
|
2113
|
+
</head>
|
|
2114
|
+
<body>
|
|
2115
|
+
<div class="card">
|
|
2116
|
+
<div style="font-size:52px;margin-bottom:20px">✅</div>
|
|
2117
|
+
<h1>Payouts connected!</h1>
|
|
2118
|
+
<p>Your Stripe account is now linked to <strong>${name}</strong>.</p>
|
|
2119
|
+
<p>Payments will be transferred to your account automatically after each sale.</p>
|
|
2120
|
+
<a href="${escHtml(shoppeUrl)}">← Back to shoppe</a>
|
|
2121
|
+
</div>
|
|
2122
|
+
</body>
|
|
2123
|
+
</html>`);
|
|
2124
|
+
});
|
|
2125
|
+
|
|
1424
2126
|
// Membership portal page
|
|
1425
2127
|
app.get('/plugin/shoppe/:identifier/membership', (req, res) => {
|
|
1426
2128
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1427
2129
|
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
1428
|
-
const wikiOrigin = `${req
|
|
2130
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1429
2131
|
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1430
2132
|
const html = fillTemplate(SUBSCRIPTION_MEMBERSHIP_TMPL, { shoppeUrl, tenantUuid: tenant.uuid });
|
|
1431
2133
|
res.set('Content-Type', 'text/html');
|
|
@@ -1444,7 +2146,7 @@ async function startServer(params) {
|
|
|
1444
2146
|
const sanoraUrl = getSanoraUrl();
|
|
1445
2147
|
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1446
2148
|
const products = await productsResp.json();
|
|
1447
|
-
const wikiOrigin = `${req
|
|
2149
|
+
const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
|
|
1448
2150
|
const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
|
|
1449
2151
|
|
|
1450
2152
|
const subscriptions = [];
|
|
@@ -1459,7 +2161,7 @@ async function startServer(params) {
|
|
|
1459
2161
|
// Only expose exclusive artifact URLs to active subscribers
|
|
1460
2162
|
const exclusiveArtifacts = status.active
|
|
1461
2163
|
? (product.artifacts || [])
|
|
1462
|
-
.filter(a => !a.
|
|
2164
|
+
.filter(a => !a.endsWith('.json'))
|
|
1463
2165
|
.map(a => ({ name: a.split('-').slice(1).join('-'), url: `${sanoraUrl}/artifacts/${a}` }))
|
|
1464
2166
|
: [];
|
|
1465
2167
|
|
|
@@ -1494,7 +2196,7 @@ async function startServer(params) {
|
|
|
1494
2196
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1495
2197
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1496
2198
|
|
|
1497
|
-
const { recoveryKey, productId, title, slotDatetime } = req.body;
|
|
2199
|
+
const { recoveryKey, productId, title, slotDatetime, payees: clientPayees } = req.body;
|
|
1498
2200
|
if (!productId) return res.status(400).json({ error: 'productId required' });
|
|
1499
2201
|
if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
|
|
1500
2202
|
|
|
@@ -1545,7 +2247,19 @@ async function startServer(params) {
|
|
|
1545
2247
|
}
|
|
1546
2248
|
|
|
1547
2249
|
// Sign and create Stripe intent via Addie
|
|
1548
|
-
|
|
2250
|
+
// Client may supply payees parsed from ?payees= URL param (pipe-separated 4-tuples).
|
|
2251
|
+
// Each payee is capped at 5% of the product price; any that exceed this are dropped.
|
|
2252
|
+
const maxPayeeAmount = amount * 0.05;
|
|
2253
|
+
const validatedPayees = Array.isArray(clientPayees)
|
|
2254
|
+
? clientPayees.filter(p => {
|
|
2255
|
+
if (p.percent != null && p.percent > 5) return false;
|
|
2256
|
+
if (p.amount != null && p.amount > maxPayeeAmount) return false;
|
|
2257
|
+
return true;
|
|
2258
|
+
})
|
|
2259
|
+
: [];
|
|
2260
|
+
const payees = validatedPayees.length > 0
|
|
2261
|
+
? validatedPayees
|
|
2262
|
+
: tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
|
|
1549
2263
|
const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
|
|
1550
2264
|
sessionless.getKeys = () => buyerKeys;
|
|
1551
2265
|
const intentTimestamp = Date.now().toString();
|
|
@@ -1578,9 +2292,18 @@ async function startServer(params) {
|
|
|
1578
2292
|
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1579
2293
|
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
1580
2294
|
|
|
1581
|
-
const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays } = req.body;
|
|
2295
|
+
const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays, paymentIntentId } = req.body;
|
|
1582
2296
|
const sanoraUrlInternal = getSanoraUrl();
|
|
1583
2297
|
|
|
2298
|
+
// Fire transfer after successful payment — fire-and-forget, does not affect response
|
|
2299
|
+
function triggerTransfer() {
|
|
2300
|
+
if (!paymentIntentId || !tenant.addieKeys) return;
|
|
2301
|
+
fetch(`${getAddieUrl()}/payment/${encodeURIComponent(paymentIntentId)}/process-transfers`, {
|
|
2302
|
+
method: 'POST',
|
|
2303
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2304
|
+
}).catch(err => console.warn('[shoppe] transfer trigger failed:', err.message));
|
|
2305
|
+
}
|
|
2306
|
+
|
|
1584
2307
|
if (recoveryKey && type === 'subscription') {
|
|
1585
2308
|
// Subscription payment — record an order with a hashed subscriber key + payment timestamp.
|
|
1586
2309
|
// The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
|
|
@@ -1595,6 +2318,7 @@ async function startServer(params) {
|
|
|
1595
2318
|
headers: { 'Content-Type': 'application/json' },
|
|
1596
2319
|
body: JSON.stringify({ timestamp: ts, signature: sig, order })
|
|
1597
2320
|
});
|
|
2321
|
+
triggerTransfer();
|
|
1598
2322
|
return res.json({ success: true });
|
|
1599
2323
|
}
|
|
1600
2324
|
|
|
@@ -1621,6 +2345,7 @@ async function startServer(params) {
|
|
|
1621
2345
|
headers: { 'Content-Type': 'application/json' },
|
|
1622
2346
|
body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
|
|
1623
2347
|
});
|
|
2348
|
+
triggerTransfer();
|
|
1624
2349
|
return res.json({ success: true });
|
|
1625
2350
|
}
|
|
1626
2351
|
|
|
@@ -1629,6 +2354,7 @@ async function startServer(params) {
|
|
|
1629
2354
|
const recoveryHash = recoveryKey + productId;
|
|
1630
2355
|
const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
|
|
1631
2356
|
const createJson = await createResp.json();
|
|
2357
|
+
triggerTransfer();
|
|
1632
2358
|
return res.json({ success: createJson.success });
|
|
1633
2359
|
}
|
|
1634
2360
|
|
|
@@ -1660,6 +2386,7 @@ async function startServer(params) {
|
|
|
1660
2386
|
headers: { 'Content-Type': 'application/json' },
|
|
1661
2387
|
body: JSON.stringify({ timestamp: orderTimestamp, signature: orderSignature, order })
|
|
1662
2388
|
});
|
|
2389
|
+
triggerTransfer();
|
|
1663
2390
|
return res.json({ success: true });
|
|
1664
2391
|
}
|
|
1665
2392
|
|