Files
sass-mohfarawati/proxy.ts
T
moh dc21c33867 ADDED - Admin SEO page, robots/sitemap hardening and media/maintenance security fixes
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.
2026-09-20 21:36:16 +02:00

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|.*\\..*).*)"],
};