diff --git a/app/[locale]/(site)/layout.tsx b/app/[locale]/(site)/layout.tsx index 134e916..11ab56c 100644 --- a/app/[locale]/(site)/layout.tsx +++ b/app/[locale]/(site)/layout.tsx @@ -1,8 +1,10 @@ import type { ReactNode } from "react"; +import { unstable_noStore as noStore } from "next/cache"; import { redirect } from "next/navigation"; import { Footer } from "@/components/footer"; import { Navbar } from "@/components/navbar"; +import { isAdminAuthenticated } from "@/lib/admin-auth"; import { getMaintenanceMode } from "@/lib/app-config"; import { resolveLocale } from "@/lib/site-data"; @@ -13,11 +15,17 @@ type SiteLayoutProps = { }; }; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + export default async function SiteLayout({ children, params: { locale } }: SiteLayoutProps) { + noStore(); + const localeKey = resolveLocale(locale); const maintenanceEnabled = await getMaintenanceMode(); + const authenticated = isAdminAuthenticated(); - if (maintenanceEnabled) { + if (maintenanceEnabled && !authenticated) { redirect(`/${localeKey}/coming-soon`); } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index f1910ce..dc30e94 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { unstable_noStore as noStore } from "next/cache"; import { NextIntlClientProvider } from "next-intl"; import { getMessages, setRequestLocale } from "next-intl/server"; import { notFound } from "next/navigation"; @@ -13,6 +14,9 @@ type LocaleLayoutProps = { }; }; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } @@ -21,6 +25,8 @@ export default async function LocaleLayout({ children, params: { locale }, }: LocaleLayoutProps) { + noStore(); + if (!routing.locales.includes(locale as (typeof routing.locales)[number])) { notFound(); } diff --git a/app/root/page.tsx b/app/root/page.tsx index 146bca3..7fad369 100644 --- a/app/root/page.tsx +++ b/app/root/page.tsx @@ -23,6 +23,7 @@ import { resetAdminFailedAttempts, setAdminSessionCookie, } from "@/lib/admin-auth"; +import { routing } from "@/i18n/routing"; import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config"; import { resolveLocale } from "@/lib/site-data"; @@ -86,8 +87,12 @@ export default async function RootPage({ searchParams }: RootPageProps) { const nextValue = formData.get("enabled") === "true"; await setMaintenanceMode(nextValue); + revalidatePath("/", "layout"); revalidatePath("/root"); - revalidatePath(`/${localeKey}/coming-soon`); + for (const appLocale of routing.locales) { + revalidatePath(`/${appLocale}`, "layout"); + revalidatePath(`/${appLocale}/coming-soon`); + } redirect("/root"); } diff --git a/middleware.ts b/middleware.ts index b8dfa6a..2780e23 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,6 +5,8 @@ import type { NextRequest } from "next/server"; import { routing } from "./i18n/routing"; const intlMiddleware = createMiddleware(routing); +const ADMIN_SESSION_COOKIE = "moh_admin_session"; +const ADMIN_SESSION_VALUE = "superadmin"; function isRootBasicAuthConfigured(): boolean { return Boolean(process.env.ROOT_BASIC_AUTH_USER && process.env.ROOT_BASIC_AUTH_PASS); @@ -39,10 +41,102 @@ function isRootBasicAuthValid(request: NextRequest): boolean { } } +function getLocaleFromPathname(pathname: string): string | null { + for (const locale of routing.locales) { + if (pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)) { + return locale; + } + } + + return null; +} + +function timingSafeEqualString(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + + let mismatch = 0; + + for (let index = 0; index < a.length; index += 1) { + mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + + return mismatch === 0; +} + +function toHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +async function signAdminValue(value: string): Promise { + const secret = process.env.ADMIN_AUTH_SECRET ?? ""; + if (!secret) { + return null; + } + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value)); + + return toHex(signature); +} + +async function isAdminSessionValid(request: NextRequest): Promise { + const token = request.cookies.get(ADMIN_SESSION_COOKIE)?.value; + if (!token) { + return false; + } + + const parts = token.split("."); + if (parts.length !== 2) { + return false; + } + + const [value, signature] = parts; + if (value !== ADMIN_SESSION_VALUE) { + return false; + } + + const expected = await signAdminValue(value); + if (!expected) { + return false; + } + + return timingSafeEqualString(signature, expected); +} + +async function getMaintenanceModeFromApi(request: NextRequest): Promise { + try { + const response = await fetch(new URL("/api/maintenance", request.nextUrl.origin), { + cache: "no-store", + headers: { + "x-middleware-check": "1", + }, + }); + + if (!response.ok) { + return false; + } + + const data = (await response.json()) as { enabled?: boolean }; + return data.enabled === true; + } catch { + return false; + } +} + export default async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/"); - const isRootRoute = isRootBaseRoute; if (isRootRoute && isRootBasicAuthConfigured() && !isRootBasicAuthValid(request)) { @@ -54,6 +148,29 @@ export default async function middleware(request: NextRequest) { }); } + const locale = getLocaleFromPathname(pathname); + const isComingSoonRoute = + locale !== null && + (pathname === `/${locale}/coming-soon` || pathname.startsWith(`/${locale}/coming-soon/`)); + const isLocalizedSiteRoute = locale !== null && !isComingSoonRoute; + const isTopLevelRootRoute = pathname === "/"; + + if (!isRootBaseRoute && (isLocalizedSiteRoute || isTopLevelRootRoute)) { + const maintenanceEnabled = await getMaintenanceModeFromApi(request); + + if (maintenanceEnabled) { + const isAdminAuthenticated = await isAdminSessionValid(request); + + if (!isAdminAuthenticated) { + const targetLocale = locale ?? routing.defaultLocale; + const redirectUrl = request.nextUrl.clone(); + redirectUrl.pathname = `/${targetLocale}/coming-soon`; + redirectUrl.search = ""; + return NextResponse.redirect(redirectUrl); + } + } + } + if (isRootBaseRoute) { return NextResponse.next(); }