Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194import { 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 againconst 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 rolloutconst 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 dataconst 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 connectionexport const upstreamPool = (ours: ReadonlySet<string>) => (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 pipeliningconst 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 streamexport 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 <html> 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);};