Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cms-i18n/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ To get started, you need to configure environment variables. Follow these steps:
- **`NEXT_PUBLIC_SITE_URL`**: The public URL of your site. This is used for SEO metadata and blog post routing.
- **`NEXT_PUBLIC_ENABLE_VISUAL_EDITING`**: Visual editing is enabled by default. Set to `false` to disable.

The form API limits multipart request size and field count. Add rate limiting or bot protection at your hosting edge before
launching a public form.

## **Running the Application**

### Local Development
Expand Down
1 change: 1 addition & 0 deletions cms-i18n/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"test": "tsx --test src/lib/directus/*.test.ts",
"generate:types": "tsx ./src/lib/directus/generateDirectusTypes.ts",
"lint": "next lint",
"lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"",
Expand Down
21 changes: 12 additions & 9 deletions cms-i18n/nextjs/src/app/api/forms/submit/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
import { submitForm } from '@/lib/directus/forms';
import { validateFormSubmission } from '@/lib/directus/validateFormSubmission';
import { parseFormRequest, validateFormSubmission } from '@/lib/directus/validateFormSubmission';
import { useDirectus } from '@/lib/directus/directus';
import type { FormField } from '@/types/directus-schema';

export async function POST(request: Request) {
const formData = await request.formData();
const parsedRequest = await parseFormRequest(request);
if (!parsedRequest.success) {
return NextResponse.json({ error: parsedRequest.error }, { status: parsedRequest.status });
}

const formData = parsedRequest.data;
const formId = formData.get('formId');

if (typeof formId !== 'string' || !formId.trim()) {
Expand All @@ -20,16 +25,14 @@ export async function POST(request: Request) {
// Fetch the authoritative form field definitions from Directus server-side.
// This ensures validation rules (required, validation patterns) come from the
// source of truth rather than client-provided data.
const { directus, readItem, withToken } = useDirectus();
const { directus, readItem } = useDirectus();
let fields: FormField[];
try {
// Public policy restricts this read to active forms; the server token only needs submission permissions.
const form = await directus.request(
withToken(
TOKEN,
readItem('forms', formId.trim(), {
fields: ['is_active', { fields: ['id', 'name', 'type', 'label', 'required', 'validation'] }],
} as any),
),
readItem('forms', formId.trim(), {
fields: ['is_active', { fields: ['id', 'name', 'type', 'label', 'required', 'validation', 'choices'] }],
} as any),
);

if (!(form as any).is_active || !Array.isArray((form as any).fields)) {
Expand Down
103 changes: 103 additions & 0 deletions cms-i18n/nextjs/src/lib/directus/validateFormSubmission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { FormField } from '@/types/directus-schema';
import {
MAX_FORM_FILE_BYTES,
MAX_FORM_REQUEST_BYTES,
MAX_FORM_REQUEST_ENTRIES,
parseFormRequest,
validateFormSubmission,
} from './validateFormSubmission';

const field = (overrides: Partial<FormField>): FormField => ({
id: 'field-id',
name: 'field',
type: 'text',
...overrides,
});

test('rejects oversized multipart requests before parsing', async () => {
const request = new Request('https://site.example.test/api/forms/submit', {
method: 'POST',
headers: { 'content-length': String(MAX_FORM_REQUEST_BYTES + 1) },
body: new FormData(),
});

const result = await parseFormRequest(request);
assert.deepEqual(result, { success: false, error: 'Form submission is too large', status: 413 });
});

test('rejects multipart requests with excessive entries', async () => {
const formData = new FormData();
for (let index = 0; index <= MAX_FORM_REQUEST_ENTRIES; index++) {
formData.set(`field-${index}`, 'value');
}
const request = new Request('https://site.example.test/api/forms/submit', { method: 'POST', body: formData });

const result = await parseFormRequest(request);
assert.equal(result.success, false);
if (!result.success) assert.equal(result.status, 413);
});

test('returns parsed data for bounded multipart requests', async () => {
const formData = new FormData();
formData.set('formId', 'form-id');
const request = new Request('https://site.example.test/api/forms/submit', { method: 'POST', body: formData });

const result = await parseFormRequest(request);
assert.equal(result.success, true);
if (result.success) assert.equal(result.data.get('formId'), 'form-id');
});

test('rejects empty required checkbox controls', () => {
const checkbox = field({ name: 'consent', type: 'checkbox', required: true });
const group = field({ name: 'topics', type: 'checkbox_group', required: true });
const formData = new FormData();
formData.set('consent', 'false');
formData.set('topics', '[]');

assert.equal(validateFormSubmission([checkbox], formData).success, false);
assert.equal(validateFormSubmission([group], formData).success, false);
});

test('rejects values outside configured choices', () => {
const choices = [
{ text: 'Basic', value: 'basic' },
{ text: 'Pro', value: 'pro' },
];
const select = field({ name: 'plan', type: 'select', required: true, choices });
const group = field({ name: 'plans', type: 'checkbox_group', choices });
const formData = new FormData();
formData.set('plan', 'enterprise');
formData.set('plans', JSON.stringify(['basic', 'enterprise']));

assert.equal(validateFormSubmission([select], formData).success, false);
assert.equal(validateFormSubmission([group], formData).success, false);
});

test('accepts configured choices and checked required controls', () => {
const choices = [{ text: 'Basic', value: 'basic' }];
const fields = [
field({ name: 'consent', type: 'checkbox', required: true }),
field({ name: 'plan', type: 'select', required: true, choices }),
field({ name: 'plans', type: 'checkbox_group', required: true, choices }),
];
const formData = new FormData();
formData.set('consent', 'true');
formData.set('plan', 'basic');
formData.set('plans', JSON.stringify(['basic']));

assert.equal(validateFormSubmission(fields, formData).success, true);
});

test('matches the licensed Directus five-megabyte upload limit', () => {
const upload = field({ name: 'upload', type: 'file' });
const accepted = new FormData();
accepted.set('upload', new File([new Uint8Array(MAX_FORM_FILE_BYTES)], 'accepted.bin'));
const rejected = new FormData();
rejected.set('upload', new File([new Uint8Array(MAX_FORM_FILE_BYTES + 1)], 'rejected.bin'));

assert.equal(MAX_FORM_FILE_BYTES, 5_000_000);
assert.equal(validateFormSubmission([upload], accepted).success, true);
assert.equal(validateFormSubmission([upload], rejected).success, false);
});
39 changes: 37 additions & 2 deletions cms-i18n/nextjs/src/lib/directus/validateFormSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,42 @@ import { buildZodSchema } from '@/lib/zodSchemaBuilder';
import type { FormField } from '@/types/directus-schema';

/** Matches licensed template Forms policy file upload limit (5 MB). */
export const MAX_FORM_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_FORM_FILE_BYTES = 5_000_000;

/** Allows up to five maximum-sized files plus multipart field overhead. */
export const MAX_FORM_REQUEST_BYTES = MAX_FORM_FILE_BYTES * 5 + 1_000_000;
export const MAX_FORM_REQUEST_ENTRIES = 100;

type ParseFormRequestResult =
| { success: true; data: FormData }
| { success: false; error: string; status: 400 | 413 };

export async function parseFormRequest(request: Request): Promise<ParseFormRequestResult> {
const contentLength = Number(request.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_FORM_REQUEST_BYTES) {
return { success: false, error: 'Form submission is too large', status: 413 };
}

let formData: FormData;
try {
formData = await request.formData();
} catch {
return { success: false, error: 'Invalid form submission', status: 400 };
}

let entryCount = 0;
let payloadBytes = 0;
for (const [, value] of formData) {
entryCount++;
payloadBytes += typeof value === 'string' ? new TextEncoder().encode(value).byteLength : value.size;

if (entryCount > MAX_FORM_REQUEST_ENTRIES || payloadBytes > MAX_FORM_REQUEST_BYTES) {
return { success: false, error: 'Form submission is too large', status: 413 };
}
}

return { success: true, data: formData };
}

function parseFieldValue(field: FormField, raw: FormDataEntryValue): unknown {
if (field.type === 'file') {
Expand Down Expand Up @@ -42,7 +77,7 @@ export function validateFormSubmission(
if (raw.size > MAX_FORM_FILE_BYTES) {
return {
success: false,
error: `${field.label || field.name} must be ${MAX_FORM_FILE_BYTES / (1024 * 1024)} MB or smaller`,
error: `${field.label || field.name} must be 5 MB or smaller`,
};
}
if (raw.size > 0) {
Expand Down
24 changes: 22 additions & 2 deletions cms-i18n/nextjs/src/lib/zodSchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const buildZodSchema = (fields: FormField[]) => {

fields.forEach((field) => {
let fieldSchema: z.ZodTypeAny;
const fieldLabel = field.label || field.name;
const choiceValues = field.choices?.map((choice) => choice.value) || [];

switch (field.type) {
case 'checkbox':
Expand Down Expand Up @@ -83,14 +85,32 @@ export const buildZodSchema = (fields: FormField[]) => {
}

if (field.required) {
if (fieldSchema instanceof z.ZodString) {
fieldSchema = fieldSchema.nonempty(`${field.label || field.name} is required`);
if (field.type === 'checkbox') {
fieldSchema = fieldSchema.refine((value) => value === true, `${fieldLabel} is required`);
} else if (field.type === 'checkbox_group') {
fieldSchema = fieldSchema.refine((value) => value.length > 0, `${fieldLabel} is required`);
} else if (fieldSchema instanceof z.ZodString) {
fieldSchema = fieldSchema.nonempty(`${fieldLabel} is required`);
}
} else {
// Allow empty strings or undefined for optional fields
fieldSchema = fieldSchema.or(z.literal('')).or(z.undefined());
}

if (choiceValues.length > 0) {
if (field.type === 'checkbox_group') {
fieldSchema = fieldSchema.refine(
(value) => value === '' || value === undefined || value.every((choice: string) => choiceValues.includes(choice)),
`${fieldLabel} contains an invalid option`,
);
} else if (field.type === 'radio' || field.type === 'select') {
fieldSchema = fieldSchema.refine(
(value) => value === '' || value === undefined || choiceValues.includes(value),
`${fieldLabel} must be a configured option`,
);
}
}

if (field.name) {
schema[field.name] = fieldSchema;
}
Expand Down
12 changes: 8 additions & 4 deletions cms/nextjs/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
NEXT_PUBLIC_DIRECTUS_URL=http://localhost:8055 # Or your cloud instance URL
DIRECTUS_SERVER_TOKEN=token_from_Webmaster_account
DIRECTUS_ADMIN_TOKEN=token_from_Admin_account # Only for local type generation, never used at runtime
NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Application URL
# Directus instance URL
NEXT_PUBLIC_DIRECTUS_URL=http://localhost:8055
# Static token from your admin user (Directus → Users Directory → Token → Save)
# Must match the instance you applied the template to — regenerate after a database reset
DIRECTUS_SERVER_TOKEN=
# Admin token for local type generation only (pnpm run generate:types)
DIRECTUS_ADMIN_TOKEN=
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Set to false to disable (enabled by default)
# NEXT_PUBLIC_ENABLE_VISUAL_EDITING=false
17 changes: 15 additions & 2 deletions cms/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ CMS-powered web applications.
Directus allows you to work on unpublished content using **Draft Mode**. This Next.js template is configured to support
Directus Draft Mode out of the box, enabling live previews of unpublished or draft content as you make changes.

### **Content Versioning in Directus 12**

In Directus 12, the published content version is called `published` (formerly `main`), and every versioned item
automatically gets a `draft` version. Published items are locked in the Studio — edits happen on the draft version and
are promoted to publish. This template handles both keys: preview URLs with `version=published` (or the legacy
`version=main`) load the live content, while `version=draft` (or any custom version key) fetches that version from the
API.

### **Live Preview Setup**

[Directus Live Preview](https://docs.directus.io/guides/headless-cms/live-preview/nextjs.html)
Expand All @@ -46,7 +54,7 @@ Directus Draft Mode out of the box, enabling live previews of unpublished or dra

To set up this template, ensure you have the following:

- **Node.js** (16.x or newer)
- **Node.js** (22.x or newer)
- **npm** or **pnpm**
- Access to a **Directus** instance ([cloud or self-hosted](../../README.md))

Expand Down Expand Up @@ -78,11 +86,16 @@ To get started, you need to configure environment variables. Follow these steps:
2. **Update the following variables in your `.env` file:**

- **`NEXT_PUBLIC_DIRECTUS_URL`**: URL of your Directus instance.
- **`DIRECTUS_SERVER_TOKEN`**: Token from the **Webmaster** account in Directus. Used server-side for preview, draft content, and form submissions.
- **`DIRECTUS_SERVER_TOKEN`**: Static token from your Directus **admin account** (created during first-launch onboarding). Used server-side for preview, draft
content, and form submissions. With a licensed instance, you can instead use a token from a user assigned only the
**Content - Live Preview** and **Forms - Submission** policies.
- **`DIRECTUS_ADMIN_TOKEN`**: Admin token for local type generation only. Never used at runtime.
- **`NEXT_PUBLIC_SITE_URL`**: The public URL of your site. This is used for SEO metadata and blog post routing.
- **`NEXT_PUBLIC_ENABLE_VISUAL_EDITING`**: Visual editing is enabled by default. Set to `false` to disable.

The form API limits multipart request size and field count. Add rate limiting or bot protection at your hosting edge before
launching a public form.

## **Running the Application**

### Local Development
Expand Down
10 changes: 7 additions & 3 deletions cms/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"test": "tsx --test src/lib/directus/*.test.ts",
"generate:types": "tsx ./src/lib/directus/generateDirectusTypes.ts",
"lint": "next lint",
"lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\""
},
"dependencies": {
"@directus/sdk": "20.0.3",
"@directus/visual-editing": "2.0.0",
"@directus/sdk": "22.0.0",
"@directus/visual-editing": "2.1.0",
"@hookform/resolvers": "5.0.1",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-collapsible": "1.1.3",
Expand Down Expand Up @@ -52,7 +53,7 @@
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"@types/node": "22.14.0",
"@types/readline-sync": "1.4.8",
"directus-sdk-typegen": "0.2.0",
"directus-sdk-typegen": "0.2.1",
"eslint": "9.24.0",
"eslint-config-next": "15.2.8",
"eslint-config-prettier": "10.1.1",
Expand All @@ -71,6 +72,9 @@
"typescript": "5.8.3",
"typescript-eslint": "8.29.1"
},
"engines": {
"node": ">=22"
},
"pnpm": {
"overrides": {
"tar": "^7.5.4"
Expand Down
Loading