wiki-plugin-shoppe 0.0.24 → 0.0.26

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/client/shoppe.js CHANGED
@@ -22,8 +22,11 @@
22
22
  .sw-shoppe-left { display: flex; flex-direction: column; gap: 2px; }
23
23
  .sw-shoppe-name { font-weight: 600; font-size: 15px; }
24
24
  .sw-shoppe-code { font-size: 18px; letter-spacing: 4px; }
25
+ .sw-shoppe-actions { display: flex; align-items: center; gap: 12px; }
25
26
  .sw-link { font-size: 13px; color: #0066cc; text-decoration: none; white-space: nowrap; }
26
27
  .sw-link:hover { text-decoration: underline; }
28
+ .sw-btn-delete { background: none; border: none; color: #cc0000; font-size: 13px; cursor: pointer; padding: 0; white-space: nowrap; }
29
+ .sw-btn-delete:hover { text-decoration: underline; }
27
30
  .sw-empty { font-size: 13px; color: #999; font-style: italic; }
28
31
  .sw-drop { border: 2px dashed #ccc; border-radius: 12px; padding: 28px 20px; text-align: center; background: #fafafa; transition: border-color 0.2s, background 0.2s; cursor: pointer; }
29
32
  .sw-drop.dragover { border-color: #0066cc; background: #e8f0fe; }
@@ -41,6 +44,8 @@
41
44
  .sw-status.success { background: #d1fae5; color: #065f46; display: block; }
42
45
  .sw-status.error { background: #fee2e2; color: #991b1b; display: block; }
43
46
  .sw-status code { background: rgba(0,0,0,0.08); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
47
+ .sw-remove { display: block; width: 100%; margin-top: 24px; padding: 8px; background: none; border: 1px solid #e5e5ea; border-radius: 8px; font-size: 12px; color: #aaa; cursor: pointer; text-align: center; }
48
+ .sw-remove:hover { border-color: #cc0000; color: #cc0000; }
44
49
  </style>
45
50
 
46
51
  <!-- Directory -->
@@ -161,37 +166,70 @@
161
166
  <div id="sw-register-status" class="sw-status"></div>
162
167
  </div>
163
168
 
169
+ <button class="sw-remove" id="sw-remove-btn">Remove plugin from page</button>
170
+
164
171
  </div>
165
172
  `;
166
173
 
174
+ div.querySelector('#sw-remove-btn').addEventListener('click', () => {
175
+ const $page = $item.parents('.page');
176
+ if (window.pageHandler && $page.length) {
177
+ pageHandler.put($page, { type: 'remove', id: item.id });
178
+ $item.remove();
179
+ } else if (window.wiki && wiki.textEditor) {
180
+ wiki.textEditor($item, item);
181
+ }
182
+ });
183
+
167
184
  setupListeners(div);
168
- loadDirectory(div);
169
- checkOwner(div);
185
+ checkOwner(div).then(isOwner => {
186
+ if (!isOwner) loadDirectory(div, false);
187
+ });
170
188
  },
171
189
 
172
- bind: function($item, item) {}
190
+ bind: function($item, item) {
191
+ $item.on('dblclick', () => {
192
+ const $page = $item.parents('.page');
193
+ if (window.pageHandler && $page.length) {
194
+ pageHandler.put($page, { type: 'remove', id: item.id });
195
+ $item.remove();
196
+ } else if (window.wiki && wiki.textEditor) {
197
+ wiki.textEditor($item, item);
198
+ }
199
+ });
200
+ }
173
201
  };
174
202
 
175
- // ── Directory (public) ──────────────────────────────────────────────────────
203
+ // ── Directory ────────────────────────────────────────────────────────────────
176
204
 
177
- async function loadDirectory(container) {
205
+ async function loadDirectory(container, isOwner = false) {
178
206
  const el = container.querySelector('#sw-directory');
179
207
  try {
180
- const resp = await fetch('/plugin/shoppe/directory');
208
+ const resp = await fetch(isOwner ? '/plugin/shoppe/tenants' : '/plugin/shoppe/directory');
181
209
  const result = await resp.json();
182
- if (!result.success || result.shoppes.length === 0) {
210
+ const shoppes = isOwner ? result.tenants : result.shoppes;
211
+ if (!result.success || !shoppes || shoppes.length === 0) {
183
212
  el.innerHTML = '<em class="sw-empty">No shoppes yet — be the first!</em>';
184
213
  return;
185
214
  }
186
- el.innerHTML = result.shoppes.map(s => `
187
- <div class="sw-shoppe">
215
+ el.innerHTML = shoppes.map(s => `
216
+ <div class="sw-shoppe" id="sw-shoppe-${s.uuid}">
188
217
  <div class="sw-shoppe-left">
189
218
  <span class="sw-shoppe-name">${s.name}</span>
190
219
  <span class="sw-shoppe-code">${s.emojicode}</span>
191
220
  </div>
192
- <a class="sw-link" href="${s.url}" target="_blank">Visit shoppe →</a>
221
+ <div class="sw-shoppe-actions">
222
+ <a class="sw-link" href="${s.url}" target="_blank">Visit shoppe →</a>
223
+ ${isOwner ? `<button class="sw-btn-delete" data-uuid="${s.uuid}" data-name="${s.name}">Delete</button>` : ''}
224
+ </div>
193
225
  </div>
194
226
  `).join('');
227
+
228
+ if (isOwner) {
229
+ el.querySelectorAll('.sw-btn-delete').forEach(btn => {
230
+ btn.addEventListener('click', () => deleteShoppe(btn.dataset.uuid, btn.dataset.name, container));
231
+ });
232
+ }
195
233
  } catch (err) {
196
234
  el.innerHTML = '<em class="sw-empty">Could not load directory.</em>';
197
235
  }
@@ -208,8 +246,30 @@
208
246
  if (result.sanoraUrl) {
209
247
  container.querySelector('#sw-url-input').value = result.sanoraUrl;
210
248
  }
249
+ loadDirectory(container, true);
250
+ return true;
211
251
  }
212
252
  } catch (err) { /* not owner, stay hidden */ }
253
+ return false;
254
+ }
255
+
256
+ // ── Delete shoppe (owner) ────────────────────────────────────────────────────
257
+
258
+ async function deleteShoppe(uuid, name, container) {
259
+ if (!confirm(`Delete "${name}"? This will remove the shoppe and all its products from Sanora.`)) return;
260
+
261
+ const row = container.querySelector(`#sw-shoppe-${uuid}`);
262
+ if (row) row.style.opacity = '0.4';
263
+
264
+ try {
265
+ const resp = await fetch(`/plugin/shoppe/${uuid}`, { method: 'DELETE' });
266
+ const result = await resp.json();
267
+ if (!result.success) throw new Error(result.error || 'Delete failed');
268
+ loadDirectory(container, true);
269
+ } catch (err) {
270
+ if (row) row.style.opacity = '1';
271
+ alert(`Could not delete shoppe: ${err.message}`);
272
+ }
213
273
  }
214
274
 
215
275
  // ── Listeners ───────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
@@ -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) {
@@ -1383,12 +1404,12 @@ const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼
1383
1404
  // Validate an owner-signed request (used for browser-facing owner routes).
1384
1405
  // Expects req.query.timestamp and req.query.signature.
1385
1406
  // Returns an error string if invalid, null if valid.
1386
- function checkOwnerSignature(req, tenant) {
1407
+ function checkOwnerSignature(req, tenant, maxAgeMs = 5 * 60 * 1000) {
1387
1408
  if (!tenant.ownerPubKey) return 'This shoppe was registered before owner signing was added';
1388
1409
  const { timestamp, signature } = req.query;
1389
1410
  if (!timestamp || !signature) return 'Missing timestamp or signature — generate a fresh URL with: node shoppe-sign.js orders';
1390
1411
  const age = Date.now() - parseInt(timestamp, 10);
1391
- 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';
1392
1413
  const message = timestamp + tenant.uuid;
1393
1414
  if (!sessionless.verifySignature(signature, message, tenant.ownerPubKey)) return 'Signature invalid';
1394
1415
  return null;
@@ -1568,7 +1589,7 @@ function renderCards(items, category) {
1568
1589
  }).join('');
1569
1590
  }
1570
1591
 
1571
- function generateShoppeHTML(tenant, goods) {
1592
+ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
1572
1593
  const total = Object.values(goods).flat().length;
1573
1594
  const tabs = [
1574
1595
  { id: 'all', label: 'All', count: total, always: true },
@@ -1667,6 +1688,7 @@ function generateShoppeHTML(tenant, goods) {
1667
1688
  </div>
1668
1689
  </div>
1669
1690
  <script>
1691
+ const UPLOAD_AUTH = ${uploadAuth ? JSON.stringify(uploadAuth) : 'null'};
1670
1692
  function show(id, tab) {
1671
1693
  document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
1672
1694
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -1697,7 +1719,9 @@ function generateShoppeHTML(tenant, goods) {
1697
1719
  progressDiv.innerHTML = 'Getting upload credentials…';
1698
1720
 
1699
1721
  try {
1700
- const infoRes = await fetch('/plugin/shoppe/' + shoppeId + '/video/' + encodeURIComponent(title) + '/upload-info');
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);
1701
1725
  if (!infoRes.ok) throw new Error('Could not get upload credentials (' + infoRes.status + ')');
1702
1726
  const { uploadUrl, timestamp, signature } = await infoRes.json();
1703
1727
 
@@ -1864,6 +1888,36 @@ async function startServer(params) {
1864
1888
  res.json({ success: true, tenants: safe });
1865
1889
  });
1866
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
+
1867
1921
  // Public directory — name, emojicode, and shoppe URL only
1868
1922
  app.get('/plugin/shoppe/directory', (req, res) => {
1869
1923
  const tenants = loadTenants();
@@ -2537,12 +2591,18 @@ async function startServer(params) {
2537
2591
  }
2538
2592
  });
2539
2593
 
2540
- // GET /plugin/shoppe/:id/video/:title/upload-info (owner only)
2594
+ // GET /plugin/shoppe/:id/video/:title/upload-info
2541
2595
  // Returns a pre-signed lucille upload URL so the browser can PUT the video file directly to lucille.
2542
- app.get('/plugin/shoppe/:identifier/video/:title/upload-info', owner, async (req, res) => {
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) => {
2543
2599
  try {
2544
2600
  const tenant = getTenantByIdentifier(req.params.identifier);
2545
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
+
2546
2606
  if (!tenant.lucilleKeys) return res.status(400).json({ error: 'tenant has no lucille user — re-register' });
2547
2607
 
2548
2608
  const title = req.params.title;
@@ -2579,8 +2639,14 @@ async function startServer(params) {
2579
2639
  const tenant = getTenantByIdentifier(req.params.identifier);
2580
2640
  if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
2581
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
+
2582
2648
  res.set('Content-Type', 'text/html');
2583
- res.send(generateShoppeHTML(tenant, goods));
2649
+ res.send(generateShoppeHTML(tenant, goods, uploadAuth));
2584
2650
  } catch (err) {
2585
2651
  console.error('[shoppe] page error:', err);
2586
2652
  res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);