a tool for shared writing and social publishing
at refactor/page-perf 205 lines 6.8 kB view raw
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};