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