Skip to content

Commit 9de48bf

Browse files
Add cloudflare worker for Gumroad.
1 parent 14b0031 commit 9de48bf

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Deploy Cloudflare Workers
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- 'cloudflare-workers/**'
9+
workflow_dispatch: # Allow manual triggering
10+
11+
jobs:
12+
deploy-gumroad-products:
13+
name: Deploy Gumroad Products Worker
14+
runs-on: ubuntu-latest
15+
if: |
16+
github.event_name == 'workflow_dispatch' ||
17+
contains(github.event.head_commit.modified, 'cloudflare-workers/gumroad-products/') ||
18+
contains(github.event.head_commit.added, 'cloudflare-workers/gumroad-products/')
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
28+
- name: Install Wrangler
29+
run: npm install -g wrangler
30+
31+
- name: Deploy to Cloudflare Workers
32+
working-directory: cloudflare-workers/gumroad-products
33+
env:
34+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
35+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
36+
run: |
37+
wrangler deploy --env production
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Cloudflare Worker (module syntax)
2+
const DEFAULT_ALLOWED_ORIGINS = [
3+
"https://tools.mathspp.com",
4+
"http://localhost:5173",
5+
"http://localhost:3000",
6+
];
7+
8+
/** Build CORS headers */
9+
function corsHeaders(origin, allowedOrigins) {
10+
const allow = allowedOrigins.has(origin) ? origin : "";
11+
return {
12+
"Access-Control-Allow-Origin": allow,
13+
"Access-Control-Allow-Methods": "GET, OPTIONS",
14+
"Access-Control-Allow-Headers": "Content-Type",
15+
"Vary": "Origin",
16+
};
17+
}
18+
19+
function json(data, init = {}) {
20+
return new Response(JSON.stringify(data), {
21+
headers: { "Content-Type": "application/json; charset=utf-8", ...init.headers },
22+
status: init.status || 200,
23+
});
24+
}
25+
26+
export default {
27+
async fetch(request, env, ctx) {
28+
const url = new URL(request.url);
29+
const origin = request.headers.get("Origin") || "";
30+
31+
// Read allowed origins from env (comma-separated) or use defaults
32+
const allowList = (env.ALLOWED_ORIGINS || DEFAULT_ALLOWED_ORIGINS.join(","))
33+
.split(",")
34+
.map(s => s.trim())
35+
.filter(Boolean);
36+
const allowedOrigins = new Set(allowList);
37+
38+
// CORS preflight
39+
if (request.method === "OPTIONS") {
40+
return new Response(null, { headers: corsHeaders(origin, allowedOrigins) });
41+
}
42+
43+
if (request.method !== "GET") {
44+
return new Response("Method Not Allowed", { status: 405 });
45+
}
46+
47+
if (url.pathname !== "/api/gumroad-products") {
48+
return new Response("Not Found", { status: 404 });
49+
}
50+
51+
const u = (url.searchParams.get("u") || "").trim();
52+
if (!u || !/^[a-z0-9-]+$/i.test(u)) {
53+
return json({ error: "Invalid or missing Gumroad username." }, {
54+
status: 400,
55+
headers: corsHeaders(origin, allowedOrigins),
56+
});
57+
}
58+
59+
const profileUrl = `https://${u}.gumroad.com/`;
60+
61+
// Cache key (don’t cache by Origin)
62+
const cacheKey = new Request(`https://gumroad-products.internal/cache?u=${encodeURIComponent(u)}`);
63+
const cache = caches.default;
64+
65+
const cached = await cache.match(cacheKey);
66+
if (cached) {
67+
return new Response(await cached.text(), {
68+
headers: {
69+
"Content-Type": "application/json; charset=utf-8",
70+
"Cache-Control": "public, max-age=3600",
71+
...corsHeaders(origin, allowedOrigins),
72+
},
73+
});
74+
}
75+
76+
// Fetch the profile HTML
77+
const upstream = await fetch(profileUrl, {
78+
redirect: "follow",
79+
headers: {
80+
"Accept": "text/html",
81+
"Accept-Language": "en",
82+
"User-Agent": "tools.mathspp.com gumroad fetcher (contact: you@example.com)",
83+
},
84+
});
85+
86+
if (!upstream.ok) {
87+
return json({ error: "Upstream error", status: upstream.status }, {
88+
status: upstream.status,
89+
headers: corsHeaders(origin, allowedOrigins),
90+
});
91+
}
92+
93+
const html = await upstream.text();
94+
95+
// Extract hrefs and find /l/<slug>
96+
const hrefs = [];
97+
const reHref = /href\s*=\s*["']([^"']+)["']/gi;
98+
let m;
99+
while ((m = reHref.exec(html)) !== null) hrefs.push(m[1]);
100+
101+
const seen = new Set();
102+
const products = [];
103+
for (const href of hrefs) {
104+
let absolute;
105+
try {
106+
absolute = new URL(href, profileUrl).toString();
107+
} catch { continue; }
108+
109+
const mm = absolute.match(/\/l\/([A-Za-z0-9-_]+)\/?$/);
110+
if (!mm) continue;
111+
112+
const slug = mm[1];
113+
if (seen.has(slug)) continue;
114+
seen.add(slug);
115+
116+
const title = slug.replace(/[-_]+/g, " ").trim();
117+
products.push({ slug, url: absolute, title });
118+
}
119+
120+
products.sort((a, b) => a.slug.localeCompare(b.slug));
121+
122+
const payload = {
123+
username: u,
124+
profile_url: profileUrl,
125+
count: products.length,
126+
products,
127+
fetched_at: new Date().toISOString(),
128+
};
129+
130+
const body = JSON.stringify(payload);
131+
132+
// Cache for 1 hour
133+
const toCache = new Response(body, {
134+
headers: {
135+
"Content-Type": "application/json; charset=utf-8",
136+
"Cache-Control": "public, max-age=3600",
137+
},
138+
});
139+
ctx.waitUntil(cache.put(cacheKey, toCache.clone()));
140+
141+
return new Response(body, {
142+
headers: {
143+
"Content-Type": "application/json; charset=utf-8",
144+
"Cache-Control": "public, max-age=3600",
145+
...corsHeaders(origin, allowedOrigins),
146+
},
147+
});
148+
},
149+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name = "gumroad-products" # service name you’ll see in the CF dashboard
2+
main = "worker.js" # entry file relative to this folder
3+
compatibility_date = "2025-11-12" # use today's date
4+
5+
[env.production]
6+
# put prod-only settings here later if you need

0 commit comments

Comments
 (0)