Initial commit
This commit is contained in:
32
landing/src/server/middleware/not-found-status.ts
Normal file
32
landing/src/server/middleware/not-found-status.ts
Normal 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);
|
||||
});
|
||||
99
landing/src/server/routes/api/contact.post.ts
Normal file
99
landing/src/server/routes/api/contact.post.ts
Normal 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' };
|
||||
}
|
||||
});
|
||||
14
landing/src/server/routes/api/health.get.ts
Normal file
14
landing/src/server/routes/api/health.get.ts
Normal 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()),
|
||||
}));
|
||||
Reference in New Issue
Block a user