From 761a3c2c8e0eeaf6715488df235066a489839886 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:57:55 +0000 Subject: [PATCH] fix: remove client-side API keys, repair capture forms, harden host canonicalisation Middleware: non-primary hosts (pages.dev preview deployments, stale custom domains) are now 301'd to the apex instead of served, removing indexable duplicates of the whole site. Adds an X-Robots-Tag noindex fallback. The verified single-hop www/protocol/trailing-slash behaviour and the /api/*, /go/* and file-extension exemptions are unchanged. New functions/api/capture-email.js proxies Systeme.io server-side, reading only env.SYSTEME_API_KEY. Validates the address, allow-lists tags, treats an upstream 409 as success, CORS restricted to the apex. Removes the hardcoded live Systeme.io API key from src/pages/shopify-automation-guides.astro and routes all capture forms through the new endpoint. Two of those forms were posting to an unreplaced pub_YOUR_PUBLICATION_ID placeholder and had never captured anything: replace-klaviyo-free.astro and the stack.astro newsletter form. Deletes the BEEHIIV_PUB_ID constant and the leftover setup-instruction comment that was being shipped in stack.astro's page source. Homepage scanner and guide captures no longer depend on window.open to beehiiv, which mobile browsers block; gfGet keeps a single popup for the PDF the visitor actually requested. Deployment prerequisite: SYSTEME_API_KEY must be bound in Cloudflare Pages for both Production and Preview, using the rotated key. public/_redirects, public/robots.txt, astro.config.mjs and src/layouts/Base.astro are untouched; all 18 /go/ rules intact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESF5YNWpVMFdYJRNoNRrMm --- functions/_middleware.js | 53 +++++++-------- functions/api/capture-email.js | 78 +++++++++++++++++++++++ src/pages/index.astro | 15 ++++- src/pages/replace-klaviyo-free.astro | 12 +--- src/pages/shopify-automation-guides.astro | 6 +- src/pages/stack.astro | 46 ++++++------- 6 files changed, 141 insertions(+), 69 deletions(-) create mode 100644 functions/api/capture-email.js diff --git a/functions/_middleware.js b/functions/_middleware.js index 9223e23..73f3832 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -1,22 +1,20 @@ // Cloudflare Pages Edge Middleware — Host, Protocol & Trailing-Slash Canonicalisation // -// WHY THIS CHANGED -// The previous version redirected www → apex but did NOT normalise the -// trailing slash. Because astro.config.mjs sets `trailingSlash: 'always'`, -// a request for: -// https://www.stackarchitect.xyz/autocrat-quota-fix -// produced TWO redirects: -// 301 → https://stackarchitect.xyz/autocrat-quota-fix (this middleware) -// 301 → https://stackarchitect.xyz/autocrat-quota-fix/ (Pages slash rule) +// WHY THIS CHANGED (2026-08-17) +// The previous version's non-primary-host branch fell through to +// `context.next()`, which SERVED the full site on any host that is neither the +// apex nor a www.* subdomain. That included every per-deployment preview host +// (`.stackarchitect.pages.dev`) — each one an indexable duplicate of the +// entire site. Those hosts are now redirected to the apex instead of served. // -// GSC's 6-month export shows Google holding exactly those un-slashed www URLs -// (e.g. www.stackarchitect.xyz/autocrat-quota-fix, 982 impressions). Every hop -// slows consolidation and is a likely contributor to the "Redirect error" (19) -// and "Page with redirect" (7) buckets in the Coverage report. +// The single-hop www/protocol/trailing-slash behaviour is UNCHANGED and remains +// verified working: +// www.stackarchitect.xyz/autocrat-quota-fix +// → one 301 → stackarchitect.xyz/autocrat-quota-fix/ +// stackarchitect.xyz/autocrat-quota-fix +// → one 301 → stackarchitect.xyz/autocrat-quota-fix/ // -// This version resolves host, protocol AND trailing slash in ONE 301. -// -// Deliberately NOT redirected: +// Deliberately NOT slash-redirected: // • /api/* — Pages Functions // • /go/* — REVENUE CRITICAL. public/_redirects already declares // both slash variants for every affiliate cloak. Adding a @@ -25,39 +23,35 @@ // become 301 → 302 instead of a single 302. Some affiliate // networks drop tracking parameters across extra hops. // • paths with a file extension (.xml, .txt, .json, .png, .csv …) — these -// must NOT gain a trailing slash or they 404. This was the main risk of -// naively appending "/" to everything. +// must NOT gain a trailing slash or they 404. const PRIMARY_HOST = 'stackarchitect.xyz'; - -// Anything with an extension in the last segment is a file, not a page. const FILE_RE = /\.[a-zA-Z0-9]{2,5}$/; export async function onRequest(context) { const url = new URL(context.request.url); let changed = false; - // 1. Force HTTPS if (url.protocol === 'http:') { url.protocol = 'https:'; changed = true; } - // 2. Force apex host (strip www.) if (url.hostname.startsWith('www.')) { url.hostname = url.hostname.replace(/^www\./, ''); changed = true; } - // Safety: never rewrite a host we don't own the canonical for. + // Any remaining non-primary host (pages.dev, preview deployments, stale + // custom domains) is redirected to the apex rather than served. if (url.hostname !== PRIMARY_HOST) { - return changed ? Response.redirect(url.toString(), 301) : context.next(); + url.hostname = PRIMARY_HOST; + changed = true; } - // 3. Force trailing slash on page routes only const p = url.pathname; const isApi = p.startsWith('/api/'); - const isGo = p.startsWith('/go/'); // affiliate cloaks — see note above + const isGo = p.startsWith('/go/'); // affiliate cloaks — must not gain a slash hop const isFile = FILE_RE.test(p.split('/').pop() || ''); if (!isApi && !isGo && !isFile && !p.endsWith('/')) { @@ -69,5 +63,12 @@ export async function onRequest(context) { return Response.redirect(url.toString(), 301); } - return context.next(); + const response = await context.next(); + const host = new URL(context.request.url).hostname; + if (host !== PRIMARY_HOST) { + const patched = new Response(response.body, response); + patched.headers.set('X-Robots-Tag', 'noindex, nofollow'); + return patched; + } + return response; } diff --git a/functions/api/capture-email.js b/functions/api/capture-email.js new file mode 100644 index 0000000..db2ad14 --- /dev/null +++ b/functions/api/capture-email.js @@ -0,0 +1,78 @@ +const SYSTEME_API_URL = "https://api.systeme.io/api/contacts"; + +const ALLOWED_TAGS = new Set([ + "guides-capture", + "homepage-capture", + "klaviyo-calculator", + "scanner-plan", + "stack-capture", +]); + +export async function onRequestPost(context) { + const { request, env } = context; + + const cors = { + "Access-Control-Allow-Origin": "https://stackarchitect.xyz", + "Content-Type": "application/json", + }; + + if (!env.SYSTEME_API_KEY) { + console.error("SYSTEME_API_KEY binding is not configured"); + return json({ error: "Capture unavailable" }, 503, cors); + } + + let body; + try { + body = await request.json(); + } catch { + return json({ error: "Invalid JSON" }, 400, cors); + } + + if (body === null || typeof body !== "object" || Array.isArray(body)) { + return json({ error: "Body must be a JSON object" }, 400, cors); + } + + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + if (!email || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return json({ error: "Invalid email" }, 400, cors); + } + + const tag = ALLOWED_TAGS.has(body.tag) ? body.tag : "site-capture"; + + let upstream; + try { + upstream = await fetch(SYSTEME_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": env.SYSTEME_API_KEY, + }, + body: JSON.stringify({ email, tags: [{ name: tag }] }), + }); + } catch { + return json({ error: "Upstream unreachable" }, 502, cors); + } + + // 409 = contact already exists; a success from the visitor's perspective. + if (upstream.ok || upstream.status === 409) { + return json({ success: true }, 200, cors); + } + + console.error("Systeme.io error", upstream.status); + return json({ error: "Capture failed" }, 502, cors); +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "https://stackarchitect.xyz", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} + +function json(payload, status, headers) { + return new Response(JSON.stringify(payload), { status, headers }); +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 44b80c3..1285c09 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1308,7 +1308,11 @@ details.faq-item-c[open] summary .faq-ico::before{transform:rotate(225deg);top:6 var e = (emailEl ? emailEl.value : '').trim(); if (!e || !e.includes('@')) { if (emailEl) { emailEl.style.borderColor = 'var(--red)'; emailEl.focus(); } return; } if (emailEl) emailEl.style.borderColor = ''; - window.open('https://stackarchitect.beehiiv.com/subscribe?email=' + encodeURIComponent(e), '_blank'); + fetch('/api/capture-email/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: e, tag: 'scanner-plan' }) + }).catch(function () {}); var plan = document.getElementById('sc-plan'); if(plan) plan.style.display = 'none'; var done = document.getElementById('sc-done'); if(done) done.style.display = 'block'; }; @@ -1320,7 +1324,14 @@ window.gfGet = function() { var e = (emailEl ? emailEl.value : '').trim(); if (!e || !e.includes('@')) { if (emailEl) { emailEl.style.borderColor = 'var(--red)'; emailEl.focus(); } return; } if (emailEl) emailEl.style.borderColor = ''; - window.open('https://stackarchitect.beehiiv.com/subscribe?email=' + encodeURIComponent(e), '_blank'); + fetch('/api/capture-email/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: e, tag: 'homepage-capture' }) + }).catch(function () {}); + // Single popup only — the PDF is the thing the visitor actually asked for, and + // one window.open per gesture survives mobile popup blocking. Signup is now + // handled by the fetch above rather than a second window. window.open('https://d1yei2z3i6k35z.cloudfront.net/16370908/69a2ac505857c_The_Master_Library.pdf', '_blank'); var form = document.getElementById('gf-form'); if(form) form.style.display = 'none'; var done = document.getElementById('gf-done'); if(done) done.style.display = 'block'; diff --git a/src/pages/replace-klaviyo-free.astro b/src/pages/replace-klaviyo-free.astro index 716083e..1939074 100644 --- a/src/pages/replace-klaviyo-free.astro +++ b/src/pages/replace-klaviyo-free.astro @@ -1068,18 +1068,10 @@ const tocHeadings = [ } if (inp) inp.classList.remove('error'); - // Beehiiv subscription (replace pub ID with yours) - fetch('https://api.beehiiv.com/v2/publications/pub_YOUR_PUBLICATION_ID/subscriptions', { + fetch('/api/capture-email/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: email, - reactivate_existing: true, - send_welcome_email: true, - utm_source: 'klaviyo-calculator', - utm_medium: 'website', - utm_campaign: 'klaviyo-migration-checklist' - }) + body: JSON.stringify({ email: email, tag: 'klaviyo-calculator' }) }).catch(function () {}); document.getElementById('calc-capture-form').style.display = 'none'; diff --git a/src/pages/shopify-automation-guides.astro b/src/pages/shopify-automation-guides.astro index 063cb1f..af0bcb4 100644 --- a/src/pages/shopify-automation-guides.astro +++ b/src/pages/shopify-automation-guides.astro @@ -1026,10 +1026,10 @@ document.getElementById('guides-submit')?.addEventListener('click', function () return; } el.style.borderColor = ''; - fetch('https://api.systeme.io/api/contacts', { + fetch('/api/capture-email/', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-Key': 'kdsymoc8fwhwwc604vrofp4jt0rb5bjosmzs5dqd4r8suo4avx4xsik4ixqvwgo4' }, - body: JSON.stringify({ email, tags: [{ name: 'guides-capture' }] }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, tag: 'guides-capture' }) }).catch(() => {}); if (typeof gtag !== 'undefined') { gtag('event', 'guides_email_capture', { event_category: 'conversion', event_label: 'guides_hub' }); diff --git a/src/pages/stack.astro b/src/pages/stack.astro index b6757c0..f9d0a7c 100644 --- a/src/pages/stack.astro +++ b/src/pages/stack.astro @@ -12,12 +12,10 @@ const SITE = "https://stackarchitect.xyz"; const CANONICAL = `${SITE}/stack/`; const UPDATED = "April 2026"; -// ─── BEEHIIV CONFIG ────────────────────────────────────────────────────────── -// To get your publication ID: -// 1. Go to beehiiv.com → your publication → Settings → Publication Details -// 2. Copy the Publication ID (format: pub_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) -// Replace the value below. The subscribe endpoint then works automatically. -const BEEHIIV_PUB_ID = "YOUR_PUBLICATION_ID"; // e.g. pub_3f47e3c5-6ce3-479c-b015-863753ac8ea7 +// ─── NEWSLETTER CONFIG ─────────────────────────────────────────────────────── +// The newsletter form posts to /api/capture-email/ (Pages Function), which +// forwards to Systeme.io server-side using the SYSTEME_API_KEY binding. +// No publication ID or API credential belongs in this file. // Your Beehiiv affiliate referral link — earns commission on paid upgrades const BEEHIIV_AFFILIATE = "/go/beehiiv"; @@ -630,22 +628,11 @@ const schemaWebPage = {

Get the Stack Architect Weekly

New automation blueprints, Shopify profit-leak fixes, and tool updates — delivered every Tuesday. Free forever.

-
@@ -667,7 +654,7 @@ const schemaWebPage = {

no spam · unsubscribe any time · 2,000+ subscribers across UK, US, AU & CA

Or open the subscribe page directly →

@@ -810,15 +797,18 @@ const schemaWebPage = { } }); - // Newsletter form — open beehiiv in new tab, show success message + // Newsletter form — POST to /api/capture-email/, show success message function handleNlSubmit(e) { - const email = document.getElementById('nl-email').value; - if (!email) return; - // Show success note; form still submits to beehiiv via GET target=_blank - setTimeout(() => { - document.getElementById('nl-success').style.display = 'block'; - document.getElementById('nl-form').reset(); - }, 200); + e.preventDefault(); + const email = (document.getElementById('nl-email').value || '').trim(); + if (!email || !email.includes('@')) return; + fetch('/api/capture-email/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, tag: 'stack-capture' }) + }).catch(function () {}); + document.getElementById('nl-success').style.display = 'block'; + document.getElementById('nl-form').reset(); }