Initial commit

This commit is contained in:
pigui
2026-08-30 21:11:59 +02:00
commit 3f930bb46a
168 changed files with 35708 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
/**
* Answers 404 for addresses this app does not serve.
*
* The status cannot be set from the catch-all page's `load`: Analog resolves
* loaders through an internal request, so anything set on that response never
* reaches the browser. Here it can be, and the route table is small enough to
* state outright — the landing is one page.
*/
const KNOWN_PATHS = new Set(['/', '/index.html']);
const PASSTHROUGH_PREFIXES = ['/api/', '/_analog/', '/assets/', '/@', '/src/', '/node_modules/'];
export default defineEventHandler((event) => {
const { pathname } = getRequestURL(event);
if (KNOWN_PATHS.has(pathname)) {
return;
}
// Static files answer for themselves — a missing one is Nitro's 404, not a
// page, and stamping this status on a hit would break the asset.
if (
PASSTHROUGH_PREFIXES.some((prefix) => pathname.startsWith(prefix)) ||
/\.[a-z0-9]+$/i.test(pathname)
) {
return;
}
setResponseStatus(event, 404);
});

View File

@@ -0,0 +1,99 @@
/**
* The browser's only write path.
*
* The public API throttles submissions per e-mail address rather than per IP
* precisely because an SSR frontend sends everything from one address, so the
* form posts here and this handler forwards it. Keeping the API host out of
* the browser also keeps CORS out of the picture.
*/
import { defineEventHandler, readBody, setResponseStatus, getQuery } from 'h3';
import {
postSubmission,
resolveLocale,
type SubmissionPayload,
} from '../../../app/core/api';
interface ContactBody {
name?: string;
email?: string;
message?: string;
organisation?: string | null;
hours_per_week?: number | string | null;
kind?: 'contribute' | 'request';
consent?: boolean;
links?: Record<string, string>;
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
website?: string | null;
}
export default defineEventHandler(async (event) => {
const body = (await readBody<ContactBody>(event)) ?? {};
const locale = resolveLocale(getQuery(event)['lang']);
const kind = body.kind === 'request' ? 'request' : 'contribute';
const name = body.name?.trim() ?? '';
const email = body.email?.trim() ?? '';
const message = body.message?.trim() ?? '';
const organisation = body.organisation?.trim() ?? '';
const errors: Record<string, string[]> = {};
if (name.length < 2) {
errors['name'] = ['required'];
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors['email'] = ['required'];
}
// The API rejects anything shorter, and rightly: a one-word submission is
// never anything but noise.
if (message.length < 20) {
errors['message'] = ['min'];
}
if (kind === 'request' && organisation.length === 0) {
errors['organisation'] = ['required'];
}
if (body.consent !== true) {
errors['consent'] = ['required'];
}
// Caught here rather than spent on a round trip: the API would answer 422
// with the same verdict, and its throttle counts the attempt either way.
if (Object.keys(errors).length > 0) {
setResponseStatus(event, 422);
return { errors, message: 'Validation failed' };
}
const hours = Number(body.hours_per_week);
const payload: SubmissionPayload = {
name,
email,
message,
consent: true,
kind,
organisation: organisation || null,
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
...(body.links && Object.keys(body.links).length > 0
? { links: body.links }
: {}),
...(body.website ? { website: body.website } : {}),
};
try {
const outcome = await postSubmission('/contact', payload, locale);
setResponseStatus(event, outcome.status);
return outcome.body;
} catch {
setResponseStatus(event, 502);
return { message: 'upstream_unreachable' };
}
});

View File

@@ -0,0 +1,14 @@
import { defineEventHandler } from 'h3';
/**
* Liveness for the container, and nothing more.
*
* It deliberately does not call the Pireon API. A health check that fails when
* the upstream is down would have the orchestrator restart a container that is
* working perfectly — the landing answers maintenance in that case, which is
* the correct behaviour, not a fault.
*/
export default defineEventHandler(() => ({
status: 'ok',
uptime: Math.round(process.uptime()),
}));