initial commit
This commit is contained in:
@@ -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=
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
|
||||
# User-Agent: *
|
||||
# Disallow: /
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user