|
1 | | -// worker.js |
2 | | -const DEFAULT_ALLOWED_ORIGINS = [ |
3 | | - "https://tools.mathspp.com", |
4 | | - "http://localhost:5173", |
5 | | - "http://localhost:3000", |
6 | | -]; |
7 | | - |
8 | | -const S_MAX_AGE = 3600; // 1h fresh cache |
9 | | -const STALE_WINDOW = 24 * 3600; // 24h serve-stale if upstream errors |
10 | | - |
11 | | -function buildAllowedOrigins(env) { |
12 | | - const allowList = (env?.ALLOWED_ORIGINS || DEFAULT_ALLOWED_ORIGINS.join(",")) |
13 | | - .split(",").map(s => s.trim()).filter(Boolean); |
14 | | - return new Set(allowList); |
15 | | -} |
16 | | -function corsHeaders(origin, allowed) { |
17 | | - const allow = allowed.has(origin) ? origin : ""; |
18 | | - return { |
19 | | - "Access-Control-Allow-Origin": allow, |
20 | | - "Access-Control-Allow-Methods": "GET, OPTIONS", |
21 | | - "Access-Control-Allow-Headers": "Content-Type", |
22 | | - "Vary": "Origin", |
23 | | - }; |
24 | | -} |
25 | | -function respondJSON(origin, allowed, data, status = 200, extra = {}) { |
26 | | - return new Response(JSON.stringify(data), { |
27 | | - status, |
28 | | - headers: { |
29 | | - "Content-Type": "application/json; charset=utf-8", |
30 | | - ...corsHeaders(origin, allowed), |
31 | | - ...extra, |
32 | | - }, |
33 | | - }); |
34 | | -} |
35 | | -function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } |
36 | | - |
37 | | -async function fetchWithBackoff(url, init, attempts = 3) { |
38 | | - for (let i = 0; i < attempts; i++) { |
39 | | - const res = await fetch(url, init); |
40 | | - if (res.ok) return res; |
41 | | - |
42 | | - if (res.status === 429 || res.status === 503) { |
43 | | - const ra = res.headers.get("Retry-After"); |
44 | | - if (ra) { |
45 | | - const secs = Number(ra); |
46 | | - if (!Number.isNaN(secs) && secs >= 0 && secs <= 120) { |
47 | | - await sleep(secs * 1000); |
48 | | - continue; |
49 | | - } |
50 | | - } |
51 | | - const delay = Math.min(1600, 400 * Math.pow(2, i)) + Math.floor(Math.random() * 150); |
52 | | - await sleep(delay); |
53 | | - continue; |
54 | | - } |
55 | | - return res; // don't retry other statuses |
56 | | - } |
57 | | - return fetch(url, init); // last shot |
58 | | -} |
59 | | - |
60 | 1 | export default { |
61 | 2 | async fetch(request, env, ctx) { |
62 | | - const origin = request.headers.get("Origin") || ""; |
63 | | - const allowed = buildAllowedOrigins(env); |
64 | 3 | const url = new URL(request.url); |
65 | 4 |
|
66 | | - if (request.method === "OPTIONS") { |
67 | | - return new Response(null, { headers: corsHeaders(origin, allowed) }); |
68 | | - } |
69 | | - if (request.method !== "GET") { |
70 | | - return respondJSON(origin, allowed, { error: "Method Not Allowed" }, 405); |
| 5 | + // Only handle /api/gumroad-products |
| 6 | + if (url.pathname !== "/api/gumroad-products") { |
| 7 | + return new Response("Not found", { status: 404 }); |
71 | 8 | } |
72 | 9 |
|
73 | | - const goodPath = url.pathname === "/api/gumroad-products" || url.pathname === "/api/gumroad-products/"; |
74 | | - if (!goodPath) { |
75 | | - return respondJSON(origin, allowed, { error: "Not Found" }, 404); |
| 10 | + // Handle CORS preflight |
| 11 | + if (request.method === "OPTIONS") { |
| 12 | + return handleOptions(request); |
76 | 13 | } |
77 | 14 |
|
78 | | - const u = (url.searchParams.get("u") || "").trim(); |
79 | | - if (!u || !/^[a-z0-9-]+$/i.test(u)) { |
80 | | - return respondJSON(origin, allowed, { error: "Invalid or missing Gumroad username." }, 400); |
| 15 | + const username = url.searchParams.get("u"); |
| 16 | + if (!username) { |
| 17 | + return new Response("Missing 'u' query parameter", { status: 400 }); |
81 | 18 | } |
82 | 19 |
|
83 | | - const cache = caches.default; |
84 | | - const dataKey = new Request(`https://gumroad-products.internal/cache?u=${encodeURIComponent(u)}`); |
85 | | - const metaKey = new Request(`https://gumroad-products.internal/meta?u=${encodeURIComponent(u)}`); |
86 | | - |
87 | | - let cachedBody = null, cachedTs = 0; |
88 | | - const c = await cache.match(dataKey); |
89 | | - if (c) cachedBody = await c.text(); |
90 | | - const cm = await cache.match(metaKey); |
91 | | - if (cm) try { cachedTs = (await cm.json()).ts || 0; } catch { } |
92 | | - |
93 | | - // Serve fresh cache (<= 1h) |
94 | | - const age = Math.floor(Date.now() / 1000) - cachedTs; |
95 | | - if (cachedBody && age <= S_MAX_AGE) { |
96 | | - return new Response(cachedBody, { |
97 | | - headers: { |
98 | | - "Content-Type": "text/html; charset=utf-8", |
99 | | - "Cache-Control": `public, max-age=${S_MAX_AGE}`, |
100 | | - "X-Cache": "HIT", |
101 | | - "X-Upstream-Status": "none", |
102 | | - ...corsHeaders(origin, allowed), |
103 | | - }, |
104 | | - }); |
| 20 | + // (Optional) Very basic sanity-check for username |
| 21 | + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { |
| 22 | + return new Response("Invalid username", { status: 400 }); |
105 | 23 | } |
106 | 24 |
|
107 | | - // Try subdomain first, then path-based profile |
108 | | - const profileUrlA = `https://${u}.gumroad.com/`; |
109 | | - const profileUrlB = `https://gumroad.com/${encodeURIComponent(u)}`; |
110 | | - |
111 | | - async function getProfileHTML(urlStr) { |
112 | | - const res = await fetchWithBackoff(urlStr, { |
113 | | - redirect: "follow", |
114 | | - headers: { |
115 | | - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", |
116 | | - "Accept-Language": "en-US,en;q=0.9", |
117 | | - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", |
118 | | - "Referer": "https://gumroad.com/", |
119 | | - "Cache-Control": "no-cache", |
120 | | - "Pragma": "no-cache", |
121 | | - }, |
122 | | - }); |
123 | | - return res; |
124 | | - } |
| 25 | + const gumroadUrl = `https://${username}.gumroad.com`; |
125 | 26 |
|
126 | | - // 1) Try subdomain and then path profile if it fails. |
127 | | - let upstream = await getProfileHTML(profileUrlA); |
128 | | - if (!upstream.ok && (upstream.status === 429 || upstream.status === 403)) { |
129 | | - upstream = await getProfileHTML(profileUrlB); |
130 | | - } |
| 27 | + // Fetch the Gumroad page |
| 28 | + const upstreamResp = await fetch(gumroadUrl); |
131 | 29 |
|
132 | | - // If still not ok, serve stale or bubble error |
133 | | - if (!upstream.ok) { |
134 | | - // Serve stale (<= 25h old total) instead of failing |
135 | | - if (cachedBody && age <= (S_MAX_AGE + STALE_WINDOW)) { |
136 | | - return new Response(cachedBody, { |
137 | | - headers: { |
138 | | - "Content-Type": "text/html; charset=utf-8", |
139 | | - "Cache-Control": `public, max-age=0, stale-while-revalidate=${STALE_WINDOW}`, |
140 | | - "X-Cache": "STALE", |
141 | | - "X-Upstream-Status": String(upstream.status), |
142 | | - ...corsHeaders(origin, allowed), |
143 | | - }, |
144 | | - }); |
145 | | - } |
146 | | - // No cache to fall back to |
147 | | - return respondJSON(origin, allowed, { error: "Upstream error", status: upstream.status }, upstream.status, { |
148 | | - "X-Cache": "MISS", |
149 | | - "X-Upstream-Status": String(upstream.status), |
150 | | - }); |
151 | | - } |
| 30 | + // Get raw HTML |
| 31 | + const html = await upstreamResp.text(); |
152 | 32 |
|
153 | | - const html = await upstream.text(); |
| 33 | + // Build response with CORS headers |
| 34 | + const responseHeaders = new Headers(upstreamResp.headers); |
| 35 | + responseHeaders.set("Content-Type", "text/html; charset=utf-8"); |
154 | 36 |
|
155 | | - // Update cache |
156 | | - const now = Math.floor(Date.now() / 1000); |
157 | | - const dataResp = new Response(html, { |
158 | | - headers: { |
159 | | - "Content-Type": "text/html; charset=utf-8", |
160 | | - "Cache-Control": `public, max-age=${S_MAX_AGE}`, |
161 | | - }, |
162 | | - }); |
163 | | - const metaResp = new Response(JSON.stringify({ ts: now }), { |
164 | | - headers: { "Content-Type": "application/json" }, |
165 | | - }); |
166 | | - ctx.waitUntil(cache.put(dataKey, dataResp.clone())); |
167 | | - ctx.waitUntil(cache.put(metaKey, metaResp.clone())); |
| 37 | + // Apply CORS policy |
| 38 | + applyCors(request, responseHeaders); |
168 | 39 |
|
169 | 40 | return new Response(html, { |
170 | | - headers: { |
171 | | - "Content-Type": "text/html; charset=utf-8", |
172 | | - "Cache-Control": `public, max-age=${S_MAX_AGE}`, |
173 | | - "X-Cache": cachedBody ? "MISS-REVAL" : "MISS", |
174 | | - "X-Upstream-Status": "200", |
175 | | - ...corsHeaders(origin, allowed), |
176 | | - }, |
| 41 | + status: upstreamResp.status, |
| 42 | + statusText: upstreamResp.statusText, |
| 43 | + headers: responseHeaders, |
177 | 44 | }); |
178 | 45 | }, |
179 | 46 | }; |
| 47 | + |
| 48 | +function applyCors(request, headers) { |
| 49 | + const origin = request.headers.get("Origin"); |
| 50 | + const allowedOrigins = new Set([ |
| 51 | + "https://mathspp.com", |
| 52 | + "https://tools.mathspp.com", |
| 53 | + ]); |
| 54 | + |
| 55 | + if (origin && allowedOrigins.has(origin)) { |
| 56 | + headers.set("Access-Control-Allow-Origin", origin); |
| 57 | + headers.set("Vary", "Origin"); |
| 58 | + headers.set("Access-Control-Allow-Credentials", "true"); |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +function handleOptions(request) { |
| 63 | + const headers = new Headers(); |
| 64 | + applyCors(request, headers); |
| 65 | + |
| 66 | + // If no allowed origin, just send generic response |
| 67 | + headers.set("Access-Control-Allow-Methods", "GET, OPTIONS"); |
| 68 | + headers.set( |
| 69 | + "Access-Control-Allow-Headers", |
| 70 | + request.headers.get("Access-Control-Request-Headers") || "" |
| 71 | + ); |
| 72 | + |
| 73 | + return new Response(null, { |
| 74 | + status: 204, |
| 75 | + headers, |
| 76 | + }); |
| 77 | +} |
0 commit comments