initial commit
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user