Skip to content

Commit

Permalink
Refactor single middleware function into multiple functions that get …
Browse files Browse the repository at this point in the history
…chained together (#671)
  • Loading branch information
nsams authored Jan 30, 2025
1 parent 89bde10 commit 8211c49
Show file tree
Hide file tree
Showing 11 changed files with 187 additions and 79 deletions.
2 changes: 1 addition & 1 deletion site/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const nextConfig = {
experimental: {
optimizePackageImports: ["@comet/cms-site"],
},
// https://nextjs.org/docs/advanced-features/security-headers (Content-Security-Policy and CORS are set in middleware.ts)
// https://nextjs.org/docs/advanced-features/security-headers (Content-Security-Policy and CORS are set in middleware/cspHeaders.ts)
headers: async () => [
{
source: "/:path*",
Expand Down
68 changes: 18 additions & 50 deletions site/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,21 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import { configureResponse } from "./util/configureResponse";
import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "./util/siteConfig";

export async function middleware(request: NextRequest) {
const headers = request.headers;
const host = getHostByHeaders(headers);
const { pathname } = new URL(request.url);

// Preview
if (request.nextUrl.pathname.startsWith("/block-preview/") || request.nextUrl.pathname === "/site-preview") {
return NextResponse.next({ request: { headers } });
}

const siteConfig = await getSiteConfigForHost(host);
if (!siteConfig) {
// Redirect to Main Host
const redirectSiteConfig = getSiteConfigs().find(
(siteConfig) =>
siteConfig.domains.additional?.includes(host) || (siteConfig.domains.pattern && host.match(new RegExp(siteConfig.domains.pattern))),
);
if (redirectSiteConfig) {
return NextResponse.redirect(redirectSiteConfig.url);
}

throw new Error(`Cannot get siteConfig for host ${host}`);
}

if (pathname.startsWith("/dam/")) {
return NextResponse.rewrite(new URL(`${process.env.API_URL_INTERNAL}${request.nextUrl.pathname}`));
}

if (request.nextUrl.pathname === "/admin" && process.env.ADMIN_URL) {
return NextResponse.redirect(new URL(process.env.ADMIN_URL));
}

return configureResponse(
NextResponse.rewrite(
new URL(
`/${siteConfig.scope.domain}${request.nextUrl.pathname}${
request.nextUrl.searchParams.toString().length > 0 ? `?${request.nextUrl.searchParams.toString()}` : ""
}`,
request.url,
),
{ request: { headers } },
),
);
}
import { withAdminRedirectMiddleware } from "./middleware/adminRedirect";
import { chain } from "./middleware/chain";
import { withContentSecurityPolicyHeadersMiddleware } from "./middleware/contentSecurityPolicyHeaders";
import { withDamRewriteMiddleware } from "./middleware/damRewrite";
import { withDomainRewriteMiddleware } from "./middleware/domainRewrite";
import { withPreviewMiddleware } from "./middleware/preview";
import { withRedirectToMainHostMiddleware } from "./middleware/redirectToMainHost";
import { withSitePreviewMiddleware } from "./middleware/sitePreview";

export default chain([
withSitePreviewMiddleware,
withRedirectToMainHostMiddleware,
withAdminRedirectMiddleware,
withDamRewriteMiddleware,
withContentSecurityPolicyHeadersMiddleware, // order matters: after redirects (that don't need csp headers), before everything else that needs csp headers
withPreviewMiddleware,
withDomainRewriteMiddleware, // must be last (rewrites all urls)
]);

export const config = {
matcher: [
Expand Down
12 changes: 12 additions & 0 deletions site/src/middleware/adminRedirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from "next/server";

import { CustomMiddleware } from "./chain";

export function withAdminRedirectMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
if (request.nextUrl.pathname === "/admin" && process.env.ADMIN_URL) {
return NextResponse.redirect(new URL(process.env.ADMIN_URL));
}
return middleware(request);
};
}
17 changes: 17 additions & 0 deletions site/src/middleware/chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export type CustomMiddleware = (request: NextRequest) => NextResponse | Response | Promise<NextResponse | Response>;

type MiddlewareFactory = (middleware: CustomMiddleware) => CustomMiddleware;
// Utility function to chain multiple middlewares together
export function chain(functions: MiddlewareFactory[], index = 0): CustomMiddleware {
const current = functions[index];

if (current) {
const next = chain(functions, index + 1);
return current(next);
}

return () => NextResponse.next();
}
34 changes: 34 additions & 0 deletions site/src/middleware/contentSecurityPolicyHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest } from "next/server";

import { CustomMiddleware } from "./chain";

export function withContentSecurityPolicyHeadersMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
const response = await middleware(request);
response.headers.set(
"Content-Security-Policy",
`
default-src 'self';
form-action 'self';
object-src 'none';
img-src 'self' https: data:${process.env.NODE_ENV === "development" ? " http:" : ""};
media-src 'self' https: data:${process.env.NODE_ENV === "development" ? " http:" : ""};
style-src 'self' 'unsafe-inline';
font-src 'self' https: data:;
script-src 'self' 'unsafe-inline' https:${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""};
connect-src 'self' https:${process.env.NODE_ENV === "development" ? " http:" : ""};
frame-ancestors ${process.env.ADMIN_URL ?? "none"};
upgrade-insecure-requests;
block-all-mixed-content;
frame-src 'self' https://*.youtube.com https://*.youtube-nocookie.com;
`
.replace(/\s{2,}/g, " ")
.trim(),
);
if (process.env.ADMIN_URL) {
response.headers.set("Access-Control-Allow-Origin", process.env.ADMIN_URL);
}

return response;
};
}
12 changes: 12 additions & 0 deletions site/src/middleware/damRewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from "next/server";

import { CustomMiddleware } from "./chain";

export function withDamRewriteMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
if (request.nextUrl.pathname.startsWith("/dam/")) {
return NextResponse.rewrite(new URL(`${process.env.API_URL_INTERNAL}${request.nextUrl.pathname}`));
}
return middleware(request);
};
}
29 changes: 29 additions & 0 deletions site/src/middleware/domainRewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getHostByHeaders, getSiteConfigForHost } from "@src/util/siteConfig";
import { NextRequest, NextResponse } from "next/server";

