The codebase that powers boop.cat boop.cat
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 191 lines 5.8 kB view raw
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};