SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
237 lines
6.7 KiB
TypeScript
237 lines
6.7 KiB
TypeScript
import { timingSafeEqual } from "crypto";
|
|
|
|
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 { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
|
|
import {
|
|
FALLBACK_LOCALE,
|
|
getLocalizedPathWithDefault,
|
|
isSupportedLocale,
|
|
stripLocalePrefix,
|
|
} from "./lib/locale";
|
|
|
|
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 timingSafeStringEqual(a: string, b: string): boolean {
|
|
const left = Buffer.from(a);
|
|
const right = Buffer.from(b);
|
|
|
|
// Buffers of different length must still be compared against something of
|
|
// equal length so the comparison time doesn't leak the expected length.
|
|
if (left.length !== right.length) {
|
|
timingSafeEqual(left, left);
|
|
return false;
|
|
}
|
|
|
|
return timingSafeEqual(left, right);
|
|
}
|
|
|
|
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);
|
|
|
|
const userValid = timingSafeStringEqual(user, getAdminBasicAuthUser());
|
|
const passValid = timingSafeStringEqual(pass, getAdminBasicAuthPass());
|
|
|
|
return userValid && passValid;
|
|
} 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 &&
|
|
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
|
|
!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|.*\\..*).*)"],
|
|
};
|