Rework PR #15 per security review SEC-14-R1: the client no longer carries an API token. js/newsletter-config.js ships only the non-secret endpoint, enforced https-only at config load time via validateEndpoint() (mirroring the protocol allowlist in js/reading-list.js); js/newsletter.js POSTs email-only with no Authorization header. Failure paths keep the single user-safe message that never leaks token, endpoint, status, or raw body; success still shows the confirmation. CI gains a gitleaks step that fails on any secret hit; README documents the server-side token, the residual signup-abuse risk, and the authoritative server-side validation follow-up.
129 lines
4.3 KiB
JavaScript
129 lines
4.3 KiB
JavaScript
/**
|
|
* Newsletter signup — posts the visitor's email to a configured serverless
|
|
* endpoint. No credential is ever sent: the serverless function authenticates
|
|
* with an API token it reads from platform env/secrets at deploy time.
|
|
*
|
|
* Pure-ish by design (no DOM, injectable fetch), so every behaviour is unit
|
|
* testable in Node; the browser wiring at the bottom is guarded accordingly.
|
|
*/
|
|
import { NEWSLETTER_ENDPOINT } from "./newsletter-config.js";
|
|
|
|
/** User-safe message shown when the signup cannot be completed. */
|
|
export const NEWSLETTER_ERROR_MESSAGE =
|
|
"Sorry, the newsletter signup isn't available right now. Please try again later.";
|
|
|
|
/** Confirmation message shown after a successful signup. */
|
|
export const NEWSLETTER_SUCCESS_MESSAGE = "Thanks for subscribing!";
|
|
|
|
/** Simple email shape check; the HTML5 `type="email"` input is the primary gate. */
|
|
export function isValidEmail(value) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value).trim());
|
|
}
|
|
|
|
/**
|
|
* Read the trimmed email value from a form.
|
|
* Works with a real DOM form and with the minimal fake form used in tests.
|
|
*
|
|
* @param {{querySelectorAll(selector: string): ArrayLike<{name: string, value: string}>}} form
|
|
* @returns {string}
|
|
*/
|
|
export function readFormEmail(form) {
|
|
for (const el of form.querySelectorAll("[name]")) {
|
|
if (el.name === "email") return el.value.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* POST the email to the serverless endpoint with no credential in the request
|
|
* — no auth header, no token in the body or URL. The serverless function reads
|
|
* its API token from platform env/secrets, never from the client. A clearly
|
|
* invalid email fails fast with a user-safe error and no network call. Any
|
|
* non-2xx response and any network failure map to the same generic message, so
|
|
* the endpoint internals, any status code, and any raw response body are never
|
|
* surfaced to the visitor.
|
|
*
|
|
* @param {string} email
|
|
* @param {{endpoint?: string, fetchImpl?: typeof fetch}} [options]
|
|
* @returns {Promise<{ok: boolean, message: string}>}
|
|
*/
|
|
export async function submitNewsletterSignup(
|
|
email,
|
|
{ endpoint = NEWSLETTER_ENDPOINT, fetchImpl = fetch } = {},
|
|
) {
|
|
if (!isValidEmail(email)) {
|
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
} catch {
|
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
|
}
|
|
return { ok: true, message: NEWSLETTER_SUCCESS_MESSAGE };
|
|
}
|
|
|
|
/**
|
|
* Handle a newsletter form submit: validate the email, POST it, and surface
|
|
* the result (confirmation or user-safe error) through `setStatus`.
|
|
*
|
|
* @param {HTMLFormElement} form
|
|
* @param {{endpoint?: string, fetchImpl?: typeof fetch, setStatus?: (message: string, kind: "success"|"error") => void}} [options]
|
|
* @returns {Promise<{ok: boolean, message: string}>}
|
|
*/
|
|
export async function handleNewsletterSubmit(
|
|
form,
|
|
{
|
|
endpoint = NEWSLETTER_ENDPOINT,
|
|
fetchImpl = fetch,
|
|
setStatus = defaultSetStatus,
|
|
} = {},
|
|
) {
|
|
const email = readFormEmail(form);
|
|
if (!isValidEmail(email)) {
|
|
setStatus(NEWSLETTER_ERROR_MESSAGE, "error");
|
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
|
}
|
|
|
|
const result = await submitNewsletterSignup(email, { endpoint, fetchImpl });
|
|
setStatus(result.message, result.ok ? "success" : "error");
|
|
return result;
|
|
}
|
|
|
|
/** Default status renderer — puts the message into `#newsletter-status`. */
|
|
function defaultSetStatus(message, kind) {
|
|
const el = document.getElementById("newsletter-status");
|
|
if (el) {
|
|
el.textContent = message;
|
|
el.dataset.kind = kind;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wire the submit handler onto a newsletter form.
|
|
* @param {HTMLFormElement} form
|
|
*/
|
|
export function initNewsletterForm(form, options = {}) {
|
|
form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
handleNewsletterSubmit(form, options);
|
|
});
|
|
}
|
|
|
|
// Browser-only wiring — guarded so this module stays importable in Node tests.
|
|
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
const form = document.getElementById("newsletter-form");
|
|
if (form) initNewsletterForm(form);
|
|
}
|