import { CustomMiddleware } from "./chain";

/**
* Rewrite request to include the matching domain (from http host) in the path, so the route can have [domain] as parameter.
*
* Doing this matching in the middleware makes it possible to keep static rendering, as the page doesn't need headers() to get the domain.
*/
export function withDomainRewriteMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
const headers = request.headers;
const host = getHostByHeaders(headers);
const siteConfig = await getSiteConfigForHost(host);
if (!siteConfig) {
throw new Error(`Cannot get siteConfig for host ${host}`);
}
return NextResponse.rewrite(
new URL(
`/${siteConfig.scope.domain}${request.nextUrl.pathname}${
request.nextUrl.searchParams.toString().length > 0 ? `?${request.nextUrl.searchParams.toString()}` : ""
}`,
request.url,
),
{ request: { headers } },
);
};
}
16 changes: 16 additions & 0 deletions site/src/middleware/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";

import { CustomMiddleware } from "./chain";

/**
* /block-preview/* and /site-preview must not use the domain rewrite, because we don't have a active domain. This middleware exists the chain in that case.
*/
export function withPreviewMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
if (request.nextUrl.pathname.startsWith("/block-preview/") || request.nextUrl.pathname === "/site-preview") {
// don't apply any other middlewares
return NextResponse.next();
}
return middleware(request);
};
}
30 changes: 30 additions & 0 deletions site/src/middleware/redirectToMainHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig";
import { NextRequest, NextResponse } from "next/server";

import { CustomMiddleware } from "./chain";

/**
* When http host isn't siteConfig.domains.main (instead .pattern or .additional match), redirect to main host.
*/
export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
const headers = request.headers;
const host = getHostByHeaders(headers);
const siteConfig = await getSiteConfigForHost(host);

if (!siteConfig) {
// Redirect to Main Host
const redirectSiteConfig = getSiteConfigs().find(
(siteConfig) =>
siteConfig.domains.additional?.includes(host) ||
(siteConfig.domains.pattern && host.match(new RegExp(siteConfig.domains.pattern))),
);
if (redirectSiteConfig) {
return NextResponse.redirect(redirectSiteConfig.url);
}

throw new Error(`Cannot get siteConfig for host ${host}`);
}
return middleware(request);
};
}
18 changes: 18 additions & 0 deletions site/src/middleware/sitePreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getHostByHeaders, getSiteConfigForHost } from "@src/util/siteConfig";
import { NextRequest, NextResponse } from "next/server";

import { CustomMiddleware } from "./chain";

/**
* Verify when on site preview domain, a siteConfig exists (by scope set as cookie)
*/
export function withSitePreviewMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
const host = getHostByHeaders(request.headers);
const siteConfig = await getSiteConfigForHost(host);
if (!siteConfig && host === process.env.PREVIEW_DOMAIN) {
return NextResponse.json({ error: "Preview has to be called from within Comet Web preview" }, { status: 404 });
}
return middleware(request);
};
}
28 changes: 0 additions & 28 deletions site/src/util/configureResponse.ts

This file was deleted.

0 comments on commit 8211c49

Please sign in to comment.