initial commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import type { StrapiApp } from '@strapi/strapi/admin';
|
||||
|
||||
export default {
|
||||
config: {
|
||||
locales: [
|
||||
// 'ar',
|
||||
// 'fr',
|
||||
// 'cs',
|
||||
// 'de',
|
||||
// 'da',
|
||||
// 'es',
|
||||
// 'he',
|
||||
// 'id',
|
||||
// 'it',
|
||||
// 'ja',
|
||||
// 'ko',
|
||||
// 'ms',
|
||||
// 'nl',
|
||||
// 'no',
|
||||
// 'pl',
|
||||
// 'pt-BR',
|
||||
// 'pt',
|
||||
// 'ru',
|
||||
// 'sk',
|
||||
// 'sv',
|
||||
// 'th',
|
||||
// 'tr',
|
||||
// 'uk',
|
||||
// 'vi',
|
||||
// 'zh-Hans',
|
||||
// 'zh',
|
||||
],
|
||||
},
|
||||
bootstrap(app: StrapiApp) {
|
||||
console.log(app);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["../plugins/**/admin/src/**/*", "./"],
|
||||
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { mergeConfig, type UserConfig } from 'vite';
|
||||
|
||||
export default (config: UserConfig) => {
|
||||
// Important: always return the modified config
|
||||
return mergeConfig(config, {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"kind": "singleType",
|
||||
"collectionName": "about_pages",
|
||||
"info": {
|
||||
"singularName": "about-page",
|
||||
"pluralName": "about-pages",
|
||||
"displayName": "About Page",
|
||||
"description": "About and Strategy: who SHARP-ER is, the consortium and the SHARP legacy."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"hero": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.hero",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocks": {
|
||||
"type": "dynamiczone",
|
||||
"components": [
|
||||
"blocks.rich-text",
|
||||
"blocks.image-gallery",
|
||||
"blocks.stats-grid",
|
||||
"blocks.cta-banner",
|
||||
"blocks.card-grid",
|
||||
"blocks.quote",
|
||||
"blocks.accordion",
|
||||
"blocks.video-embed",
|
||||
"blocks.partner-logos"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"consortium": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "page.consortium-member",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"pillars": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.titled-text",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacyHeadline": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacyBody": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacyLink": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.link",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* about-page controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::about-page.about-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* about-page router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::about-page.about-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* about-page service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::about-page.about-page');
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "activities",
|
||||
"info": {
|
||||
"singularName": "activity",
|
||||
"pluralName": "activities",
|
||||
"displayName": "Activity",
|
||||
"description": "Activities and initiatives delivered under the SHARP-ER workstreams."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "text",
|
||||
"maxLength": 500,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocks": {
|
||||
"type": "dynamiczone",
|
||||
"components": [
|
||||
"blocks.rich-text",
|
||||
"blocks.image-gallery",
|
||||
"blocks.stats-grid",
|
||||
"blocks.cta-banner",
|
||||
"blocks.card-grid",
|
||||
"blocks.quote",
|
||||
"blocks.accordion",
|
||||
"blocks.video-embed",
|
||||
"blocks.partner-logos"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"activityStream": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"sharp-network",
|
||||
"cso-subprojects-p2r",
|
||||
"suq-dialogues",
|
||||
"human-security",
|
||||
"research"
|
||||
],
|
||||
"required": true,
|
||||
"default": "sharp-network",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"implementingPartners": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"regions": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::region.region"
|
||||
},
|
||||
"topics": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::topic.topic"
|
||||
},
|
||||
"activityStatus": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"planned",
|
||||
"ongoing",
|
||||
"completed"
|
||||
],
|
||||
"required": true,
|
||||
"default": "ongoing",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"coverImage": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* activity controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::activity.activity');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* activity router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::activity.activity');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* activity service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::activity.activity');
|
||||
@@ -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');
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"kind": "singleType",
|
||||
"collectionName": "contact_pages",
|
||||
"info": {
|
||||
"singularName": "contact-page",
|
||||
"pluralName": "contact-pages",
|
||||
"displayName": "Contact Page",
|
||||
"description": "Contact details for SHARP-ER and each consortium member."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"hero": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.hero",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"intro": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"generalEmail": {
|
||||
"type": "email",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"websiteLabel": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"socialLinks": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.link",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"consortiumContacts": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.address",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* contact-page controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::contact-page.contact-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* contact-page router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::contact-page.contact-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* contact-page service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::contact-page.contact-page');
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* contact-submission 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,50 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "contact_submissions",
|
||||
"info": {
|
||||
"singularName": "contact-submission",
|
||||
"pluralName": "contact-submissions",
|
||||
"displayName": "Contact Submission",
|
||||
"description": "A message sent through the contact form. Not translated; managed by staff."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 200
|
||||
},
|
||||
"organisation": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"email": {
|
||||
"type": "email",
|
||||
"required": true
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250
|
||||
},
|
||||
"message": {
|
||||
"type": "text",
|
||||
"required": true
|
||||
},
|
||||
"submissionStatus": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"new",
|
||||
"in-progress",
|
||||
"resolved"
|
||||
],
|
||||
"required": true,
|
||||
"default": "new"
|
||||
},
|
||||
"submittedAt": {
|
||||
"type": "datetime"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* contact-submission controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::contact-submission.contact-submission');
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* contact-submission sanitize-submission middleware
|
||||
*
|
||||
* The public role may create contact submissions, so the request body is
|
||||
* attacker-controlled. Only the fields a visitor is allowed to supply are
|
||||
* copied out of it; the triage state and the timestamp are set here, on the
|
||||
* server, and anything else the caller sent is dropped.
|
||||
*/
|
||||
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const CLIENT_FIELDS = ['name', 'organisation', 'email', 'subject', 'message'] 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.submissionStatus = 'new';
|
||||
data.submittedAt = new Date().toISOString();
|
||||
|
||||
request.body = { data };
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
export default sanitizeSubmission;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* contact-submission 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::contact-submission.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::contact-submission.contact-submission', {
|
||||
config: {
|
||||
create: {
|
||||
middlewares: [
|
||||
{
|
||||
name: 'global::rate-limit',
|
||||
config: { name: 'contact-submission-create', interval: 60_000, max: 5 },
|
||||
},
|
||||
'api::contact-submission.sanitize-submission',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* contact-submission service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::contact-submission.contact-submission');
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* event-registration 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,64 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "event_registrations",
|
||||
"info": {
|
||||
"singularName": "event-registration",
|
||||
"pluralName": "event-registrations",
|
||||
"displayName": "Event Registration",
|
||||
"description": "A submitted registration for an event. Not translated; managed by staff."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"attributes": {
|
||||
"event": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::event.event"
|
||||
},
|
||||
"fullName": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 200
|
||||
},
|
||||
"email": {
|
||||
"type": "email",
|
||||
"required": true
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"organisation": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"dietaryOrAccess": {
|
||||
"type": "text"
|
||||
},
|
||||
"registrationState": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"confirmed",
|
||||
"waitlisted",
|
||||
"cancelled",
|
||||
"attended"
|
||||
],
|
||||
"required": true,
|
||||
"default": "confirmed"
|
||||
},
|
||||
"consentGdpr": {
|
||||
"type": "boolean",
|
||||
"required": true
|
||||
},
|
||||
"submittedAt": {
|
||||
"type": "datetime"
|
||||
},
|
||||
"reminderSentAt": {
|
||||
"type": "datetime"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* event-registration controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::event-registration.event-registration');
|
||||
@@ -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;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* event-registration 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::event-registration.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::event-registration.event-registration', {
|
||||
config: {
|
||||
create: {
|
||||
middlewares: [
|
||||
{
|
||||
name: 'global::rate-limit',
|
||||
config: { name: 'event-registration-create', interval: 60_000, max: 5 },
|
||||
},
|
||||
'api::event-registration.sanitize-submission',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* event-registration service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::event-registration.event-registration');
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "events",
|
||||
"info": {
|
||||
"singularName": "event",
|
||||
"pluralName": "events",
|
||||
"displayName": "Event",
|
||||
"description": "Entries in the events calendar: Suq dialogues, trainings, law sessions, network meetings and launches."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "text",
|
||||
"maxLength": 500,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"eventType": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"suq-dialogue",
|
||||
"training",
|
||||
"law-session",
|
||||
"network-meeting",
|
||||
"launch",
|
||||
"other"
|
||||
],
|
||||
"required": true,
|
||||
"default": "other",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"startDateTime": {
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"endDateTime": {
|
||||
"type": "datetime",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"default": "Africa/Tripoli",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"format": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"in-person",
|
||||
"online",
|
||||
"hybrid"
|
||||
],
|
||||
"required": true,
|
||||
"default": "in-person",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"venue": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"onlineUrl": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::region.region"
|
||||
},
|
||||
"topics": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::topic.topic"
|
||||
},
|
||||
"implementingPartners": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"registrationStatus": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"open",
|
||||
"closed",
|
||||
"full",
|
||||
"waitlist",
|
||||
"not-required"
|
||||
],
|
||||
"required": true,
|
||||
"default": "not-required",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"capacity": {
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"waitlistEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"registrationDeadline": {
|
||||
"type": "datetime",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"coverImage": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"recapUrl": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"relatedResources": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::resource.resource"
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* event controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::event.event');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* event router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::event.event');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* event service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::event.event');
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"kind": "singleType",
|
||||
"collectionName": "home_pages",
|
||||
"info": {
|
||||
"singularName": "home-page",
|
||||
"pluralName": "home-pages",
|
||||
"displayName": "Home Page",
|
||||
"description": "Content of the SHARP-ER home page."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"hero": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.hero",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.stat",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocks": {
|
||||
"type": "dynamiczone",
|
||||
"components": [
|
||||
"blocks.rich-text",
|
||||
"blocks.image-gallery",
|
||||
"blocks.stats-grid",
|
||||
"blocks.cta-banner",
|
||||
"blocks.card-grid",
|
||||
"blocks.quote",
|
||||
"blocks.accordion",
|
||||
"blocks.video-embed",
|
||||
"blocks.partner-logos"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"featuredNews": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::news-article.news-article"
|
||||
},
|
||||
"featuredEvents": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::event.event"
|
||||
},
|
||||
"partnersHeadline": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"partners": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* home-page controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::home-page.home-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* home-page router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::home-page.home-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* home-page service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::home-page.home-page');
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "knowledge_items",
|
||||
"info": {
|
||||
"singularName": "knowledge-item",
|
||||
"pluralName": "knowledge-items",
|
||||
"displayName": "Knowledge Item",
|
||||
"description": "Long-term Knowledge Hub repository: training modules, toolkits, law session guides, case studies and FAQs."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "text",
|
||||
"maxLength": 1000,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocks": {
|
||||
"type": "dynamiczone",
|
||||
"components": [
|
||||
"blocks.rich-text",
|
||||
"blocks.image-gallery",
|
||||
"blocks.stats-grid",
|
||||
"blocks.cta-banner",
|
||||
"blocks.card-grid",
|
||||
"blocks.quote",
|
||||
"blocks.accordion",
|
||||
"blocks.video-embed",
|
||||
"blocks.partner-logos"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"itemType": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"training-module",
|
||||
"toolkit",
|
||||
"law-session-guide",
|
||||
"case-study",
|
||||
"faq"
|
||||
],
|
||||
"required": true,
|
||||
"default": "toolkit",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::topic.topic"
|
||||
},
|
||||
"relatedItems": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::knowledge-item.knowledge-item"
|
||||
},
|
||||
"relatedResources": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::resource.resource"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "json",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sourceActivity": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::activity.activity"
|
||||
},
|
||||
"publicationDate": {
|
||||
"type": "date",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* knowledge-item controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::knowledge-item.knowledge-item');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* knowledge-item router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::knowledge-item.knowledge-item');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* knowledge-item service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::knowledge-item.knowledge-item');
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "news_articles",
|
||||
"info": {
|
||||
"singularName": "news-article",
|
||||
"pluralName": "news-articles",
|
||||
"displayName": "News Article",
|
||||
"description": "News and updates, split between SHARP-ER news and stories from the network."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"excerpt": {
|
||||
"type": "text",
|
||||
"maxLength": 500,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocks": {
|
||||
"type": "dynamiczone",
|
||||
"components": [
|
||||
"blocks.rich-text",
|
||||
"blocks.image-gallery",
|
||||
"blocks.stats-grid",
|
||||
"blocks.cta-banner",
|
||||
"blocks.card-grid",
|
||||
"blocks.quote",
|
||||
"blocks.accordion",
|
||||
"blocks.video-embed",
|
||||
"blocks.partner-logos"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"sharper-news",
|
||||
"from-the-network"
|
||||
],
|
||||
"required": true,
|
||||
"default": "sharper-news",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"authorOrganisation": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"publishDate": {
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"coverImage": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::topic.topic"
|
||||
},
|
||||
"regions": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::region.region"
|
||||
},
|
||||
"featured": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* news-article controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::news-article.news-article');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* news-article router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::news-article.news-article');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* news-article service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::news-article.news-article');
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "opportunities",
|
||||
"info": {
|
||||
"singularName": "opportunity",
|
||||
"pluralName": "opportunities",
|
||||
"displayName": "Opportunity",
|
||||
"description": "Calls for expression of interest, network membership, trainings and grants open for application."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "text",
|
||||
"maxLength": 500,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"opportunityType": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"call-eoi",
|
||||
"network-membership",
|
||||
"training",
|
||||
"grant",
|
||||
"other"
|
||||
],
|
||||
"required": true,
|
||||
"default": "call-eoi",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"opportunityStatus": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"upcoming",
|
||||
"open",
|
||||
"closed"
|
||||
],
|
||||
"required": true,
|
||||
"default": "upcoming",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"openDate": {
|
||||
"type": "date",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"deadline": {
|
||||
"type": "datetime",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"eligibility": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"howToApply": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"externalApplicationUrl": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::topic.topic"
|
||||
},
|
||||
"implementingPartners": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"coverImage": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* opportunity controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::opportunity.opportunity');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* opportunity router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::opportunity.opportunity');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* opportunity service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::opportunity.opportunity');
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "partners",
|
||||
"info": {
|
||||
"singularName": "partner",
|
||||
"pluralName": "partners",
|
||||
"displayName": "Partner",
|
||||
"description": "Consortium members, CSO partners and institutions SHARP-ER works with."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 200,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "name",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"logoDark": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "text",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"partnerRole": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"consortium-lead",
|
||||
"consortium-partner",
|
||||
"cso-partner",
|
||||
"institution"
|
||||
],
|
||||
"required": true,
|
||||
"default": "cso-partner",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* partner controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::partner.partner');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* partner router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::partner.partner');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* partner service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::partner.partner');
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "regions",
|
||||
"info": {
|
||||
"singularName": "region",
|
||||
"pluralName": "regions",
|
||||
"displayName": "Region",
|
||||
"description": "Libyan region or city used to filter events and activities geographically."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 120,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "name",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* region controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::region.region');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* region router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::region.region');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* region service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::region.region');
|
||||
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "resources",
|
||||
"info": {
|
||||
"singularName": "resource",
|
||||
"pluralName": "resources",
|
||||
"displayName": "Resource",
|
||||
"description": "Publications: perception research, policy articles, project reports and visibility factsheets."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 250,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "text",
|
||||
"maxLength": 1000,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourceType": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"research-perception",
|
||||
"policy-article",
|
||||
"project-report",
|
||||
"visibility-factsheet"
|
||||
],
|
||||
"required": true,
|
||||
"default": "project-report",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"files",
|
||||
"images",
|
||||
"videos",
|
||||
"audios"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"externalUrl": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileSizeLabel": {
|
||||
"type": "string",
|
||||
"maxLength": 40,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"pageCount": {
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourceLanguage": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"en",
|
||||
"ar",
|
||||
"bilingual"
|
||||
],
|
||||
"required": true,
|
||||
"default": "en",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicationDate": {
|
||||
"type": "date",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"authors": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"partnerOrganisations": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"topics": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::topic.topic"
|
||||
},
|
||||
"doi": {
|
||||
"type": "string",
|
||||
"maxLength": 200,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"maxLength": 200,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"default": "1.0",
|
||||
"maxLength": 20,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"supersedes": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::resource.resource"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"featured": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* resource controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::resource.resource');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* resource router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::resource.resource');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* resource service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::resource.resource');
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"kind": "singleType",
|
||||
"collectionName": "site_settings",
|
||||
"info": {
|
||||
"singularName": "site-settings",
|
||||
"pluralName": "site-settings-list",
|
||||
"displayName": "Site Settings",
|
||||
"description": "Global, per-locale site chrome: navigation, footer, EU visibility statement and default SEO."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"siteName": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": "SHARP-ER",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tagline": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"logoDark": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"primaryNav": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "navigation.nav-group",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"footerColumns": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "navigation.footer-column",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"socialLinks": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.link",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"euFundingStatement": {
|
||||
"type": "text",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"euBadge": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"euBadgeDark": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"implementedByLabel": {
|
||||
"type": "string",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"consortiumLogos": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"defaultSeo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* site-settings controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::site-settings.site-settings');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* site-settings router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::site-settings.site-settings');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* site-settings service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::site-settings.site-settings');
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "topics",
|
||||
"info": {
|
||||
"singularName": "topic",
|
||||
"pluralName": "topics",
|
||||
"displayName": "Topic",
|
||||
"description": "Thematic tag used to filter activities, events, news, resources and knowledge items."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 120,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "name",
|
||||
"required": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "text",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* topic controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::topic.topic');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* topic router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::topic.topic');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* topic service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::topic.topic');
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"kind": "singleType",
|
||||
"collectionName": "what_we_do_pages",
|
||||
"info": {
|
||||
"singularName": "what-we-do-page",
|
||||
"pluralName": "what-we-do-pages",
|
||||
"displayName": "What We Do Page",
|
||||
"description": "Workstreams, principles and guiding values of SHARP-ER."
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"hero": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.hero",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"intro": {
|
||||
"type": "richtext",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"workstreams": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "page.workstream",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"principles": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.titled-text",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"guidingValues": {
|
||||
"type": "json",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocks": {
|
||||
"type": "dynamiczone",
|
||||
"components": [
|
||||
"blocks.rich-text",
|
||||
"blocks.image-gallery",
|
||||
"blocks.stats-grid",
|
||||
"blocks.cta-banner",
|
||||
"blocks.card-grid",
|
||||
"blocks.quote",
|
||||
"blocks.accordion",
|
||||
"blocks.video-embed",
|
||||
"blocks.partner-logos"
|
||||
],
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.seo",
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* what-we-do-page controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::what-we-do-page.what-we-do-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* what-we-do-page router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::what-we-do-page.what-we-do-page');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* what-we-do-page service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::what-we-do-page.what-we-do-page');
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import {
|
||||
AUTH_PERMISSION_PREFIX,
|
||||
DISABLED_PUBLIC_AUTH_ACTIONS,
|
||||
LOG_PREFIX,
|
||||
PUBLIC_ROLE_TYPE,
|
||||
readDeliberatePublicAuthActions,
|
||||
} from './constants';
|
||||
|
||||
const PERMISSION_UID = 'plugin::users-permissions.permission';
|
||||
const ROLE_UID = 'plugin::users-permissions.role';
|
||||
|
||||
/**
|
||||
* Revoke the users-permissions AUTH endpoints from the Public role.
|
||||
*
|
||||
* WHY
|
||||
* Strapi enables `auth.register` (and, depending on the version, `callback`
|
||||
* and the password/e-mail-confirmation flows) for the Public role on a fresh
|
||||
* database. SHARP-ER has no end-user accounts at all: the site is read-only
|
||||
* plus three write-only submission forms, and staff sign in to the ADMIN
|
||||
* panel, which is a different permission system entirely. Left as Strapi
|
||||
* ships it, `POST /api/auth/local/register` lets anyone create an
|
||||
* authenticated API account on a site that was never meant to have one, and
|
||||
* every account created that way inherits whatever the Authenticated role is
|
||||
* later given.
|
||||
*
|
||||
* A permission row in `up_permissions` IS the grant - the table has no
|
||||
* `enabled` column - so revoking means deleting the row. Deleting a row that
|
||||
* is not there is a no-op, which is what keeps this safe on every boot.
|
||||
*
|
||||
* IF THE SITE EVER NEEDS PUBLIC ACCOUNTS
|
||||
* Do not delete this step. List the actions that are deliberately public in
|
||||
* `PUBLIC_AUTH_ACTIONS` (comma-separated, e.g. `callback,forgotPassword`) and
|
||||
* they are left alone; everything else stays revoked. That keeps the decision
|
||||
* visible in the environment instead of hidden in a database row someone
|
||||
* clicked once.
|
||||
*/
|
||||
export const revokePublicAuthActions = async (strapi: Core.Strapi): Promise<void> => {
|
||||
if (!strapi.plugins['users-permissions']) {
|
||||
strapi.log.warn(`${LOG_PREFIX} users-permissions plugin is disabled - skipping auth lockdown.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const publicRole = await strapi.db.query(ROLE_UID).findOne({
|
||||
where: { type: PUBLIC_ROLE_TYPE },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (!publicRole) {
|
||||
strapi.log.warn(`${LOG_PREFIX} no "${PUBLIC_ROLE_TYPE}" role found - skipping auth lockdown.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deliberate = readDeliberatePublicAuthActions();
|
||||
const revokable = DISABLED_PUBLIC_AUTH_ACTIONS.filter((action) => !deliberate.has(action));
|
||||
|
||||
if (deliberate.size > 0) {
|
||||
strapi.log.info(
|
||||
`${LOG_PREFIX} PUBLIC_AUTH_ACTIONS keeps these public auth action(s): ` +
|
||||
`${[...deliberate].sort().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (revokable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = revokable.map((action) => `${AUTH_PERMISSION_PREFIX}.${action}`);
|
||||
|
||||
const granted = (await strapi.db.query(PERMISSION_UID).findMany({
|
||||
where: { role: { id: publicRole.id }, action: { $in: actions } },
|
||||
select: ['id', 'action'],
|
||||
})) as { id: number; action: string }[];
|
||||
|
||||
if (granted.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await strapi.db.query(PERMISSION_UID).deleteMany({
|
||||
where: { id: { $in: granted.map(({ id }) => id) } },
|
||||
});
|
||||
|
||||
strapi.log.info(
|
||||
`${LOG_PREFIX} revoked ${granted.length} public auth permission(s): ` +
|
||||
`${granted
|
||||
.map(({ action }) => action)
|
||||
.sort()
|
||||
.join(', ')}.`
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Shared constants for the boot-time reconciliation steps.
|
||||
*
|
||||
* Everything here describes the state the CMS must be in after a boot. The
|
||||
* steps in this folder only ever add what is missing, so a second boot is a
|
||||
* no-op.
|
||||
*/
|
||||
|
||||
/** Prefix used for every log line written by the bootstrap steps. */
|
||||
export const LOG_PREFIX = '[sharp-er]';
|
||||
|
||||
export type LocaleDefinition = {
|
||||
code: string;
|
||||
name: string;
|
||||
/** The locale content editors get by default, and the one `/` falls back to. */
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used only if `config/plugins.ts` does not declare `plugin::i18n.locales`
|
||||
* (for example when the config file is being edited). The real source of truth
|
||||
* is that config file.
|
||||
*/
|
||||
export const FALLBACK_LOCALES: LocaleDefinition[] = [
|
||||
{ code: 'en', name: 'English (en)', isDefault: true },
|
||||
{ code: 'ar', name: 'Arabic (ar)' },
|
||||
];
|
||||
|
||||
/** `type` of the users-permissions role that unauthenticated requests use. */
|
||||
export const PUBLIC_ROLE_TYPE = 'public';
|
||||
|
||||
/**
|
||||
* Read actions granted to the Public role on every API content type.
|
||||
*
|
||||
* Single types only expose `find`, collection types expose both; the
|
||||
* intersection with the actions a controller actually has is taken at runtime,
|
||||
* so listing both here is safe.
|
||||
*/
|
||||
export const PUBLIC_READ_ACTIONS = ['find', 'findOne'];
|
||||
|
||||
/** Write action granted to the Public role on the submission content types. */
|
||||
export const PUBLIC_SUBMIT_ACTIONS = ['create'];
|
||||
|
||||
/**
|
||||
* Content types the public site is allowed to POST to: event sign-ups, calls
|
||||
* for applications and the contact form.
|
||||
*
|
||||
* These hold personal data, so they get `create` and nothing else - no `find`,
|
||||
* no `findOne`. Submissions are read in the admin panel only.
|
||||
*
|
||||
* Names are the API/controller names of the content types (kebab-case
|
||||
* singular), owned by the content-type agent under `src/api/**`.
|
||||
*/
|
||||
export const PUBLIC_SUBMISSION_CONTENT_TYPES = [
|
||||
'event-registration',
|
||||
'application',
|
||||
'contact-submission',
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PUBLIC AUTH LOCKDOWN
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** Permission namespace of the users-permissions auth controller. */
|
||||
export const AUTH_PERMISSION_PREFIX = 'plugin::users-permissions.auth';
|
||||
|
||||
/**
|
||||
* Auth actions the Public role must NOT hold.
|
||||
*
|
||||
* This site has no end-user accounts: readers read, three forms post, and
|
||||
* staff sign in to the admin panel, which is a separate permission system. So
|
||||
* every public-facing account flow is dead weight and pure attack surface -
|
||||
* `register` most of all, because Strapi grants it by default and it creates
|
||||
* real API accounts on a site that has none.
|
||||
*
|
||||
* `logout`, `refresh`, `getSessions` and `revokeSession` are deliberately NOT
|
||||
* listed: they act on the caller's own session, they are useless without an
|
||||
* account, and revoking them would break session handling the moment accounts
|
||||
* are ever introduced.
|
||||
*/
|
||||
export const DISABLED_PUBLIC_AUTH_ACTIONS = [
|
||||
'register',
|
||||
'callback',
|
||||
'connect',
|
||||
'forgotPassword',
|
||||
'resetPassword',
|
||||
'changePassword',
|
||||
'emailConfirmation',
|
||||
'sendEmailConfirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Escape hatch for the day the site does grow public accounts:
|
||||
* `PUBLIC_AUTH_ACTIONS=callback,forgotPassword` leaves those two alone. The
|
||||
* decision then lives in the environment, where it is reviewable, rather than
|
||||
* in a checkbox someone once ticked in the admin panel.
|
||||
*/
|
||||
export const readDeliberatePublicAuthActions = (): ReadonlySet<string> =>
|
||||
new Set(
|
||||
(process.env.PUBLIC_AUTH_ACTIONS ?? '')
|
||||
.split(',')
|
||||
.map((action) => action.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import { revokePublicAuthActions } from './auth-permissions';
|
||||
import { LOG_PREFIX } from './constants';
|
||||
import { ensureLocales } from './locales';
|
||||
import { ensurePublicPermissions } from './public-permissions';
|
||||
|
||||
export { revokePublicAuthActions } from './auth-permissions';
|
||||
export { ensureLocales } from './locales';
|
||||
export { ensurePublicPermissions } from './public-permissions';
|
||||
|
||||
/**
|
||||
* Boot-time reconciliation, run from `src/index.ts`.
|
||||
*
|
||||
* Every step is idempotent: the first two only add what is missing, and the
|
||||
* third only removes grants this site has decided it never wants. Running the
|
||||
* lot on each boot - including on every hot reload in development - leaves an
|
||||
* already-reconciled instance untouched.
|
||||
*
|
||||
* Runs after the plugin bootstraps, so the `en` locale and the Public role that
|
||||
* Strapi creates on a fresh database already exist here - including the auth
|
||||
* permissions users-permissions grants that role by default, which is exactly
|
||||
* what the third step takes back.
|
||||
*/
|
||||
export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise<void> => {
|
||||
await ensureLocales(strapi);
|
||||
await ensurePublicPermissions(strapi);
|
||||
await revokePublicAuthActions(strapi);
|
||||
|
||||
strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import { FALLBACK_LOCALES, LOG_PREFIX, type LocaleDefinition } from './constants';
|
||||
|
||||
type Locale = { id: number; code: string; name: string };
|
||||
|
||||
/** The subset of `plugin::i18n.locales` service methods used here. */
|
||||
type LocaleService = {
|
||||
findByCode(code: string): Promise<Locale | null>;
|
||||
create(locale: { code: string; name: string }): Promise<Locale>;
|
||||
getDefaultLocale(): Promise<string | null>;
|
||||
setDefaultLocale(params: { code: string }): Promise<void>;
|
||||
};
|
||||
|
||||
const isLocaleDefinition = (value: unknown): value is LocaleDefinition => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { code, name } = value as Partial<LocaleDefinition>;
|
||||
|
||||
return typeof code === 'string' && code.length > 0 && typeof name === 'string' && name.length > 0;
|
||||
};
|
||||
|
||||
/** Locales declared in `config/plugins.ts`, falling back to the built-in pair. */
|
||||
const readLocaleDefinitions = (strapi: Core.Strapi): LocaleDefinition[] => {
|
||||
const configured = strapi.plugin('i18n').config<unknown>('locales');
|
||||
const definitions = Array.isArray(configured) ? configured.filter(isLocaleDefinition) : [];
|
||||
|
||||
return definitions.length > 0 ? definitions : FALLBACK_LOCALES;
|
||||
};
|
||||
|
||||
/** The code the `defaultLocale` config key asks for, or the flagged/first one. */
|
||||
const resolveDefaultLocaleCode = (
|
||||
strapi: Core.Strapi,
|
||||
definitions: LocaleDefinition[]
|
||||
): string | undefined => {
|
||||
const configured = strapi.plugin('i18n').config<unknown>('defaultLocale');
|
||||
|
||||
if (typeof configured === 'string' && definitions.some(({ code }) => code === configured)) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return (definitions.find(({ isDefault }) => isDefault) ?? definitions[0])?.code;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure every configured locale (`en`, `ar`) exists and that the default
|
||||
* one is the configured default.
|
||||
*
|
||||
* Idempotent: existing locales are left untouched, and the default is only
|
||||
* written when it differs from what is stored.
|
||||
*/
|
||||
export const ensureLocales = async (strapi: Core.Strapi): Promise<void> => {
|
||||
if (!strapi.plugins.i18n) {
|
||||
strapi.log.warn(`${LOG_PREFIX} i18n plugin is disabled - skipping locale bootstrap.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const localeService = strapi.plugin('i18n').service<LocaleService>('locales');
|
||||
const definitions = readLocaleDefinitions(strapi);
|
||||
const created: string[] = [];
|
||||
|
||||
for (const definition of definitions) {
|
||||
if (await localeService.findByCode(definition.code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await localeService.create({ code: definition.code, name: definition.name });
|
||||
created.push(definition.code);
|
||||
} catch (error) {
|
||||
// Another instance booting at the same time may have inserted it between
|
||||
// the lookup and the insert; only a still-missing locale is a real error.
|
||||
if (!(await localeService.findByCode(definition.code))) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length > 0) {
|
||||
strapi.log.info(`${LOG_PREFIX} created locale(s): ${created.join(', ')}.`);
|
||||
}
|
||||
|
||||
const defaultLocaleCode = resolveDefaultLocaleCode(strapi, definitions);
|
||||
|
||||
if (!defaultLocaleCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDefault = await localeService.getDefaultLocale();
|
||||
|
||||
if (currentDefault !== defaultLocaleCode) {
|
||||
await localeService.setDefaultLocale({ code: defaultLocaleCode });
|
||||
strapi.log.info(`${LOG_PREFIX} default locale set to "${defaultLocaleCode}".`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import {
|
||||
LOG_PREFIX,
|
||||
PUBLIC_READ_ACTIONS,
|
||||
PUBLIC_ROLE_TYPE,
|
||||
PUBLIC_SUBMISSION_CONTENT_TYPES,
|
||||
PUBLIC_SUBMIT_ACTIONS,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* Shape returned by the users-permissions `getActions()` service:
|
||||
* `{ 'api::event': { controllers: { event: { find: {...}, findOne: {...} } } } }`
|
||||
*/
|
||||
type ActionMap = Record<string, { controllers?: Record<string, Record<string, unknown>> }>;
|
||||
|
||||
type UsersPermissionsService = {
|
||||
getActions(options?: { defaultEnable?: boolean }): ActionMap;
|
||||
};
|
||||
|
||||
const API_NAMESPACE_PREFIX = 'api::';
|
||||
|
||||
/**
|
||||
* Actions the Public role should hold on a given content type.
|
||||
*
|
||||
* Submission types are write-only for the public: granting `find`/`findOne`
|
||||
* there would expose the personal data people send through the site.
|
||||
*/
|
||||
const desiredActionsFor = (controllerName: string): string[] =>
|
||||
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName)
|
||||
? [...PUBLIC_SUBMIT_ACTIONS]
|
||||
: [...PUBLIC_READ_ACTIONS];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => {
|
||||
if (!strapi.plugins['users-permissions']) {
|
||||
strapi.log.warn(`${LOG_PREFIX} users-permissions plugin is disabled - skipping permissions.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const publicRole = await strapi.db.query('plugin::users-permissions.role').findOne({
|
||||
where: { type: PUBLIC_ROLE_TYPE },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (!publicRole) {
|
||||
strapi.log.warn(`${LOG_PREFIX} no "${PUBLIC_ROLE_TYPE}" role found - skipping permissions.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMap = strapi
|
||||
.plugin('users-permissions')
|
||||
.service<UsersPermissionsService>('users-permissions')
|
||||
.getActions();
|
||||
|
||||
const desiredActions = new Set<string>();
|
||||
const forbiddenReadActions = new Set<string>();
|
||||
|
||||
for (const [namespace, { controllers }] of Object.entries(actionMap)) {
|
||||
// Only the project's own APIs; plugin permissions are managed by Strapi.
|
||||
if (!namespace.startsWith(API_NAMESPACE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [controllerName, actions] of Object.entries(controllers ?? {})) {
|
||||
const availableActions = Object.keys(actions ?? {});
|
||||
const wantedActions = desiredActionsFor(controllerName);
|
||||
|
||||
for (const action of availableActions) {
|
||||
const permission = `${namespace}.${controllerName}.${action}`;
|
||||
|
||||
if (wantedActions.includes(action)) {
|
||||
desiredActions.add(permission);
|
||||
} else if (
|
||||
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName) &&
|
||||
PUBLIC_READ_ACTIONS.includes(action)
|
||||
) {
|
||||
forbiddenReadActions.add(permission);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingPermissions = await strapi.db
|
||||
.query('plugin::users-permissions.permission')
|
||||
.findMany({
|
||||
where: { role: { id: publicRole.id } },
|
||||
select: ['action'],
|
||||
});
|
||||
|
||||
const existingActions = new Set(existingPermissions.map(({ action }) => action));
|
||||
const missingActions = [...desiredActions].filter((action) => !existingActions.has(action)).sort();
|
||||
|
||||
for (const action of missingActions) {
|
||||
await strapi.db.query('plugin::users-permissions.permission').create({
|
||||
data: { action, role: publicRole.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (missingActions.length > 0) {
|
||||
strapi.log.info(
|
||||
`${LOG_PREFIX} granted ${missingActions.length} public permission(s): ${missingActions.join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const leakedActions = [...forbiddenReadActions].filter((action) => existingActions.has(action));
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"collectionName": "components_blocks_accordions",
|
||||
"info": {
|
||||
"displayName": "Accordion",
|
||||
"icon": "bulletList",
|
||||
"description": "A collapsible list of questions and answers (FAQs)."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"headline": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.accordion-item"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"collectionName": "components_blocks_card_grids",
|
||||
"info": {
|
||||
"displayName": "Card Grid",
|
||||
"icon": "grid",
|
||||
"description": "A headed grid of cards."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"headline": {
|
||||
"type": "string"
|
||||
},
|
||||
"intro": {
|
||||
"type": "text"
|
||||
},
|
||||
"cards": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.card"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"collectionName": "components_blocks_cta_banners",
|
||||
"info": {
|
||||
"displayName": "CTA Banner",
|
||||
"icon": "cursor",
|
||||
"description": "A full-width banner with a single call to action."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"headline": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"body": {
|
||||
"type": "text"
|
||||
},
|
||||
"cta": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.cta"
|
||||
},
|
||||
"surface": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"dark",
|
||||
"light",
|
||||
"accent"
|
||||
],
|
||||
"default": "dark",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"collectionName": "components_blocks_image_galleries",
|
||||
"info": {
|
||||
"displayName": "Image Gallery",
|
||||
"icon": "picture",
|
||||
"description": "A gallery of images with an optional caption."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"images": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": true,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
]
|
||||
},
|
||||
"caption": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"collectionName": "components_blocks_partner_logos",
|
||||
"info": {
|
||||
"displayName": "Partner Logos",
|
||||
"icon": "apps",
|
||||
"description": "A row of partner logos pulled from the Partner collection."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"headline": {
|
||||
"type": "string"
|
||||
},
|
||||
"partners": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::partner.partner"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"collectionName": "components_blocks_quotes",
|
||||
"info": {
|
||||
"displayName": "Quote",
|
||||
"icon": "quote",
|
||||
"description": "A pull quote with attribution."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"quote": {
|
||||
"type": "text",
|
||||
"required": true
|
||||
},
|
||||
"attribution": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"collectionName": "components_blocks_rich_texts",
|
||||
"info": {
|
||||
"displayName": "Rich Text",
|
||||
"icon": "feather",
|
||||
"description": "A block of formatted Markdown text."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"body": {
|
||||
"type": "richtext",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"collectionName": "components_blocks_stats_grids",
|
||||
"info": {
|
||||
"displayName": "Stats Grid",
|
||||
"icon": "chartBubble",
|
||||
"description": "A grid of headline figures."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"stats": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.stat"
|
||||
},
|
||||
"note": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"collectionName": "components_blocks_video_embeds",
|
||||
"info": {
|
||||
"displayName": "Video Embed",
|
||||
"icon": "play",
|
||||
"description": "An embedded video with an optional poster image."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"caption": {
|
||||
"type": "string"
|
||||
},
|
||||
"poster": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"collectionName": "components_navigation_footer_columns",
|
||||
"info": {
|
||||
"displayName": "Footer Column",
|
||||
"icon": "layout",
|
||||
"description": "A heading plus a list of links in the footer."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"heading": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"links": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.link"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"collectionName": "components_navigation_nav_groups",
|
||||
"info": {
|
||||
"displayName": "Nav Group",
|
||||
"icon": "bulletList",
|
||||
"description": "A top-level navigation entry with optional child links."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"href": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "shared.link"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"collectionName": "components_page_consortium_members",
|
||||
"info": {
|
||||
"displayName": "Consortium Member",
|
||||
"icon": "user",
|
||||
"description": "A consortium partner plus its role in SHARP-ER."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"partner": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::partner.partner"
|
||||
},
|
||||
"roleDescription": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"collectionName": "components_page_workstreams",
|
||||
"info": {
|
||||
"displayName": "Workstream",
|
||||
"icon": "puzzle",
|
||||
"description": "A strand of work with a title, description and icon."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"icon": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"collectionName": "components_shared_accordion_items",
|
||||
"info": {
|
||||
"displayName": "Accordion Item",
|
||||
"icon": "bulletList",
|
||||
"description": "A question / answer pair."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"answer": {
|
||||
"type": "richtext",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"collectionName": "components_shared_addresses",
|
||||
"info": {
|
||||
"displayName": "Address",
|
||||
"icon": "pinMap",
|
||||
"description": "A consortium office / contact address block."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"organisation": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"addressLine": {
|
||||
"type": "text"
|
||||
},
|
||||
"city": {
|
||||
"type": "string"
|
||||
},
|
||||
"country": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "email"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"collectionName": "components_shared_cards",
|
||||
"info": {
|
||||
"displayName": "Card",
|
||||
"icon": "layer",
|
||||
"description": "A card inside a card grid."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"body": {
|
||||
"type": "text"
|
||||
},
|
||||
"media": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images"
|
||||
]
|
||||
},
|
||||
"link": {
|
||||
"type": "component",
|
||||
"repeatable": false,
|
||||
"component": "shared.link"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"collectionName": "components_shared_ctas",
|
||||
"info": {
|
||||
"displayName": "CTA",
|
||||
"icon": "cursor",
|
||||
"description": "A call-to-action button."
|
||||
},
|
||||
"options": {},
|
||||
"attributes": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"href": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"variant": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"dark",
|
||||
"danger",
|
||||
"ghost"
|
||||
],
|
||||
"default": "primary",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user