worldwide todo list: public todo list of 68 real, sourced problems (planet to street) at /projects/worldwide-todo. worldtodo schema (categories, tasks, change_requests), idempotent seed on deploy, public add/edit/delete requests with Turnstile + salted daily-rotating IP-hash rate limit, owner-only review queue (approve, edit & approve, reject) with live updates over /ws/worldtodo(-owner), JSON API, detail pages with OSM map and history; listed in projects, header, /start and sitemap
Some checks failed
Deploy production / deploy (push) Failing after 9s
Some checks failed
Deploy production / deploy (push) Failing after 9s
This commit is contained in:
247
src/lib/server/worldtodo/validate.ts
Normal file
247
src/lib/server/worldtodo/validate.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { 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;
|
||||
}
|
||||
Reference in New Issue
Block a user