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,24 @@
/**
* application lifecycles
*
* `submittedAt` is never required in the schema, because the public API sets it
* in the sanitize-submission middleware rather than trusting the caller. This
* backfills it for entries created any other way (the admin panel, a seed
* script) so the column is never null.
*/
interface CreateEvent {
params: {
data: Record<string, unknown>;
};
}
export default {
beforeCreate(event: CreateEvent) {
const { data } = event.params;
if (!data.submittedAt) {
data.submittedAt = new Date().toISOString();
}
},
};
@@ -0,0 +1,70 @@
{
"kind": "collectionType",
"collectionName": "applications",
"info": {
"singularName": "application",
"pluralName": "applications",
"displayName": "Application",
"description": "A submitted application against an opportunity. Not translated; managed by staff."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"opportunity": {
"type": "relation",
"relation": "oneToOne",
"target": "api::opportunity.opportunity"
},
"applicantName": {
"type": "string",
"required": true,
"maxLength": 200
},
"email": {
"type": "email",
"required": true
},
"phone": {
"type": "string"
},
"organisation": {
"type": "string",
"maxLength": 200
},
"organisationType": {
"type": "string",
"maxLength": 200
},
"answers": {
"type": "json"
},
"attachments": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images",
"files"
]
},
"applicationStatus": {
"type": "enumeration",
"enum": [
"submitted",
"under-review",
"shortlisted",
"accepted",
"rejected"
],
"required": true,
"default": "submitted"
},
"submittedAt": {
"type": "datetime"
},
"acknowledgementSentAt": {
"type": "datetime"
}
}
}
@@ -0,0 +1,7 @@
/**
* application controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::application.application');
@@ -0,0 +1,56 @@
/**
* application sanitize-submission middleware
*
* The public role may create applications, so the request body is
* attacker-controlled. Only the fields an applicant is allowed to supply are
* copied out of it; the review state and the timestamps are set here, on the
* server, and anything else the caller sent is dropped.
*
* `attachments` is deliberately NOT on the whitelist. It is a media relation,
* so its value is a list of ids of files that are ALREADY in the Media
* Library - accepting it from an unauthenticated caller lets anyone bind any
* uploaded file, including staff-only documents, onto an application row that
* reviewers then open. The Public role cannot upload either, so there is no
* legitimate id a public applicant could send. Attachments are added by staff
* in the admin panel; a public upload flow would need its own authenticated
* endpoint that returns ids scoped to the submission being created.
*/
import type { Core } from '@strapi/strapi';
const CLIENT_FIELDS = [
'opportunity',
'applicantName',
'email',
'phone',
'organisation',
'organisationType',
'answers',
] as const;
const sanitizeSubmission: Core.MiddlewareFactory = () => 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.applicationStatus = 'submitted';
data.submittedAt = new Date().toISOString();
data.acknowledgementSentAt = null;
request.body = { data };
await next();
};
export default sanitizeSubmission;
@@ -0,0 +1,31 @@
/**
* application router
*
* `create` is the one action the Public role holds here, so the POST route
* carries two middlewares and the ORDER matters:
*
* 1. `global::rate-limit` caps how often one IP may post, before any
* work is done on the body.
* 2. `api::application.sanitize-submission`
* drops every field that is not on the client
* whitelist and sets the server-owned state.
*
* See `src/middlewares/rate-limit.ts` for the limiter's scope and its
* single-instance caveat.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::application.application', {
config: {
create: {
middlewares: [
{
name: 'global::rate-limit',
config: { name: 'application-create', interval: 60_000, max: 5 },
},
'api::application.sanitize-submission',
],
},
},
});
@@ -0,0 +1,7 @@
/**
* application service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');