wiki-plugin-shoppe 0.0.23 → 0.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
@@ -185,7 +185,7 @@ function createZip() {
185
185
  execSync(`powershell -NoProfile -Command "${psCmd}"`, { stdio: 'pipe' });
186
186
  } else {
187
187
  execSync(
188
- `zip -r "${outputZip}" . -x "*/shoppe-key.json"`,
188
+ `zip -r "${outputZip}" . -x "*/shoppe-key.json" -x "*.mp4" -x "*.mov" -x "*.mkv" -x "*.webm" -x "*.avi"`,
189
189
  { cwd: SHOPPE_DIR, stdio: 'pipe' }
190
190
  );
191
191
  }
@@ -273,6 +273,71 @@ async function orders() {
273
273
  console.log('');
274
274
  }
275
275
 
276
+ // ── upload — generate a signed shoppe URL for video uploading ────────────────
277
+
278
+ async function upload() {
279
+ let sessionless;
280
+ try {
281
+ sessionless = require('sessionless-node');
282
+ } catch (err) {
283
+ console.error('❌ sessionless-node is not installed.');
284
+ console.error(' Run: npm install');
285
+ process.exit(1);
286
+ }
287
+
288
+ const manifest = readManifest();
289
+
290
+ if (!manifest.uuid) {
291
+ console.error('❌ manifest.json is missing uuid.');
292
+ process.exit(1);
293
+ }
294
+
295
+ if (fs.existsSync(LOCAL_KEY)) {
296
+ console.error('⚠️ shoppe-key.json is still in this folder.');
297
+ console.error(' Run node shoppe-sign.js init first.');
298
+ process.exit(1);
299
+ }
300
+
301
+ const keyData = loadStoredKey(manifest.uuid);
302
+
303
+ const timestamp = Date.now().toString();
304
+ const message = timestamp + manifest.uuid;
305
+
306
+ sessionless.getKeys = () => ({ pubKey: keyData.pubKey, privateKey: keyData.privateKey });
307
+ const signature = await sessionless.sign(message);
308
+
309
+ const wikiUrlArg = process.argv[3];
310
+ const baseUrl = wikiUrlArg
311
+ ? wikiUrlArg.replace(/\/+$/, '')
312
+ : manifest.wikiUrl
313
+ ? manifest.wikiUrl.replace(/\/plugin.*$/, '')
314
+ : null;
315
+
316
+ const shoppePath = `/plugin/shoppe/${manifest.uuid}?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`;
317
+ const fullUrl = baseUrl ? `${baseUrl}${shoppePath}` : null;
318
+
319
+ console.log('\n🎬 Signed shoppe URL for video uploading (valid for 24 hours):\n');
320
+ if (fullUrl) {
321
+ console.log(' ' + fullUrl);
322
+ } else {
323
+ console.log(' Path: ' + shoppePath);
324
+ console.log('\n Prepend your wiki URL, e.g.:');
325
+ console.log(' https://mywiki.com' + shoppePath);
326
+ console.log('\n Or pass your wiki URL as an argument:');
327
+ console.log(' node shoppe-sign.js upload https://mywiki.com');
328
+ }
329
+
330
+ if (fullUrl) {
331
+ console.log('\n Opening in browser...');
332
+ try {
333
+ const open = process.platform === 'win32' ? 'start' :
334
+ process.platform === 'darwin' ? 'open' : 'xdg-open';
335
+ execSync(`${open} "${fullUrl}"`, { stdio: 'ignore' });
336
+ } catch (_) {}
337
+ }
338
+ console.log('');
339
+ }
340
+
276
341
  // ── payouts — open Stripe Connect Express onboarding ─────────────────────────
277
342
 
