initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
# ---------------------------------------------------------------------------
# SHARP-ER CMS (Strapi 5) - environment template
#
# Copy to .env and fill in real values: cp .env.example .env
# Generate every secret/salt/key with: openssl rand -base64 32
# Never commit .env, and never copy real secrets back into this file.
# ---------------------------------------------------------------------------
# Node environment: development | production | test. Drives the CORS defaults
# in config/middlewares.ts (localhost is only allowed outside production).
NODE_ENV=development
# --- Server (config/server.ts) ---------------------------------------------
# Address Strapi binds to. 0.0.0.0 listens on every interface.
HOST=0.0.0.0
# Port the API and admin panel are served on.
PORT=1337
# Public URL of the API, used to build absolute media/admin URLs.
# Leave commented out in local development; set it behind a proxy or a custom
# domain, e.g. PUBLIC_URL=https://cms.sharper.ly
# PUBLIC_URL=
# true when Strapi sits behind nginx / a load balancer, so X-Forwarded-* headers
# are trusted for the client IP and protocol.
IS_PROXIED=false
# Log verbosity: fatal | error | warn | info | http | debug | trace
LOG_LEVEL=info
# Enable the Strapi cron runner (config/server.ts -> cron.enabled).
CRON_ENABLED=false
# Include relations in webhook payloads. Off by default: heavier payloads.
WEBHOOKS_POPULATE_RELATIONS=false
# --- Secrets (config/server.ts, config/admin.ts, config/plugins.ts) ---------
# All of these are per-environment. Rotating APP_KEYS invalidates sessions;
# rotating ENCRYPTION_KEY makes already-encrypted values unreadable.
# Comma-separated session signing keys (at least two, so keys can be rotated).
APP_KEYS="replace-with-random-key-1,replace-with-random-key-2"
# Salt used to hash API tokens.
API_TOKEN_SALT=replace-with-random-salt
# Signing secret for admin-panel sessions.
ADMIN_JWT_SECRET=replace-with-random-secret
# Signing secret for Content API (users-permissions) tokens.
JWT_SECRET=replace-with-random-secret
# Salt used to hash data-transfer tokens (strapi transfer / import / export).
TRANSFER_TOKEN_SALT=replace-with-random-salt
# Key used to encrypt secrets stored in the database (e.g. provider settings).
ENCRYPTION_KEY=replace-with-random-key
# --- Admin panel (config/admin.ts) -----------------------------------------
# Path (or absolute URL) the admin panel is served from.
ADMIN_URL=/admin
# Serve the admin panel from this process. false when it is hosted separately.
SERVE_ADMIN_PANEL=true
# Open a browser window on `strapi develop`. Keep false for headless runs.
ADMIN_AUTO_OPEN=false
# Admin panel UI flags: NPS survey, EE promotion, documentation links.
FLAG_NPS=true
FLAG_PROMOTE_EE=true
FLAG_DOC_LINKS=true
# --- CORS (config/middlewares.ts) ------------------------------------------
# Comma-separated list of browser origins allowed to call the API.
# In development http://localhost:5173 and http://127.0.0.1:5173 (the React
# Router dev server) are always allowed and need not be listed here.
# Example for production: https://sharper.ly,https://www.sharper.ly
CORS_ORIGINS=
# --- Database (config/database.ts) -----------------------------------------
# postgres | mysql | sqlite. This project runs on PostgreSQL 16.
DATABASE_CLIENT=postgres
# Full connection string. When set it takes precedence over the parts below,
# so leave it commented out to use DATABASE_HOST/PORT/NAME/USERNAME/PASSWORD.
# Example: postgres://user:password@host:5432/database
# DATABASE_URL=
# Database host.
DATABASE_HOST=127.0.0.1
# Database port (5432 for PostgreSQL, 3306 for MySQL).
DATABASE_PORT=5432
# Database name.
DATABASE_NAME=sharper_cms
# Database user.
DATABASE_USERNAME=postgres
# Database password.
DATABASE_PASSWORD=replace-with-your-database-password
# PostgreSQL schema Strapi writes its tables into.
DATABASE_SCHEMA=public
# Enable TLS for the database connection. Usually true on managed databases.
DATABASE_SSL=false
# Optional TLS material, only read when DATABASE_SSL=true.
# DATABASE_SSL_KEY=
# DATABASE_SSL_CERT=
# DATABASE_SSL_CA=
# DATABASE_SSL_CAPATH=
# DATABASE_SSL_CIPHER=
# Set to false only for a self-signed database certificate.
# DATABASE_SSL_REJECT_UNAUTHORIZED=true
# Connection pool bounds.
# DATABASE_POOL_MIN=2
# DATABASE_POOL_MAX=10
# Milliseconds to wait for a free connection before failing.
# DATABASE_CONNECTION_TIMEOUT=60000
# SQLite file, relative to apps/cms. Only used when DATABASE_CLIENT=sqlite.
# DATABASE_FILENAME=.tmp/data.db
# --- Authentication rate limit (config/plugins.ts) --------------------------
# Window in milliseconds for the users-permissions auth rate limit.
AUTH_RATE_LIMIT_INTERVAL=60000
# Maximum auth requests (login, forgot password, ...) per window per IP.
AUTH_RATE_LIMIT_MAX=5
# --- Public submission rate limit (src/middlewares/rate-limit.ts) -----------
# The three public POST endpoints (contact-submission, event-registration,
# application) are capped per client IP. The limiter is in-process, so with
# several Strapi instances the effective cap is this many PER INSTANCE - put a
# shared limiter in front of a multi-instance deployment.
# Window in milliseconds.
SUBMISSION_RATE_LIMIT_INTERVAL=60000
# Maximum submissions per window per IP, per endpoint.
SUBMISSION_RATE_LIMIT_MAX=5
# Set to false to switch the limiter off (load tests only - never in production).
# SUBMISSION_RATE_LIMIT_ENABLED=false
# --- Public account flows (src/bootstrap/auth-permissions.ts) ---------------
# This site has no end-user accounts, so every users-permissions auth endpoint
# is revoked from the Public role on each boot - `register` above all, which
# Strapi grants by default and which would let anyone create an API account.
# List here, comma-separated, only the actions that are DELIBERATELY public,
# e.g. PUBLIC_AUTH_ACTIONS=callback,forgotPassword. Empty means "none", which
# is the intended state.
# PUBLIC_AUTH_ACTIONS=
# --- Media / uploads (config/plugins.ts) ------------------------------------
# Maximum size of a single uploaded file, in bytes. Default 50 MB.
UPLOAD_MAX_FILE_SIZE=52428800
# Cache-Control max-age (ms) for files served by the local upload provider.
UPLOAD_LOCAL_MAX_AGE=300000
# Production only - switching the upload provider to S3. Uncomment these after
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
# config/plugins.ts (the switch is documented there step by step).
# AWS_ACCESS_KEY_ID=
# AWS_ACCESS_SECRET=
# AWS_REGION=
# AWS_BUCKET=
# AWS_ACL=private
# AWS_SIGNED_URL_EXPIRES=900
# S3_ROOT_PATH=
# CDN_URL=
+131
View File
@@ -0,0 +1,131 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
.tsbuildinfo
.eslintcache
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Package managers
############################
.yarn/*
!.yarn/cache
!.yarn/unplugged
!.yarn/patches
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
yarn-error.log
############################
# Tests
############################
coverage
############################
# Strapi
############################
.env
license.txt
exports
.strapi
dist
build
.strapi-updater.json
.strapi-cloud.json
+86
View File
@@ -0,0 +1,86 @@
# syntax=docker/dockerfile:1
###############################################################################
# SHARP-ER CMS - Strapi 5 on Node 22
#
# The build context is the REPOSITORY ROOT, not apps/cms: this is a pnpm
# workspace, so pnpm-lock.yaml, pnpm-workspace.yaml and .npmrc all live one
# level up and pnpm refuses to do a frozen install without them.
#
# docker build -f apps/cms/Dockerfile -t sharper-cms .
# docker compose build cms # does exactly that
#
# Debian slim rather than Alpine: Strapi's image pipeline (sharp) ships glibc
# prebuilt binaries, so this avoids a source build of libvips.
###############################################################################
# --- base: the toolchain both stages share ----------------------------------
FROM node:22-bookworm-slim AS base
ENV PNPM_VERSION=9.12.3
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global "pnpm@${PNPM_VERSION}"
WORKDIR /repo
# --- build: install dependencies and compile the admin panel ----------------
FROM base AS build
# Manifests first, so the (slow) install layer is cached until a dependency
# actually changes. Both workspace manifests are copied even though only the
# CMS is installed: pnpm compares the lockfile against every project in the
# workspace and calls the lockfile stale if one is missing.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY apps/cms/package.json ./apps/cms/
COPY apps/web/package.json ./apps/web/
# `--filter cms...` = the CMS and anything in the workspace it depends on.
# devDependencies are needed here: `strapi build` compiles TypeScript.
RUN pnpm install --frozen-lockfile --filter cms...
COPY apps/cms ./apps/cms
# `strapi build` loads config/*.ts, which reads these. They are throwaway
# build-time values that never reach the runtime stage below - the real ones
# are injected by docker compose at start-up.
ENV NODE_ENV=production \
APP_KEYS=build-only-key-1,build-only-key-2 \
API_TOKEN_SALT=build-only \
ADMIN_JWT_SECRET=build-only \
JWT_SECRET=build-only \
TRANSFER_TOKEN_SALT=build-only \
ENCRYPTION_KEY=build-only \
DATABASE_CLIENT=sqlite
RUN pnpm --filter cms build
# --- runtime ----------------------------------------------------------------
FROM base AS runtime
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=1337
# node_modules is carried over rather than reinstalled with --prod: the Strapi
# CLI still resolves its TypeScript toolchain at start-up, so pruning
# devDependencies breaks `strapi start` on a TypeScript project.
COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/package.json /repo/pnpm-workspace.yaml /repo/.npmrc ./
COPY --from=build /repo/apps/cms ./apps/cms
# The Media Library writes here. Ownership is set before the named volume is
# mounted, so the volume inherits it on first start.
RUN mkdir -p /repo/apps/cms/public/uploads \
&& chown -R node:node /repo/apps/cms
USER node
WORKDIR /repo/apps/cms
EXPOSE 1337
# Strapi answers /_health with 204 once the HTTP server is listening. Node 22
# has fetch built in, so the image needs no curl or wget.
HEALTHCHECK --interval=15s --timeout=5s --start-period=90s --retries=10 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||1337)+'/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# `strapi start` serves the API and the pre-built admin panel from dist/.
CMD ["pnpm", "start"]
+61
View File
@@ -0,0 +1,61 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
### `develop`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
```
npm run develop
# or
yarn develop
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
```
yarn strapi deploy
```
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
+34
View File
@@ -0,0 +1,34 @@
import type { Core } from '@strapi/strapi';
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({
auth: {
secret: env('ADMIN_JWT_SECRET')!,
},
apiToken: {
salt: env('API_TOKEN_SALT')!,
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT')!,
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY')!,
},
/**
* Path the admin panel is served from, relative to `server.url`.
* Set ADMIN_URL to an absolute URL only when the panel is hosted separately
* from the API (in which case also set SERVE_ADMIN_PANEL=false).
*/
url: env('ADMIN_URL', '/admin'),
serveAdminPanel: env.bool('SERVE_ADMIN_PANEL', true),
// Do not pop a browser open on `strapi develop` - agents and CI run headless.
autoOpen: env.bool('ADMIN_AUTO_OPEN', false),
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
docLinks: env.bool('FLAG_DOC_LINKS', true),
},
});
export default config;
+16
View File
@@ -0,0 +1,16 @@
import type { Core } from '@strapi/strapi';
const config: Core.Config.Api = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
strictParams: true,
},
documents: {
strictParams: true,
strictRelations: true,
},
};
export default config;
+72
View File
@@ -0,0 +1,72 @@
import path from 'path';
import type { Core } from '@strapi/strapi';
import { isDatabaseClientKind } from '@strapi/database';
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Database => {
const client = env('DATABASE_CLIENT', 'sqlite');
if (!isDatabaseClientKind(client)) {
throw new Error(
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`
);
}
const connections: Record<Core.Config.Database.ClientKind, Core.Config.Database['connection']> = {
mysql: {
client: 'mysql',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
postgres: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
client: 'sqlite',
connection: {
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
return {
connection: {
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};
export default config;
+63
View File
@@ -0,0 +1,63 @@
import type { Core } from '@strapi/strapi';
/**
* Origins that are always allowed outside production: the React Router dev
* server (Vite) on both hostnames it is commonly reached on.
*/
const DEV_ORIGINS = [
'http://localhost:5173',
'http://127.0.0.1:5173',
];
/**
* The default Strapi middleware stack, in order, with CORS configured for the
* SHARP-ER web app.
*
* Order matters: `strapi::errors` must wrap everything after it, `strapi::cors`
* must run before routing so pre-flight requests are answered, and
* `strapi::public` stays last so it only handles what nothing else claimed.
*/
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Middlewares => {
const isProduction = env('NODE_ENV', 'development') === 'production';
/**
* Production origins come from the environment, e.g.
* CORS_ORIGINS=https://sharper.ly,https://www.sharper.ly
* Outside production the local dev server is allowed as well.
*/
const configuredOrigins = env.array('CORS_ORIGINS', []) as string[];
const origin = Array.from(
new Set([...(isProduction ? [] : DEV_ORIGINS), ...configuredOrigins].filter(Boolean))
);
return [
'strapi::logger',
'strapi::errors',
// If uploads move to S3/CDN, add the bucket host to img-src and media-src
// here - see the S3 notes in config/plugins.ts.
'strapi::security',
{
name: 'strapi::cors',
config: {
// An empty list blocks every cross-origin browser request, which is the
// safe default if CORS_ORIGINS was forgotten in production.
origin,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
// The web app sends the locale it wants and reads pagination metadata.
expose: ['Content-Range', 'Content-Length'],
credentials: true,
maxAge: 31536000,
keepHeadersOnError: false,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
};
export default config;
+143
View File
@@ -0,0 +1,143 @@
import type { Core } from '@strapi/strapi';
/**
* Media types the Media Library accepts.
*
* The site publishes reports, guidance notes and event material, so office
* documents and PDFs are allowed next to the usual image/video/audio types.
*/
const allowedMediaTypes = [
'image/*',
'video/*',
'audio/*',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.*',
'text/plain',
'text/csv',
];
/**
* Types that are never accepted, even if they slip past `allowedMediaTypes`.
* SVG is denied on purpose: it can carry script and is served from the same
* origin as the API.
*/
const deniedTypes = [
'image/svg+xml',
'application/vnd.microsoft.portable-executable',
'application/x-msdownload',
'application/x-msdos-program',
'application/x-executable',
'application/x-dosexec',
'application/x-sh',
'text/x-shellscript',
'application/x-mach-binary',
];
/**
* The locales the site ships with.
*
* `en` is the default (LTR) and `ar` is the right-to-left translation. The web
* app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the
* codes here are part of the public contract - do not rename them.
*
* The i18n plugin itself does not read this key; it is our own declaration and
* is read back at boot by `src/bootstrap/locales.ts`, which creates any missing
* locale and pins the default one. Adding a locale is therefore a one-line edit
* here plus a restart.
*/
const locales = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
];
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
/**
* Internationalisation - core plugin, listed explicitly so the two supported
* locales live in version control rather than only in the database.
*/
i18n: {
enabled: true,
config: {
defaultLocale: 'en',
locales,
},
},
'users-permissions': {
config: {
jwtSecret: env('JWT_SECRET'),
/**
* "refresh" uses the SessionManager (short-lived access token + refresh
* token) instead of the legacy long-lived plugin JWT.
*/
jwtManagement: 'refresh',
sessions: {
// Refresh token is stored in a cookie that JavaScript cannot read.
httpOnly: true,
},
/**
* Rate limit on the auth endpoints (login, forgot-password, ...).
* Tightened from the Strapi default of 10 requests per minute.
*/
ratelimit: {
interval: env.int('AUTH_RATE_LIMIT_INTERVAL', 60_000),
max: env.int('AUTH_RATE_LIMIT_MAX', 5),
},
},
},
/**
* Uploads stay on the default `local` provider: files land in
* `apps/cms/public/uploads` and are served by `strapi::public`.
*
* TO SWITCH TO S3 IN PRODUCTION
* -----------------------------
* 1. Add the provider package to apps/cms/package.json:
* "@strapi/provider-upload-aws-s3": "5.53.0"
* 2. Replace the `provider` / `providerOptions` keys below with:
*
* provider: 'aws-s3',
* providerOptions: {
* baseUrl: env('CDN_URL'), // optional CloudFront/CDN origin
* rootPath: env('S3_ROOT_PATH'), // optional key prefix
* s3Options: {
* credentials: {
* accessKeyId: env('AWS_ACCESS_KEY_ID'),
* secretAccessKey: env('AWS_ACCESS_SECRET'),
* },
* region: env('AWS_REGION'),
* params: {
* ACL: env('AWS_ACL', 'private'),
* signedUrlExpires: env.int('AWS_SIGNED_URL_EXPIRES', 15 * 60),
* Bucket: env('AWS_BUCKET'),
* },
* },
* },
* actionOptions: { upload: {}, uploadStream: {}, delete: {} },
*
* 3. Add the bucket/CDN host to the Content Security Policy in
* `config/middlewares.ts` (`img-src` and `media-src`), otherwise the admin
* panel shows broken thumbnails.
* 4. Set the AWS_* / CDN_URL variables listed in `.env.example`.
*/
upload: {
config: {
provider: 'local',
providerOptions: {
// Cache-Control max-age (seconds) sent for locally served uploads.
localServer: {
maxage: env.int('UPLOAD_LOCAL_MAX_AGE', 300_000),
},
},
// Hard ceiling per file, in bytes. Defaults to 50 MB.
sizeLimit: env.int('UPLOAD_MAX_FILE_SIZE', 50 * 1024 * 1024),
security: {
allowedTypes: allowedMediaTypes,
deniedTypes,
},
},
},
});
export default config;
+30
View File
@@ -0,0 +1,30 @@
import type { Core } from '@strapi/strapi';
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
/**
* Public URL of the API. Required behind a reverse proxy / custom domain so
* that generated links (media URLs, admin redirects) are absolute and https.
* Leave unset in local development.
*/
url: env('PUBLIC_URL'),
// Trust X-Forwarded-* headers when Strapi runs behind nginx / a load balancer.
proxy: env.bool('IS_PROXIED', false),
app: {
keys: env.array('APP_KEYS')!,
},
logger: {
config: {
level: env('LOG_LEVEL', 'info'),
},
},
cron: {
enabled: env.bool('CRON_ENABLED', false),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

+43
View File
@@ -0,0 +1,43 @@
{
"name": "cms",
"version": "0.1.0",
"private": true,
"description": "A Strapi application",
"scripts": {
"build": "strapi build",
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"typecheck": "tsc --noEmit",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/database": "5.53.0",
"@strapi/plugin-cloud": "5.53.0",
"@strapi/plugin-users-permissions": "5.53.0",
"@strapi/strapi": "5.53.0",
"pg": "^8.23.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.30.3",
"styled-components": "^6.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0 <=26.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "e6ef164d-b72a-4161-a38e-7a9acda47ace",
"installId": "17a7dd9f8aabfc1974cafe13966c2ef4914ce9a64c816f389522108bc4721cdd"
}
}
+3
View File
@@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /
View File
+37
View File
@@ -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);
},
};
+20
View File
@@ -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"]
}
+12
View File
@@ -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',
},
},
});
};
View File
@@ -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');
+7
View File
@@ -0,0 +1,7 @@
/**
* event router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::event.event');
+7
View File
@@ -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');
+7
View File
@@ -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');
+7
View File
@@ -0,0 +1,7 @@
/**
* topic router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::topic.topic');
+7
View File
@@ -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(', ')}.`
);
};
+104
View File
@@ -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)
);
+31
View File
@@ -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.`);
};
+97
View File
@@ -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"
}
}
}

Some files were not shown because too many files have changed in this diff Show More