Openstatus www.openstatus.dev
at 40ee67dc9bbbb4d39796e1b7e8b2ae17c61dd77e 213 lines 6.8 kB view raw
1import { NextResponse } from "next/server"; 2 3import { db } from "@openstatus/db/src/db"; 4import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 5import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; 6 7import { auth } from "@/lib/auth"; 8import { eq } from "@openstatus/db"; 9import { Redis } from "@openstatus/upstash"; 10import { env } from "./env"; 11 12const MAX_AGE = 30; // 30 seconds 13 14export const getValidSubdomain = (host?: string | null) => { 15 let subdomain: string | null = null; 16 if (!host && typeof window !== "undefined") { 17 // On client side, get the host from window 18 // biome-ignore lint: to fix later 19 host = window.location.host; 20 } 21 // we should improve here for custom vercel deploy page 22 if (host?.includes(".") && !host.includes(".vercel.app")) { 23 const candidate = host.split(".")[0]; 24 if (candidate && !candidate.includes("www")) { 25 // Valid candidate 26 subdomain = candidate; 27 } 28 } 29 if (host?.includes("ngrok-free.app")) { 30 return null; 31 } 32 // In case the host is a custom domain 33 if ( 34 host && 35 !(host?.includes(env.NEXT_PUBLIC_URL) || host?.endsWith(".vercel.app")) 36 ) { 37 subdomain = host; 38 } 39 return subdomain; 40}; 41 42const publicAppPaths = [ 43 "/app/sign-in", 44 "/app/sign-up", 45 "/app/login", 46 "/app/invite", 47 "/app/onboarding", 48]; 49 50// remove auth middleware if needed 51// export const middleware = () => NextResponse.next(); 52 53export default auth(async (req) => { 54 const url = req.nextUrl.clone(); 55 const continent = req.headers.get("x-vercel-ip-continent") || "NA"; 56 const country = req.headers.get("x-vercel-ip-country") || "US"; 57 const currency = getCurrency({ continent, country }); 58 59 if (url.pathname.includes("api/trpc")) { 60 return NextResponse.next(); 61 } 62 63 const host = req.headers.get("host"); 64 const pathname = req.nextUrl.pathname; 65 const subdomain = getValidSubdomain(host); 66 67 console.log({ subdomain }); 68 69 // Subdomain handling: set mode cookie (legacy/new) and let next.config rewrites proxy 70 if (subdomain) { 71 const modeCookie = req.cookies.get("sp_mode")?.value; // "legacy" | "new" 72 const cached = modeCookie === "legacy" || modeCookie === "new"; 73 let mode: "legacy" | "new" | undefined = cached ? modeCookie : undefined; 74 75 console.log({ mode, cached }); 76 77 if (!mode) { 78 try { 79 const redis = Redis.fromEnv(); 80 // NOTE: we are storing the slug in the cache if it's the new status page 81 const cache = await redis.get(`page:${subdomain}`); 82 console.log({ cache }); 83 // Determine legacy flag from cache 84 mode = cache ? "new" : "legacy"; 85 } catch (e) { 86 console.error("error getting cache", e); 87 mode = "legacy"; 88 } 89 } 90 91 console.log({ mode }); 92 93 if (mode === "legacy") { 94 url.pathname = `/status-page/${subdomain}${url.pathname}`; 95 return NextResponse.rewrite(url); 96 } 97 98 const res = NextResponse.next(); 99 // Mark that this request is being proxied so downstream can adapt 100 res.headers.set("x-proxy", "1"); 101 // Short-lived cookie so toggles apply relatively quickly 102 res.cookies.set("sp_mode", "new", { path: "/", maxAge: MAX_AGE }); 103 // If we just set the cookie, trigger one redirect so next.config.js 104 // rewrites that depend on sp_mode can apply on the next request. 105 if (!cached) { 106 const redirect = NextResponse.redirect(url); 107 redirect.headers.set("x-proxy", "1"); 108 redirect.cookies.set("sp_mode", "new", { path: "/", maxAge: MAX_AGE }); 109 return redirect; 110 } 111 112 return res; 113 } 114 115 const isPublicAppPath = publicAppPaths.some((path) => 116 pathname.startsWith(path), 117 ); 118 119 if (!req.auth && pathname.startsWith("/app/invite")) { 120 return NextResponse.redirect( 121 new URL( 122 `/app/login?redirectTo=${encodeURIComponent(req.nextUrl.href)}`, 123 req.url, 124 ), 125 ); 126 } 127 128 if (!req.auth && pathname.startsWith("/app") && !isPublicAppPath) { 129 return NextResponse.redirect( 130 new URL( 131 `/app/login?redirectTo=${encodeURIComponent(req.nextUrl.href)}`, 132 req.url, 133 ), 134 ); 135 } 136 137 if (req.auth?.user?.id) { 138 if (pathname.startsWith("/app") && !isPublicAppPath) { 139 const workspaceSlug = req.nextUrl.pathname.split("/")?.[2]; 140 const hasWorkspaceSlug = !!workspaceSlug && workspaceSlug.trim() !== ""; 141 142 const allowedWorkspaces = await db 143 .select() 144 .from(usersToWorkspaces) 145 .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) 146 .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) 147 .where(eq(user.id, Number.parseInt(req.auth.user.id))) 148 .all(); 149 150 if (hasWorkspaceSlug) { 151 const hasAccessToWorkspace = allowedWorkspaces.find( 152 ({ workspace }) => workspace.slug === workspaceSlug, 153 ); 154 if (hasAccessToWorkspace) { 155 const workspaceCookie = req.cookies.get("workspace-slug")?.value; 156 const hasChanged = workspaceCookie !== workspaceSlug; 157 if (hasChanged) { 158 const response = NextResponse.redirect(url); 159 response.cookies.set("workspace-slug", workspaceSlug); 160 return response; 161 } 162 } else { 163 return NextResponse.redirect(new URL("/app", req.url)); 164 } 165 } else { 166 if (allowedWorkspaces.length > 0) { 167 const firstWorkspace = allowedWorkspaces[0].workspace; 168 const { slug } = firstWorkspace; 169 return NextResponse.redirect( 170 new URL(`/app/${slug}/monitors`, req.url), 171 ); 172 } 173 } 174 } 175 } 176 177 // reset workspace slug cookie if no auth 178 if (!req.auth && req.cookies.has("workspace-slug")) { 179 const response = NextResponse.next(); 180 response.cookies.set("x-currency", currency); 181 response.cookies.delete("workspace-slug"); 182 return response; 183 } 184 185 const response = NextResponse.next(); 186 response.cookies.set("x-currency", currency); 187 return response; 188}); 189 190export const config = { 191 matcher: [ 192 "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", 193 "/", 194 "/(api/webhook|api/trpc)(.*)", 195 "/(!api/checker/:path*|!api/og|!api/ping)", 196 ], 197 unstable_allowDynamic: [ 198 // use a glob to allow anything in the function-bind 3rd party module 199 // "**/packages/analytics/src/**", 200 // // "@jitsu/js/**", 201 // "**/node_modules/@jitsu/**", 202 // "**/node_modules/**/@jitsu/**", 203 // "**/node_modules/@openstatus/analytics/**", 204 // "@openstatus/analytics/**", 205 // "@jitsu/js/dist/jitsu.es.js", 206 // "**/analytics/src/**", 207 // "**/node_modules/.pnpm/@jitsu/**", 208 // "/node_modules/function-bind/**", 209 // "**/node_modules/.pnpm/**/function-bind/**", 210 // "../../packages/analytics/src/index.ts", 211 ], 212 runtime: "nodejs", 213};