Effect renders need a little over 4s, which the 4s primary timeout cut off, so every avatar with the default effect fell through to an unreachable public fallback and rendered as a placeholder. Raise the primary budget above the observed render cost and shorten the fallback budget. Also stop the proxy from stamping no-store over the avatar and media responses, so browsers keep the long-lived Cache-Control the route already sends, and recreate the imaging cache directories with the container user on every deploy, since root ownership made those cache writes fail silently.
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { getToken } from "next-auth/jwt";
|
|
import { env } from "@/env";
|
|
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
|
|
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
|
|
import {
|
|
isCacheableAssetPath,
|
|
shouldRedirectAdminRequest,
|
|
} from "@/lib/proxy-access";
|
|
|
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"X-XSS-Protection": "0",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
|
};
|
|
|
|
export const proxy = async (req: import("next/server").NextRequest) => {
|
|
const decision = await enforceDdosRateLimit(req);
|
|
if (decision.outcome === "suspect") {
|
|
return ddosReject(403);
|
|
}
|
|
if (decision.outcome === "block") {
|
|
return ddosReject(429, decision.retryAfterSeconds);
|
|
}
|
|
|
|
const pathname = req.nextUrl.pathname;
|
|
|
|
// Only /admin needs a real session token for the redirect guard; skipping
|
|
// JWT decoding on every other request keeps the proxy cheap under load.
|
|
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
|
|
const token = adminPath
|
|
? await getToken({
|
|
req,
|
|
secret: env.AUTH_SECRET,
|
|
secureCookie: true,
|
|
})
|
|
: null;
|
|
|
|
if (shouldRedirectAdminRequest(pathname, token)) {
|
|
return NextResponse.redirect(new URL("/login", req.url));
|
|
}
|
|
|
|
const nonce = createCspNonce();
|
|
const csp = buildContentSecurityPolicy(nonce);
|
|
|
|
const headers = new Headers(req.headers);
|
|
headers.set("x-pathname", pathname);
|
|
headers.set("x-nonce", nonce);
|
|
|
|
// A client may supply this legacy derived header; no consumer should trust it.
|
|
headers.delete("x-real-client-ip");
|
|
|
|
const response = NextResponse.next({ request: { headers } });
|
|
|
|
// HTML is never cached (browser/CDN/edge) so that after a deploy the page
|
|
// always references the current build's chunks. Static assets are
|
|
// content-hashed + immutable and can be cached aggressively; a stale HTML
|
|
// document would reference chunk URLs that no longer exist after a rebuild.
|
|
// Rendered avatars and uploaded media are immutable per key and already
|
|
// carry their own long-lived Cache-Control, so they keep it here.
|
|
if (!isCacheableAssetPath(pathname)) {
|
|
response.headers.set(
|
|
"Cache-Control",
|
|
"private, no-cache, no-store, max-age=0, must-revalidate",
|
|
);
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
|
response.headers.set(key, value);
|
|
}
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
|
|
return response;
|
|
};
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging).*)",
|
|
],
|
|
};
|