Forms¶
A form posts to its own page ; the page's action takes it. It works without any script, in every browser.
import { redirect } from 'am-ssr';
import type { PageConfig, PageContext, PageProps } from 'am-ssr';
export const config: PageConfig = { title: 'Contact' };
interface Sent {
values: { email: string; message: string };
errors: Record<string, string>;
}
export async function action(ctx: PageContext): Promise<Sent> {
const values = { email: String(ctx.form.email ?? '').trim(), message: String(ctx.form.message ?? '').trim() };
const errors: Record<string, string> = {};
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = 'An email address, please.';
if (values.message.length < 10) errors.message = 'At least 10 characters.';
if (Object.keys(errors).length > 0) {
ctx.status(422);
return { values, errors };
}
await ctx.db.saveSingleOrMultiple({ instance: 'mongodb', database: 'site', table: 'messages', saveData: { ...values, at: new Date() } });
ctx.flash('success', 'Thanks, your message was sent.');
redirect('/contact', 303);
}
export default function Contact({ actionData }: PageProps<undefined, Sent>) {
const values = actionData?.values ?? { email: '', message: '' };
const errors = actionData?.errors ?? {};
return (
<form method="post">
<label>Email <input type="email" name="email" value={values.email} required /></label>
{errors.email && <p class="error">{errors.email}</p>}
<label>Message <textarea name="message" value={values.message} required></textarea></label>
{errors.message && <p class="error">{errors.message}</p>}
<button type="submit">Send</button>
</form>
);
}
How a post goes¶
- The browser posts the form to the address of the page.
- The server checks the CSRF token of the form (the form got it by itself) : a post without the right token is refused with
403. - The middlewares run, then
action(ctx).ctx.formholds the fields ;ctx.bodythe body as it came (a form, or JSON). - What the action returns is
actionDataof the page, which renders again (with its loader) : the errors and what the visitor wrote. - Or the action redirects : after a post which worked, a
303sends the browser to a page it opens withGET. A reload does not post twice.
A page without an action answers 405 to a post.
The CSRF token¶
Every <form method="post"> of a page gets a hidden field _csrf with the token of its visitor : nothing to write. A form which posts to another site gets none.
A token is made from a secret kept in a cookie of the website (HttpOnly) and the key of the installation : another site can not read it, so it can not post in the name of a visitor.
A script sends the token as the header x-csrf-token. The server writes it into every page, with the base path of the website :
const { base, csrf } = window.__AM_SSR__;
await fetch(base + '/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
body: JSON.stringify({ text: 'Hello' }),
});
With htmx (in libraries.json), one listener sends it with every request :
document.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['x-csrf-token'] = window.__AM_SSR__.csrf;
});
<meta name="csrf-token"> holds the token too, for the libraries which read it there.
An endpoint which other servers call (a webhook) says csrf: false and checks a secret of its own :
import type { PageConfig, PageContext } from 'am-ssr';
export const config: PageConfig = { csrf: false };
export async function POST(ctx: PageContext) {
const expected = await ctx.cache.get<string>('payments-webhook-secret');
if (!expected || ctx.headers['x-webhook-secret'] !== expected) ctx.error(401, 'Not a webhook of ours.');
// …
return { received: true };
}
Check on the server¶
What the browser checks (required, type="email", maxlength) helps the visitor ; a request can skip all of it. Check every field in the action, and take only the fields you name : never save ctx.form as it came.
Messages shown once¶
ctx.flash('success', 'Saved.') keeps a message for the next page the visitor opens : a cookie of the website, sealed, for five minutes. The layout shows ctx.flashes ({ type, text }). A page which shows messages is never kept for other visitors.
Files¶
A form of several parts sends files :
<form method="post" enctype="multipart/form-data">
<input type="file" name="photo" accept="image/*" />
<button type="submit">Upload</button>
</form>
export async function action(ctx: PageContext) {
const photo = ctx.files.find((file) => file.fieldname === 'photo');
if (!photo || !photo.mimetype.startsWith('image/') || photo.size > 5_000_000) return { error: 'An image of 5 MB at most.' };
// photo.path : where the server wrote it, in the folder of the uploads.
// …
}
ctx.files lists { fieldname, originalname, mimetype, size, path, filename }. The limits are the ones of the uploads of the server.