返回 CodeWhale
interest.ts
根目录 / web / lib / merch / interest.ts
1 /**
2 * Private, unverified merch launch opt-ins, separate from orders and payment.
3 * Real KV and the native rate limiter are mandatory: no in-memory success path.
4 * A new submission replaces that email's choices and expires after 180 days.
5 * No email verification, campaign delivery, public counts, or inventory promise.
6 * KV is eventually consistent; the maintainer list may lag a successful write.
7 */
8 import { readBoundedBody, BodyReadError } from "../bounded-body";
9 import { getAgentEnv, validateSession, type CommunityAgentEnv } from "../community-agent";
10 import { isValidLocale } from "../i18n/config";
11 import { getEnv, type KVNamespace } from "../kv";
12 import { MERCH_INTEREST_ITEMS, MERCH_INTEREST_PRICE_CHOICES, type MerchInterestPriceChoice, type MerchInterestCurrency } from "./interest-options";
13
14 const PREFIX = "private:merch-interest:v1:";
15 const RETENTION_SECONDS = 180 * 24 * 60 * 60;
16 const MAX_BODY_BYTES = 8192;
17 type PriceChoice = MerchInterestPriceChoice;
18 type Currency = MerchInterestCurrency;
19 type Pick = { id: string; priceChoice: PriceChoice; surveyAmount: number | null };
20 export type MerchInterest = {
21 schemaVersion: 1;
22 email: string;
23 country: string;
24 currency: Currency;
25 locale: string;
26 consent: true;
27 emailVerified: false;
28 submittedAt: string;
29 expiresAt: string;
30 picks: Pick[];
31 };
32 export interface MerchInterestEnv {
33 CURATED_KV?: KVNamespace;
34 MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
35 MERCH_INTEREST_SITE_ORIGIN?: string;
36 }
37 class InterestError extends Error {
38 constructor(readonly status: number, readonly code: string, message: string) { super(message); }
39 }
40 function json(data: unknown, status = 200) {
41 return Response.json(data, { status, headers: { "Cache-Control": "no-store", ...(status === 429 ? { "Retry-After": "60" } : {}) } });
42 }
43 function invalid(): never { throw new InterestError(422, "invalid_request", "Check the interest form and try again."); }
44 function object(value: unknown): Record<string, unknown> {
45 if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
46 return value as Record<string, unknown>;
47 }
48 async function body(request: Request) {
49 const mediaType = request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
50 if (mediaType !== "application/json") throw new InterestError(415, "invalid_request", "Use the interest form on the website.");
51 const bytes = await readBoundedBody(request, MAX_BODY_BYTES);
52 try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; }
53 catch { invalid(); }
54 }
55 function loopback(url: URL) { return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); }
56 export function interestOriginAllowed(request: Request, env: MerchInterestEnv, development = process.env.NODE_ENV === "development") {
57 const origin = request.headers.get("origin");
58 if (!origin) return false;
59 try {
60 const requestUrl = new URL(request.url), incoming = new URL(origin);
61 if (incoming.origin !== origin) return false;
62 if (development && loopback(requestUrl) && origin === requestUrl.origin) return true;
63 if (env.MERCH_INTEREST_SITE_ORIGIN) {
64 const configured = new URL(env.MERCH_INTEREST_SITE_ORIGIN);
65 if (configured.origin !== env.MERCH_INTEREST_SITE_ORIGIN || origin !== configured.origin) return false;
66 // OpenNext's local production preview also needs its real bindings. An
67 // explicit loopback origin remains confined to that same local request.
68 if (loopback(configured)) return ["http:", "https:"].includes(configured.protocol) && requestUrl.origin === configured.origin;
69 return configured.protocol === "https:";
70 }
71 return origin === "https://codewhale.net" || origin === "https://www.codewhale.net";
72 } catch { return false; }
73 }
74 function requireOrigin(request: Request, env: MerchInterestEnv) {
75 if (!interestOriginAllowed(request, env)) throw new InterestError(403, "request_blocked", "Use the interest form on the website.");
76 }
77 function available(env: MerchInterestEnv) { return Boolean(env.CURATED_KV && env.MERCH_INTEREST_LIMITER); }
78 function requireStorage(env: MerchInterestEnv): KVNamespace {
79 if (!available(env)) throw new InterestError(503, "interest_unavailable", "The interest list is temporarily unavailable. Please try again later.");
80 return env.CURATED_KV!;
81 }
82 async function digest(value: string) {
83 const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
84 return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join("");
85 }
86 async function limited(env: MerchInterestEnv, key: string) {
87 const result = await env.MERCH_INTEREST_LIMITER!.limit({ key });
88 if (!result.success) throw new InterestError(429, "try_later", "Please wait a minute and try again.");
89 }
90 function validated(value: unknown, now: number): MerchInterest {
91 const input = object(value);
92 if (input.website !== undefined && input.website !== "") invalid();
93 if (typeof input.email !== "string" || input.email.length > 254) invalid();
94 const email = input.email.trim().toLowerCase();
95 if (!/^[^\s@<>\x00-\x1f\x7f]+@[^\s@<>\x00-\x1f\x7f]+\.[^\s@<>\x00-\x1f\x7f]+$/.test(email)) invalid();
96 if (typeof input.country !== "string" || !input.country.trim() || input.country.length > 80 || /[\x00-\x1f\x7f]/.test(input.country)) invalid();
97 if (input.currency !== "cny" && input.currency !== "usd") invalid();
98 if (typeof input.locale !== "string" || input.locale.length > 16 || !isValidLocale(input.locale) || input.consent !== true) invalid();
99 if (!Array.isArray(input.picks) || !input.picks.length || input.picks.length > Math.min(12, MERCH_INTEREST_ITEMS.length)) invalid();
100 const seen = new Set<string>(), currency = input.currency;
101 const picks = input.picks.map(value => {
102 const pick = object(value);
103 if (typeof pick.id !== "string" || seen.has(pick.id) || Object.keys(pick).some(key => !["id", "priceChoice"].includes(key))) invalid();
104 const item = MERCH_INTEREST_ITEMS.find(item => item.id === pick.id);
105 const priceChoice = pick.priceChoice ?? "unsure";
106 if (!item || !MERCH_INTEREST_PRICE_CHOICES.includes(priceChoice as PriceChoice)) invalid();
107 seen.add(pick.id);
108 const index = ["low", "mid", "high"].indexOf(priceChoice as string);
109 const surveyAmount = index === -1 ? null : item.prices[currency][index];
110 if (surveyAmount !== null && (!Number.isFinite(surveyAmount) || surveyAmount <= 0)) throw new InterestError(503, "interest_unavailable", "The interest list is temporarily unavailable.");
111 return { id: pick.id, priceChoice: priceChoice as PriceChoice, surveyAmount };
112 });
113 return { schemaVersion: 1, email, country: input.country.trim(), currency, locale: input.locale, consent: true, emailVerified: false, submittedAt: new Date(now).toISOString(), expiresAt: new Date(now + RETENTION_SECONDS * 1000).toISOString(), picks };
114 }
115 function errorResponse(error: unknown) {
116 if (error instanceof InterestError) return json({ error: error.message, code: error.code }, error.status);
117 if (error instanceof BodyReadError) return json({ error: "The interest form is too large or invalid.", code: "invalid_request" }, error.status);
118 // Never echo provider errors, keys, addresses, or request payloads.
119 return json({ error: "The interest list is temporarily unavailable. Please try again later.", code: "interest_unavailable" }, 503);
120 }
121 export async function handleMerchInterest(request: Request, env?: MerchInterestEnv): Promise<Response> {
122 try {
123 const current = env ?? await getEnv();
124 if (request.method === "GET") return json({ available: available(current) });
125 if (request.method !== "POST") return json({ error: "Method not allowed." }, 405);
126 requireOrigin(request, current);
127 const kv = requireStorage(current);
128 // Cloudflare supplies this trusted header at the edge. Missing headers share
129 // one bucket; X-Forwarded-For and user-provided identifiers are not accepted.
130 const ip = request.headers.get("cf-connecting-ip")?.slice(0, 64) ?? "unattributed";
131 await limited(current, "merch-interest:ip:" + await digest(ip));
132 const record = validated(await body(request), Date.now());
133 const emailHash = await digest(record.email);
134 await limited(current, "merch-interest:email:" + emailHash);
135 await kv.put(PREFIX + emailHash, JSON.stringify(record), { expirationTtl: RETENTION_SECONDS });
136 return json({ ok: true });
137 } catch (error) { return errorResponse(error); }
138 }
139 async function requireAdmin(request: Request, auth: CommunityAgentEnv) {
140 const sid = request.headers.get("cookie")?.split(";").map(cookie => cookie.trim()).find(cookie => cookie.startsWith("mt_sid="))?.slice(7);
141 if (!auth.MAINTAINER_TOKEN || !await validateSession(auth.CURATED_KV, sid)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
142 }
143 export async function handleAdminMerchInterest(request: Request, env?: MerchInterestEnv, authEnv?: CommunityAgentEnv): Promise<Response> {
144 try {
145 const current = env ?? await getEnv();
146 await requireAdmin(request, authEnv ?? await getAgentEnv());
147 if (!current.CURATED_KV) throw new InterestError(503, "interest_unavailable", "Interest storage is unavailable.");
148 const kv = current.CURATED_KV;
149 if (request.method === "GET") {
150 const url = new URL(request.url), cursor = url.searchParams.get("cursor") ?? undefined;
151 const rawLimit = url.searchParams.get("limit") ?? "50";
152 if ((cursor && (cursor.length > 2048 || /[\x00-\x1f\x7f]/.test(cursor))) || !/^\d{1,2}$/.test(rawLimit)) invalid();
153 const limit = Number(rawLimit);
154 if (limit < 1 || limit > 50) invalid();
155 const listed = await kv.list({ prefix: PREFIX, limit, ...(cursor ? { cursor } : {}) });
156 const entries = await Promise.all(listed.keys.slice(0, limit).map(async ({ name }) => {
157 if (!name.startsWith(PREFIX) || !/^[a-f0-9]{64}$/.test(name.slice(PREFIX.length))) return null;
158 const raw = await kv.get(name);
159 if (!raw || raw.length > MAX_BODY_BYTES) return null;
160 try {
161 const record = JSON.parse(raw) as MerchInterest;
162 if (record.schemaVersion !== 1 || Date.parse(record.expiresAt) <= Date.now() || !Number.isFinite(Date.parse(record.expiresAt))) return null;
163 return { id: name.slice(PREFIX.length), ...record };
164 } catch { return null; }
165 }));
166 return json({ entries: entries.filter(entry => entry !== null), cursor: listed.list_complete === false ? listed.cursor ?? null : null });
167 }
168 if (request.method === "DELETE") {
169 requireOrigin(request, current);
170 const input = object(await body(request));
171 if (typeof input.id !== "string" || !/^[a-f0-9]{64}$/.test(input.id)) invalid();
172 await kv.delete(PREFIX + input.id);
173 return json({ ok: true });
174 }
175 return json({ error: "Method not allowed." }, 405);
176 } catch (error) { return errorResponse(error); }
177 }
178
178 lines TYPESCRIPT