278
343
  async function payouts() {
@@ -347,6 +412,11 @@ async function payouts() {
347
412
  const command = process.argv[2];
348
413
  if (command === 'init') {
349
414
  init();
415
+ } else if (command === 'upload') {
416
+ upload().catch(err => {
417
+ console.error('❌ ', err.message);
418
+ process.exit(1);
419
+ });
350
420
  } else if (command === 'orders') {
351
421
  orders().catch(err => {
352
422
  console.error('❌ ', err.message);
package/server/server.js CHANGED
@@ -261,6 +261,13 @@ function generateBundleBuffer(tenant, ownerPrivateKey, ownerPubKey, wikiOrigin)
261
261
  'Add or update content, then run `node shoppe-sign.js` again.',
262
262
  'Each upload overwrites existing items and adds new ones.',
263
263
  '',
264
+ '## Uploading videos',
265
+ '',
266
+ 'Run: `node shoppe-sign.js upload`',
267
+ '',
268
+ 'Opens your shoppe page with a signed URL (valid for 24 hours).',
269
+ 'Any video items without a file will show an "Upload Video" button.',
270
+ '',
264
271
  '## Viewing orders',
265
272
  '',
266
273
  'Run: `node shoppe-sign.js orders`',
@@ -560,6 +567,20 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
560
567
 
561
568
  // ============================================================
562
569
  // LUCILLE HELPERS
570
+ async function sanoraDeleteProduct(tenant, title) {
571
+ const { uuid, keys } = tenant;
572
+ const timestamp = Date.now().toString();
573
+ const message = timestamp + uuid + title;
574
+
575
+ sessionless.getKeys = () => keys;
576
+ const signature = await sessionless.sign(message);
577
+
578
+ await fetch(
579
+ `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`,
580
+ { method: 'DELETE' }
581
+ );
582
+ }
583
+
563
584
  // ============================================================
564
585
 
565
586
  async function lucilleCreateUser(lucilleUrl) {
@@ -1125,26 +1146,10 @@ async function processArchive(zipPath) {
1125
1146
  await sanoraUploadImage(tenant, title, coverBuf, coverFile);
1126
1147
  }
1127
1148
 
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
- }
1149
+ // Register video metadata in Lucille (file upload happens separately via upload-info endpoint)
1150
+ await lucilleRegisterVideo(tenant, title, description, tags, effectiveLucilleUrl);
1151
+ results.videos.push({ title, price });
1152
+ console.log(`[shoppe] 🎬 video registered: ${title} (upload file separately)`);
1148
1153
  } catch (err) {
1149
1154
  console.warn(`[shoppe] ⚠️ video ${entry}: ${err.message}`);
1150
1155
  results.warnings.push(`video "${entry}": ${err.message}`);
@@ -1262,7 +1267,8 @@ async function getShoppeGoods(tenant) {
1262
1267
  shipping: product.shipping || 0,
1263
1268
  image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
1264
1269
  url: (bucketName && redirects[bucketName]) || defaultUrl,
1265
- ...(lucillePlayerUrl && { lucillePlayerUrl })
1270
+ ...(lucillePlayerUrl && { lucillePlayerUrl }),
1271
+ ...(product.category === 'video' && { shoppeId: tenant.uuid })
1266
1272
  };
1267
1273
  const bucket = goods[bucketName];
1268
1274
  if (bucket) bucket.push(item);
@@ -1398,12 +1404,12 @@ const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼
1398
1404
  // Validate an owner-signed request (used for browser-facing owner routes).
1399
1405
  // Expects req.query.timestamp and req.query.signature.
1400
1406
  // Returns an error string if invalid, null if valid.
1401
- function checkOwnerSignature(req, tenant) {
1407
+ function checkOwnerSignature(req, tenant, maxAgeMs = 5 * 60 * 1000) {
1402
1408
  if (!tenant.ownerPubKey) return 'This shoppe was registered before owner signing was added';
1403
1409
  const { timestamp, signature } = req.query;
1404
1410
  if (!timestamp || !signature) return 'Missing timestamp or signature — generate a fresh URL with: node shoppe-sign.js orders';
1405
1411
  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';
1412
+ if (isNaN(age) || age < 0 || age > maxAgeMs) return 'URL has expired — generate a new one with: node shoppe-sign.js orders';
1407
1413
  const message = timestamp + tenant.uuid;
1408
1414
  if (!sessionless.verifySignature(signature, message, tenant.ownerPubKey)) return 'Signature invalid';
1409
1415
  return null;
@@ -1539,12 +1545,35 @@ function renderCards(items, category) {
1539
1545
  }
1540
1546
  return items.map(item => {
1541
1547
  const isVideo = !!item.lucillePlayerUrl;
1548
+ const isUnuploadedVideo = item.shoppeId && !item.lucillePlayerUrl;
1542
1549
  const imgHtml = item.image
1543
1550
  ? `<div class="card-img${isVideo ? ' card-video-play' : ''}"><img src="${item.image}" alt="" loading="lazy"></div>`
1544
- : `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
1551
+ : isUnuploadedVideo
1552
+ ? `<div class="card-img-placeholder card-video-upload"><span style="font-size:44px">🎬</span></div>`
1553
+ : `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
1545
1554
  const priceHtml = (item.price > 0 || category === 'product')
1546
1555
  ? `<div class="price">$${(item.price / 100).toFixed(2)}${item.shipping ? ` <span class="shipping">+ $${(item.shipping / 100).toFixed(2)} shipping</span>` : ''}</div>`
1547
1556
  : '';
1557
+ if (isUnuploadedVideo) {
1558
+ const safeTitle = item.title.replace(/'/g, "\\'");
1559
+ return `
1560
+ <div class="card" id="video-card-${item.shoppeId}-${item.title.replace(/[^a-z0-9]/gi,'_')}">
1561
+ ${imgHtml}
1562
+ <div class="card-body">
1563
+ <div class="card-title">${item.title}</div>
1564
+ ${item.description ? `<div class="card-desc">${item.description}</div>` : ''}
1565
+ ${priceHtml}
1566
+ <div class="video-upload-area" id="upload-area-${item.shoppeId}-${item.title.replace(/[^a-z0-9]/gi,'_')}">
1567
+ <label class="upload-btn-label">
1568
+ 📁 Upload Video
1569
+ <input type="file" accept="video/*" style="display:none"
1570
+ onchange="startVideoUpload(this,'${item.shoppeId}','${safeTitle}')">
1571
+ </label>
1572
+ <div class="upload-progress" style="display:none"></div>
1573
+ </div>
1574
+ </div>
1575
+ </div>`;
1576
+ }
1548
1577
  const clickHandler = isVideo
1549
1578
  ? `playVideo('${item.lucillePlayerUrl}')`
1550
1579
  : `window.open('${item.url}','_blank')`;
@@ -1560,7 +1589,7 @@ function renderCards(items, category) {
1560
1589
  }).join('');
1561
1590
  }
1562
1591
 
1563
- function generateShoppeHTML(tenant, goods) {
1592
+ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
1564
1593
  const total = Object.values(goods).flat().length;
1565
1594
  const tabs = [
1566
1595
  { id: 'all', label: 'All', count: total, always: true },
@@ -1622,6 +1651,12 @@ function generateShoppeHTML(tenant, goods) {
1622
1651
  .video-modal-content iframe { width: 100%; height: 100%; border: none; display: block; }
1623
1652
  .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
1653
  .video-modal-close:hover { background: rgba(0,0,0,0.8); }
1654
+ .card-video-upload { cursor: default !important; }
1655
+ .upload-btn-label { display: inline-block; background: #0066cc; color: white; border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 8px; }
1656
+ .upload-btn-label:hover { background: #0052a3; }
1657
+ .upload-progress { margin-top: 8px; font-size: 12px; color: #555; }
1658
+ .upload-progress-bar { height: 4px; background: #e0e0e0; border-radius: 2px; margin-top: 4px; overflow: hidden; }
1659
+ .upload-progress-bar-fill { height: 100%; background: #0066cc; border-radius: 2px; transition: width 0.2s; }
1625
1660
  </style>
1626
1661
  </head>
1627
1662
  <body>
@@ -1653,6 +1688,7 @@ function generateShoppeHTML(tenant, goods) {
1653
1688
  </div>
1654
1689
  </div>
1655
1690
  <script>
1691
+ const UPLOAD_AUTH = ${uploadAuth ? JSON.stringify(uploadAuth) : 'null'};
1656
1692
  function show(id, tab) {
1657
1693
  document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
1658
1694
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -1668,6 +1704,58 @@ function generateShoppeHTML(tenant, goods) {
1668
1704
  document.getElementById('video-iframe').src = '';
1669
1705
  }
1670
1706
  document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
1707
+
1708
+ async function startVideoUpload(input, shoppeId, title) {
1709
+ const file = input.files[0];
1710
+ if (!file) return;
1711
+
1712
+ const areaId = 'upload-area-' + shoppeId + '-' + title.replace(/[^a-z0-9]/gi,'_');
1713
+ const area = document.getElementById(areaId);
1714
+ const progressDiv = area.querySelector('.upload-progress');
1715
+ const label = area.querySelector('.upload-btn-label');
1716
+
1717
+ label.style.display = 'none';
1718
+ progressDiv.style.display = 'block';
1719
+ progressDiv.innerHTML = 'Getting upload credentials…';
1720
+
1721
+ try {
1722
+ if (!UPLOAD_AUTH) throw new Error('Not authorized to upload — visit the shoppe via a signed URL (node shoppe-sign.js upload)');
1723
+ const authParams = '?timestamp=' + encodeURIComponent(UPLOAD_AUTH.timestamp) + '&signature=' + encodeURIComponent(UPLOAD_AUTH.signature);
1724
+ const infoRes = await fetch('/plugin/shoppe/' + shoppeId + '/video/' + encodeURIComponent(title) + '/upload-info' + authParams);
1725
+ if (!infoRes.ok) throw new Error('Could not get upload credentials (' + infoRes.status + ')');
1726
+ const { uploadUrl, timestamp, signature } = await infoRes.json();
1727
+
1728
+ progressDiv.innerHTML = 'Uploading… 0%<div class="upload-progress-bar"><div class="upload-progress-bar-fill" id="fill-' + areaId + '" style="width:0%"></div></div>';
1729
+
1730
+ const form = new FormData();
1731
+ form.append('video', file, file.name);
1732
+
1733
+ await new Promise((resolve, reject) => {
1734
+ const xhr = new XMLHttpRequest();
1735
+ xhr.upload.onprogress = e => {
1736
+ if (e.lengthComputable) {
1737
+ const pct = Math.round(e.loaded / e.total * 100);
1738
+ progressDiv.querySelector('div').textContent = '';
1739
+ progressDiv.firstChild.textContent = 'Uploading… ' + pct + '%';
1740
+ const fill = document.getElementById('fill-' + areaId);
1741
+ if (fill) fill.style.width = pct + '%';
1742
+ }
1743
+ };
1744
+ xhr.onload = () => xhr.status === 200 ? resolve() : reject(new Error('Upload failed: ' + xhr.status));
1745
+ xhr.onerror = () => reject(new Error('Network error during upload'));
1746
+ xhr.open('PUT', uploadUrl);
1747
+ xhr.setRequestHeader('x-pn-timestamp', timestamp);
1748
+ xhr.setRequestHeader('x-pn-signature', signature);
1749
+ xhr.send(form);
1750
+ });
1751
+
1752
+ progressDiv.innerHTML = '✅ Uploaded! Reloading…';
1753
+ setTimeout(() => location.reload(), 1500);
1754
+ } catch (err) {
1755
+ progressDiv.innerHTML = '❌ ' + err.message;
1756
+ label.style.display = 'inline-block';
1757
+ }
1758
+ }
1671
1759
  </script>
1672
1760
  </body>
1673
1761
  </html>`;
@@ -1800,6 +1888,36 @@ async function startServer(params) {
1800
1888
  res.json({ success: true, tenants: safe });
1801
1889
  });
1802
1890
 
1891
+ // Delete a shoppe tenant (owner only)
1892
+ app.delete('/plugin/shoppe/:identifier', owner, async (req, res) => {
1893
+ try {
1894
+ const tenant = getTenantByIdentifier(req.params.identifier);
1895
+ if (!tenant) return res.status(404).json({ error: 'tenant not found' });
1896
+
1897
+ // Fetch all products from Sanora and fire-and-forget delete each one
1898
+ const sanoraUrl = getSanoraUrl();
1899
+ fetch(`${sanoraUrl}/products/${tenant.uuid}`)
1900
+ .then(r => r.json())
1901
+ .then(products => {
1902
+ for (const title of Object.keys(products)) {
1903
+ sanoraDeleteProduct(tenant, title).catch(err =>
1904
+ console.warn(`[shoppe] delete product "${title}" failed:`, err.message)
1905
+ );
1906
+ }
1907
+ })
1908
+ .catch(err => console.warn('[shoppe] fetch products for delete failed:', err.message));
1909
+
1910
+ // Remove tenant from local registry
1911
+ const tenants = loadTenants();
1912
+ delete tenants[tenant.uuid];
1913
+ saveTenants(tenants);
1914
+
1915
+ res.json({ success: true, deleted: tenant.uuid });
1916
+ } catch (err) {
1917
+ res.status(404).json({ error: err.message });
1918
+ }
1919
+ });
1920
+
1803
1921
  // Public directory — name, emojicode, and shoppe URL only
1804
1922
  app.get('/plugin/shoppe/directory', (req, res) => {
1805
1923
  const tenants = loadTenants();
@@ -2473,6 +2591,35 @@ async function startServer(params) {
2473
2591
  }
2474
2592
  });
2475
2593
 
2594
+ // GET /plugin/shoppe/:id/video/:title/upload-info
2595
+ // Returns a pre-signed lucille upload URL so the browser can PUT the video file directly to lucille.
2596
+ // Auth: shoppe tenant owner signature (timestamp + uuid), valid for 24 hours.
2597
+ // Generate the signed URL with: node shoppe-sign.js upload
2598
+ app.get('/plugin/shoppe/:identifier/video/:title/upload-info', async (req, res) => {
2599
+ try {
2600
+ const tenant = getTenantByIdentifier(req.params.identifier);
2601
+ if (!tenant) return res.status(404).json({ error: 'tenant not found' });
2602
+
2603
+ const sigErr = checkOwnerSignature(req, tenant, 24 * 60 * 60 * 1000);
2604
+ if (sigErr) return res.status(403).json({ error: sigErr });
2605
+
2606
+ if (!tenant.lucilleKeys) return res.status(400).json({ error: 'tenant has no lucille user — re-register' });
2607
+
2608
+ const title = req.params.title;
2609
+ const lucilleBase = getLucilleUrl().replace(/\/$/, '');
2610
+ const { uuid: lucilleUuid, pubKey, privateKey } = tenant.lucilleKeys;
2611
+
2612
+ const timestamp = Date.now().toString();
2613
+ sessionless.getKeys = () => ({ pubKey, privateKey });
2614
+ const signature = await sessionless.sign(timestamp + pubKey);
2615
+
2616
+ const uploadUrl = `${lucilleBase}/user/${lucilleUuid}/video/${encodeURIComponent(title)}/file`;
2617
+ res.json({ uploadUrl, timestamp, signature });
2618
+ } catch (err) {
2619
+ res.status(500).json({ error: err.message });
2620
+ }
2621
+ });
2622
+
2476
2623
  // Goods JSON (public)
2477
2624
  app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
2478
2625
  try {
@@ -2492,8 +2639,14 @@ async function startServer(params) {
2492
2639
  const tenant = getTenantByIdentifier(req.params.identifier);
2493
2640
  if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
2494
2641
  const goods = await getShoppeGoods(tenant);
2642
+
2643
+ // Check if the request carries a valid owner signature — if so, embed auth
2644
+ // params in the page so the upload button can authenticate with upload-info.
2645
+ const sigErr = checkOwnerSignature(req, tenant, 24 * 60 * 60 * 1000);
2646
+ const uploadAuth = sigErr ? null : { timestamp: req.query.timestamp, signature: req.query.signature };
2647
+
2495
2648
  res.set('Content-Type', 'text/html');
2496
- res.send(generateShoppeHTML(tenant, goods));
2649
+ res.send(generateShoppeHTML(tenant, goods, uploadAuth));
2497
2650
  } catch (err) {
2498
2651
  console.error('[shoppe] page error:', err);
2499
2652
  res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);