a tool for shared writing and social publishing
1import { AtUri } from "@atproto/syntax";
2import { createClient } from "@supabase/supabase-js";
3import { getCache } from "@vercel/functions";
4import { NextRequest, NextResponse } from "next/server";
5import { Database } from "supabase/database.types";
6
7export const config = {
8 matcher: [
9 /*
10 * Match all paths except for:
11 * 1. /api routes
12 * 2. /_next (Next.js internals)
13 * 3. /_static (inside /public)
14 * 4. all root files inside /public (e.g. /favicon.ico)
15 */
16 "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)",
17 ],
18};
19
20let supabase = createClient<Database>(
21 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
22 process.env.SUPABASE_SERVICE_ROLE_KEY as string,
23);
24
25const cache = getCache();
26
27async function getDomainRoutes(hostname: string) {
28 let { data } = await supabase
29 .from("custom_domains")
30 .select(
31 "*, custom_domain_routes(*), publication_domains(*, publications(*))",
32 )
33 .eq("domain", hostname)
34 .single();
35 return data;
36}
37type DomainRoutes = Awaited<ReturnType<typeof getDomainRoutes>>;
38
39const auth_callback_route = "/auth_callback";
40const receive_auth_callback_route = "/receive_auth_callback";
41export default async function middleware(req: NextRequest) {
42 let hostname = req.headers.get("host")!;
43 if (req.nextUrl.pathname === auth_callback_route) return authCallback(req);
44 if (req.nextUrl.pathname === receive_auth_callback_route)
45 return receiveAuthCallback(req);
46
47 if (hostname === "leaflet.pub") return;
48 if (req.nextUrl.pathname === "/not-found") return;
49 let routes: DomainRoutes = null;
50 try {
51 routes = (await cache.get(`domain:${hostname}`)) as DomainRoutes;
52 } catch {}
53 if (!routes) {
54 routes = await getDomainRoutes(hostname);
55 if (routes) {
56 try {
57 await cache.set(`domain:${hostname}`, routes, {
58 ttl: 60,
59 tags: [`domain:${hostname}`],
60 });
61 } catch {}
62 }
63 }
64
65 let pub = routes?.publication_domains[0]?.publications;
66 if (pub) {
67 if (req.nextUrl.pathname.startsWith("/lish")) return;
68 let cookie = req.cookies.get("external_auth_token");
69 let isStaticReq =
70 req.nextUrl.pathname.includes("/rss") ||
71 req.nextUrl.pathname.includes("/atom") ||
72 req.nextUrl.pathname.includes("/json");
73
74 // Check if we've already completed auth (prevents redirect loop when cookies are disabled)
75 let authCompleted = req.nextUrl.searchParams.has("auth_completed");
76
77 if (
78 !isStaticReq &&
79 (!cookie || req.nextUrl.searchParams.has("refreshAuth")) &&
80 !authCompleted &&
81 !hostname.includes("leaflet.pub")
82 ) {
83 return initiateAuthCallback(req);
84 }
85
86 // If auth was completed but we still don't have a cookie, cookies might be disabled
87 // Continue without auth rather than looping
88 if (authCompleted && !cookie) {
89 console.warn(
90 "Auth completed but no cookie set - cookies may be disabled",
91 );
92 }
93 let aturi = new AtUri(pub?.uri);
94 return NextResponse.rewrite(
95 new URL(
96 `/lish/${aturi.host}/${aturi.rkey}${req.nextUrl.pathname}`,
97 req.url,
98 ),
99 );
100 }
101 if (routes) {
102 let route = routes.custom_domain_routes.find(
103 (r) => r.route === req.nextUrl.pathname,
104 );
105 if (route)
106 return NextResponse.rewrite(
107 new URL(`/${route.view_permission_token}`, req.url),
108 );
109 else {
110 return NextResponse.redirect(new URL("/not-found", req.url));
111 }
112 }
113}
114
115type CROSS_SITE_AUTH_REQUEST = { redirect: string; ts: string };
116type CROSS_SITE_AUTH_RESPONSE = {
117 redirect: string;
118 auth_token: string | null;
119 ts: string;
120};
121async function initiateAuthCallback(req: NextRequest) {
122 let redirectUrl = new URL(req.url);
123 redirectUrl.searchParams.delete("refreshAuth");
124 let token: CROSS_SITE_AUTH_REQUEST = {
125 redirect: redirectUrl.toString(),
126 ts: new Date().toISOString(),
127 };
128 let payload = btoa(JSON.stringify(token));
129 let signature = await signCrossSiteToken(payload);
130 return NextResponse.redirect(
131 `https://leaflet.pub${auth_callback_route}?payload=${encodeURIComponent(payload)}&signature=${encodeURIComponent(signature)}`,
132 );
133}
134
135async function authCallback(req: NextRequest) {
136 let payload = req.nextUrl.searchParams.get("payload");
137 let signature = req.nextUrl.searchParams.get("signature");
138
139 if (typeof payload !== "string" || typeof signature !== "string")
140 return new NextResponse("Payload or Signature not string", { status: 401 });
141
142 payload = decodeURIComponent(payload);
143 signature = decodeURIComponent(signature);
144
145 let verifySig = await signCrossSiteToken(payload);
146 if (verifySig !== signature)
147 return new NextResponse("Incorrect Signature", { status: 401 });
148
149 let token: CROSS_SITE_AUTH_REQUEST = JSON.parse(atob(payload));
150 let auth_token = req.cookies.get("auth_token")?.value || null;
151 let redirect_url = new URL(token.redirect);
152 let response_token: CROSS_SITE_AUTH_RESPONSE = {
153 redirect: token.redirect,
154 auth_token,
155 ts: new Date().toISOString(),
156 };
157
158 let response_payload = btoa(JSON.stringify(response_token));
159 let sig = await signCrossSiteToken(response_payload);
160 return NextResponse.redirect(
161 `https://${redirect_url.host}${receive_auth_callback_route}?payload=${encodeURIComponent(response_payload)}&signature=${encodeURIComponent(sig)}`,
162 );
163}
164
165async function receiveAuthCallback(req: NextRequest) {
166 let payload = req.nextUrl.searchParams.get("payload");
167 let signature = req.nextUrl.searchParams.get("signature");
168
169 if (typeof payload !== "string" || typeof signature !== "string")
170 return new NextResponse(null, { status: 401 });
171 payload = decodeURIComponent(payload);
172 signature = decodeURIComponent(signature);
173
174 let verifySig = await signCrossSiteToken(payload);
175 if (verifySig !== signature) return new NextResponse(null, { status: 401 });
176
177 let token: CROSS_SITE_AUTH_RESPONSE = JSON.parse(atob(payload));
178
179 let url = new URL(token.redirect);
180 url.searchParams.set("auth_completed", "true");
181 let response = NextResponse.redirect(url.toString());
182 response.cookies.set("external_auth_token", token.auth_token || "null");
183 return response;
184}
185
186const signCrossSiteToken = async (input: string) => {
187 if (!process.env.CROSS_SITE_AUTH_SECRET)
188 throw new Error("Environment variable CROSS_SITE_AUTH_SECRET not set ");
189 const encoder = new TextEncoder();
190 const data = encoder.encode(input);
191 const secretKey = process.env.CROSS_SITE_AUTH_SECRET;
192 const keyData = encoder.encode(secretKey);
193
194 const key = await crypto.subtle.importKey(
195 "raw",
196 keyData,
197 { name: "HMAC", hash: "SHA-256" },
198 false,
199 ["sign"],
200 );
201
202 const signature = await crypto.subtle.sign("HMAC", key, data);
203
204 return btoa(String.fromCharCode(...new Uint8Array(signature)));
205};