The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5const ASSET_EXTENSIONS =
6 /\.(js|mjs|css|png|jpg|jpeg|webp|avif|svg|gif|ico|woff|woff2|ttf|otf|eot|map|json|xml|txt|pdf|mp4|webm|mp3|wav)$/i;
7
8function parseSubdomain(hostname, rootDomain) {
9 if (!rootDomain) return null;
10 const h = hostname.toLowerCase();
11 const root = rootDomain.toLowerCase();
12 if (h === root || !h.endsWith(`.${root}`)) return null;
13 const sub = h.slice(0, -(root.length + 1));
14 return !sub || sub.includes('.') ? null : sub;
15}
16
17function isAssetPath(pathname) {
18 return pathname.startsWith('/assets/') || ASSET_EXTENSIONS.test(pathname);
19}
20
21function getCacheControl(pathname) {
22 if (pathname === '/' || pathname.endsWith('.html')) {
23 return 'public, max-age=60, s-maxage=60';
24 }
25
26 if (pathname.startsWith('/assets/') || /\.[a-f0-9]{8,}\.(js|css)$/i.test(pathname)) {
27 return 'public, max-age=31536000, immutable';
28 }
29
30 if (ASSET_EXTENSIONS.test(pathname)) {
31 return 'public, max-age=86400, s-maxage=604800';
32 }
33
34 return 'public, max-age=300, s-maxage=3600';
35}
36
37function stripFirstSegment(pathname) {
38 const parts = pathname.split('/').filter(Boolean);
39 return parts.length > 1 ? `/${parts.slice(1).join('/')}` : pathname;
40}
41
42let b2AuthCache = { token: null, downloadUrl: null, expiresAt: 0 };
43
44async function ensureB2Auth(env) {
45 const now = Date.now();
46 if (b2AuthCache.token && now < b2AuthCache.expiresAt) return b2AuthCache;
47
48 if (!env.B2_KEY_ID || !env.B2_APP_KEY) {
49 throw new Error('missing-b2-credentials');
50 }
51
52 const res = await fetch('https://api.backblazeb2.com/b2api/v2/b2_authorize_account', {
53 headers: {
54 authorization: `Basic ${btoa(`${env.B2_KEY_ID}:${env.B2_APP_KEY}`)}`
55 }
56 });
57
58 if (!res.ok) {
59 const t = await res.text().catch(() => '');
60 throw new Error(`b2_authorize_account failed (${res.status}): ${t}`);
61 }
62 const data = await res.json();
63 b2AuthCache = {
64 token: data.authorizationToken,
65 downloadUrl: String(data.downloadUrl || '').replace(/\/$/, ''),
66 expiresAt: now + 1000 * 60 * 60 * 12
67 };
68 return b2AuthCache;
69}
70
71async function fetchFromB2({ base, bucket, objectKey, acceptEncoding, authToken }) {
72 const url = `${base}/file/${bucket}/${objectKey}`;
73 const headers = new Headers();
74 if (acceptEncoding) headers.set('accept-encoding', acceptEncoding);
75 if (authToken) headers.set('authorization', authToken);
76 return fetch(url, { headers });
77}
78
79export default {
80 async fetch(request, env, ctx) {
81 const url = new URL(request.url);
82 const hostname = (request.headers.get('x-forwarded-host') || url.hostname).toLowerCase();
83 const { ROOT_DOMAIN, B2_DOWNLOAD_BASE, B2_BUCKET_NAME, B2_KEY_ID, B2_APP_KEY, ROUTING } = env;
84
85 if (hostname === ROOT_DOMAIN) {
86 return fetch(request);
87 }
88
89 if (!B2_DOWNLOAD_BASE || !B2_BUCKET_NAME) {
90 return new Response('Service misconfigured', { status: 500 });
91 }
92
93 let siteId = await ROUTING.get(`host:${hostname}`);
94 if (!siteId) {
95 const sub = parseSubdomain(hostname, ROOT_DOMAIN);
96 if (sub) siteId = await ROUTING.get(`host:${sub}`);
97 }
98 if (!siteId) {
99 return new Response('Site not found', { status: 404 });
100 }
101
102 const deployId = await ROUTING.get(`current:${siteId}`);
103 if (!deployId) {
104 return new Response('No deployment found', { status: 404 });
105 }
106
107 const pathname = url.pathname;
108 const keyPath = pathname.replace(/^\//, '') || 'index.html';
109 const basePath = `sites/${siteId}/${deployId}`;
110 const acceptEncoding = request.headers.get('accept-encoding');
111
112 let authToken = null;
113 let base = (B2_DOWNLOAD_BASE || '').replace(/\/$/, '');
114
115 if (B2_KEY_ID && B2_APP_KEY) {
116 const auth = await ensureB2Auth(env);
117 authToken = auth.token;
118 base = auth.downloadUrl;
119 }
120
121 let res = await fetchFromB2({
122 base,
123 bucket: B2_BUCKET_NAME,
124 objectKey: `${basePath}/${keyPath}`,
125 acceptEncoding,
126 authToken
127 });
128
129 if (res.status === 404 && isAssetPath(pathname)) {
130 const rewritten = stripFirstSegment(pathname);
131 if (rewritten !== pathname) {
132 const rewrittenKey = rewritten.replace(/^\//, '');
133 res = await fetchFromB2({
134 base,
135 bucket: B2_BUCKET_NAME,
136 objectKey: `${basePath}/${rewrittenKey}`,
137 acceptEncoding,
138 authToken
139 });
140 }
141 }
142
143 if (res.status === 404 && !isAssetPath(pathname)) {
144 const dirPath = pathname.endsWith('/') ? pathname : `${pathname}/`;
145 const dirKey = `${keyPath.replace(/\/$/, '')}/index.html`;
146
147 const dirRes = await fetchFromB2({
148 base,
149 bucket: B2_BUCKET_NAME,
150 objectKey: `${basePath}/${dirKey}`,
151 acceptEncoding,
152 authToken
153 });
154
155 if (dirRes.ok) {
156 res = dirRes;
157 }
158 }
159
160 if (res.status === 404 && !isAssetPath(pathname)) {
161 res = await fetchFromB2({
162 base,
163 bucket: B2_BUCKET_NAME,
164 objectKey: `${basePath}/index.html`,
165 acceptEncoding,
166 authToken
167 });
168 }
169
170 if (!res.ok) {
171 return new Response('Not found', { status: 404 });
172 }
173
174 const headers = new Headers(res.headers);
175 headers.set('cache-control', getCacheControl(pathname));
176 headers.set('x-content-type-options', 'nosniff');
177
178 headers.set('server', 'boop.cat');
179 headers.set('x-boop-host', 'boop.cat');
180 headers.set('x-boop-site-id', siteId);
181 headers.set('x-boop-deploy-id', deployId);
182
183 headers.delete('x-bz-file-id');
184 headers.delete('x-bz-file-name');
185 headers.delete('x-bz-content-sha1');
186 headers.delete('x-bz-upload-timestamp');
187 headers.delete('x-bz-info-src_last_modified_millis');
188
189 return new Response(res.body, { status: 200, headers });
190 }
191};