~james/core

core/web/src/hooks.server.ts -rw-r--r-- 8.7 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 14 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
import { isDid, isHandle } from "@atcute/lexicons/syntax";
import { redirect, type RequestEvent } from "@sveltejs/kit";
import type { Handle, HandleFetch, HandleServerError, ServerInit } from "@sveltejs/kit/hooks";
import { Agent, Client, Pool, setGlobalDispatcher } from "undici";
import { ClientResponseError, createBobbinClient } from "#lib/api/client.ts";
import { errorMessage, httpStatusFor } from "#lib/api/load.ts";
import { resolveRepoByRepoDid } from "#lib/api/repo.ts";
import { signAvatarSrcs } from "#lib/server/avatar.ts";
import { getConfig } from "#lib/server/config.ts";
import { startLookups } from "#lib/server/lookupEvents.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<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 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) }));
	startLookups(config);
};

// 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 handleError: HandleServerError = ({ kind, error, issues }) => {
	if (kind === "validation") {
		console.error("Remote function schema validation failed:", issues);
		return;
	}
	if (kind !== "unknown") return;
	console.error(error);
	if (error instanceof ClientResponseError) {
		return { status: httpStatusFor(error), message: errorMessage(error) };
	}
	if (error instanceof DOMException && error.name === "AbortError") {
		return {
			status: 504,
			message: "A service this page needs took too long to respond. Try again in a moment."
		};
	}
};

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);
};