initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,180 @@
/**
* event-registration sanitize-submission middleware
*
* The public role may create event registrations, so the request body is
* attacker-controlled. Only the fields a registrant is allowed to supply are
* copied out of it; the workflow state and the timestamps are set here, on the
* server, and anything else the caller sent is dropped.
*
* REGISTRATION STATE IS DERIVED, NEVER SENT
* `registrationState` used to be hard-set to `confirmed`, which meant a FULL
* event silently confirmed everyone and staff had to unpick it by hand. It is
* now worked out here, from the event itself:
*
* capacity reached (or the editors marked the event `full`)
* + a waitlist -> `waitlisted`
* + no waitlist -> the request is REFUSED with a 400
* otherwise -> `confirmed`
*
* This is the only place that decision can safely be made. Putting it in the
* browser would let anyone confirm a place on a sold-out event with one
* `curl`, and the count it depends on - how many places are already taken - is
* not something a client can be trusted to report.
*
* The count is read and the row is written without a transaction, so two
* registrations arriving in the same instant can both see the last free place.
* Overbooking by the number of concurrent requests is the accepted trade-off
* for a public form; staff see the overflow in the admin panel.
*/
import type { Core } from '@strapi/strapi';
import { errors } from '@strapi/utils';
const CLIENT_FIELDS = [
'event',
'fullName',
'email',
'phone',
'organisation',
'role',
'dietaryOrAccess',
'consentGdpr',
] as const;
const EVENT_UID = 'api::event.event';
const REGISTRATION_UID = 'api::event-registration.event-registration';
/** The subset of the event that decides whether there is a place left. */
type EventCapacity = {
documentId: string;
capacity: number | null;
waitlistEnabled: boolean | null;
registrationStatus: string | null;
};
/**
* The `event` value a client may send is a documentId (what the REST API uses
* for relations), and older clients may send a numeric id. Both are accepted;
* anything else is treated as "no event given".
*/
const readEventReference = (value: unknown): { documentId?: string; id?: number } => {
if (typeof value === 'string' && value.length > 0) {
return { documentId: value };
}
if (typeof value === 'number' && Number.isInteger(value)) {
return { id: value };
}
// `{ connect: [...] }` / `{ set: [...] }` and arrays are relation-write
// shapes a public form has no business sending, so they resolve to nothing
// and the registration is treated as having no known event.
return {};
};
/**
* `capacity`, `waitlistEnabled` and `registrationStatus` are not localised, so
* any row sharing the documentId carries the same values and the first match
* is enough.
*/
const findEvent = async (
strapi: Core.Strapi,
reference: { documentId?: string; id?: number }
): Promise<EventCapacity | null> => {
if (!reference.documentId && reference.id === undefined) {
return null;
}
const where = reference.documentId
? { documentId: reference.documentId }
: { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({
where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'],
})) as EventCapacity | null;
};
/** How many places on this event are already taken. */
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
strapi.db.query(REGISTRATION_UID).count({
where: {
event: { documentId },
registrationState: 'confirmed',
},
});
type RegistrationState = 'confirmed' | 'waitlisted';
/**
* Work out the state this registration is entitled to.
*
* Throws `ApplicationError` (HTTP 400) when the event has no place and no
* waitlist to offer - the honest answer to "may I register?" is no, and
* storing a row that says `confirmed` would be a lie to both the registrant
* and the staff reading the list.
*/
const resolveRegistrationState = async (
strapi: Core.Strapi,
eventValue: unknown
): Promise<RegistrationState> => {
const event = await findEvent(strapi, readEventReference(eventValue));
if (!event) {
// Either no event was given or the id does not resolve. There is no
// capacity to weigh, and nothing to confirm a place on.
return 'waitlisted';
}
if (event.registrationStatus === 'closed') {
throw new errors.ApplicationError('Registration for this event is closed.');
}
const capacity =
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId);
const isFull =
event.registrationStatus === 'full' || (capacity !== null && taken >= capacity);
if (!isFull) {
return 'confirmed';
}
const waitlistOpen = event.waitlistEnabled === true || event.registrationStatus === 'waitlist';
if (!waitlistOpen) {
throw new errors.ApplicationError('This event is full and has no waitlist.');
}
return 'waitlisted';
};
const sanitizeSubmission: Core.MiddlewareFactory =
(_config, { strapi }) =>
async (ctx, next) => {
const request = ctx.request as typeof ctx.request & { body?: unknown };
const body = (request.body ?? {}) as { data?: unknown };
const input = (typeof body.data === 'object' && body.data !== null ? body.data : {}) as Record<
string,
unknown
>;
const data: Record<string, unknown> = {};
for (const field of CLIENT_FIELDS) {
if (field in input) {
data[field] = input[field];
}
}
data.registrationState = await resolveRegistrationState(strapi, data.event);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
request.body = { data };
await next();
};
export default sanitizeSubmission;