fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode

This commit is contained in:
2026-09-24 11:51:24 +02:00
parent e5bce55bf7
commit eba419bfc2
24 changed files with 762 additions and 244 deletions
+4
View File
@@ -896,6 +896,10 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
For per-visitor submission limits, the edge proxy must overwrite `X-Forwarded-For` with
the verified client address (not append to a value supplied by the client). The web server
relays that header to Strapi, which trusts it only when `IS_PROXIED=true`.
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in
@@ -13,7 +13,7 @@
"attributes": {
"opportunity": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::opportunity.opportunity"
},
"applicantName": {
@@ -1,7 +1,68 @@
/**
* application service
*
* `create` is overridden to fire the acknowledgement e-mail *after* the row is
* written, mirroring the event-registration service. The send is deliberately
* not awaited: a slow or failing SMTP call must never turn the application
* POST into a 502. The mail helper stamps `acknowledgementSentAt` only when
* the provider accepted the mail, and swallows and logs its own errors.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');
import { sendApplicationAcknowledgement } from '../../../lib/application-mail';
const APPLICATIONS_UID = 'api::application.application';
const LOG_PREFIX = '[application]';
/**
* The `opportunity` value a client may have sent is a documentId (what the
* REST API uses for relations), or a legacy numeric id. The Get Involved form
* sends none at all - an application is not always filed against a call.
*/
const readOpportunityReference = (value: unknown): { documentId?: string; id?: number } | undefined => {
if (typeof value === 'string' && value.length > 0) {
return { documentId: value };
}
if (typeof value === 'number' && Number.isInteger(value)) {
return { id: value };
}
return undefined;
};
const readCreatedResult = (result: unknown): { documentId?: unknown; email?: unknown } =>
(typeof result === 'object' && result !== null ? result : {}) as {
documentId?: unknown;
email?: unknown;
};
export default factories.createCoreService(APPLICATIONS_UID, ({ strapi }) => ({
async create(params: { data?: { opportunity?: unknown } } & Record<string, unknown>) {
const result = await super.create(params);
const { documentId, email } = readCreatedResult(result);
if (typeof documentId === 'string' && documentId.length > 0) {
if (typeof email === 'string' && email.length > 0) {
void sendApplicationAcknowledgement({
strapi,
to: email,
applicationDocumentId: documentId,
opportunityReference: readOpportunityReference(params.data?.opportunity),
});
} else {
strapi.log.warn(
`${LOG_PREFIX} no email on the created application; acknowledgement skipped.`,
);
}
} else {
strapi.log.warn(
`${LOG_PREFIX} no document id on the created application; acknowledgement skipped.`,
);
}
return result;
},
}));
@@ -13,7 +13,7 @@
"attributes": {
"event": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::event.event"
},
"fullName": {
@@ -21,10 +21,8 @@
* `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.
* A transaction locks the event row while the confirmed count and registration
* write run, so concurrent requests cannot claim the same final place.
*/
import type { Core } from '@strapi/strapi';
@@ -50,6 +48,7 @@ type EventCapacity = {
capacity: number | null;
waitlistEnabled: boolean | null;
registrationStatus: string | null;
registrationDeadline: string | Date | null;
};
/**
@@ -89,9 +88,9 @@ const findEvent = async (
? { documentId: reference.documentId }
: { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({
where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'],
return (await strapi.documents(EVENT_UID).findFirst({
filters: where,
status: 'published',
})) as EventCapacity | null;
};
@@ -121,13 +120,22 @@ const resolveRegistrationState = async (
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';
throw new errors.ApplicationError('A valid published event is required.');
}
if (event.registrationStatus === 'closed') {
throw new errors.ApplicationError('Registration for this event is closed.');
if (
event.registrationStatus !== 'open' &&
event.registrationStatus !== 'full' &&
event.registrationStatus !== 'waitlist'
) {
throw new errors.ApplicationError('Registration for this event is not open.');
}
if (event.registrationDeadline) {
const deadline = new Date(event.registrationDeadline).getTime();
if (!Number.isFinite(deadline) || Date.now() >= deadline) {
throw new errors.ApplicationError('The registration deadline has passed.');
}
}
const capacity =
@@ -168,13 +176,28 @@ const sanitizeSubmission: Core.MiddlewareFactory =
}
}
data.registrationState = await resolveRegistrationState(strapi, data.event);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
await strapi.db.transaction(async ({ trx }) => {
const event = await findEvent(strapi, readEventReference(data.event));
if (!event) {
throw new errors.ApplicationError('A valid published event is required.');
}
request.body = { data };
await trx('events')
.select('id')
.where({ document_id: event.documentId })
.orderBy('id')
.forUpdate()
.first();
await next();
data.registrationState = await resolveRegistrationState(strapi, {
documentId: event.documentId,
});
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
request.body = { data };
await next();
});
};
export default sanitizeSubmission;
+19 -16
View File
@@ -35,11 +35,9 @@ const desiredActionsFor = (controllerName: string): string[] =>
* Grant the Public role read access to the public content (collection types and
* single types) and create access to the three submission content types.
*
* Only missing permissions are inserted, and permissions granted by hand in the
* admin panel are never removed - so this is safe to run on every boot. A read
* grant on a submission type is reported as a warning rather than deleted, so
* that an unexpected grant is visible without the boot silently undoing an
* administrator's decision.
* Missing public-content permissions are inserted. Submission types are kept
* create-only: any other public grant is revoked on boot because those entries
* contain personal data.
*/
export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => {
if (!strapi.plugins['users-permissions']) {
@@ -63,7 +61,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.getActions();
const desiredActions = new Set<string>();
const forbiddenReadActions = new Set<string>();
const forbiddenSubmissionActions = new Set<string>();
for (const [namespace, { controllers }] of Object.entries(actionMap)) {
// Only the project's own APIs; plugin permissions are managed by Strapi.
@@ -80,11 +78,8 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
if (wantedActions.includes(action)) {
desiredActions.add(permission);
} else if (
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName) &&
PUBLIC_READ_ACTIONS.includes(action)
) {
forbiddenReadActions.add(permission);
} else if (PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName)) {
forbiddenSubmissionActions.add(permission);
}
}
}
@@ -94,7 +89,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.query('plugin::users-permissions.permission')
.findMany({
where: { role: { id: publicRole.id } },
select: ['action'],
select: ['id', 'action'],
});
const existingActions = new Set(existingPermissions.map(({ action }) => action));
@@ -112,12 +107,20 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
);
}
const leakedActions = [...forbiddenReadActions].filter((action) => existingActions.has(action));
const forbiddenPermissions = existingPermissions.filter(({ action }) =>
forbiddenSubmissionActions.has(action)
);
if (forbiddenPermissions.length > 0) {
await strapi.db.query('plugin::users-permissions.permission').deleteMany({
where: { id: { $in: forbiddenPermissions.map(({ id }) => id) } },
});
if (leakedActions.length > 0) {
strapi.log.warn(
`${LOG_PREFIX} the Public role can read submission data: ${leakedActions.join(', ')}. ` +
'Revoke these in Settings > Roles > Public unless this is intentional.'
`${LOG_PREFIX} revoked forbidden Public submission permission(s): ${forbiddenPermissions
.map(({ action }) => action)
.sort()
.join(', ')}.`
);
}
};
+6 -1
View File
@@ -2,6 +2,7 @@ import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders';
import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default {
/**
@@ -33,8 +34,12 @@ export default {
task: runEventReminders,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
'run-waitlist-promotion': {
task: runWaitlistPromotion,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
});
strapi.log.info('[index] event reminder cron job registered.');
strapi.log.info('[index] event reminder + waitlist promotion cron jobs registered.');
}
},
};
+134
View File
@@ -0,0 +1,134 @@
/**
* application-mail — bilingual acknowledgement e-mail for applications.
*
* The web copy promises every application is "acknowledged automatically by
* email" (apps/web/app/routes/_events-opportunities/copy.ts:
* `apply.acknowledgement` / `apply.receivedBody`), and the content type models
* that with `application.acknowledgementSentAt`. This module is the half that
* delivers: fired fire-and-forget from the application service's `create`
* override, mirroring the event-registration confirmation, and stamping
* `acknowledgementSentAt` ONLY after the mail was accepted, so a failure is
* retried by nothing today but is visible in the admin panel as a null.
*
* The (Get Involved) section form can submit an application with no
* opportunity relation. The mail then drops the call name and stays a plain
* acknowledgement, so the promise holds for both entry points.
*
* Copy is derived from the approved web copy; the footer is the same
* EU-visibility block the event mails use. Nothing here is invented.
*/
import type { Core } from '@strapi/strapi';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
const APPLICATION_UID = 'api::application.application';
const LOG_PREFIX = '[application-mail]';
/** Identifier a public form may have sent for the opportunity relation. */
type OpportunityReference = { documentId?: string; id?: number } | undefined;
/** An opportunity looked up in both locales; missing locales are allowed. */
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
const ACKNOWLEDGEMENT_SUBJECT = {
ar: 'تم استلام الطلب',
en: 'Application received',
} as const;
/*
* The approved wording this mirrors, from
* apps/web/app/routes/_events-opportunities/copy.ts:
* applicationReceivedBody en: "Thank you for your application. Our team
* will review it and follow up by email."
* ar: "شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل
* معك عبر البريد الإلكتروني."
* receivedBody ar: "شكرًا لتقديمك على {route}..." (the "على"
* preposition is kept for the call name).
*/
const ACKNOWLEDGEMENT_BODY_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
} as const;
const ACKNOWLEDGEMENT_BODY = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
} as const;
/**
* Fetch an opportunity in both locales. Only published entries are ever
* fetched; either a documentId or a legacy numeric id is accepted, mirroring
* the event-registration middleware. Returns an empty record when there is no
* usable reference (the Get Involved form sends none).
*/
export async function fetchBilingualOpportunity(
strapi: Core.Strapi,
reference: OpportunityReference,
): Promise<BilingualOpportunity> {
const filters = reference?.documentId
? { documentId: reference.documentId }
: reference?.id !== undefined
? { id: reference.id }
: null;
if (!filters) return {};
const [en, ar] = await Promise.all([
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'en' }),
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'ar' }),
]);
return { en: en ?? undefined, ar: ar ?? undefined };
}
/**
* Build and send the acknowledgement for an application, and stamp
* `acknowledgementSentAt` when the provider accepted it. Best-effort and
* never awaited by the request path: a slow or failing SMTP call must not make
* the application POST fail.
*/
export async function sendApplicationAcknowledgement({
strapi,
to,
applicationDocumentId,
opportunityReference,
}: {
strapi: Core.Strapi;
to: string;
applicationDocumentId: string;
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
const mail = buildBilingualMail({
subjectAr: ACKNOWLEDGEMENT_SUBJECT.ar,
subjectEn: ACKNOWLEDGEMENT_SUBJECT.en,
bodyAr: (callAr ? ACKNOWLEDGEMENT_BODY_WITH_CALL.ar : ACKNOWLEDGEMENT_BODY.ar)
.replace(/\{call\}/g, callAr ?? ''),
bodyEn: (callEn ? ACKNOWLEDGEMENT_BODY_WITH_CALL.en : ACKNOWLEDGEMENT_BODY.en)
.replace(/\{call\}/g, callEn ?? ''),
});
const ok = await trySendMail(strapi, { to, ...mail });
if (!ok) {
return;
}
await strapi
.documents(APPLICATION_UID)
.update({
documentId: applicationDocumentId,
data: { acknowledgementSentAt: new Date().toISOString() },
});
} catch (error) {
strapi.log.error(`${LOG_PREFIX} acknowledgement failed: ${String(error)}`);
}
}
+19 -4
View File
@@ -136,11 +136,20 @@ const FOOTER = {
const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;";
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function wrapParagraphs(text: string): string {
return text
.trim()
.split(/\n\n+/)
.map((paragraph) => `<p>${paragraph.trim()}</p>`)
.map((paragraph) => `<p>${escapeHtml(paragraph.trim())}</p>`)
.join('');
}
@@ -301,6 +310,11 @@ function locationLineFor(event: BilingualEvent): { ar: string; en: string } | un
/**
* Build and send the confirmation (or waitlist) e-mail for a registration.
* Best-effort: logs failures, never throws into the request path.
*
* Returns true when the mail was accepted by the provider. The waitlist
* promotion cron uses this to only flip a registration to `confirmed` AFTER
* the "your place is now confirmed" mail went out (at-least-once, like the
* reminder job).
*/
export async function sendRegistrationConfirmation({
strapi,
@@ -312,7 +326,7 @@ export async function sendRegistrationConfirmation({
to: string;
state: RegistrationState;
eventReference: EventReference;
}): Promise<void> {
}): Promise<boolean> {
try {
const event = await fetchBilingualEvent(strapi, eventReference);
@@ -320,7 +334,7 @@ export async function sendRegistrationConfirmation({
strapi.log.warn(
'[event-mail] confirmation skipped: no published event for the registration.',
);
return;
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
@@ -355,9 +369,10 @@ export async function sendRegistrationConfirmation({
locationLine: locationLineFor(event),
});
await trySendMail(strapi, { to, ...mail });
return trySendMail(strapi, { to, ...mail });
} catch (error) {
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
return false;
}
}
+203
View File
@@ -0,0 +1,203 @@
/**
* waitlist-promotion — the cron task that honours the "we will email you if a
* place becomes available" promise.
*
* Registrations are stamped `confirmed` OR `waitlisted` at signup
* (event-registration/middlewares/sanitize-submission.ts). Nothing ever moved
* a `waitlisted` row to `confirmed` when a seat freed up, so the waitlist was
* a queue nobody drained. This job does it, first-come-first-served:
*
* 1. find every event that still has `waitlisted` registrations;
* 2. for each, lock the event row (the same serialisation the registration
* middleware uses, so a concurrent POST cannot race the count) and
* compute how many seats are left below capacity;
* 3. promote the OLDEST waitlisted registrations (`submittedAt` asc) into
* those seats, sending the standard confirmation mail first and flipping
* the row to `confirmed` ONLY after the mail was accepted.
*
* Semantics are at-least-once, like the reminder job: a row whose mail failed
* stays `waitlisted` and is retried on the next run. A waitlisted row with no
* email cannot be told anything, so it is left as-is for staff to deal with.
*
* Registered from src/index.ts only when `server.cron.enabled` is true.
*/
import type { Core } from '@strapi/strapi';
import { sendRegistrationConfirmation } from './event-mail';
const EVENT_UID = 'api::event.event';
const REGISTRATION_UID = 'api::event-registration.event-registration';
const LOG_PREFIX = '[waitlist-promotion]';
const PAGE_SIZE = 50;
/** The subset of the event that decides how many seats can be promoted into. */
type EventCapacity = {
documentId: string;
capacity: number | null;
};
/** How many places on this event are already taken, mirroring the middleware. */
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
strapi.db.query(REGISTRATION_UID).count({
where: {
event: { documentId },
registrationState: 'confirmed',
},
});
/**
* Promote the oldest waiting registrants of ONE event into its free seats.
*
* The lock mirrors `sanitize-submission`: registering and promoting both
* serialize on the event row, so a concurrent request cannot steal a seat the
* promotion just counted. Count and updates run inside the same transaction.
*/
async function promoteEvent(
strapi: Core.Strapi,
eventDocumentId: string,
): Promise<{ promoted: number; failed: number }> {
return strapi.db.transaction(async ({ trx }) => {
await trx('events')
.select('id')
.where({ document_id: eventDocumentId })
.orderBy('id')
.forUpdate()
.first();
const event = (await strapi.documents(EVENT_UID).findFirst({
filters: { documentId: eventDocumentId },
status: 'published',
})) as EventCapacity | null;
if (!event) {
return { promoted: 0, failed: 0 };
}
const capacity =
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
// No finite capacity means there is never a seat to free up.
if (capacity === null) {
return { promoted: 0, failed: 0 };
}
const taken = await countConfirmed(strapi, eventDocumentId);
let slots = capacity - taken;
if (slots <= 0) {
return { promoted: 0, failed: 0 };
}
// Oldest waiters first; the page size is a safety bound, the loop stops
// at `slots` anyway.
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: {
event: { documentId: eventDocumentId },
registrationState: { $eq: 'waitlisted' },
},
sort: 'submittedAt:asc',
pagination: { page: 1, pageSize: PAGE_SIZE },
});
let promoted = 0;
let failed = 0;
for (const registration of registrations) {
if (slots <= 0) break;
const email = (registration as { email?: unknown }).email;
if (typeof email !== 'string' || email.length === 0) {
strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
);
continue;
}
const ok = await sendRegistrationConfirmation({
strapi,
to: email,
state: 'confirmed',
eventReference: { documentId: eventDocumentId },
});
if (!ok) {
failed += 1;
continue;
}
await strapi
.documents(REGISTRATION_UID)
.update({
documentId: registration.documentId as string,
data: { registrationState: 'confirmed' },
});
promoted += 1;
slots -= 1;
}
return { promoted, failed };
});
}
/**
* Run one pass over every event that still has waitlisted registrations.
* One failing event never stops the batch; totals are logged.
*/
export async function runWaitlistPromotion({
strapi,
}: {
strapi: Core.Strapi;
}): Promise<void> {
const eventIds = new Set<string>();
let page = 1;
while (true) {
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: { registrationState: { $eq: 'waitlisted' } },
populate: { event: true },
pagination: { page, pageSize: PAGE_SIZE },
});
for (const registration of registrations) {
const event = (registration as { event?: { documentId?: unknown } }).event;
if (typeof event?.documentId === 'string' && event.documentId.length > 0) {
eventIds.add(event.documentId);
}
}
if (registrations.length < PAGE_SIZE) break;
page += 1;
}
if (eventIds.size === 0) {
strapi.log.info(`${LOG_PREFIX} no events with a waitlist, skipping.`);
return;
}
let promotedTotal = 0;
let failedTotal = 0;
for (const eventId of eventIds) {
try {
const { promoted, failed } = await promoteEvent(strapi, eventId);
promotedTotal += promoted;
failedTotal += failed;
} catch (error) {
failedTotal += 1;
strapi.log.error(
`${LOG_PREFIX} failed for event ${eventId}: ${String(error)}`,
);
}
}
strapi.log.info(
`${LOG_PREFIX} finished: ${promotedTotal} waitlisted registration(s) promoted, ${failedTotal} mail(s) failed (retried next run).`,
);
}
+2 -2
View File
@@ -707,7 +707,7 @@ export interface ApiApplicationApplication extends Struct.CollectionTypeSchema {
> &
Schema.Attribute.Private;
opportunity: Schema.Attribute.Relation<
'oneToOne',
'manyToOne',
'api::opportunity.opportunity'
>;
organisation: Schema.Attribute.String &
@@ -871,7 +871,7 @@ export interface ApiEventRegistrationEventRegistration
Schema.Attribute.Private;
dietaryOrAccess: Schema.Attribute.Text;
email: Schema.Attribute.Email & Schema.Attribute.Required;
event: Schema.Attribute.Relation<'oneToOne', 'api::event.event'>;
event: Schema.Attribute.Relation<'manyToOne', 'api::event.event'>;
fullName: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
+2 -2
View File
@@ -120,8 +120,8 @@ export function resetServerEnv(): void {
* the incoming request is trusted, honouring the standard forwarding headers.
*/
export function getSiteUrl(request: Request): string {
const { SITE_URL } = getServerEnv();
if (process.env.SITE_URL?.trim()) return SITE_URL;
const env = getServerEnv();
if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
const url = new URL(request.url);
const forwardedHost =
@@ -114,9 +114,8 @@ const EN = {
uploaded here (see `documentsNote`), so no limit is quoted at all.
* "an automatic acknowledgment with your reference number" - there is no
reference-number field anywhere in the CMS, so no number is promised.
The acknowledgement itself IS modelled, as
`application.acknowledgementSentAt`, so it is the only half that
survives.
The acknowledgement itself IS sent: the CMS mails it on submission
and stamps `application.acknowledgementSentAt` when it went out.
* "follows up within four weeks" - that response time appears in no
approved document; §8 of the draft marks it "[X weeks] - TBD". The
follow-up is stated without a period attached, exactly as the per-call
@@ -168,7 +167,7 @@ const EN = {
*/
documentsNote:
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
/** `application.acknowledgementSentAt` - sent, not just modelled. */
acknowledgement: "Your submission is acknowledged automatically by email.",
submit: "Submit application",
errorSummaryTitle: "Please check the form",
@@ -147,6 +147,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required",
registrationDeadline: text(entry.registrationDeadline),
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"),
@@ -114,6 +114,7 @@ export interface EventCardData {
/** Name of the related region entry, not a free-text string. */
readonly region?: string;
readonly registrationStatus: EventRegistrationStatus;
readonly registrationDeadline?: string;
readonly timeStatus: EventTimeStatus;
/** Set on a past event that has a write-up - drives the "Read recap" label. */
readonly recapUrl?: string;
@@ -126,7 +127,6 @@ export interface EventDetailData extends EventCardData {
/** The long body. There is no external registration portal field in the CMS. */
readonly description?: CmsRichText;
readonly onlineUrl?: string;
readonly registrationDeadline?: string;
readonly venue?: string;
/** Editorial: the CMS offers a waitlist once the event is full. */
readonly waitlistEnabled: boolean;
@@ -330,8 +330,16 @@ export function collectRegions(
* reader simply turns up, so there is nothing to submit; "closed" speaks for
* itself; "full" and "waitlist" still take names for the waiting list.
*/
export function isRegistrationOpen(event: EventCardData): boolean {
export function isRegistrationOpen(
event: EventCardData,
now = Date.now(),
): boolean {
const deadline = event.registrationDeadline
? Date.parse(event.registrationDeadline)
: undefined;
return (
(deadline === undefined || (Number.isFinite(deadline) && now < deadline)) &&
event.timeStatus !== "completed" &&
(event.registrationStatus === "open" ||
event.registrationStatus === "full" ||
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
export interface StrapiWriteInit {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
readonly forwardedFor?: string;
}
interface StrapiErrorEnvelope {
@@ -49,6 +50,7 @@ export async function createStrapiEntry<T>(
accept: "application/json",
};
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
if (init.forwardedFor) headers["x-forwarded-for"] = init.forwardedFor;
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
@@ -68,26 +68,43 @@ export interface RegistrationSubmission {
readonly values: RegistrationValues;
/** documentId of the event being registered for. */
readonly eventId: string;
readonly forwardedFor?: string;
}
export async function submitRegistration(
input: RegistrationSubmission,
): Promise<void> {
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
event: input.eventId,
fullName: input.values.fullName,
email: input.values.email,
phone: optional(input.values.phone),
organisation: optional(input.values.organisation),
// Required in the schema: without it every POST is a 400.
consentGdpr: input.values.consentGdpr,
});
): Promise<"confirmed" | "waitlisted"> {
const created = await createStrapiEntry<{
readonly registrationState?: unknown;
}>(
EVENT_REGISTRATIONS_RESOURCE,
{
event: input.eventId,
fullName: input.values.fullName,
email: input.values.email,
phone: optional(input.values.phone),
organisation: optional(input.values.organisation),
// Required in the schema: without it every POST is a 400.
consentGdpr: input.values.consentGdpr,
},
{ forwardedFor: input.forwardedFor },
);
if (
created?.registrationState !== "confirmed" &&
created?.registrationState !== "waitlisted"
) {
throw new Error("Strapi returned an invalid registration state.");
}
return created.registrationState;
}
export interface ApplicationSubmission {
readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */
readonly opportunityId: string;
readonly forwardedFor?: string;
}
/** The key the free-text answer is filed under inside `answers` (json). */
@@ -96,17 +113,21 @@ export const APPLICATION_MESSAGE_KEY = "message";
export async function submitApplication(
input: ApplicationSubmission,
): Promise<void> {
await createStrapiEntry(APPLICATIONS_RESOURCE, {
opportunity: input.opportunityId,
// `fullName` is the registration's field name; an application's is
// `applicantName`.
applicantName: input.values.fullName,
email: input.values.email,
organisation: optional(input.values.organisation),
// `answers` is a json field - the form's one free-text question goes in
// it, keyed, so more questions can be added without another migration.
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
});
await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
opportunity: input.opportunityId,
// `fullName` is the registration's field name; an application's is
// `applicantName`.
applicantName: input.values.fullName,
email: input.values.email,
organisation: optional(input.values.organisation),
// `answers` is a json field - the form's one free-text question goes in
// it, keyed, so more questions can be added without another migration.
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
},
{ forwardedFor: input.forwardedFor },
);
}
/* ---------------------------------------------------------------------------
@@ -134,6 +155,7 @@ export const APPLICATION_ANSWER_KEYS = {
export interface PathwayApplicationSubmission {
readonly values: PathwayApplicationValues;
readonly forwardedFor?: string;
/**
* documentId of the opportunity this application belongs to, when one is
* known. The Get Involved form is NOT tied to a single call - it is the
@@ -159,22 +181,26 @@ export async function submitPathwayApplication(
): Promise<void> {
const { values } = input;
await createStrapiEntry(APPLICATIONS_RESOURCE, {
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName,
email: values.email,
organisation: optional(values.organisation),
answers: {
[APPLICATION_ANSWER_KEYS.route]: values.route,
// Only the answers actually given: an empty string in the admin panel
// reads as "answered, with nothing", which is not what happened.
...(values.region
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
: {}),
...(values.yearsOperating
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
: {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName,
email: values.email,
organisation: optional(values.organisation),
answers: {
[APPLICATION_ANSWER_KEYS.route]: values.route,
// Only the answers actually given: an empty string in the admin panel
// reads as "answered, with nothing", which is not what happened.
...(values.region
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
: {}),
...(values.yearsOperating
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
: {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
},
},
});
{ forwardedFor: input.forwardedFor },
);
}
@@ -127,7 +127,11 @@ export async function action({ params, request }: Route.ActionArgs) {
}
try {
await submitApplication({ values, opportunityId: opportunity.documentId });
await submitApplication({
values,
opportunityId: opportunity.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
@@ -46,8 +46,7 @@
uploaded here at all (see `documentsNote`);
* "an automatic acknowledgment with your reference number" - no
reference-number field exists anywhere in the CMS. The acknowledgement
is modelled (`application.acknowledgementSentAt`) and survives; the
number does not;
IS sent (the CMS mails it on submission) and the number does not;
* "follows up within four weeks" - a response time that appears in no
approved document. §8 of the draft marks it "[X weeks] - TBD".
@@ -226,7 +225,10 @@ export async function action({ params, request }: Route.ActionArgs) {
}
try {
await submitPathwayApplication({ values });
await submitPathwayApplication({
values,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
+15 -4
View File
@@ -36,7 +36,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
import { data, Form, Link, useLocation, useNavigation } from "react-router";
import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
@@ -60,6 +60,7 @@ import {
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
/* ---------------------------------------------------------------------------
LOADER
@@ -230,7 +231,10 @@ function validate(values: ContactValues, locale: Locale): ContactErrors {
* though the browser decides triage state, and the value would be silently
* discarded anyway - so the body below is the whitelist and only it.
*/
async function createContactSubmission(values: ContactValues): Promise<void> {
async function createContactSubmission(
values: ContactValues,
forwardedFor?: string,
): Promise<void> {
const env = getServerEnv();
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
@@ -240,6 +244,7 @@ async function createContactSubmission(values: ContactValues): Promise<void> {
"content-type": "application/json",
accept: "application/json",
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
},
body: JSON.stringify({
data: {
@@ -282,7 +287,10 @@ export async function action({ params, request }: Route.ActionArgs) {
}
try {
await createContactSubmission(values);
await createContactSubmission(
values,
request.headers.get("x-forwarded-for") ?? undefined,
);
} catch (error) {
console.error("[contact] submission failed", error);
const t = createTranslator(locale);
@@ -370,6 +378,9 @@ export default function Contact({
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
const errors = actionData?.errors ?? {};
const values = actionData?.values ?? EMPTY_VALUES;
const formError =
@@ -681,7 +692,7 @@ export default function Contact({
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
surface={theme}
/>
</div>
</div>
+11 -5
View File
@@ -128,8 +128,6 @@ export async function action({ params, request }: Route.ActionArgs) {
const { event } = requireEntity(await fetchEvent(slug, locale));
const form = await request.formData();
const waitlist = isWaitlisted(event);
/* A form nobody can submit through the page must not be submittable through
a POST either - registration state is checked again on the server. */
if (!isRegistrationOpen(event)) {
@@ -138,7 +136,7 @@ export async function action({ params, request }: Route.ActionArgs) {
/* A bot that fills every field gets the acknowledgement and writes nothing. */
if (isHoneypotFilled(form)) {
return { ok: true, waitlist } satisfies ActionResult;
return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
}
const { values, errors, valid } = validateRegistration(form);
@@ -150,6 +148,7 @@ export async function action({ params, request }: Route.ActionArgs) {
);
}
let registrationState: "confirmed" | "waitlisted";
try {
/* Only the fields the CMS whitelist accepts are sent - see
./_events-opportunities/submissions.server.ts. `registrationState` is
@@ -159,7 +158,11 @@ export async function action({ params, request }: Route.ActionArgs) {
`waitlist` flag below is therefore presentational - it decides which
sentence the reader sees, and it is read from the same event fields the
middleware weighs, so the two agree. */
await submitRegistration({ values, eventId: event.documentId });
registrationState = await submitRegistration({
values,
eventId: event.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
/* A 4xx here is the CMS refusing the registration on its own terms - the
@@ -175,7 +178,10 @@ export async function action({ params, request }: Route.ActionArgs) {
);
}
return { ok: true, waitlist } satisfies ActionResult;
return {
ok: true,
waitlist: registrationState === "waitlisted",
} satisfies ActionResult;
}
/* ---------------------------------------------------------------------------
+4 -3
View File
@@ -38,7 +38,7 @@ services:
ports:
# Published on 5433 by default so it cannot collide with a PostgreSQL
# already running on the host's 5432.
- "${POSTGRES_PUBLISHED_PORT:-5433}:5432"
- "127.0.0.1:${POSTGRES_PUBLISHED_PORT:-5433}:5432"
healthcheck:
# pg_isready also fails while the entrypoint's temporary init server is
# up, so `service_healthy` really does mean "ready for connections".
@@ -69,6 +69,7 @@ services:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 1337
IS_PROXIED: ${IS_PROXIED:-false}
# Absolute URL the admin panel and generated links use. Change it
# together with CMS_PORT, and set it to the public origin in production.
PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337}
@@ -99,7 +100,7 @@ services:
# only removed by `down -v`.
- cms-uploads:/repo/apps/cms/public/uploads
ports:
- "${CMS_PORT:-1337}:1337"
- "127.0.0.1:${CMS_PORT:-1337}:1337"
# --------------------------------------------------------------------------
# React Router 8 server-side renderer. Waits for a CMS that answers /_health,
@@ -133,7 +134,7 @@ services:
# local container use them without ever baking them into an artefact.
- ./apps/web/public/fonts:/repo/apps/web/build/client/fonts:ro
ports:
- "${WEB_PORT:-3000}:3000"
- "127.0.0.1:${WEB_PORT:-3000}:3000"
volumes:
postgres-data:
+5
View File
@@ -17,6 +17,11 @@
"typecheck": "pnpm -r --stream typecheck"
},
"pnpm": {
"overrides": {
"nodemailer": "9.1.1",
"qs": "6.16.0",
"sharp": "0.35.4"
},
"onlyBuiltDependencies": [
"@swc/core",
"core-js-pure",
+144 -139
View File
@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
nodemailer: 9.1.1
qs: 6.16.0
sharp: 0.35.4
importers:
.: {}
@@ -792,144 +797,144 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.3':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
'@img/sharp-darwin-arm64@0.35.4':
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.3':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
'@img/sharp-darwin-x64@0.35.4':
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.3':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
'@img/sharp-freebsd-wasm32@0.35.4':
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.2':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
'@img/sharp-libvips-darwin-arm64@1.3.3':
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.2':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
'@img/sharp-libvips-darwin-x64@1.3.3':
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.2':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
'@img/sharp-libvips-linux-arm64@1.3.3':
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
'@img/sharp-libvips-linux-arm@1.3.3':
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
'@img/sharp-libvips-linux-ppc64@1.3.3':
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
'@img/sharp-libvips-linux-riscv64@1.3.3':
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
'@img/sharp-libvips-linux-s390x@1.3.3':
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
'@img/sharp-libvips-linux-x64@1.3.3':
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
'@img/sharp-linux-arm64@0.35.4':
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
'@img/sharp-linux-arm@0.35.4':
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
'@img/sharp-linux-ppc64@0.35.4':
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
'@img/sharp-linux-riscv64@0.35.4':
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
'@img/sharp-linux-s390x@0.35.4':
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
'@img/sharp-linux-x64@0.35.4':
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
'@img/sharp-linuxmusl-arm64@0.35.4':
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
'@img/sharp-linuxmusl-x64@0.35.4':
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
'@img/sharp-wasm32@0.35.4':
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.3':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
'@img/sharp-webcontainers-wasm32@0.35.4':
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.3':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
'@img/sharp-win32-arm64@0.35.4':
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.3':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
'@img/sharp-win32-ia32@0.35.4':
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.3':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
'@img/sharp-win32-x64@0.35.4':
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -5106,8 +5111,8 @@ packages:
resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==}
engines: {node: '>=6'}
nodemailer@9.0.1:
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
nodemailer@9.1.1:
resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==}
engines: {node: '>=6.0.0'}
normalize-package-data@2.5.0:
@@ -5520,8 +5525,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
range-parser@1.3.0:
@@ -5973,8 +5978,8 @@ packages:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
engines: {node: '>=8'}
sharp@0.35.3:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
sharp@0.35.4:
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
@@ -7476,108 +7481,108 @@ snapshots:
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.3':
'@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true
'@img/sharp-darwin-x64@0.35.3':
'@img/sharp-darwin-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.3
optional: true
'@img/sharp-freebsd-wasm32@0.35.3':
'@img/sharp-freebsd-wasm32@0.35.4':
dependencies:
'@img/sharp-wasm32': 0.35.3
'@img/sharp-wasm32': 0.35.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.2':
'@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.2':
'@img/sharp-libvips-darwin-x64@1.3.3':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.2':
'@img/sharp-libvips-linux-arm64@1.3.3':
optional: true
'@img/sharp-libvips-linux-arm@1.3.2':
'@img/sharp-libvips-linux-arm@1.3.3':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.2':
'@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.2':
'@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.2':
'@img/sharp-libvips-linux-s390x@1.3.3':
optional: true
'@img/sharp-libvips-linux-x64@1.3.2':
'@img/sharp-libvips-linux-x64@1.3.3':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true
'@img/sharp-linux-arm64@0.35.3':
'@img/sharp-linux-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.3
optional: true
'@img/sharp-linux-arm@0.35.3':
'@img/sharp-linux-arm@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.3
optional: true
'@img/sharp-linux-ppc64@0.35.3':
'@img/sharp-linux-ppc64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true
'@img/sharp-linux-riscv64@0.35.3':
'@img/sharp-linux-riscv64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true
'@img/sharp-linux-s390x@0.35.3':
'@img/sharp-linux-s390x@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.3
optional: true
'@img/sharp-linux-x64@0.35.3':
'@img/sharp-linux-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.3
optional: true
'@img/sharp-linuxmusl-arm64@0.35.3':
'@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true
'@img/sharp-linuxmusl-x64@0.35.3':
'@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true
'@img/sharp-wasm32@0.35.3':
'@img/sharp-wasm32@0.35.4':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.3':
'@img/sharp-webcontainers-wasm32@0.35.4':
dependencies:
'@img/sharp-wasm32': 0.35.3
'@img/sharp-wasm32': 0.35.4
optional: true
'@img/sharp-win32-arm64@0.35.3':
'@img/sharp-win32-arm64@0.35.4':
optional: true
'@img/sharp-win32-ia32@0.35.3':
'@img/sharp-win32-ia32@0.35.4':
optional: true
'@img/sharp-win32-x64@0.35.3':
'@img/sharp-win32-x64@0.35.4':
optional: true
'@inquirer/external-editor@1.0.3(@types/node@20.19.43)':
@@ -8707,7 +8712,7 @@ snapshots:
p-map: 4.0.0
passport-local: 1.0.0
pluralize: 8.0.0
qs: 6.15.3
qs: 6.16.0
react: 18.3.1
react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1)
react-dnd-html5-backend: 16.0.1
@@ -8819,7 +8824,7 @@ snapshots:
markdown-it-sub: 1.0.0
markdown-it-sup: 1.0.0
prismjs: 1.30.0
qs: 6.15.3
qs: 6.16.0
react: 18.3.1
react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1)
react-dnd-html5-backend: 16.0.1
@@ -8875,7 +8880,7 @@ snapshots:
date-fns-tz: 2.0.1(date-fns@2.30.0)
formik: 2.4.9(@types/react@18.3.31)(react@18.3.1)
lodash: 4.18.1
qs: 6.15.3
qs: 6.16.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3)
@@ -8928,7 +8933,7 @@ snapshots:
lodash: 4.18.1
micromatch: 4.0.8
pluralize: 8.0.0
qs: 6.15.3
qs: 6.16.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-dropzone: 14.3.8(react@18.3.1)
@@ -9011,7 +9016,7 @@ snapshots:
ora: 5.4.1
package-json: 10.0.1
pkg-up: 3.1.0
qs: 6.15.3
qs: 6.16.0
resolve.exports: 2.0.2
semver: 7.7.4
typescript: 5.9.3
@@ -9217,7 +9222,7 @@ snapshots:
'@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@strapi/utils': 5.53.0(ts-toolbelt@9.6.0)
lodash: 4.18.1
qs: 6.15.3
qs: 6.16.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3)
@@ -9265,7 +9270,7 @@ snapshots:
'@casl/ability': 6.7.5
'@strapi/utils': 5.53.0(ts-toolbelt@9.6.0)
lodash: 4.18.1
qs: 6.15.3
qs: 6.16.0
sift: 16.0.1
transitivePeerDependencies:
- ts-toolbelt
@@ -9339,11 +9344,11 @@ snapshots:
'@strapi/provider-email-nodemailer@5.54.0':
dependencies:
nodemailer: 9.0.1
nodemailer: 9.1.1
'@strapi/provider-email-sendmail@5.53.0':
dependencies:
nodemailer: 9.0.1
nodemailer: 9.1.1
'@strapi/provider-upload-local@5.53.0(ts-toolbelt@9.6.0)':
dependencies:
@@ -9616,7 +9621,7 @@ snapshots:
lodash: 4.18.1
mime-types: 2.1.35
prop-types: 15.8.1
qs: 6.15.3
qs: 6.16.0
react: 18.3.1
react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
@@ -9625,7 +9630,7 @@ snapshots:
react-redux: 8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1)
react-router-dom: 6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-select: 5.8.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
sharp: 0.35.3(@types/node@20.19.43)
sharp: 0.35.4(@types/node@20.19.43)
styled-components: 6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
yup: 0.32.9
zod: 4.4.3
@@ -10370,7 +10375,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.3
on-finished: 2.4.1
qs: 6.15.3
qs: 6.16.0
raw-body: 3.0.2
type-is: 2.1.0
transitivePeerDependencies:
@@ -10554,7 +10559,7 @@ snapshots:
dependencies:
'@hapi/bourne': 3.0.0
inflation: 2.1.0
qs: 6.15.3
qs: 6.16.0
raw-body: 2.5.3
type-is: 1.6.18
@@ -11097,7 +11102,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.3
qs: 6.16.0
range-parser: 1.3.0
router: 2.2.0
send: 1.2.1
@@ -11252,7 +11257,7 @@ snapshots:
'@paralleldrive/cuid2': 2.2.2
dezalgo: 1.0.4
once: 1.4.0
qs: 6.15.3
qs: 6.16.0
formik@2.4.9(@types/react@18.3.31)(react@18.3.1):
dependencies:
@@ -12667,7 +12672,7 @@ snapshots:
long-timeout: 0.1.1
sorted-array-functions: 1.3.0
nodemailer@9.0.1: {}
nodemailer@9.1.1: {}
normalize-package-data@2.5.0:
dependencies:
@@ -13069,7 +13074,7 @@ snapshots:
punycode@2.3.1: {}
qs@6.15.3:
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
@@ -13616,37 +13621,37 @@ snapshots:
dependencies:
kind-of: 6.0.3
sharp@0.35.3(@types/node@20.19.43):
sharp@0.35.4(@types/node@20.19.43):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
'@img/sharp-darwin-arm64': 0.35.4
'@img/sharp-darwin-x64': 0.35.4
'@img/sharp-freebsd-wasm32': 0.35.4
'@img/sharp-libvips-darwin-arm64': 1.3.3
'@img/sharp-libvips-darwin-x64': 1.3.3
'@img/sharp-libvips-linux-arm': 1.3.3
'@img/sharp-libvips-linux-arm64': 1.3.3
'@img/sharp-libvips-linux-ppc64': 1.3.3
'@img/sharp-libvips-linux-riscv64': 1.3.3
'@img/sharp-libvips-linux-s390x': 1.3.3
'@img/sharp-libvips-linux-x64': 1.3.3
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
'@img/sharp-linux-arm': 0.35.4
'@img/sharp-linux-arm64': 0.35.4
'@img/sharp-linux-ppc64': 0.35.4
'@img/sharp-linux-riscv64': 0.35.4
'@img/sharp-linux-s390x': 0.35.4
'@img/sharp-linux-x64': 0.35.4
'@img/sharp-linuxmusl-arm64': 0.35.4
'@img/sharp-linuxmusl-x64': 0.35.4
'@img/sharp-webcontainers-wasm32': 0.35.4
'@img/sharp-win32-arm64': 0.35.4
'@img/sharp-win32-ia32': 0.35.4
'@img/sharp-win32-x64': 0.35.4
'@types/node': 20.19.43
shebang-command@2.0.0: