Openstatus
www.openstatus.dev
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};