import { isDid, isHandle } from "@atcute/lexicons/syntax"; import { redirect, type RequestEvent } from "@sveltejs/kit"; import type { Handle, HandleFetch, ServerInit } from "@sveltejs/kit/hooks"; import { Agent, Client, Pool, setGlobalDispatcher } from "undici"; import { createBobbinClient } from "#lib/api/client.ts"; import { resolveRepoByRepoDid } from "#lib/api/repo.ts"; import { signAvatarSrcs } from "#lib/server/avatar.ts"; import { getConfig } from "#lib/server/config.ts"; import { setBlobUrl } from "#lib/markup/paths.ts"; import { themeAttribute, themeFromCookies } from "#lib/theme.ts"; import { decodeOrEmpty } from "#lib/uri.ts"; // node's fetch hands back the body decoded but keeps these, which would make the // browser decode it again const passThrough = (res: Response) => { const headers = new Headers(res.headers); headers.delete("content-encoding"); headers.delete("content-length"); return new Response(res.body, { status: res.status, headers }); }; // set on every response the app renders, unless a route set its own. none of them changes // what a page may load or run: nosniff only stops a script or stylesheet served under the // wrong type, the referrer policy is the browsers' default spelled out, and the frame // rules refuse being framed (nothing frames the app, and an embedded tangled page could // otherwise be clickjacked into a star, fork or settings change). a full content policy // and hsts need their own rollout const SECURITY_HEADERS: [string, string][] = [ ["x-content-type-options", "nosniff"], ["referrer-policy", "strict-origin-when-cross-origin"], ["x-frame-options", "DENY"], ["content-security-policy", "frame-ancestors 'none'"] ]; const withSecurityHeaders = (res: Response): Response => { const missing = SECURITY_HEADERS.filter(([name]) => !res.headers.has(name)); if (missing.length === 0) return res; try { for (const [name, value] of missing) res.headers.set(name, value); return res; } catch { // an upstream fetch's response handed back as is has immutable headers const copy = new Response(res.body, res); for (const [name, value] of missing) copy.headers.set(name, value); return copy; } }; const firstSegment = (url: URL) => /^\/([^/]*)(.*)$/.exec(url.pathname) ?? []; // links from the old appview: /tangled.sh/x means /tangled.org/x, and a handle // may be written with a leading @ const legacyRedirect = (url: URL): Response | undefined => { const [, first = "", rest = ""] = firstSegment(url); const segment = first.replace(/^%40/i, "@"); const redirect = (status: number, handle: string) => new Response(null, { status, headers: { location: `/${handle}${rest}${url.search}` } }); if (segment === "tangled.sh" || segment === "@tangled.sh") return redirect(301, "tangled.org"); if (segment.startsWith("@") && isHandle(segment.slice(1))) return redirect(302, segment.slice(1)); return undefined; }; // a repo did permalink goes to the repo's url with whatever path follows, even one no // route matches. thrown, so a client navigation's data request gets it as data const repoDidRedirect = async (event: RequestEvent) => { const [, first = "", rest = ""] = firstSegment(event.url); // kit answers a malformed uri with a 400 once it routes, so don't throw before that const did = decodeOrEmpty(first); // git clones the permalink, that route sends it to the knot in one hop if (!isDid(did) || event.route.id === "/[handle]/info/refs") return; const ctx = createBobbinClient({ serviceUrl: getConfig().bobbinUrl, fetch: event.fetch }); const repo = await resolveRepoByRepoDid(ctx, did); if (repo) redirect(307, `/${repo.owner}/${repo.name}${rest}${event.url.search}`); }; // our own services get a few shared http/2 connections instead of one per call in flight. // undici only multiplexes up to pipelining streams per connection export const upstreamPool = (ours: ReadonlySet) => (origin: string | URL, options: Pool.Options) => ours.has(new URL(origin).origin) ? new Pool(origin, { ...options, allowH2: true, connections: 8, pipelining: 100 }) : new Pool(origin, { ...options, allowH2: true, factory: multiplexOverH2Only }); // null brings back undici's per protocol default, which the constructor overwrites with 1: // as many streams as the server allows over http/2, one request at a time over http/1.1. // knots and pdses sit behind whatever their owners chose and may not survive pipelining const multiplexOverH2Only = (origin: URL, options: object) => { const client = new Client(origin, options); client.pipelining = null as unknown as number; return client; }; export const init: ServerInit = () => { const config = getConfig(); const ours = new Set( [ config.bobbinUrl, config.deliberiUrl, config.delegationUrl, config.migratorUrl, config.emailDidUrl, config.sitemapUrl, config.ogreUrl, config.sitesUrl ] .filter(Boolean) .map((url) => new URL(url).origin) ); setGlobalDispatcher(new Agent({ factory: upstreamPool(ours) })); }; // a stalled upstream would hold the page and an instance's request slot until cloud run // gives up at 300 s. only the wait for headers is bounded. big bodies still stream export const handleFetch: HandleFetch = async ({ request, fetch }) => { const { upstreamTimeoutMs } = getConfig(); if (request.method !== "GET" || upstreamTimeoutMs === 0) return fetch(request); const timeout = new AbortController(); const timer = setTimeout(() => timeout.abort(), upstreamTimeoutMs); try { return await fetch( new Request(request, { signal: AbortSignal.any([request.signal, timeout.signal]) }) ); } catch (error) { if (timeout.signal.aborted) { const url = new URL(request.url); console.warn( `[hooks.server] ${url.host}${url.pathname} gave no answer in ${upstreamTimeoutMs} ms` ); } throw error; } finally { clearTimeout(timer); } }; export const handle: Handle = async ({ event, resolve }) => { setBlobUrl(getConfig().blobUrl); const legacy = legacyRedirect(event.url); if (legacy) return legacy; await repoDidRedirect(event); // Keep bobbin off the public network. In Cloudflare this is a Worker // service binding; the browser only ever sees the web origin. if (event.url.pathname === "/xrpc" || event.url.pathname.startsWith("/xrpc/")) { const bobbin = event.platform?.env?.BOBBIN; if (bobbin) { const upstream = new URL(event.request.url); upstream.hostname = "bobbin.internal"; return bobbin.fetch(new Request(upstream, event.request)); } } if (event.url.pathname === "/sitemap.xml" || event.url.pathname.startsWith("/sitemaps/")) { const sitemap = event.platform?.env?.SITEMAP; if (sitemap) { const upstream = new URL(event.request.url); upstream.hostname = "sitemap.internal"; return sitemap.fetch(new Request(upstream, event.request)); } const { sitemapUrl } = getConfig(); if (sitemapUrl) { return passThrough( await fetch(`${sitemapUrl}${event.url.pathname}${event.url.search}`) ); } console.warn("[hooks.server] no SITEMAP binding or SITEMAP_URL; sitemap request unhandled"); } // stamp the saved theme onto so the first paint is already right const theme = themeAttribute(themeFromCookies(event.request.headers.get("cookie"))); const { avatarUrl, avatarSecret, camoUrl } = getConfig(); const response = await resolve(event, { transformPageChunk: ({ html }) => { const page = html .replace("%tangled.theme%", theme) .replace("%tangled.avatar%", avatarUrl) .replace("%tangled.camo%", camoUrl); return avatarSecret ? signAvatarSrcs(page, avatarUrl, avatarSecret) : page; }, // sveltekit blocks atcute fetch handler from reading headers // because it assumes backend APIs might return sensitive headers. // this happens when during CSR we run a fetch that was the same // as one ran during SSR, so sveltekit tries to give that fetch // the data we already had. // so we allow these headers to have atcute function properly. filterSerializedResponseHeaders(name) { return name === "content-type" || name === "content-length"; } }); return withSecurityHeaders(response); };