Skip to content

Commit de72ad8

Browse files
committed
fix: fix the form page issue and improve the middleware
1 parent 66d0238 commit de72ad8

8 files changed

Lines changed: 363 additions & 42 deletions

File tree

apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/form/[...slug]/page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getPathAndSearchParams } from '@frameless/utils';
12
import { headers } from 'next/headers';
23
import Link from 'next/link';
34
import React from 'react';
@@ -12,18 +13,28 @@ type FormPageProps = {
1213
locale: string;
1314
slug: [formId: string, formStep: string];
1415
};
16+
searchParams: {
17+
formType: string;
18+
};
1519
};
1620

1721
const FormPage = async ({
1822
params: {
1923
locale,
2024
slug: [formId],
2125
},
26+
searchParams,
2227
}: FormPageProps) => {
2328
const { t } = await useTranslation(locale, 'common');
2429
const nonce = headers().get('x-nonce') || '';
2530
const formInfo = await openFormValidator({ formId });
2631

32+
const { pathSegments: formPathSegments } = getPathAndSearchParams({
33+
translations: t,
34+
segments: [searchParams.formType, formId],
35+
locale,
36+
});
37+
2738
return (
2839
<>
2940
<Breadcrumbs
@@ -58,6 +69,7 @@ const FormPage = async ({
5869
sdkUrl={createOpenFormsSdkUrl()?.href || ''}
5970
cssUrl={createOpenFormsCssUrl()?.href || ''}
6071
nonce={nonce}
72+
basePath={`/${formPathSegments}`}
6173
slug={formId}
6274
fallback={formInfo ? <Heading1>{formInfo.name}</Heading1> : null}
6375
/>

apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
'use client';
21
import { OpenFormsContainer } from '@utrecht/open-forms-container-react/dist/css';
3-
import { usePathname } from 'next/navigation';
42
import React, { type ReactNode, useId } from 'react';
53
import { OpenFormsNLDesignSystem } from './OpenFormsNLDesignSystem';
64
import { OpenFormsScript } from './OpenFormsScript';
@@ -17,22 +15,17 @@ export type OpenFormsEmbedProps = {
1715
sdkUrl: string;
1816
cssUrl: string;
1917
fallback?: ReactNode;
18+
basePath?: string;
2019
};
2120

22-
export const OpenFormsEmbed = ({ nonce, slug, apiUrl, sdkUrl, cssUrl, fallback }: OpenFormsEmbedProps) => {
21+
export const OpenFormsEmbed = ({ nonce, slug, apiUrl, sdkUrl, cssUrl, fallback, basePath }: OpenFormsEmbedProps) => {
2322
const id = useId();
24-
const pathname = usePathname();
2523

2624
return (
2725
<RichText>
2826
<OpenFormsContainer>
2927
<OpenFormsNLDesignSystem targetId={id}>
30-
<div
31-
id={id}
32-
data-base-url={apiUrl}
33-
data-form-id={slug}
34-
data-base-path={pathname.split('/').slice(0, 4).join('/')}
35-
>
28+
<div id={id} data-base-url={apiUrl} data-form-id={slug} data-base-path={basePath}>
3629
{fallback}
3730
</div>
3831
</OpenFormsNLDesignSystem>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import { NextRequest, NextResponse } from 'next/server';
6+
import { middleware } from './middleware';
7+
import * as util from './util';
8+
import * as handleFormTypeRewriteModule from './util/handleFormTypeRewrite';
9+
10+
jest.mock('./util');
11+
jest.mock('./util/handleFormTypeRewrite');
12+
13+
const mockFetchData = util.fetchData as jest.Mock;
14+
const mockHandleFormTypeRewrite = handleFormTypeRewriteModule.handleFormTypeRewrite as jest.Mock;
15+
16+
const createMockRequest = (
17+
options: Partial<{
18+
pathname: string;
19+
search: string;
20+
cookies: Record<string, string>;
21+
headers: Record<string, string>;
22+
referer?: string;
23+
}> = {},
24+
) => {
25+
const { pathname = '/foo', search = '', cookies = {}, headers = {}, referer } = options;
26+
27+
const url = `https://example.com${pathname}${search}`;
28+
const reqHeaders = new Headers(headers);
29+
if (referer) reqHeaders.set('referer', referer);
30+
31+
return {
32+
nextUrl: {
33+
pathname,
34+
search,
35+
href: url,
36+
clone: () => ({ pathname, search, href: url }),
37+
},
38+
url,
39+
cookies: {
40+
has: (name: string) => Object.prototype.hasOwnProperty.call(cookies, name),
41+
get: (name: string) => (cookies[name] ? { value: cookies[name] } : undefined),
42+
set: jest.fn(),
43+
},
44+
headers: reqHeaders,
45+
} as unknown as NextRequest;
46+
};
47+
48+
beforeEach(() => {
49+
jest.clearAllMocks();
50+
mockFetchData.mockResolvedValue({ data: { products: { data: [] } } });
51+
mockHandleFormTypeRewrite.mockReturnValue(undefined);
52+
});
53+
54+
describe('middleware', () => {
55+
it('redirects to locale-prefixed path if missing', async () => {
56+
const req = createMockRequest({ pathname: '/foo' });
57+
const res = await middleware(req);
58+
expect(res instanceof NextResponse).toBe(true);
59+
// Assert that the redirect location matches the expected locale-prefixed path
60+
const locationHeader = (res as NextResponse).headers.get('location');
61+
const regex = /^https:\/\/example\.com\/[a-z]{2}\/foo/;
62+
expect(locationHeader).toMatch(regex);
63+
});
64+
65+
it('skips processing for icon and chrome paths', async () => {
66+
const req = createMockRequest({ pathname: '/icon-192.png' });
67+
const res = await middleware(req);
68+
expect(res).toBeInstanceOf(NextResponse);
69+
});
70+
71+
it('sets language cookie from referer if present', async () => {
72+
const req = createMockRequest({
73+
pathname: '/nl/foo',
74+
headers: {},
75+
referer: 'https://example.com/nl/bar',
76+
});
77+
const res = await middleware(req);
78+
expect(res).toBeInstanceOf(NextResponse);
79+
// Should set cookie to 'nl'
80+
expect(res.cookies.get('i18next')?.value).toBe('nl');
81+
});
82+
83+
it('redirects to new URL if old slug matches', async () => {
84+
const redirectUrl = new URL('https://example.com/nl/new-slug');
85+
(util.getRedirectURL as jest.Mock).mockReturnValue(redirectUrl);
86+
const req = createMockRequest({ pathname: '/nl/old-slug' });
87+
const res = await middleware(req);
88+
expect(res).toBeInstanceOf(NextResponse);
89+
expect((res as NextResponse).status).toBe(308);
90+
expect((res as NextResponse).headers.get('location')).toBe(redirectUrl.toString());
91+
});
92+
93+
it('calls handleFormTypeRewrite and returns its response if present', async () => {
94+
const fakeResponse = NextResponse.next();
95+
mockHandleFormTypeRewrite.mockReturnValue(fakeResponse);
96+
97+
// Ensure this locale is one of your supported locales in `languages`
98+
const req = createMockRequest({ pathname: '/nl/formulier/demo' });
99+
100+
// Also ensure getRedirectURL returns undefined
101+
(util.getRedirectURL as jest.Mock).mockReturnValue(undefined);
102+
103+
const res = await middleware(req);
104+
105+
expect(mockHandleFormTypeRewrite).toHaveBeenCalled();
106+
expect(res).toBe(fakeResponse);
107+
});
108+
109+
it('returns NextResponse.next with security headers if no redirect/rewrite', async () => {
110+
const req = createMockRequest({ pathname: '/nl/foo' });
111+
const res = await middleware(req);
112+
const csp = res.headers.get('Content-Security-Policy');
113+
expect(csp).toBeDefined();
114+
expect(csp).toContain('default-src');
115+
expect(csp).toContain('nonce-');
116+
});
117+
118+
it('uses locale from cookie if present', async () => {
119+
const req = createMockRequest({ pathname: '/foo', cookies: { i18next: 'nl' } });
120+
await middleware(req);
121+
expect(mockFetchData).toHaveBeenCalledWith(expect.objectContaining({ variables: { locale: 'nl' } }));
122+
});
123+
124+
it('uses fallbackLng if no locale detected', async () => {
125+
const req = createMockRequest({ pathname: '/foo' });
126+
await middleware(req);
127+
expect(mockFetchData).toHaveBeenCalledWith(expect.objectContaining({ variables: expect.any(Object) }));
128+
});
129+
});

apps/pdc-frontend/src/middleware.ts

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,62 @@ import { getContentSecurityPolicy } from '@/util/cspConfig';
44
import { fallbackLng, languages } from './app/i18n/settings';
55
import { GET_PRODUCTS_OLD_SLUGS } from './query';
66
import { fetchData, getRedirectURL, getStrapiGraphqlURL } from './util';
7+
import { handleFormTypeRewrite } from './util/handleFormTypeRewrite';
8+
import { withSecurityHeaders } from './util/withSecurityHeaders'; // ✅ Add this line
79
import { GetProductsOldSlugsQuery } from '../gql/graphql';
810

911
acceptLanguage.languages(languages);
1012

1113
export const config = {
1214
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)'],
13-
// https://nextjs.org/docs/messages/edge-dynamic-code-evaluation
1415
unstable_allowDynamic: ['**/node_modules/lodash.mergewith/index.js'],
1516
};
1617

1718
const cookieName = 'i18next';
19+
/**
20+
* 🌐 Global Middleware
21+
*
22+
* This middleware handles localization, form rewrites, legacy URL redirects,
23+
* and security headers for all incoming requests.
24+
*
25+
* ⚠️ ORDER MATTERS – Execution is top-down and early returns stop processing.
26+
*
27+
* Key Responsibilities:
28+
* 1. ✅ Set security headers (CSP, Referrer-Policy, etc.) via `withSecurityHeaders`
29+
* 2. ✅ Skip unnecessary processing for static/icon/chrome-related assets
30+
* 3. ✅ Redirect to a locale-prefixed path if not present (e.g., /nl, /en)
31+
* 4. ✅ Rewrite `/form` or `/formulier` URLs to include `formType` query param
32+
* 5. ✅ Set language cookie from Referer if available
33+
* 6. ✅ Redirect legacy URLs (e.g., old slugs from CMS)
34+
* 7. ✅ Return a default response with all security headers applied
35+
*
36+
* ⚠️ CRITICAL NOTES:
37+
* - `NextResponse.redirect()` or `NextResponse.rewrite()` will immediately return a response.
38+
* 👉 Ensure critical logic like `handleFormTypeRewrite` runs BEFORE any potential redirect.
39+
*
40+
* - Always wrap your `NextResponse` with `withSecurityHeaders()` to ensure security headers are applied consistently.
41+
* This includes responses returned by `next()`, `rewrite()`, and `redirect()`.
42+
*
43+
* - Nonce generation is included to support strict CSP rules for inline styles/scripts.
44+
*
45+
* See `withSecurityHeaders.ts` and `handleFormTypeRewrite.ts` for supporting logic.
46+
*/
1847

1948
export async function middleware(req: NextRequest) {
20-
let locale;
21-
if (req.cookies.has(cookieName)) locale = acceptLanguage.get(req.cookies.get(cookieName)?.value);
22-
if (!locale) locale = acceptLanguage.get(req.headers.get('Accept-Language'));
23-
if (!locale) locale = fallbackLng;
49+
const locale =
50+
(req.cookies.has(cookieName) && acceptLanguage.get(req.cookies.get(cookieName)?.value)) ||
51+
acceptLanguage.get(req.headers.get('Accept-Language')) ||
52+
fallbackLng;
2453

2554
const { data } = await fetchData<{ data: GetProductsOldSlugsQuery }>({
2655
url: getStrapiGraphqlURL(),
2756
query: GET_PRODUCTS_OLD_SLUGS,
28-
variables: {
29-
locale,
30-
},
57+
variables: { locale },
3158
});
3259

3360
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
34-
3561
const cspHeader = getContentSecurityPolicy({ nonce, node_env: process.env.NODE_ENV });
62+
3663
const headers = new Headers(req.headers);
3764
headers.set('X-Nonce', nonce);
3865

@@ -43,41 +70,46 @@ export async function middleware(req: NextRequest) {
4370
'Permissions-Policy': 'geolocation=(self)',
4471
};
4572

46-
if (req.nextUrl.pathname.indexOf('icon') > -1 || req.nextUrl.pathname.indexOf('chrome') > -1)
47-
return NextResponse.next();
73+
const pathname = req.nextUrl.pathname;
74+
75+
// Skip icons and Chrome-specific routes
76+
if (pathname.includes('icon') || pathname.includes('chrome')) {
77+
return withSecurityHeaders(NextResponse.next(), responseHeaders);
78+
}
79+
80+
// Redirect if locale is missing from path
81+
if (!languages.some((loc) => pathname.startsWith(`/${loc}`)) && !pathname.startsWith('/_next')) {
82+
const redirectUrl = new URL(`/${locale}${pathname}${req.nextUrl.search}`, req.url);
83+
return withSecurityHeaders(NextResponse.redirect(redirectUrl), responseHeaders);
84+
}
4885

49-
// Redirect if lng in path is not supported
50-
if (
51-
!languages.some((loc) => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
52-
!req.nextUrl.pathname.startsWith('/_next')
53-
) {
54-
return NextResponse.redirect(new URL(`/${locale}${req.nextUrl.pathname}${req.nextUrl.search}`, req.url));
86+
// Handle /form or /formulier rewrite
87+
const formRewriteResponse = handleFormTypeRewrite(req, headers, responseHeaders);
88+
if (formRewriteResponse) {
89+
return withSecurityHeaders(formRewriteResponse, responseHeaders);
5590
}
5691

92+
// Set cookie based on referer
5793
if (req.headers.has('referer')) {
58-
const refererUrl = new URL(req.headers.get('referer') as any);
94+
const refererUrl = new URL(req.headers.get('referer') as string);
5995
const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`));
60-
const response = NextResponse.next({
61-
request: { headers },
62-
headers: responseHeaders,
63-
});
96+
const response = withSecurityHeaders(NextResponse.next({ request: { headers } }), responseHeaders);
6497
if (lngInReferer) response.cookies.set(cookieName, lngInReferer);
6598
return response;
6699
}
67100

68-
// Fetches the redirect URL for a given request if the current path matches any old slugs.
69-
const url = getRedirectURL({
101+
// Redirect legacy slugs
102+
const redirectUrl = getRedirectURL({
70103
url: req.nextUrl.clone(),
71-
currentPathname: req.nextUrl.pathname,
104+
currentPathname: pathname,
72105
data: data.products?.data,
73106
});
74-
// Use 308 for permanent redirects to inform browsers and search engines that the resource has moved permanently.
75-
if (url && url.toString() !== req.nextUrl.href) return NextResponse.redirect(url, 308);
76107

77-
headers.set('x-pathname', req.nextUrl.pathname);
108+
if (redirectUrl && redirectUrl.toString() !== req.nextUrl.href) {
109+
return withSecurityHeaders(NextResponse.redirect(redirectUrl, 308), responseHeaders);
110+
}
78111

79-
return NextResponse.next({
80-
request: { headers },
81-
headers: responseHeaders,
82-
});
112+
// Default: return regular response with headers
113+
headers.set('x-pathname', pathname);
114+
return withSecurityHeaders(NextResponse.next({ request: { headers } }), responseHeaders);
83115
}

0 commit comments

Comments
 (0)