(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import type { APIContext } from "astro";
2import { readFile } from "node:fs/promises";
3import { join } from "node:path";
4import { clearSessionCacheForCookie, getSession } from "./lib/api";
5
6const API_PORT = process.env.API_PORT || 8081;
7const API_URL = process.env.API_URL || `http://localhost:${API_PORT}`;
8
9const PROXY_PATHS = [
10 "/api/",
11 "/auth/",
12 "/oauth-client-metadata.json",
13 "/jwks.json",
14];
15
16const CORS_HEADERS = {
17 "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
18 "Access-Control-Allow-Headers":
19 "Accept, Authorization, Content-Type, X-CSRF-Token, X-Session-Token",
20 "Access-Control-Expose-Headers": "Link",
21 "Access-Control-Allow-Credentials": "true",
22 "Access-Control-Max-Age": "300",
23};
24
25function isExtensionOrigin(origin: string | null): origin is string {
26 if (!origin) return false;
27 return (
28 origin.startsWith("chrome-extension://") ||
29 origin.startsWith("moz-extension://") ||
30 origin.startsWith("safari-web-extension://")
31 );
32}
33
34export async function onRequest(
35 context: APIContext,
36 next: () => Promise<Response>,
37): Promise<Response> {
38 const { request, url, locals } = context;
39
40 if (url.pathname === "/favicon.ico") {
41 try {
42 const file = await readFile(
43 join(process.cwd(), "dist", "client", "favicon.ico"),
44 );
45 return new Response(file, {
46 headers: {
47 "Content-Type": "image/x-icon",
48 "Cache-Control": "public, max-age=86400",
49 },
50 });
51 } catch {
52 /* ignore */
53 }
54 }
55
56 const shouldProxy = PROXY_PATHS.some(
57 (p) => url.pathname.startsWith(p) || url.pathname === p.replace(/\/$/, ""),
58 );
59
60 if (shouldProxy) {
61 const response = await proxyToBackend(request, url);
62 if (url.pathname === "/auth/logout") {
63 clearSessionCacheForCookie(request.headers.get("cookie") || "");
64 }
65 return response;
66 }
67
68 const cookie = request.headers.get("cookie") || "";
69
70 if (cookie.includes("margin_session")) {
71 locals.user = await getSession(cookie);
72 } else {
73 locals.user = null;
74 }
75
76 return next();
77}
78
79async function proxyToBackend(request: Request, url: URL): Promise<Response> {
80 const origin = request.headers.get("origin");
81
82 if (request.method === "OPTIONS" && isExtensionOrigin(origin)) {
83 return new Response(null, {
84 status: 204,
85 headers: {
86 "Access-Control-Allow-Origin": origin,
87 ...CORS_HEADERS,
88 },
89 });
90 }
91
92 const target = new URL(url.pathname + url.search, API_URL);
93
94 const headers = new Headers(request.headers);
95 const host = headers.get("host");
96 headers.delete("host");
97 headers.delete("origin");
98 headers.delete("referer");
99 if (host) {
100 headers.set("X-Forwarded-Host", host);
101 headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
102 }
103
104 const init: RequestInit = {
105 method: request.method,
106 headers,
107 redirect: "manual",
108 };
109
110 if (request.method !== "GET" && request.method !== "HEAD" && request.body) {
111 init.body = request.body;
112 // @ts-expect-error duplex is generic on RequestInit
113 init.duplex = "half";
114 }
115
116 try {
117 const res = await fetch(target.toString(), init);
118 const responseHeaders = new Headers(res.headers);
119
120 if (isExtensionOrigin(origin)) {
121 responseHeaders.set("Access-Control-Allow-Origin", origin);
122 for (const [key, value] of Object.entries(CORS_HEADERS)) {
123 responseHeaders.set(key, value);
124 }
125 }
126
127 return new Response(res.body, {
128 status: res.status,
129 statusText: res.statusText,
130 headers: responseHeaders,
131 });
132 } catch {
133 return new Response("Backend unavailable", { status: 502 });
134 }
135}