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 +70 -10
- package/package.json +1 -1
- package/server/scripts/shoppe-sign.js +70 -0
- package/server/server.js +73 -7
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
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
<
|
|
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
|
@@ -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 >
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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>`);
|