@blamejs/blamejs-shop 0.3.22 → 0.3.24
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 +4 -0
- package/lib/admin.js +905 -3
- package/lib/asset-manifest.json +1 -1
- package/lib/security-middleware.js +7 -0
- package/lib/storefront.js +211 -10
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
|
@@ -105,6 +105,13 @@ var EDGE_POST_PATHS = [
|
|
|
105
105
|
"/consent",
|
|
106
106
|
"/currency",
|
|
107
107
|
"/newsletter",
|
|
108
|
+
// One-click unsubscribe (RFC 8058). The unsubscribe token in the URL IS
|
|
109
|
+
// the bearer; the POST a mail client fires from List-Unsubscribe-Post
|
|
110
|
+
// carries no cookies and no session CSRF token, so the path must be
|
|
111
|
+
// exempt from the double-submit check (it keeps its SameSite +
|
|
112
|
+
// fetch-metadata defense and is single-use + timing-safe at the token
|
|
113
|
+
// layer). Container-only — there's no edge copy to keep parity with.
|
|
114
|
+
"/unsubscribe",
|
|
108
115
|
"/announcements/",
|
|
109
116
|
];
|
|
110
117
|
|
package/lib/storefront.js
CHANGED
|
@@ -106,6 +106,23 @@ function webhookRawBodyCapture(paths) {
|
|
|
106
106
|
// same XSS guard, same unknown / unused refusal).
|
|
107
107
|
var _render = emailModule._render;
|
|
108
108
|
|
|
109
|
+
// Splice a fully-rendered HTML fragment into `html` at the first
|
|
110
|
+
// occurrence of a literal `RAW_*` token, inserting the fragment LITERALLY.
|
|
111
|
+
//
|
|
112
|
+
// `String.prototype.replace(token, replacementString)` gives the
|
|
113
|
+
// replacement string special meaning to `$` sequences — `$$`, `$&`,
|
|
114
|
+
// `` $` `` (the text before the match), `$'` (the text after the match),
|
|
115
|
+
// `$1`. A page/blog body that contains a dollar followed by a backtick
|
|
116
|
+
// would otherwise splice the entire document HEAD into the body, and any
|
|
117
|
+
// other dollar sequence corrupts the output. The fragment is already
|
|
118
|
+
// escaped/rendered at its own build site, so this is purely about the
|
|
119
|
+
// replace mechanics: passing a REPLACER FUNCTION makes `String.replace`
|
|
120
|
+
// insert the return value verbatim, with no dollar interpretation. Use
|
|
121
|
+
// this for every dynamic (operator- or customer-supplied) fragment swap.
|
|
122
|
+
function _spliceRaw(html, token, fragment) {
|
|
123
|
+
return html.replace(token, function () { return fragment == null ? "" : String(fragment); });
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
// ---- double-submit CSRF token injection ---------------------------------
|
|
110
127
|
|
|
111
128
|
// The container's authenticated state-changing forms (account /
|
|
@@ -758,15 +775,23 @@ function _wrap(opts) {
|
|
|
758
775
|
|
|
759
776
|
// Per-page robots directive. Indexable pages (the default) emit no
|
|
760
777
|
// robots meta — the absence means "index, follow". Pages the storefront
|
|
761
|
-
// keeps out of search
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
778
|
+
// keeps out of search pass a directive:
|
|
779
|
+
// - `noindex` → `noindex,nofollow`. Session-scoped surfaces (cart /
|
|
780
|
+
// account) whose links are all session-scoped too, so there's
|
|
781
|
+
// nothing for a crawler to follow.
|
|
782
|
+
// - `noindex,follow` → `noindex,follow`. Internal search result pages
|
|
783
|
+
// are thin/duplicate indexable URLs (one per query + facet combo) —
|
|
784
|
+
// keep the query URL out of the index but let crawlers follow the
|
|
785
|
+
// product links the page lists.
|
|
786
|
+
// It's a belt-and-suspenders pairing with the robots.txt Disallow + the
|
|
787
|
+
// edge `x-robots-tag` header, so a directly-fetched page is also
|
|
765
788
|
// self-describing. A noindex page needs no canonical (a crawler won't
|
|
766
789
|
// index it to dedupe), so these pages don't thread a canonical_url.
|
|
767
|
-
var robotsMeta = (opts.robots === "noindex")
|
|
768
|
-
? " <meta name=\"robots\" content=\"noindex,
|
|
769
|
-
: ""
|
|
790
|
+
var robotsMeta = (opts.robots === "noindex,follow")
|
|
791
|
+
? " <meta name=\"robots\" content=\"noindex,follow\">\n"
|
|
792
|
+
: (opts.robots === "noindex")
|
|
793
|
+
? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
|
|
794
|
+
: "";
|
|
770
795
|
|
|
771
796
|
var assembled = _render(LAYOUT, vars)
|
|
772
797
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
@@ -776,15 +801,18 @@ function _wrap(opts) {
|
|
|
776
801
|
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
777
802
|
.replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
|
|
778
803
|
.replace("RAW_CURRENCY_SWITCHER", switcherHtml)
|
|
779
|
-
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
|
|
780
|
-
.replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
804
|
+
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "");
|
|
781
805
|
// The body is RAW HTML (already rendered + escaped at the
|
|
782
806
|
// per-fragment level). The placeholder swap is post-render so the
|
|
783
807
|
// outer renderer's HTML-escape doesn't double-escape the inner
|
|
784
|
-
// markup
|
|
808
|
+
// markup, and routes through `_spliceRaw` so a body carrying a `$`
|
|
809
|
+
// sequence (a blog/CMS post, a reflected query) is inserted literally
|
|
810
|
+
// rather than triggering `String.replace`'s dollar-substitution.
|
|
811
|
+
// `search_q` is HTML-escaped by the renderer like any
|
|
785
812
|
// other placeholder, so a customer-supplied query like
|
|
786
813
|
// `"><script>` lands as escaped text inside the input's `value`.
|
|
787
814
|
//
|
|
815
|
+
assembled = _spliceRaw(assembled, "RAW_BODY_PLACEHOLDER", opts.body);
|
|
788
816
|
// Final pass: token the container's authenticated POST forms with the
|
|
789
817
|
// per-request double-submit CSRF value (stashed on the locale ALS by
|
|
790
818
|
// `localeMiddleware`). EDGE_POST_PATHS forms are skipped to preserve
|
|
@@ -1557,6 +1585,10 @@ function renderSearch(opts) {
|
|
|
1557
1585
|
og_description: searchMetaDescription,
|
|
1558
1586
|
canonical_url: opts.canonical_url,
|
|
1559
1587
|
og_url: opts.og_url,
|
|
1588
|
+
// Internal search results are thin/duplicate indexable URLs (one per
|
|
1589
|
+
// query + facet combination) — keep the query URL out of the index
|
|
1590
|
+
// but let crawlers follow the product links the page lists.
|
|
1591
|
+
robots: "noindex,follow",
|
|
1560
1592
|
body: body,
|
|
1561
1593
|
}, _currencyWrapOpts(opts)));
|
|
1562
1594
|
}
|
|
@@ -5226,6 +5258,110 @@ function renderNewsletterError(opts) {
|
|
|
5226
5258
|
});
|
|
5227
5259
|
}
|
|
5228
5260
|
|
|
5261
|
+
// ---- newsletter unsubscribe (CAN-SPAM / RFC 8058) ----------------------
|
|
5262
|
+
//
|
|
5263
|
+
// A marketing email's unsubscribe link points at GET /unsubscribe?token=…
|
|
5264
|
+
// (a friendly confirm page) and List-Unsubscribe-Post fires a one-click
|
|
5265
|
+
// POST /unsubscribe with the same token. Both are unauthenticated — the
|
|
5266
|
+
// opaque, single-use, timing-safe token is the bearer — and CSRF-exempt
|
|
5267
|
+
// (the mail-client POST carries no session). The token is never echoed
|
|
5268
|
+
// back to the page; only its structured outcome is rendered.
|
|
5269
|
+
|
|
5270
|
+
// The confirm page (GET). A no-JS form POSTs the token back to
|
|
5271
|
+
// /unsubscribe to perform the unsubscribe, so a visitor who followed the
|
|
5272
|
+
// link sees a deliberate "yes, unsubscribe me" step rather than being
|
|
5273
|
+
// unsubscribed by a link-prefetcher. The token rides in a hidden field
|
|
5274
|
+
// (HTML-escaped) and is the only handle — no email address is shown.
|
|
5275
|
+
function renderUnsubscribeConfirm(opts) {
|
|
5276
|
+
opts = opts || {};
|
|
5277
|
+
var token = typeof opts.token === "string" ? opts.token : "";
|
|
5278
|
+
var body =
|
|
5279
|
+
"<section class=\"newsletter-thanks\">" +
|
|
5280
|
+
"<div class=\"newsletter-thanks__card\">" +
|
|
5281
|
+
"<p class=\"eyebrow\">Newsletter</p>" +
|
|
5282
|
+
"<h1 class=\"newsletter-thanks__title\">Unsubscribe from the list?</h1>" +
|
|
5283
|
+
"<p class=\"newsletter-thanks__lede\">Confirm below and we'll stop emailing this address. You can re-subscribe any time from the footer of the site.</p>" +
|
|
5284
|
+
"<form class=\"newsletter-unsub__form\" method=\"post\" action=\"/unsubscribe\">" +
|
|
5285
|
+
"<input type=\"hidden\" name=\"token\" value=\"" + _escAttr(token) + "\">" +
|
|
5286
|
+
"<div class=\"newsletter-thanks__cta\">" +
|
|
5287
|
+
"<button type=\"submit\" class=\"btn-primary\">Unsubscribe</button>" +
|
|
5288
|
+
"<a href=\"/\" class=\"btn-ghost\">Keep me subscribed</a>" +
|
|
5289
|
+
"</div>" +
|
|
5290
|
+
"</form>" +
|
|
5291
|
+
"</div>" +
|
|
5292
|
+
"</section>";
|
|
5293
|
+
return _wrap({
|
|
5294
|
+
title: "Unsubscribe",
|
|
5295
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
5296
|
+
cart_count: opts.cart_count,
|
|
5297
|
+
theme_css: opts.theme_css,
|
|
5298
|
+
// The confirm + result pages are token-bearer surfaces with no
|
|
5299
|
+
// canonical/indexable identity — keep them out of search (`noindex`
|
|
5300
|
+
// maps to noindex,nofollow in _wrap).
|
|
5301
|
+
robots: "noindex",
|
|
5302
|
+
body: body,
|
|
5303
|
+
});
|
|
5304
|
+
}
|
|
5305
|
+
|
|
5306
|
+
// The POST outcome page. `opts.outcome` is one of the structured results
|
|
5307
|
+
// the unsubscribe flow produces: "unsubscribed" (success — offers a
|
|
5308
|
+
// re-subscribe form), "not-found" (invalid / unknown token), "already"
|
|
5309
|
+
// (the token was already consumed — the address is already off the list),
|
|
5310
|
+
// "expired" (the token's TTL lapsed). Every branch renders a clean,
|
|
5311
|
+
// server-rendered page; no raw error is ever surfaced.
|
|
5312
|
+
function renderUnsubscribeResult(opts) {
|
|
5313
|
+
opts = opts || {};
|
|
5314
|
+
var outcome = opts.outcome;
|
|
5315
|
+
var heading, lede, extra = "";
|
|
5316
|
+
if (outcome === "unsubscribed") {
|
|
5317
|
+
heading = "You're unsubscribed.";
|
|
5318
|
+
lede = "This address won't receive any more newsletter email. Changed your mind? Re-subscribe below.";
|
|
5319
|
+
// Re-subscribe affordance — the storefront newsletter signup form,
|
|
5320
|
+
// pre-pointed at the footer band's POST /newsletter so a single click
|
|
5321
|
+
// (after typing the address) puts the visitor back on the list.
|
|
5322
|
+
extra =
|
|
5323
|
+
"<form class=\"newsletter-resub__form\" method=\"post\" action=\"/newsletter\">" +
|
|
5324
|
+
"<label class=\"skip-link\" for=\"resub-email\">Email address</label>" +
|
|
5325
|
+
"<input id=\"resub-email\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">" +
|
|
5326
|
+
"<button type=\"submit\" class=\"btn-primary\">Re-subscribe</button>" +
|
|
5327
|
+
"</form>";
|
|
5328
|
+
} else if (outcome === "already") {
|
|
5329
|
+
heading = "Already unsubscribed.";
|
|
5330
|
+
lede = "This link was already used — the address is off the list, so there's nothing more to do.";
|
|
5331
|
+
} else if (outcome === "expired") {
|
|
5332
|
+
heading = "That link has expired.";
|
|
5333
|
+
lede = "Unsubscribe links are valid for a limited time. You're still on the list; use the unsubscribe link in a more recent email, or contact us and we'll remove you.";
|
|
5334
|
+
} else {
|
|
5335
|
+
// "not-found" + any unexpected code → the same generic, non-leaking
|
|
5336
|
+
// copy. A bad / unknown / malformed token can't be distinguished from
|
|
5337
|
+
// outside, by design.
|
|
5338
|
+
heading = "This link isn't valid.";
|
|
5339
|
+
lede = "We couldn't match this unsubscribe link to a subscription. It may have already been used, or the link may be incomplete.";
|
|
5340
|
+
}
|
|
5341
|
+
var body =
|
|
5342
|
+
"<section class=\"newsletter-thanks\">" +
|
|
5343
|
+
"<div class=\"newsletter-thanks__card\">" +
|
|
5344
|
+
"<p class=\"eyebrow\">Newsletter</p>" +
|
|
5345
|
+
"<h1 class=\"newsletter-thanks__title\">" + heading + "</h1>" +
|
|
5346
|
+
"<p class=\"newsletter-thanks__lede\">" + lede + "</p>" +
|
|
5347
|
+
extra +
|
|
5348
|
+
"<div class=\"newsletter-thanks__cta\">" +
|
|
5349
|
+
"<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
|
|
5350
|
+
"</div>" +
|
|
5351
|
+
"</div>" +
|
|
5352
|
+
"</section>";
|
|
5353
|
+
return _wrap({
|
|
5354
|
+
title: "Unsubscribe",
|
|
5355
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
5356
|
+
cart_count: opts.cart_count,
|
|
5357
|
+
theme_css: opts.theme_css,
|
|
5358
|
+
// Token-bearer outcome page — keep it out of search (`noindex` maps to
|
|
5359
|
+
// noindex,nofollow in _wrap).
|
|
5360
|
+
robots: "noindex",
|
|
5361
|
+
body: body,
|
|
5362
|
+
});
|
|
5363
|
+
}
|
|
5364
|
+
|
|
5229
5365
|
// ---- cookie preference center ------------------------------------------
|
|
5230
5366
|
|
|
5231
5367
|
// The four toggleable categories + their operator-facing copy, mirroring
|
|
@@ -11263,6 +11399,71 @@ function mount(router, deps) {
|
|
|
11263
11399
|
}));
|
|
11264
11400
|
}
|
|
11265
11401
|
});
|
|
11402
|
+
|
|
11403
|
+
// Newsletter unsubscribe (CAN-SPAM one-click / RFC 8058).
|
|
11404
|
+
//
|
|
11405
|
+
// GET /unsubscribe?token=… renders a friendly confirm page; the
|
|
11406
|
+
// visitor clicks "Unsubscribe", which POSTs the token back. POST
|
|
11407
|
+
// /unsubscribe is also the List-Unsubscribe-Post one-click target a
|
|
11408
|
+
// mail client fires directly. The opaque, single-use, timing-safe
|
|
11409
|
+
// token is the bearer — no session, no auth — so the route is
|
|
11410
|
+
// CSRF-exempt (see EDGE_POST_PATHS) and reads each structured outcome
|
|
11411
|
+
// `consumeUnsubscribeToken` returns onto a clean server-rendered page.
|
|
11412
|
+
// The token is never echoed back beyond the confirm form's hidden
|
|
11413
|
+
// field; the outcome page reveals nothing about whether a token
|
|
11414
|
+
// existed, was already used, or expired beyond its own friendly copy.
|
|
11415
|
+
|
|
11416
|
+
// Map a `consumeUnsubscribeToken` result code onto the outcome the
|
|
11417
|
+
// result page renders. The structured codes are stable; an unknown
|
|
11418
|
+
// code degrades to the generic "not valid" page rather than a 500.
|
|
11419
|
+
function _unsubscribeOutcome(result) {
|
|
11420
|
+
if (result && result.ok === true) return "unsubscribed";
|
|
11421
|
+
var code = result && result.error;
|
|
11422
|
+
if (code === "already-consumed") return "already";
|
|
11423
|
+
if (code === "expired") return "expired";
|
|
11424
|
+
return "not-found"; // "not-found" + anything unexpected
|
|
11425
|
+
}
|
|
11426
|
+
|
|
11427
|
+
router.get("/unsubscribe", async function (req, res) {
|
|
11428
|
+
var token = (req.query && typeof req.query.token === "string") ? req.query.token : "";
|
|
11429
|
+
var cartCount = 0;
|
|
11430
|
+
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
11431
|
+
return _send(res, 200, renderUnsubscribeConfirm({
|
|
11432
|
+
shop_name: shopName,
|
|
11433
|
+
cart_count: cartCount,
|
|
11434
|
+
token: token,
|
|
11435
|
+
}));
|
|
11436
|
+
});
|
|
11437
|
+
|
|
11438
|
+
router.post("/unsubscribe", async function (req, res) {
|
|
11439
|
+
var body = req.body || {};
|
|
11440
|
+
var token = typeof body.token === "string" ? body.token : "";
|
|
11441
|
+
var cartCount = 0;
|
|
11442
|
+
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
11443
|
+
var outcome;
|
|
11444
|
+
try {
|
|
11445
|
+
// `consumeUnsubscribeToken` returns a structured result (it does
|
|
11446
|
+
// not throw on a bad/missing token — it returns `{ ok:false,
|
|
11447
|
+
// error:"not-found" }`). An empty token is handled the same way.
|
|
11448
|
+
var result = await deps.newsletter.consumeUnsubscribeToken(token);
|
|
11449
|
+
outcome = _unsubscribeOutcome(result);
|
|
11450
|
+
} catch (e) {
|
|
11451
|
+
// A real infrastructure fault (D1 unreachable) — record it server-
|
|
11452
|
+
// side and render the generic non-leaking page rather than a 500
|
|
11453
|
+
// that exposes internals on a public, unauthenticated route.
|
|
11454
|
+
_log.error("storefront unsubscribe failed", {
|
|
11455
|
+
route: "/unsubscribe",
|
|
11456
|
+
request_id: (req && req.requestId) || null,
|
|
11457
|
+
err: (e && e.message) || String(e),
|
|
11458
|
+
});
|
|
11459
|
+
outcome = "not-found";
|
|
11460
|
+
}
|
|
11461
|
+
return _send(res, 200, renderUnsubscribeResult({
|
|
11462
|
+
shop_name: shopName,
|
|
11463
|
+
cart_count: cartCount,
|
|
11464
|
+
outcome: outcome,
|
|
11465
|
+
}));
|
|
11466
|
+
});
|
|
11266
11467
|
}
|
|
11267
11468
|
|
|
11268
11469
|
// ---- cookie consent -----------------------------------------------------
|
package/package.json
CHANGED