@blamejs/blamejs-shop 0.3.34 → 0.3.35
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/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +345 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.35 (2026-05-31) — **Share a wishlist with a private link.** A customer's wishlist could only be viewed by the customer themselves. From the wishlist page a shopper can now create a shareable link and send it to someone — a friend buying a gift, for example — who opens it without needing an account and sees the wishlist's products. The link can be revoked at any time, after which it stops working. The shared page shows only the products: it does not reveal who owns the wishlist or any private notes on it, and it is not indexed by search engines. **Added:** *Shareable wishlist links* — The wishlist page now has a Share control that creates a private link to the wishlist. The full link is shown once, to copy and send; anyone with it can open the shared wishlist without an account and see its products, each linking to the product page. The owner sees their active links and can revoke any of them, which immediately stops the link from working. The shared view shows only the products — never the owner's identity or private notes — and is marked not to be indexed by search engines, so a personal wishlist stays private to whoever holds the link.
|
|
12
|
+
|
|
11
13
|
- v0.3.34 (2026-05-31) — **Edit, pin, and archive the internal notes on a customer.** Internal notes on a customer could only be added — a note with a typo or one that was no longer true could not be corrected or removed, which is a liability for notes like "do not ship to this address" or "allergic to X". The customer detail screen now lets you edit a note's text, pin important notes to the top, and archive notes that no longer apply (and restore them). Each action is limited to notes that belong to the customer being viewed. **Added:** *Edit, pin, and archive customer notes* — The notes panel on a customer's detail screen now supports the full lifecycle: edit a note's text to fix or update it, pin the notes that matter so they sort to the top, and archive notes that no longer apply, with a toggle to show archived notes and restore them. Previously notes were append-only, so a wrong or stale note could not be corrected or retired. Every edit, pin, and archive action is scoped to the customer being viewed, so a note can only be changed from the record it belongs to.
|
|
12
14
|
|
|
13
15
|
- v0.3.33 (2026-05-31) — **Create and manage customer segments from the admin console.** Customer segments could be shown on a customer's profile but there was no way to define or manage them, so the feature could not actually be used. A new Segments screen in the admin console creates rule-based segments from recency, frequency, lifetime-order, spend, average-order-value, and refund-rate criteria, edits their rules, archives and restores them, and recomputes membership on demand — showing how many customers each segment now contains. Segment membership stays rule-derived: it is recalculated from order history, not assigned by hand. **Added:** *Customer segments administration* — A Segments screen in the admin console manages rule-based customer segments. Each segment is defined by RFM-style criteria — how recently and how often a customer orders, their lifetime order count and spend, average order value, and refund rate — and a list shows every segment with its rules, current member count, and status, filterable by active or archived. You can create and edit segments, archive and restore them, and recompute membership on demand, which re-evaluates the rules against order history and reports how many customers match. Membership is always derived from the rules, never assigned per customer.
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -2488,6 +2488,12 @@ function renderWishlist(opts) {
|
|
|
2488
2488
|
var notice = okMsg
|
|
2489
2489
|
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(okMsg) + "</p>"
|
|
2490
2490
|
: "";
|
|
2491
|
+
// The owner's share panel — a "Share this wishlist" control plus the
|
|
2492
|
+
// list of active share links each with a Revoke action. The route
|
|
2493
|
+
// builds the panel HTML (it owns the share-link reads); absent it (the
|
|
2494
|
+
// sharing primitive isn't wired, or a unit test calling the renderer
|
|
2495
|
+
// directly) the panel is empty so the page renders unchanged.
|
|
2496
|
+
var sharePanel = opts.share_panel || "";
|
|
2491
2497
|
var body =
|
|
2492
2498
|
"<section class=\"account-wishlist\">" +
|
|
2493
2499
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -2496,6 +2502,7 @@ function renderWishlist(opts) {
|
|
|
2496
2502
|
"</ol></nav>" +
|
|
2497
2503
|
"<h1 class=\"account-wishlist__title\">Saved items</h1>" +
|
|
2498
2504
|
notice +
|
|
2505
|
+
sharePanel +
|
|
2499
2506
|
inner +
|
|
2500
2507
|
"</section>";
|
|
2501
2508
|
return _wrap({
|
|
@@ -2507,6 +2514,136 @@ function renderWishlist(opts) {
|
|
|
2507
2514
|
});
|
|
2508
2515
|
}
|
|
2509
2516
|
|
|
2517
|
+
// The owner-side wishlist share panel (mounts on /account/wishlist). It
|
|
2518
|
+
// renders a "Create a share link" control plus the owner's active share
|
|
2519
|
+
// links, each with a status + view count and a Revoke form. `opts.shares`
|
|
2520
|
+
// is the owner's links from `listSharesForOwner` (already scoped to the
|
|
2521
|
+
// session customer by the route). A revoked / expired link still appears
|
|
2522
|
+
// (greyed, no Revoke) so the owner sees the history; only an active link
|
|
2523
|
+
// carries a Revoke control.
|
|
2524
|
+
//
|
|
2525
|
+
// The plaintext share token is surfaced EXACTLY ONCE — at create time, via
|
|
2526
|
+
// `opts.fresh_url` (the full public URL of the just-minted link). The list
|
|
2527
|
+
// rows never carry the URL because the token isn't persisted; the owner
|
|
2528
|
+
// copies the link from this one-time confirmation. `opts.notice` surfaces a
|
|
2529
|
+
// created/revoked confirmation via role="status".
|
|
2530
|
+
var WISHLIST_SHARE_NOTICES = {
|
|
2531
|
+
created: "Share link created. Copy it below — it's shown only once.",
|
|
2532
|
+
revoked: "Share link revoked. The link no longer works.",
|
|
2533
|
+
};
|
|
2534
|
+
function _wishlistSharePanel(opts) {
|
|
2535
|
+
opts = opts || {};
|
|
2536
|
+
var esc = b.template.escapeHtml;
|
|
2537
|
+
var shares = opts.shares || [];
|
|
2538
|
+
var nowTs = Date.now();
|
|
2539
|
+
var rows = "";
|
|
2540
|
+
for (var i = 0; i < shares.length; i += 1) {
|
|
2541
|
+
var s = shares[i];
|
|
2542
|
+
var revoked = s.revoked_at != null;
|
|
2543
|
+
var expired = !revoked && s.expires_at != null && Number(s.expires_at) < nowTs;
|
|
2544
|
+
var inactive = revoked || expired;
|
|
2545
|
+
var statusLabel = revoked ? "Revoked" : (expired ? "Expired" : "Active");
|
|
2546
|
+
var statusCls = revoked ? "wishlist-share__status--revoked"
|
|
2547
|
+
: (expired ? "wishlist-share__status--expired" : "wishlist-share__status--active");
|
|
2548
|
+
var revokeForm = inactive
|
|
2549
|
+
? ""
|
|
2550
|
+
: "<form class=\"wishlist-share__revoke\" method=\"post\" action=\"/wishlist/share/" + esc(s.id) + "/revoke\">" +
|
|
2551
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Revoke</button>" +
|
|
2552
|
+
"</form>";
|
|
2553
|
+
var viewN = Number(s.view_count) || 0;
|
|
2554
|
+
rows +=
|
|
2555
|
+
"<li class=\"wishlist-share" + (inactive ? " wishlist-share--inactive" : "") + "\">" +
|
|
2556
|
+
"<div class=\"wishlist-share__head\">" +
|
|
2557
|
+
"<span class=\"wishlist-share__status " + statusCls + "\">" + esc(statusLabel) + "</span>" +
|
|
2558
|
+
"<span class=\"wishlist-share__views\">" + esc(String(viewN)) + " view" + (viewN === 1 ? "" : "s") + "</span>" +
|
|
2559
|
+
"</div>" +
|
|
2560
|
+
revokeForm +
|
|
2561
|
+
"</li>";
|
|
2562
|
+
}
|
|
2563
|
+
var list = rows ? "<ul class=\"wishlist-share__list\">" + rows + "</ul>" : "";
|
|
2564
|
+
var noticeMsg = WISHLIST_SHARE_NOTICES[opts.notice];
|
|
2565
|
+
var notice = noticeMsg
|
|
2566
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
|
|
2567
|
+
: "";
|
|
2568
|
+
// The one-time URL block — present only on the create confirmation. A
|
|
2569
|
+
// read-only text field the owner can copy; it never re-renders after the
|
|
2570
|
+
// owner navigates away (the token isn't persisted).
|
|
2571
|
+
var freshUrl = opts.fresh_url
|
|
2572
|
+
? "<div class=\"wishlist-share__fresh\">" +
|
|
2573
|
+
"<label class=\"wishlist-share__fresh-label\" for=\"wishlist-share-url\">Your share link</label>" +
|
|
2574
|
+
"<input id=\"wishlist-share-url\" class=\"wishlist-share__url\" type=\"text\" readonly value=\"" + esc(opts.fresh_url) + "\" " +
|
|
2575
|
+
"aria-label=\"Shareable wishlist link\" onfocus=\"this.select()\">" +
|
|
2576
|
+
"</div>"
|
|
2577
|
+
: "";
|
|
2578
|
+
return "<section class=\"wishlist-share-panel\" aria-labelledby=\"wishlist-share-heading\">" +
|
|
2579
|
+
"<h2 id=\"wishlist-share-heading\" class=\"wishlist-share-panel__title\">Share this wishlist</h2>" +
|
|
2580
|
+
"<p class=\"wishlist-share-panel__lede\">Create a link so friends and family can see what you're hoping for. The link shows your saved products only — it never reveals your account.</p>" +
|
|
2581
|
+
notice +
|
|
2582
|
+
freshUrl +
|
|
2583
|
+
"<form class=\"wishlist-share-panel__create\" method=\"post\" action=\"/wishlist/share\">" +
|
|
2584
|
+
"<button type=\"submit\" class=\"btn-secondary\">Create a share link</button>" +
|
|
2585
|
+
"</form>" +
|
|
2586
|
+
list +
|
|
2587
|
+
"</section>";
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// Public, no-auth shared-wishlist page (`GET /wishlist/shared/:token`). It
|
|
2591
|
+
// renders ONLY the shared product cards (title, image, link to each PDP) —
|
|
2592
|
+
// the owner's identity, the per-entry private notes, and the owner customer
|
|
2593
|
+
// id are NEVER emitted, mirroring what the primitive's `viewShared` is
|
|
2594
|
+
// trusted to surface to a giver. `opts.entries` is the `viewShared` entry
|
|
2595
|
+
// list resolved to products; `opts.title` is the share's display title.
|
|
2596
|
+
// Carries a noindex robots directive — a personal shared wishlist is not a
|
|
2597
|
+
// page search engines should index. An unknown / revoked / expired token
|
|
2598
|
+
// never reaches this renderer (the route 404s first via renderNotFound).
|
|
2599
|
+
function renderSharedWishlist(opts) {
|
|
2600
|
+
opts = opts || {};
|
|
2601
|
+
var esc = b.template.escapeHtml;
|
|
2602
|
+
var items = opts.items || [];
|
|
2603
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
2604
|
+
var heading = (opts.title && String(opts.title).length) ? opts.title : "A shared wishlist";
|
|
2605
|
+
var rowsHtml = "";
|
|
2606
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
2607
|
+
var it = items[i];
|
|
2608
|
+
if (!it.product) continue; // archived/deleted product → drop the card (no owner data to leak)
|
|
2609
|
+
var slug = esc(it.product.slug);
|
|
2610
|
+
var thumb = it.hero_media
|
|
2611
|
+
? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
|
|
2612
|
+
: "<span class=\"wishlist-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
2613
|
+
rowsHtml +=
|
|
2614
|
+
"<li class=\"wishlist-item\">" +
|
|
2615
|
+
"<a class=\"wishlist-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
|
|
2616
|
+
"<div class=\"wishlist-item__body\">" +
|
|
2617
|
+
"<a class=\"wishlist-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
|
|
2618
|
+
"<a class=\"wishlist-item__view card-link\" href=\"/products/" + slug + "\">View product →</a>" +
|
|
2619
|
+
"</div>" +
|
|
2620
|
+
"</li>";
|
|
2621
|
+
}
|
|
2622
|
+
var inner = rowsHtml
|
|
2623
|
+
? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
|
|
2624
|
+
: "<div class=\"account-empty\">" +
|
|
2625
|
+
"<p class=\"account-empty__lede\">This wishlist is empty right now — nothing's been saved yet.</p>" +
|
|
2626
|
+
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
|
|
2627
|
+
"</div>";
|
|
2628
|
+
var body =
|
|
2629
|
+
"<section class=\"account-wishlist\">" +
|
|
2630
|
+
"<p class=\"eyebrow\">Shared with you</p>" +
|
|
2631
|
+
"<h1 class=\"account-wishlist__title\">" + esc(heading) + "</h1>" +
|
|
2632
|
+
inner +
|
|
2633
|
+
"</section>";
|
|
2634
|
+
return _wrap({
|
|
2635
|
+
title: heading,
|
|
2636
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
2637
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
2638
|
+
theme_css: opts.theme_css,
|
|
2639
|
+
// A personal shared wishlist is not search-index material — keep it
|
|
2640
|
+
// out of the index. `noindex` maps to noindex,nofollow in _wrap; the
|
|
2641
|
+
// page needs no canonical (a crawler won't index it to dedupe).
|
|
2642
|
+
robots: "noindex",
|
|
2643
|
+
body: body,
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2510
2647
|
// Compare page notice banner — surfaced after a toggle / a full-basket
|
|
2511
2648
|
// refusal. The route passes a short message key; unknown keys render
|
|
2512
2649
|
// nothing so a forged `?notice=` query can't inject arbitrary copy.
|
|
@@ -10695,13 +10832,221 @@ function mount(router, deps) {
|
|
|
10695
10832
|
}
|
|
10696
10833
|
var cartCount = await _cartCountForReq(req);
|
|
10697
10834
|
var wlUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
10835
|
+
// Owner share panel — only when the sharing primitive is wired.
|
|
10836
|
+
// The share-link list read is SCOPED to the session customer's id,
|
|
10837
|
+
// so the owner only ever sees their own links. A reads failure
|
|
10838
|
+
// (missing table on a fresh deploy) degrades to no panel rather
|
|
10839
|
+
// than 500-ing the page.
|
|
10840
|
+
var sharePanel = "";
|
|
10841
|
+
if (deps.wishlistSharing) {
|
|
10842
|
+
// Scoped to the session customer — the panel read keys on the
|
|
10843
|
+
// signed-in customer's id, so the owner only ever sees their own
|
|
10844
|
+
// links (this list surface carries no path id, so no IDOR).
|
|
10845
|
+
var sessionOwnerId = auth.customer_id;
|
|
10846
|
+
var ownShares = [];
|
|
10847
|
+
try { ownShares = await deps.wishlistSharing.listSharesForOwner(sessionOwnerId); }
|
|
10848
|
+
catch (_e) { ownShares = []; }
|
|
10849
|
+
sharePanel = _wishlistSharePanel({
|
|
10850
|
+
shares: ownShares,
|
|
10851
|
+
notice: wlUrl ? wlUrl.searchParams.get("share") : null,
|
|
10852
|
+
});
|
|
10853
|
+
}
|
|
10698
10854
|
_send(res, 200, renderWishlist({
|
|
10699
10855
|
items: items,
|
|
10700
10856
|
notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
|
|
10857
|
+
share_panel: sharePanel,
|
|
10858
|
+
shop_name: shopName,
|
|
10859
|
+
cart_count: cartCount,
|
|
10860
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
10861
|
+
}));
|
|
10862
|
+
});
|
|
10863
|
+
}
|
|
10864
|
+
|
|
10865
|
+
// ---- wishlist sharing -----------------------------------------------
|
|
10866
|
+
//
|
|
10867
|
+
// Owner-scoped share links on the customer's own wishlist, plus the
|
|
10868
|
+
// public, no-auth shared view a giver opens. Mounts only when the
|
|
10869
|
+
// sharing primitive is wired (it composes the wishlist primitive, so a
|
|
10870
|
+
// store with sharing also has the solo wishlist above).
|
|
10871
|
+
//
|
|
10872
|
+
// Owner side (gated on the session customer, CSRF-protected by the
|
|
10873
|
+
// container form chokepoint): create a share link, see active links,
|
|
10874
|
+
// revoke a link. Every owner action is scoped to the session customer —
|
|
10875
|
+
// the create keys on the signed-in customer's id; the revoke loads the
|
|
10876
|
+
// session customer's links by their id and refuses a link that isn't
|
|
10877
|
+
// the session customer's (clean 404, no IDOR).
|
|
10878
|
+
//
|
|
10879
|
+
// Public side (NO auth): `GET /wishlist/shared/:token` resolves the
|
|
10880
|
+
// wishlist ONLY through `viewShared(token)` — never by a guessable
|
|
10881
|
+
// wishlist/customer id — renders the saved products (redacting the
|
|
10882
|
+
// owner's identity + private notes), records the view, and 404s an
|
|
10883
|
+
// unknown / revoked / expired token. noindex (a personal wishlist isn't
|
|
10884
|
+
// index material).
|
|
10885
|
+
if (deps.wishlistSharing) {
|
|
10886
|
+
// Build the owner's share panel from links scoped to the session
|
|
10887
|
+
// customer. `opts.fresh_url` (the one-time URL of a just-created link)
|
|
10888
|
+
// and `opts.notice` (created / revoked) thread through to the panel.
|
|
10889
|
+
// A reads failure (missing table on a fresh deploy) degrades to a
|
|
10890
|
+
// panel with no list rather than throwing.
|
|
10891
|
+
async function _buildSharePanel(customerId, opts) {
|
|
10892
|
+
opts = opts || {};
|
|
10893
|
+
var shareRows = [];
|
|
10894
|
+
try { shareRows = await deps.wishlistSharing.listSharesForOwner(customerId); }
|
|
10895
|
+
catch (_e) { shareRows = []; }
|
|
10896
|
+
return _wishlistSharePanel({
|
|
10897
|
+
shares: shareRows,
|
|
10898
|
+
notice: opts.notice || null,
|
|
10899
|
+
fresh_url: opts.fresh_url || null,
|
|
10900
|
+
});
|
|
10901
|
+
}
|
|
10902
|
+
|
|
10903
|
+
// Re-render the /account/wishlist page (used by the create POST so the
|
|
10904
|
+
// one-time share URL is shown inline, and on a revoke). Resolves the
|
|
10905
|
+
// same saved-items list the GET route renders.
|
|
10906
|
+
async function _renderWishlistWithPanel(req, res, customerId, panelOpts) {
|
|
10907
|
+
var page = await deps.wishlist.listForCustomer(customerId, { limit: 50 });
|
|
10908
|
+
var items = [];
|
|
10909
|
+
for (var i = 0; i < page.rows.length; i += 1) {
|
|
10910
|
+
var entry = page.rows[i];
|
|
10911
|
+
var product = null;
|
|
10912
|
+
try { product = await deps.catalog.products.get(entry.product_id); } catch (_e) { product = null; }
|
|
10913
|
+
if (!product) { items.push({ product: null, product_id: entry.product_id }); continue; }
|
|
10914
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
10915
|
+
items.push({ product: product, hero_media: media.length ? media[0] : null });
|
|
10916
|
+
}
|
|
10917
|
+
var cartCount = await _cartCountForReq(req);
|
|
10918
|
+
var panel = await _buildSharePanel(customerId, panelOpts || {});
|
|
10919
|
+
_send(res, 200, renderWishlist({
|
|
10920
|
+
items: items,
|
|
10921
|
+
share_panel: panel,
|
|
10701
10922
|
shop_name: shopName,
|
|
10702
10923
|
cart_count: cartCount,
|
|
10703
10924
|
asset_prefix: deps.asset_prefix || "/assets/",
|
|
10704
10925
|
}));
|
|
10926
|
+
}
|
|
10927
|
+
|
|
10928
|
+
// POST /wishlist/share — mint a share link for the SESSION customer's
|
|
10929
|
+
// wishlist. The link is owner-scoped (keyed on `auth.customer_id`);
|
|
10930
|
+
// the plaintext token is returned once and surfaced inline as the
|
|
10931
|
+
// public URL the owner copies (never persisted). `unlisted` is the
|
|
10932
|
+
// default privacy: link-bearer access, no friends-graph requirement,
|
|
10933
|
+
// not advertised. The page re-renders directly (200) rather than a
|
|
10934
|
+
// redirect so the one-time URL is shown.
|
|
10935
|
+
router.post("/wishlist/share", async function (req, res) {
|
|
10936
|
+
var auth;
|
|
10937
|
+
try { auth = _currentCustomer(req); }
|
|
10938
|
+
catch (e) {
|
|
10939
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
10940
|
+
throw e;
|
|
10941
|
+
}
|
|
10942
|
+
if (!auth) {
|
|
10943
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
10944
|
+
return res.end ? res.end() : res.send("");
|
|
10945
|
+
}
|
|
10946
|
+
var created;
|
|
10947
|
+
try {
|
|
10948
|
+
created = await deps.wishlistSharing.createShareLink({
|
|
10949
|
+
owner_customer_id: auth.customer_id,
|
|
10950
|
+
privacy: "unlisted",
|
|
10951
|
+
});
|
|
10952
|
+
} catch (e) {
|
|
10953
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
10954
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
10955
|
+
}
|
|
10956
|
+
// The full public URL the giver opens. Built from this request's
|
|
10957
|
+
// ORIGIN (scheme + host) so the one-time link is a correct absolute
|
|
10958
|
+
// URL — this POST lands on /wishlist/share, so trimming a path off
|
|
10959
|
+
// the canonical URL would mangle the link (and the token shows once).
|
|
10960
|
+
var shareOrigin = "";
|
|
10961
|
+
try { shareOrigin = new URL(_requestUrls(req).canonical_url).origin; }
|
|
10962
|
+
catch (_e) { shareOrigin = ""; /* unparseable — fall back to a host-relative link */ }
|
|
10963
|
+
var freshUrl = shareOrigin + "/wishlist/shared/" + encodeURIComponent(created.plaintext_token);
|
|
10964
|
+
await _renderWishlistWithPanel(req, res, auth.customer_id, { notice: "created", fresh_url: freshUrl });
|
|
10965
|
+
});
|
|
10966
|
+
|
|
10967
|
+
// POST /wishlist/share/:share_id/revoke — revoke one of the SESSION
|
|
10968
|
+
// customer's share links. The primitive's revokeShareLink moves a link
|
|
10969
|
+
// by id alone (no owner notion), so the route owns the ownership
|
|
10970
|
+
// decision: it loads the session customer's own links and refuses a
|
|
10971
|
+
// share_id that isn't among them (clean 404). Without that scope, any
|
|
10972
|
+
// signed-in shopper could revoke another customer's link by guessing
|
|
10973
|
+
// its id (IDOR).
|
|
10974
|
+
router.post("/wishlist/share/:share_id/revoke", async function (req, res) {
|
|
10975
|
+
var auth;
|
|
10976
|
+
try { auth = _currentCustomer(req); }
|
|
10977
|
+
catch (e) {
|
|
10978
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
10979
|
+
throw e;
|
|
10980
|
+
}
|
|
10981
|
+
if (!auth) {
|
|
10982
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
10983
|
+
return res.end ? res.end() : res.send("");
|
|
10984
|
+
}
|
|
10985
|
+
var shareId = (req.params && req.params.share_id) || "";
|
|
10986
|
+
// Ownership scope: the link must belong to the session customer.
|
|
10987
|
+
var owned = [];
|
|
10988
|
+
try { owned = await deps.wishlistSharing.listSharesForOwner(auth.customer_id); }
|
|
10989
|
+
catch (_e) { owned = []; }
|
|
10990
|
+
var match = null;
|
|
10991
|
+
for (var i = 0; i < owned.length; i += 1) {
|
|
10992
|
+
if (owned[i].id === shareId) { match = owned[i]; break; }
|
|
10993
|
+
}
|
|
10994
|
+
if (!match) {
|
|
10995
|
+
// Unknown id, malformed id, or a link owned by someone else all
|
|
10996
|
+
// resolve the same way — a clean 404, no act, no leak.
|
|
10997
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
10998
|
+
}
|
|
10999
|
+
try {
|
|
11000
|
+
await deps.wishlistSharing.revokeShareLink({ link_id: shareId, reason: "owner revoked from account" });
|
|
11001
|
+
} catch (e) {
|
|
11002
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
11003
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
11004
|
+
}
|
|
11005
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?share=revoked");
|
|
11006
|
+
return res.end ? res.end() : res.send("");
|
|
11007
|
+
});
|
|
11008
|
+
|
|
11009
|
+
// GET /wishlist/shared/:token — the public, no-auth shared view. The
|
|
11010
|
+
// wishlist is resolved ONLY via viewShared(token); a giver with the
|
|
11011
|
+
// link sees the saved products (with the owner identity + private
|
|
11012
|
+
// notes redacted — the renderer emits product cards only). An unknown
|
|
11013
|
+
// / revoked / expired token 404s exactly like an unknown route. The
|
|
11014
|
+
// view is recorded best-effort (analytics for the owner) and never
|
|
11015
|
+
// blocks the render. noindex.
|
|
11016
|
+
router.get("/wishlist/shared/:token", async function (req, res) {
|
|
11017
|
+
var token = (req.params && req.params.token) || "";
|
|
11018
|
+
var view = null;
|
|
11019
|
+
try {
|
|
11020
|
+
view = await deps.wishlistSharing.viewShared({ token: token });
|
|
11021
|
+
} catch (_e) {
|
|
11022
|
+
// A malformed token (TypeError) and a not-found / revoked / expired
|
|
11023
|
+
// token (coded errors) all render the same 404 — no oracle on why.
|
|
11024
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
11025
|
+
}
|
|
11026
|
+
// Record the open for the owner's view counter. Best-effort: a
|
|
11027
|
+
// record failure never breaks the giver's page.
|
|
11028
|
+
try { await deps.wishlistSharing.recordView({ token: token }); }
|
|
11029
|
+
catch (_e) { /* drop-silent — analytics, not the render path */ }
|
|
11030
|
+
// Resolve each shared entry to its product + hero image. The entry's
|
|
11031
|
+
// owner customer_id + private notes are NOT carried into the view
|
|
11032
|
+
// (the renderer emits product cards only) so the giver never sees
|
|
11033
|
+
// the owner's identity.
|
|
11034
|
+
var items = [];
|
|
11035
|
+
var entries = (view && view.entries) || [];
|
|
11036
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
11037
|
+
var product = null;
|
|
11038
|
+
try { product = await deps.catalog.products.get(entries[i].product_id); } catch (_e) { product = null; }
|
|
11039
|
+
if (!product) continue;
|
|
11040
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
11041
|
+
items.push({ product: product, hero_media: media.length ? media[0] : null });
|
|
11042
|
+
}
|
|
11043
|
+
_send(res, 200, renderSharedWishlist({
|
|
11044
|
+
items: items,
|
|
11045
|
+
title: (view && view.share && view.share.title) || "A shared wishlist",
|
|
11046
|
+
shop_name: shopName,
|
|
11047
|
+
cart_count: await _cartCountForReq(req),
|
|
11048
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
11049
|
+
}));
|
|
10705
11050
|
});
|
|
10706
11051
|
}
|
|
10707
11052
|
|
package/package.json
CHANGED