- Move admin login lockout from client cookie to AppConfig (DB), keyed by hashed client IP — clearing browser cookies no longer bypasses it - Replace rate-limit $transaction (TOCTOU) with atomic SQL INSERT...ON CONFLICT...RETURNING; add stale-entry cleanup on each submission to prevent table bloat - Add 5 s module-level cache for middleware runtime state fetch, reducing per-request DB roundtrips - Rename middleware.ts → proxy.ts to resolve Next.js 16 deprecation warning; update test import accordingly - Require ADMIN_PASSWORD, ADMIN_AUTH_SECRET, ADMIN_BASIC_AUTH_USER, and ADMIN_BASIC_AUTH_PASS in docker-compose.yml (:? syntax) — startup fails loudly instead of using placeholder defaults - Add set -e and informative echo lines to Dockerfile CMD for clearer startup failure attribution - Export requireAdminAuth() from lib/admin-auth for centralised use in admin pages - Add CLAUDE.md with architecture notes and working rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { appLocales, createI18nRouting } from "./i18n/routing";
|
||||
import {
|
||||
fromDevelopmentAdminPath,
|
||||
getAdminBaseUrl,
|
||||
getRequestHostname,
|
||||
isDevelopmentAdminPath,
|
||||
isAdminHost,
|
||||
hasDedicatedAdminHost,
|
||||
isInternalAdminPath,
|
||||
isLegacyAdminPath,
|
||||
toInternalAdminPath,
|
||||
} from "./lib/admin-routing";
|
||||
import {
|
||||
FALLBACK_LOCALE,
|
||||
getLocalizedPathWithDefault,
|
||||
isSupportedLocale,
|
||||
stripLocalePrefix,
|
||||
} from "./lib/locale";
|
||||
|
||||
const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||
|
||||
type SiteRuntimeState = {
|
||||
defaultLocale: (typeof appLocales)[number];
|
||||
maintenanceEnabled: boolean;
|
||||
};
|
||||
|
||||
let runtimeStateCache: { value: SiteRuntimeState; expiresAt: number } | null = null;
|
||||
const RUNTIME_STATE_CACHE_TTL_MS = 5_000;
|
||||
|
||||
function getSiteRuntimeStateOrigin(request: NextRequest): string {
|
||||
const configuredOrigin = process.env.SITE_RUNTIME_ORIGIN?.trim();
|
||||
|
||||
if (configuredOrigin) {
|
||||
return configuredOrigin;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return "http://127.0.0.1:3000";
|
||||
}
|
||||
|
||||
return request.nextUrl.origin;
|
||||
}
|
||||
|
||||
function getPathLocale(pathname: string, fallbackLocale: (typeof appLocales)[number]) {
|
||||
const locale = pathname.split("/")[1];
|
||||
|
||||
return isSupportedLocale(locale) ? locale : fallbackLocale;
|
||||
}
|
||||
|
||||
function isComingSoonPath(pathname: string) {
|
||||
return stripLocalePrefix(pathname) === "/coming-soon";
|
||||
}
|
||||
|
||||
async function getSiteRuntimeState(request: NextRequest): Promise<SiteRuntimeState> {
|
||||
const now = Date.now();
|
||||
|
||||
if (runtimeStateCache !== null && runtimeStateCache.expiresAt > now) {
|
||||
return runtimeStateCache.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeStateUrl = new URL("/api/site/default-locale", getSiteRuntimeStateOrigin(request));
|
||||
const response = await fetch(runtimeStateUrl, {
|
||||
headers: {
|
||||
"x-middleware-request": "1",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
defaultLocale: FALLBACK_LOCALE,
|
||||
maintenanceEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
defaultLocale?: string;
|
||||
maintenanceEnabled?: boolean;
|
||||
};
|
||||
|
||||
const value: SiteRuntimeState = {
|
||||
defaultLocale: isSupportedLocale(data.defaultLocale) ? data.defaultLocale : FALLBACK_LOCALE,
|
||||
maintenanceEnabled: data.maintenanceEnabled === true,
|
||||
};
|
||||
|
||||
runtimeStateCache = { value, expiresAt: now + RUNTIME_STATE_CACHE_TTL_MS };
|
||||
|
||||
return value;
|
||||
} catch {
|
||||
return {
|
||||
defaultLocale: FALLBACK_LOCALE,
|
||||
maintenanceEnabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getAdminBasicAuthUser(): string {
|
||||
return process.env.ADMIN_BASIC_AUTH_USER ?? "";
|
||||
}
|
||||
|
||||
function getAdminBasicAuthPass(): string {
|
||||
return process.env.ADMIN_BASIC_AUTH_PASS ?? "";
|
||||
}
|
||||
|
||||
function isAdminBasicAuthConfigured(): boolean {
|
||||
return Boolean(getAdminBasicAuthUser() && getAdminBasicAuthPass());
|
||||
}
|
||||
|
||||
function isAdminBasicAuthValid(request: NextRequest): boolean {
|
||||
if (!isAdminBasicAuthConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header || !header.startsWith("Basic ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = atob(header.slice(6));
|
||||
const index = decoded.indexOf(":");
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = decoded.slice(0, index);
|
||||
const pass = decoded.slice(index + 1);
|
||||
|
||||
return user === getAdminBasicAuthUser() && pass === getAdminBasicAuthPass();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isDevelopmentAdminRequest =
|
||||
process.env.NODE_ENV !== "production" && isDevelopmentAdminPath(pathname);
|
||||
const hostname = getRequestHostname(
|
||||
request.headers.get("x-forwarded-host"),
|
||||
request.headers.get("host"),
|
||||
request.nextUrl.hostname,
|
||||
);
|
||||
const isAdminRequest = isDevelopmentAdminRequest || isAdminHost(hostname);
|
||||
const hasDedicatedAdminHostname = hasDedicatedAdminHost();
|
||||
const adminRobotsHeaders = {
|
||||
"X-Robots-Tag": "noindex, nofollow, noarchive, nosnippet, noimageindex",
|
||||
};
|
||||
|
||||
if (
|
||||
isDevelopmentAdminRequest &&
|
||||
hasDedicatedAdminHostname &&
|
||||
!isAdminHost(hostname)
|
||||
) {
|
||||
const redirectUrl = new URL(getAdminBaseUrl());
|
||||
redirectUrl.pathname = fromDevelopmentAdminPath(pathname);
|
||||
redirectUrl.search = request.nextUrl.search;
|
||||
return NextResponse.redirect(redirectUrl, 308);
|
||||
}
|
||||
|
||||
if (isLegacyAdminPath(pathname) && process.env.NODE_ENV === "production") {
|
||||
return new NextResponse("Not Found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (isInternalAdminPath(pathname) && process.env.NODE_ENV === "production" && !isAdminRequest) {
|
||||
return new NextResponse("Not Found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdminRequest) {
|
||||
if (isAdminBasicAuthConfigured() && !isAdminBasicAuthValid(request)) {
|
||||
return new NextResponse("Authentication required", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": 'Basic realm="Admin Area", charset="UTF-8"',
|
||||
...adminRobotsHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rewriteUrl = request.nextUrl.clone();
|
||||
rewriteUrl.pathname = toInternalAdminPath(
|
||||
isDevelopmentAdminRequest ? fromDevelopmentAdminPath(pathname) : pathname,
|
||||
);
|
||||
const response = NextResponse.rewrite(rewriteUrl);
|
||||
response.headers.set("X-Robots-Tag", adminRobotsHeaders["X-Robots-Tag"]);
|
||||
return response;
|
||||
}
|
||||
|
||||
const siteRuntimeState = await getSiteRuntimeState(request);
|
||||
const configuredDefaultLocale = siteRuntimeState.defaultLocale;
|
||||
const intlMiddleware = createMiddleware(createI18nRouting(configuredDefaultLocale));
|
||||
|
||||
if (
|
||||
siteRuntimeState.maintenanceEnabled &&
|
||||
!request.cookies.has(ADMIN_SESSION_COOKIE) &&
|
||||
!isComingSoonPath(pathname)
|
||||
) {
|
||||
const locale = getPathLocale(pathname, configuredDefaultLocale);
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.pathname = getLocalizedPathWithDefault(locale, "/coming-soon", configuredDefaultLocale);
|
||||
return NextResponse.redirect(redirectUrl, 307);
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user