Files
website/src/lib/server/worldtodo/validate.ts
2026-10-26 01:28:23 +00:00

248 lines
7.9 KiB
TypeScript

import * as z from 'zod';
import {
WORLDTODO_REQUEST_KINDS,
WORLDTODO_SCALES,
WORLDTODO_STATUSES,
type RequestKind,
type TaskFields
} from '#lib/worldtodo/shared.js';
// Validation for public change requests (form action and JSON API share it). Everything is
// plain text: HTML tags and control characters are stripped, Markdown is never rendered.
// Source URLs must be http(s) and are stored as given (never fetched on submit).
export const MAX_URLS = 5;
export const MAX_URL_LENGTH = 500;
/** Minimum time between opening the form and submitting it (cheap bot filter). */
export const MIN_FILL_MS = 3000;
/** Plain text: no tags, no control characters, trimmed. `multiline` keeps line breaks. */
export function plain(value: unknown, multiline = false): string {
if (typeof value !== 'string') return '';
let s = value.replace(/<[^>]*>?/g, '').replace(/[<>]/g, '');
s = s.replace(/\r\n?/g, '\n');
// eslint-disable-next-line no-control-regex
s = s.replace(multiline ? /[\u0000-\u0009\u000b-\u001f\u007f]/g : /[\u0000-\u001f\u007f]/g, ' ');
s = multiline
? s
.split('\n')
.map((l) => l.replace(/[ \t]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
: s.replace(/\s+/g, ' ');
return s.trim();
}
export function normaliseUrl(raw: string): string | null {
const s = raw.trim();
if (!s || s.length > MAX_URL_LENGTH) return null;
try {
const u = new URL(s);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname.includes('.')) return null;
return s;
} catch {
return null;
}
}
const optionalNumber = (min: number, max: number, label: string) =>
z.union([z.string(), z.number(), z.null(), z.undefined()]).transform((v, ctx) => {
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) return null;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n) || n < min || n > max) {
ctx.addIssue({ code: 'custom', message: `${label} must be between ${min} and ${max}` });
return z.NEVER;
}
return Math.round(n * 1e6) / 1e6;
});
export const fieldsSchema = z
.object({
title: z
.string({ message: 'Add a title' })
.transform((v) => plain(v))
.pipe(
z
.string()
.min(5, 'Title needs at least 5 characters')
.max(140, 'Title can be at most 140 characters')
),
description: z
.string({ message: 'Add a description' })
.transform((v) => plain(v, true))
.pipe(
z
.string()
.min(10, 'Description needs at least 10 characters')
.max(2000, 'Description can be at most 2000 characters')
),
scale: z.enum(WORLDTODO_SCALES, { message: 'Pick a scale' }),
category: z
.string({ message: 'Pick a category' })
.trim()
.regex(/^[a-z][a-z0-9-]{1,30}$/, 'Pick a category'),
location_name: z
.string({ message: 'Add a location' })
.transform((v) => plain(v))
.pipe(
z
.string()
.min(2, 'Location needs at least 2 characters')
.max(120, 'Location can be at most 120 characters')
),
country_code: z.union([z.string(), z.null(), z.undefined()]).transform((v, ctx) => {
const s = (v ?? '').trim().toUpperCase();
if (!s) return null;
if (!/^[A-Z]{2}$/.test(s)) {
ctx.addIssue({ code: 'custom', message: 'Country code is two letters, e.g. NL' });
return z.NEVER;
}
return s;
}),
lat: optionalNumber(-90, 90, 'Latitude'),
lon: optionalNumber(-180, 180, 'Longitude'),
status: z.enum(WORLDTODO_STATUSES, { message: 'Pick a status' }),
source_urls: z.array(z.string()).transform((list, ctx) => {
const filled = list.map((s) => s.trim()).filter(Boolean);
if (filled.length > MAX_URLS) {
ctx.addIssue({ code: 'custom', message: `At most ${MAX_URLS} source links` });
return z.NEVER;
}
const out: string[] = [];
for (const raw of filled) {
const url = normaliseUrl(raw);
if (!url) {
ctx.addIssue({
code: 'custom',
message: `Source links must be http(s) URLs of at most ${MAX_URL_LENGTH} characters`
});
return z.NEVER;
}
if (!out.includes(url)) out.push(url);
}
if (!out.length) {
ctx.addIssue({ code: 'custom', message: 'Add at least one source link' });
return z.NEVER;
}
return out;
})
})
.superRefine((v, ctx) => {
if ((v.lat === null) !== (v.lon === null))
ctx.addIssue({ code: 'custom', path: ['lat'], message: 'Give both latitude and longitude' });
});
const metaSchema = z.object({
kind: z.enum(WORLDTODO_REQUEST_KINDS, { message: 'Unknown request type' }),
task: z.string().trim().max(120).optional().default(''),
reason: z
.string()
.transform((v) => plain(v, true))
.pipe(
z
.string()
.min(10, 'Reason needs at least 10 characters')
.max(1000, 'Reason can be at most 1000 characters')
),
display_name: z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => plain(v ?? '').slice(0, 200))
.pipe(z.string().max(40, 'Display name can be at most 40 characters'))
.transform((v) => v || null)
});
export type ParsedRequest = {
kind: RequestKind;
taskSlug: string;
fields: TaskFields | null;
reason: string;
displayName: string | null;
};
export type FieldErrors = Record<string, string>;
/** Raw input (from FormData or JSON) → a validated request, or field errors. */
export function parseRequest(
input: Record<string, unknown>,
base?: Record<string, unknown>
): { ok: true; value: ParsedRequest } | { ok: false; errors: FieldErrors } {
const errors: FieldErrors = {};
// Optional keys are defaulted here: zod 4 treats a missing key in front of a transform as
// required.
const meta = metaSchema.safeParse({
...input,
task: input.task ?? '',
display_name: input.display_name ?? ''
});
if (!meta.success)
for (const issue of meta.error.issues)
errors[String(issue.path[0] ?? 'form')] ??= issue.message;
const kind = meta.success ? meta.data.kind : (input.kind as RequestKind);
if ((kind === 'edit' || kind === 'delete') && !(meta.success && meta.data.task))
errors.task ??= 'Which task is this about?';
let fields: TaskFields | null = null;
if (kind === 'add' || kind === 'edit') {
// Edits may send only the fields that change: the rest comes from the current task.
// Optional keys are defaulted for the same zod 4 reason as above.
const merged = { ...(kind === 'edit' ? base : undefined), ...input };
const urls = merged.source_urls;
const parsed = fieldsSchema.safeParse({
...merged,
country_code: merged.country_code ?? '',
lat: merged.lat ?? '',
lon: merged.lon ?? '',
status: merged.status ?? (kind === 'add' ? 'open' : undefined),
source_urls: Array.isArray(urls) ? urls : typeof urls === 'string' ? [urls] : []
});
if (parsed.success) fields = parsed.data;
else
for (const issue of parsed.error.issues)
errors[String(issue.path[0] ?? 'form')] ??= issue.message;
}
if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
return {
ok: true,
value: {
kind: meta.data.kind,
taskSlug: meta.data.task,
fields,
reason: meta.data.reason,
displayName: meta.data.display_name
}
};
}
/** FormData → the raw input record parseRequest takes. */
export function formInput(form: FormData): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of [
'kind',
'task',
'title',
'description',
'scale',
'category',
'location_name',
'country_code',
'lat',
'lon',
'status',
'reason',
'display_name'
]) {
const v = form.get(key);
if (typeof v === 'string') out[key] = v;
}
out.source_urls = form.getAll('source_urls').filter((v): v is string => typeof v === 'string');
return out;
}
/** Honeypot filled in, or submitted faster than a person could. */
export function looksLikeBot(honeypot: unknown, startedAt: unknown, now = Date.now()): boolean {
if (typeof honeypot === 'string' && honeypot.trim()) return true;
const started = Number(startedAt);
if (!Number.isFinite(started) || started <= 0) return true;
return now - started < MIN_FILL_MS;
}