Replace Strapi CMS with PocketBase

Strapi + Postgres are gone in favor of PocketBase: a single Go binary
with embedded SQLite, built-in admin UI and per-collection API rules.
No content existed yet, so this is a clean swap with no data migration.

Collections and rules are defined as code in pocketbase/pb_migrations/
and applied automatically on first boot. Draft & Publish has no native
PocketBase equivalent, so it's reproduced with a nullable `publishedAt`
field enforced by listRule/viewRule, matching the old Strapi semantics.

Routing flips: PocketBase's admin UI and REST/file API are hardwired to
`/_/` and `/api/*` at the domain root (its own dashboard assets and API
calls reference those paths directly, so a stripped path prefix like
`/admin/*` would break them). `/api` is therefore reserved for
PocketBase now, and the frontend's Nitro endpoints move to `/content/*`
(frontend/server/routes/content/, not server/api/). A `/admin` vanity
route in Nitro (not Caddy) redirects to `/_/`, so it works the same in
dev, where Caddy isn't part of the stack, and in production.

frontend/server/utils/strapi.ts becomes pocketbase.ts; queries.ts is
rewritten for PocketBase's filter/sort/fields/expand query syntax.
StrapiImage becomes MediaImage (no width/height — PocketBase file
fields don't store dimensions, and the cover images already reserve
their aspect ratio via CSS, so this is not a regression).

docs/*.md, CLAUDE.md and README.md are updated in the same commit.
This commit is contained in:
2026-09-11 11:25:14 +02:00
parent 91540224a9
commit 49c42ecc96
64 changed files with 546 additions and 22706 deletions
+13 -18
View File
@@ -4,25 +4,20 @@
PUBLIC_DOMAIN=blog.localhost PUBLIC_DOMAIN=blog.localhost
ACME_EMAIL=admin@example.com ACME_EMAIL=admin@example.com
# --- Database --- # --- PocketBase ---
POSTGRES_DB=blog # Bootstraps (or updates, on restart) the initial superuser account — the
POSTGRES_USER=blog # only login the admin UI accepts. Pick a real password (10+ chars); never
POSTGRES_PASSWORD=change-me # committed as anything but this placeholder.
POCKETBASE_ADMIN_EMAIL=admin@example.com
# --- Strapi --- POCKETBASE_ADMIN_PASSWORD=change-me-1234
# Generate each secret with: openssl rand -base64 32
APP_KEYS=change-me-1,change-me-2
API_TOKEN_SALT=change-me
ADMIN_JWT_SECRET=change-me
TRANSFER_TOKEN_SALT=change-me
JWT_SECRET=change-me
ENCRYPTION_KEY=change-me
# --- Frontend --- # --- Frontend ---
# Server-side only: internal Docker address of Strapi. # Server-side only: internal Docker address of PocketBase.
STRAPI_URL=http://cms:1337 POCKETBASE_URL=http://pocketbase:8090
# Public base URL of the website, used for canonical URLs and Open Graph. # Public base URL of the website, used for canonical URLs and Open Graph.
PUBLIC_SITE_URL=https://blog.localhost PUBLIC_SITE_URL=https://blog.localhost
# Public base URL of Strapi, used to build absolute media URLs in the browser. # Public base URL of PocketBase, used to build absolute cover-image URLs.
# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi. # Same origin as PUBLIC_SITE_URL: Caddy proxies /_/* and /api/* there (the
PUBLIC_STRAPI_URL=https://blog.localhost # admin UI is at PUBLIC_POCKETBASE_URL/_/, reachable from PUBLIC_SITE_URL/admin
# too — see caddy/Caddyfile).
PUBLIC_POCKETBASE_URL=https://blog.localhost
+47 -41
View File
@@ -16,39 +16,45 @@ sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora conf
## Architettura ## Architettura
```text ```text
Browser → Caddy ─┬─ /admin e i path dei plugin Strapi → Strapi 5 → PostgreSQL Browser → Caddy ─┬─ /_/* e /api/* → PocketBase (admin UI + API)
└─ tutto il resto → Nuxt 4 (SSR) → REST Strapi └─ tutto il resto → Nuxt 4 (SSR) → REST PocketBase (interno)
("/admin" fa redirect a /_/, gestito da Nitro)
``` ```
Caddy instrada per **path**, non per sottodominio: `PUBLIC_DOMAIN` serve sia il sito che il Caddy instrada per **path**, non per sottodominio o porta: `PUBLIC_DOMAIN` serve sia il sito
pannello Strapi. Ogni plugin Strapi monta la propria API admin sul proprio path di primo che il pannello PocketBase. `/_/*` (dashboard) e `/api/*` (REST/file API) vanno **senza prefisso**
livello, non tutto sotto `/admin` (es. `/content-manager`, `/upload`, `/i18n`...): l'elenco a PocketBase — la sua dashboard referenzia se stessa con quei path assoluti, quindi non si possono
completo dei prefissi da instradare a Strapi vive nel `Caddyfile`. Se aggiungi un plugin instradare con uno strip-prefix (es. `/admin/*` riscritto): romperebbe gli asset/le chiamate della
Strapi, aggiungi il suo prefisso lì. Niente di questo tocca `/api`, riservato agli endpoint dashboard. Per questo `/api` è riservato a PocketBase, non a Nitro: gli endpoint del frontend
Nitro del frontend. Un solo dominio, un solo certificato TLS. vivono sotto `/content/*` (`frontend/server/routes/content/`, non `server/api/`). `/admin` è una
route Nitro (`frontend/server/routes/admin.get.ts`) che fa redirect a `/_/`, non una regola
Caddy: così funziona identico anche in sviluppo, dove Caddy non fa parte dello stack. Un solo
dominio, un solo certificato TLS. Se cambi questa scelta di routing, spiega il trade-off prima
(vedi [Vincoli](#vincoli)).
- Strapi è la **sola** fonte di verità editoriale. Niente altro backend (no Express/Nest/Fastify): - PocketBase è la **sola** fonte di verità editoriale (CMS + database SQLite in un solo processo).
se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi. Niente altro backend (no Express/Nest/Fastify): se serve logica server, sta in Nitro
- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi. (`frontend/server/`) o in una regola/migration PocketBase.
- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli - I visitatori pubblici non si autenticano mai. Solo il superuser usa l'auth PocketBase.
endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono - **Il browser dei visitatori pubblici non parla mai con PocketBase per i contenuti.** Le pagine
query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e chiamano gli endpoint Nitro in `frontend/server/routes/content/`, che sono l'unico posto dove si
niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il costruiscono query PocketBase. Così `NUXT_POCKETBASE_URL` resta l'indirizzo interno Docker,
ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel niente CORS e niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì (sotto
(`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica `/content/*`, mai `/api/*`) e tipizza il ritorno in `shared/types/blog.ts`. Fanno eccezione, per
direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione costruzione: l'admin panel (`/_/`, uso superuser autenticato, raggiungibile anche da `/admin`) e
richiesta né concessa). le immagini cover, che il browser carica direttamente da `PUBLIC_POCKETBASE_URL`
(`/api/files/...`, sola lettura, nessun'autenticazione richiesta né concessa).
- Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il - Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client. contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente - L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in `@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione `nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
Google Translate del browser, non è gestita dall'app. I **contenuti** restano Google Translate del browser, non è gestita dall'app. I **contenuti** restano monolingua.
monolingua — la localizzazione di Strapi è disattivata. - `pocketbase/pb_migrations/*.js` definisce collection e regole: `listRule`/`viewRule` pubblici
- `cms/src/index.ts` (`bootstrap`) dà al ruolo Public solo `find`/`findOne` su Article e Category, solo su `articles` (solo pubblicati, via `publishedAt`) e `categories`; `createRule`/
e disattiva la registrazione pubblica: non esistono utenti front-end, solo amministratori. `updateRule`/`deleteRule` sempre `null` (solo superuser). Qualsiasi permesso in più va motivato.
Qualsiasi permesso in più va motivato. - SQLite (PocketBase) non è esposto pubblicamente sulla rete — solo tramite l'API PocketBase
- Postgres non è esposto pubblicamente. Media su volume persistente, mai binari nel DB. stessa. Media sul volume persistente `pocketbase-data`, mai binari fuori da lì.
## Comandi ## Comandi
@@ -61,17 +67,15 @@ npm run build # build produzione
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
npm run lint npm run lint
# cms/ # pocketbase/ (nessun npm script: binario singolo, le migration si applicano da sole all'avvio)
npm run develop # Strapi con content-type builder attivo docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
npm run build # admin panel
npm run start # produzione
# stack completo (con Caddy e domini reali) # stack completo (con Caddy e domini reali)
docker compose up -d --build docker compose up -d --build
docker compose logs -f cms docker compose logs -f pocketbase
# stack locale senza domini né TLS: porte su localhost, niente Caddy # stack locale senza domini né TLS: porte su localhost, niente Caddy
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
``` ```
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio `docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
@@ -84,15 +88,16 @@ lanciare **un singolo test**.
| Type | Campi | | Type | Campi |
|---|---| |---|---|
| Article | title, slug (UID da title), content (Markdown), cover, category (rel) | | Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName |
| Category | name, slug | | Category | name, slug |
Deliberatamente minimale: **niente Author** (l'unico autore è l'admin), niente tag, nessun campo Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata
SEO separato. La meta description è ricavata dall'inizio del body (`summarise` in dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è
`frontend/server/utils/strapi.ts`), la data è il `publishedAt` di Draft & Publish, l'immagine `publishedAt`, l'immagine social è la cover. Non reintrodurre campi senza che servano davvero.
social è la cover. Non reintrodurre questi campi senza che servano davvero.
Draft & Publish attivo su Article. Le URL pubbliche usano lo **slug**, mai l'id numerico. PocketBase non ha Draft & Publish nativo: `publishedAt` vuoto = bozza, valorizzato (e non nel
futuro) = pubblicato, imposto dalla `listRule`/`viewRule` della collection `articles`, non da
codice applicativo. Le URL pubbliche usano lo **slug**, mai l'id del record.
## Frontend ## Frontend
@@ -101,9 +106,9 @@ Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
- SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto. - SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto.
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`, - `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
`composables/`, `layouts/`, `server/`). `composables/`, `layouts/`, `server/`).
- Un solo punto di accesso a Strapi: un composable/util tipizzato. Non sparpagliare `$fetch` nelle pagine. - Un solo punto di accesso a PocketBase: un composable/util tipizzato. Non sparpagliare `$fetch` nelle pagine.
- Tipizza esplicitamente il confine API. Niente `any``unknown` + narrowing. - Tipizza esplicitamente il confine API. Niente `any``unknown` + narrowing.
- Query Strapi: richiedi solo i campi e le relazioni che servono (`fields`, `populate` mirati). - Query PocketBase: richiedi solo i campi e le relazioni che servono (`fields`, `expand` mirati).
Gestisci sempre 404, lista vuota, errore API. Gestisci sempre 404, lista vuota, errore API.
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD - Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
`BlogPosting`, gerarchia heading semantica. Il contenuto deve esistere nell'HTML server-rendered. `BlogPosting`, gerarchia heading semantica. Il contenuto deve esistere nell'HTML server-rendered.
@@ -125,13 +130,14 @@ nuovo se non esiste una sezione adatta. Non lasciare `docs/` disallineata col co
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance. Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
Nessuna dipendenza, astrazione o servizio senza un bisogno concreto e attuale. Preferisci i Nessuna dipendenza, astrazione o servizio senza un bisogno concreto e attuale. Preferisci i
built-in Strapi al reimplementare funzioni CMS in Nuxt. built-in PocketBase (regole per-collection, migration) al reimplementare funzioni CMS in Nuxt.
## Vincoli ## Vincoli
- Mai committare `.env`, segreti, token, credenziali, chiavi. Mantieni `.env.example` sanificato. - Mai committare `.env`, segreti, token, credenziali, chiavi. Mantieni `.env.example` sanificato.
- Mai hard-codare domini di produzione, URL privilegiati o credenziali. Vanno in env var. - Mai hard-codare domini di produzione, URL privilegiati o credenziali. Vanno in env var.
- Non indebolire auth, CORS, TLS o security header per comodità. Least privilege sui ruoli Strapi. - Non indebolire auth, CORS, TLS o security header per comodità. Least privilege sulle regole
PocketBase.
- Richiedono **approvazione esplicita**: operazioni distruttive, migrazioni irreversibili, modifiche - Richiedono **approvazione esplicita**: operazioni distruttive, migrazioni irreversibili, modifiche
a dati o configurazione di produzione, cambi di credenziali. a dati o configurazione di produzione, cambi di credenziali.
- Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history, - Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history,
+42 -49
View File
@@ -1,15 +1,15 @@
# Blog # Blog
Blog platform: a public website built with Nuxt, and a private Strapi CMS where the Blog platform: a public website built with Nuxt, and a private PocketBase CMS where the
articles are written. Everything runs behind Caddy via Docker Compose. articles are written. Everything runs behind Caddy via Docker Compose.
```text ```text
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL Browser → Caddy ─┬─ /_/*, /api/* → PocketBase (CMS)
└─ everything else → Nuxt (website) └─ everything else → Nuxt (website), which also redirects /admin → /_/
``` ```
There are no front-end accounts: sign-up is disabled and only administrators write There are no front-end accounts: only the superuser writes content. An article is a
content. An article is a title, a Markdown body, a cover image and a category. title, a Markdown body, a cover image and a category.
## Development ## Development
@@ -22,37 +22,32 @@ through Caddy. Requires Docker.
cp .env.example .env cp .env.example .env
``` ```
**2. Generate the secrets.** Every `change-me` must become a different random value — **2. Set the admin credentials.** Replace `POCKETBASE_ADMIN_EMAIL` and
Strapi refuses to start otherwise. This fills them all: `POCKETBASE_ADMIN_PASSWORD` in `.env` with your own — this is the superuser account
PocketBase creates (or updates) on every start. The domain and URL variables can stay as
they are for local use.
```bash ```bash
for var in POSTGRES_PASSWORD API_TOKEN_SALT ADMIN_JWT_SECRET TRANSFER_TOKEN_SALT JWT_SECRET ENCRYPTION_KEY; do
sed -i "s|^$var=.*|$var=$(openssl rand -base64 32)|" .env
done
sed -i "s|^APP_KEYS=.*|APP_KEYS=$(openssl rand -base64 32),$(openssl rand -base64 32)|" .env
chmod 600 .env chmod 600 .env
``` ```
`grep change-me .env` must print nothing. The domain and URL variables can stay as they
are for local use.
**3. Start the stack** **3. Start the stack**
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
``` ```
The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on
`127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be `127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be
picked up by accident in production. picked up by accident in production.
**4. Create the administrator account** at http://localhost:1337/admin (dev bypasses Caddy, **4. Open the admin UI** at http://localhost:3000/admin (redirects to PocketBase's dashboard
so the CMS is reached directly on its port). This is the first — this works in dev too, without Caddy, since the redirect is handled by the frontend itself)
run, so the form creates the account — pick your own credentials. and log in with the credentials from `.env`.
**5. Write something.** In the admin panel: create a **Category**, then an **Article** **5. Write something.** In the admin UI: create a **categories** record, then an
(the body field is Markdown), then press **Publish** — the website only shows published **articles** record (the `content` field is Markdown), then set `publishedAt` — the
content. website only shows articles whose `publishedAt` is set and not in the future.
**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and **6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and
`/category/<slug>`. `/category/<slug>`.
@@ -60,16 +55,15 @@ content.
Useful commands: Useful commands:
```bash ```bash
docker compose logs -f cms # follow the CMS logs docker compose logs -f pocketbase # follow the CMS logs
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend
docker compose down # stop, keep the data docker compose down # stop, keep the data
docker compose down -v # stop and WIPE the database and media docker compose down -v # stop and WIPE the CMS database and media
``` ```
To iterate on the code without rebuilding an image every time, run a package directly — To iterate on the frontend without rebuilding an image every time: `cd frontend && npm run
`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to dev`. It defaults to `http://localhost:8090` for PocketBase, so it works against the
`http://localhost:1337` for Strapi, so it works against the containerised CMS as is; containerised CMS as is; override it with `NUXT_POCKETBASE_URL` if needed.
override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`.
## Production ## Production
@@ -78,14 +72,16 @@ public IP of the machine:
| Record | Purpose | | Record | Purpose |
|---|---| |---|---|
| `example.com` | the website and, at `/admin`, the Strapi admin panel | | `example.com` | the website, and, at `/admin`, the PocketBase admin UI |
Wait for the record to resolve before starting the stack — Caddy requests the Wait for the record to resolve before starting the stack — Caddy requests the
certificate on the first boot and a failed challenge means a retry delay. certificate on the first boot and a failed challenge means a retry delay.
**2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses **2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses
it for the ACME challenge and to redirect to HTTPS. PostgreSQL, Strapi and Nuxt are only it for the ACME challenge and to redirect to HTTPS. The admin UI shares port 443 with the
reachable inside the Docker network — do not publish their ports. site (reachable by anyone who knows `/admin`, protected only by the superuser login, so
keep the password strong); PocketBase itself is never published directly, only Caddy's
proxy to it.
**3. Configure the environment.** Copy `.env.example` to `.env` on the server and set: **3. Configure the environment.** Copy `.env.example` to `.env` on the server and set:
@@ -94,16 +90,15 @@ reachable inside the Docker network — do not publish their ports.
| `PUBLIC_DOMAIN` | `example.com` | | `PUBLIC_DOMAIN` | `example.com` |
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there | | `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
| `PUBLIC_SITE_URL` | `https://example.com` | | `PUBLIC_SITE_URL` | `https://example.com` |
| `PUBLIC_STRAPI_URL` | `https://example.com` — same origin, Caddy proxies `/admin` and Strapi's other plugin paths there (see `caddy/Caddyfile`) | | `PUBLIC_POCKETBASE_URL` | `https://example.com` — same origin, Caddy proxies `/_/` and `/api/` there |
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public | | `POCKETBASE_URL` | leave it as `http://pocketbase:8090` — internal address, never public |
| `POCKETBASE_ADMIN_EMAIL` / `POCKETBASE_ADMIN_PASSWORD` | your real superuser credentials |
Then generate **fresh** secrets on that machine with the same loop as in development — Keep `.env` out of version control; it is already ignored.
different values from the ones you use locally. Keep `.env` out of version control; it is
already ignored.
> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out. > Changing `POCKETBASE_ADMIN_PASSWORD` later and restarting rotates the superuser
> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values > password immediately (the entrypoint upserts it on every boot) — a credential change,
> unreadable. Set them once, then back up the file somewhere safe. > so treat it with the same care as any production credential rotation.
**4. Start everything** **4. Start everything**
@@ -111,24 +106,22 @@ already ignored.
docker compose up -d --build docker compose up -d --build
``` ```
This time Caddy is included: it serves both the website and, under `/admin` and Strapi's This time Caddy is included: it serves both the website and, under `/admin`, the CMS
other plugin paths, the CMS on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on admin UI on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on its own, and adds
its own, and adds HSTS and the other security headers. HSTS and the other security headers.
**5. Create the administrator account** at `https://example.com/admin`, immediately, **5. Log into the admin UI** at `https://example.com/admin` with the credentials from
before anyone else finds the URL — the first visitor to that form is the one who gets the `.env`, then publish as in development.
account. Then publish as in development.
**6. Back up what is not in git**: the `postgres-data` volume (all content) and the **6. Back up what is not in git**: the `pocketbase-data` volume (database and uploaded
`cms-uploads` volume (all images). Nothing else on the server holds state. media together). Nothing else on the server holds state.
```bash ```bash
docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql docker run --rm -v blog_pocketbase-data:/data -v "$PWD:/out" alpine tar czf /out/pocketbase-data.tar.gz -C /data .
docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data .
``` ```
**Updating a running site**: pull the new code, then `docker compose up -d --build`. **Updating a running site**: pull the new code, then `docker compose up -d --build`.
Strapi applies its own schema changes at startup; take a backup first. PocketBase applies new `pb_migrations/` files at startup; take a backup first.
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md). Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).
+14 -15
View File
@@ -16,24 +16,23 @@
import security_headers import security_headers
encode zstd gzip encode zstd gzip
# Strapi mounts each of its (and its plugins') admin APIs at its own # /admin is a vanity redirect to /_/, PocketBase's own fixed dashboard
# top-level path, not all under /admin - the admin panel's dashboard # route — handled by Nitro (frontend/server/routes/admin.get.ts), not
# widgets, media library, i18n, etc each call their own plugin prefix. # here, so it also works in dev where Caddy isn't in the stack.
# This list is every @strapi/* package in cms/package.json plus the
# core-bundled plugins (content-manager, content-type-builder, upload, # PocketBase's admin UI (/_/) and its own REST/file API (/api/*) both
# i18n, email, content-releases, review-workflows). Adding a new Strapi # reference themselves with root-absolute paths, so they must be
# plugin later means adding its prefix here too. # reachable unprefixed at the domain root — a path like /admin/api/...
# None of this touches /api, reserved for the frontend's own Nitro # with a stripped prefix would break the dashboard's own asset and API
# endpoints. # calls. This is why the frontend's Nitro endpoints live under
@cms path /admin* /content-manager* /content-type-builder* /upload*\ # /content/*, not /api/*: /api is reserved for PocketBase here.
/i18n* /email* /content-releases* /review-workflows*\ @pocketbase path /_/* /api/*
/users-permissions* /cloud* handle @pocketbase {
handle @cms { # Cover images and admin uploads pass through here too.
# Uploaded media can be large; Strapi's own limit still applies.
request_body { request_body {
max_size 100MB max_size 100MB
} }
reverse_proxy cms:1337 reverse_proxy pocketbase:8090
} }
handle { handle {
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
build
.strapi
.tmp
.env
.git
public/uploads/*
!public/uploads/.gitkeep
-9
View File
@@ -1,9 +0,0 @@
HOST=0.0.0.0
PORT=1337
APP_KEYS="toBeModified1,toBeModified2"
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified
STRAPI_TELEMETRY_DISABLED=true
-131
View File
@@ -1,131 +0,0 @@
############################
# 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
-19
View File
@@ -1,19 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /app
# Sharp (image processing) needs these at install time on Alpine.
RUN apk add --no-cache build-base python3 vips-dev
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:22-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache vips
ENV NODE_ENV=production
COPY --from=build /app /app
RUN mkdir -p public/uploads && chown -R node:node /app
USER node
EXPOSE 1337
CMD ["npm", "run", "start"]
-61
View File
@@ -1,61 +0,0 @@
# 🚀 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>
-25
View File
@@ -1,25 +0,0 @@
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')!,
},
flags: {
nps: env.bool('FLAG_NPS', false),
promoteEE: env.bool('FLAG_PROMOTE_EE', false),
docLinks: env.bool('FLAG_DOC_LINKS', true),
},
});
export default config;
-16
View File
@@ -1,16 +0,0 @@
import type { Core } from '@strapi/strapi';
const config: Core.Config.Api = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
strictParams: true,
},
documents: {
strictParams: true,
strictRelations: true,
},
};
export default config;
-72
View File
@@ -1,72 +0,0 @@
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;
-16
View File
@@ -1,16 +0,0 @@
import type { Core } from '@strapi/strapi';
const config: Core.Config.Middlewares = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
export default config;
-44
View File
@@ -1,44 +0,0 @@
import type { Core } from '@strapi/strapi';
const allowedMediaTypes = [
'image/*',
'video/*',
'audio/*',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.*',
'text/plain',
'text/csv',
];
const deniedExecutableTypes = [
'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',
];
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
'users-permissions': {
config: {
jwtManagement: 'refresh',
sessions: {
httpOnly: true,
},
},
},
upload: {
config: {
security: {
allowedTypes: allowedMediaTypes,
deniedTypes: deniedExecutableTypes,
},
},
},
});
export default config;
-14
View File
@@ -1,14 +0,0 @@
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),
app: {
keys: env.array('APP_KEYS')!,
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
export default config;
View File
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

-21551
View File
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
{
"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",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/database": "5.52.1",
"@strapi/plugin-users-permissions": "5.52.1",
"@strapi/strapi": "5.52.1",
"pg": "8.20.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": "a5980fd5-c31f-4ef1-8525-3dc2beb7b474",
"installId": "18a390c5c080d58c3b9f83ca5586b6d49a0dce670f8c2f91a34447fd019ecc15"
}
}
-3
View File
@@ -1,3 +0,0 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /
View File
-37
View File
@@ -1,37 +0,0 @@
import type { StrapiApp } from '@strapi/strapi/admin';
export default {
config: {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'da',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
},
bootstrap(app: StrapiApp) {
console.log(app);
},
};
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["../plugins/**/admin/src/**/*", "./"],
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
}
-12
View File
@@ -1,12 +0,0 @@
import { mergeConfig, type UserConfig } from 'vite';
export default (config: UserConfig) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
'@': '/src',
},
},
});
};
View File
@@ -1,41 +0,0 @@
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article",
"description": "Blog article"
},
"options": {
"draftAndPublish": true,
"populateCreatorFields": true
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 160
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"content": {
"type": "richtext",
"required": true
},
"cover": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category",
"inversedBy": "articles"
}
}
}
@@ -1,3 +0,0 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article');
-3
View File
@@ -1,3 +0,0 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article');
-3
View File
@@ -1,3 +0,0 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article');
@@ -1,31 +0,0 @@
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category",
"description": "Article category"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true,
"unique": true
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true
},
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "api::article.article",
"mappedBy": "category"
}
}
}
@@ -1,3 +0,0 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::category.category');
-3
View File
@@ -1,3 +0,0 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::category.category');
@@ -1,3 +0,0 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::category.category');
View File
-55
View File
@@ -1,55 +0,0 @@
import type { Core } from '@strapi/strapi';
/** Read-only endpoints the public website needs. Nothing else is granted. */
const PUBLIC_READ_ACTIONS = ['article', 'category'].flatMap((type) => [
`api::${type}.${type}.find`,
`api::${type}.${type}.findOne`,
]);
/**
* Grants the Public role read access to the blog content types, so a fresh
* deployment serves content without anyone clicking through the admin panel.
* Existing permissions are left untouched.
*/
async function grantPublicReadAccess(strapi: Core.Strapi) {
const publicRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
if (!publicRole) return;
for (const action of PUBLIC_READ_ACTIONS) {
const existing = await strapi
.query('plugin::users-permissions.permission')
.findOne({ where: { action, role: publicRole.id } });
if (!existing) {
await strapi
.query('plugin::users-permissions.permission')
.create({ data: { action, role: publicRole.id } });
}
}
}
/**
* The site has no front-end accounts: only the administrators authoring content
* in the admin panel. Self-registration is therefore closed, so nobody can
* create an Authenticated user through the public API.
*/
async function disablePublicSignUp(strapi: Core.Strapi) {
const store = strapi.store({ type: 'plugin', name: 'users-permissions', key: 'advanced' });
const advanced = ((await store.get({ key: 'advanced' })) ?? {}) as Record<string, unknown>;
if (advanced.allow_register !== false) {
await store.set({ key: 'advanced', value: { ...advanced, allow_register: false } });
}
}
export default {
register() {},
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
await grantPublicReadAccess(strapi);
await disablePublicSignUp(strapi);
},
};
-44
View File
@@ -1,44 +0,0 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2020"],
"target": "ES2019",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmitOnError": true,
"noImplicitThis": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
// Include root files
"./",
// Include all ts files
"./**/*.ts",
// Include all js files
"./**/*.js",
// Force the JSON files in the src folder to be included
"src/**/*.json"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
".cache/",
".tmp/",
".strapi/",
// Do not include admin files in the server compilation
"src/admin/",
// Do not include test files
"**/*.test.*",
// Do not include plugins in the server compilation
"src/plugins/**"
]
}
+4 -8
View File
@@ -1,18 +1,14 @@
# Local testing without domains or TLS: publishes the app ports on localhost and # Local testing without domains or TLS: publishes the app ports on localhost and
# leaves Caddy out. Never used in production — it must be passed explicitly: # leaves Caddy out. Never used in production — it must be passed explicitly:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build cms frontend # docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
services: services:
database: pocketbase:
ports: ports:
- "127.0.0.1:5432:5432" - "127.0.0.1:8090:8090"
cms:
ports:
- "127.0.0.1:1337:1337"
frontend: frontend:
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
environment: environment:
NUXT_PUBLIC_SITE_URL: http://localhost:3000 NUXT_PUBLIC_SITE_URL: http://localhost:3000
NUXT_PUBLIC_STRAPI_URL: http://localhost:1337 NUXT_PUBLIC_POCKETBASE_URL: http://localhost:8090
+12 -39
View File
@@ -1,64 +1,38 @@
services: services:
database: pocketbase:
image: postgres:17-alpine build: ./pocketbase
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB} POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL}
POSTGRES_USER: ${POSTGRES_USER} POCKETBASE_ADMIN_PASSWORD: ${POCKETBASE_ADMIN_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - pocketbase-data:/pb/pb_data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8090/api/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
cms:
build: ./cms
restart: unless-stopped
depends_on:
database:
condition: service_healthy
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 1337
DATABASE_CLIENT: postgres
DATABASE_HOST: database
DATABASE_PORT: 5432
DATABASE_NAME: ${POSTGRES_DB}
DATABASE_USERNAME: ${POSTGRES_USER}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
APP_KEYS: ${APP_KEYS}
API_TOKEN_SALT: ${API_TOKEN_SALT}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
STRAPI_TELEMETRY_DISABLED: "true"
volumes:
- cms-uploads:/app/public/uploads
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- cms pocketbase:
condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 3000 PORT: 3000
NUXT_STRAPI_URL: ${STRAPI_URL} NUXT_POCKETBASE_URL: ${POCKETBASE_URL}
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL} NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
NUXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL} NUXT_PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- frontend - frontend
- cms - pocketbase
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
@@ -76,7 +50,6 @@ services:
- caddy-config:/config - caddy-config:/config
volumes: volumes:
postgres-data: pocketbase-data:
cms-uploads:
caddy-data: caddy-data:
caddy-config: caddy-config:
+6 -6
View File
@@ -6,7 +6,7 @@ the root [README.md](../README.md); for coding conventions and constraints, see
depth than either of those. depth than either of those.
- [architecture.md](./architecture.md) — services, routing, request flow, environment variables. - [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
- [content-model.md](./content-model.md) — Strapi content types, admin panel, permissions, editorial workflow. - [content-model.md](./content-model.md) — PocketBase collections, admin panel, permissions, editorial workflow.
- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO. - [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
## What this repository is ## What this repository is
@@ -15,9 +15,9 @@ A blog website with two faces:
- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered, - **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
no login, no client-side calls to the CMS. no login, no client-side calls to the CMS.
- **Admin panel** — the site owner logs into Strapi's admin UI (`/admin`) to write, edit and - **Admin panel** — the site owner logs into PocketBase's admin UI (`/admin`, redirects to `/_/`)
publish articles and categories. This is the only way content changes; there is no other CMS to write, edit and publish articles and categories. This is the only way content changes; there
and no public user accounts. is no other CMS and no public user accounts.
One codebase, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a One codebase, two runtime components (Nuxt frontend, PocketBase — CMS and database in one
single Caddy reverse proxy on one domain. process), fronted by a single Caddy reverse proxy on one domain.
+58 -53
View File
@@ -2,22 +2,22 @@
## Services ## Services
Four containers (production, see [docker-compose.yml](../docker-compose.yml)): Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
```text ```text
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database) Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal) └─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
("/admin" redirects to /_/, handled by Nitro)
``` ```
- **database**`postgres:17-alpine`. Not exposed publicly; only reachable by `cms` on the - **pocketbase**a single PocketBase binary, the sole source of editorial truth. Embedded
Docker network. Data on the `postgres-data` volume. SQLite, no separate database service. No other backend framework exists in this repo; any
- **cms** — Strapi 5, the sole source of editorial truth. No other backend framework exists in server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new
this repo; any server-side logic that isn't content management belongs in Nuxt's Nitro server, service.
not in a new service.
- **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints - **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints
under `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi. under `/content/*` (Nitro), which are the only code in the repo allowed to call PocketBase.
- **caddy** — single reverse proxy, single TLS certificate, single public domain - **caddy** — single reverse proxy, single TLS certificate, single public domain
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain. (`PUBLIC_DOMAIN`). Routes by **path**, not subdomain or port.
`docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy) `docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy)
and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml` and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml`
@@ -26,74 +26,79 @@ production.
## Path routing (Caddy) ## Path routing (Caddy)
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`, one path matcher See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
``` - `@pocketbase path /_/* /api/*``pocketbase:8090`, **unprefixed** (100MB body limit, for
/admin* /content-manager* /content-type-builder* /upload* /i18n* uploads).
/email* /content-releases* /review-workflows* /users-permissions* /cloud* - everything else → `frontend:3000`.
```
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything `/admin` is **not** a Caddy rule: it's a Nitro route
else — including `/api/*` — goes to `frontend:3000`. (`frontend/server/routes/admin.get.ts`) that redirects to `${pocketbaseUrl}/_/` (PocketBase's own
fixed dashboard route, since it can't be told to serve elsewhere). Handling it in Nitro rather
than Caddy means it works identically in dev, where Caddy isn't part of the stack — `/admin`
redirects to `http://localhost:8090/_/` there — and in production, where it redirects to the same
origin's `/_/`, which Caddy then proxies to PocketBase.
**`/api/*` is reserved for Nuxt's own Nitro endpoints, never for Strapi.** Strapi's public REST **Why unprefixed, not a stripped `/admin/*` prefix:** PocketBase's admin dashboard references its
API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the own assets and API with paths rooted at `/_/` and `/api/`. A reverse-proxy rule that rewrites
Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content `/admin/foo``/foo` before forwarding would serve the dashboard's HTML fine, but every asset and
— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated). API call the dashboard's own JS makes afterwards targets `/_/...`/`/api/...` directly — those
requests would then miss the `/admin` prefix and never reach the rewrite rule, landing on Nuxt
instead and breaking the dashboard. Routing `/_/*` and `/api/*` at the domain root, unprefixed, is
the only configuration PocketBase's own code is written to expect (confirmed against a live
container: dashboard HTML, its JS/CSS assets under `/_/assets/...`, and REST calls under
`/api/...` all resolve correctly this way). This is also PocketBase's own documented recommendation
for reverse-proxy deployments.
If you add a Strapi plugin that mounts its own admin API path, add its prefix to `@cms` in the **`/api/*` is reserved for PocketBase here — the inverse of the old Strapi setup.** Nuxt's own
Caddyfile — this is the one place that list is maintained. Nitro endpoints live under `/content/*` instead (`frontend/server/routes/content/`, not
`frontend/server/api/`, since Nitro auto-prefixes anything under `server/api/` with `/api`).
PocketBase's public REST API is reached two ways: from inside the Docker network by the Nitro
server, over `POCKETBASE_URL=http://pocketbase:8090`; and directly by the browser for the two
things that don't go through Nitro — the admin UI and cover images.
## Request flow: reading an article ## Request flow: reading an article
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR. 1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/api/articles/my-slug')` — a same-origin 2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/content/articles/my-slug')` — a
call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip over the same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip
network in production). over the network in production).
3. `frontend/server/api/articles/[slug].get.ts` calls `strapiFetch()` (in 3. `frontend/server/routes/content/articles/[slug].get.ts` calls `pbFetch()` (in
`frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a `frontend/server/utils/pocketbase.ts`), which hits `POCKETBASE_URL` (internal Docker address)
Strapi-specific query built by `frontend/server/utils/queries.ts`. with a PocketBase filter/fields query built by `frontend/server/utils/queries.ts`. PocketBase's
`listRule`/`viewRule` on the `articles` collection already exclude unpublished entries — the
endpoint doesn't need to check that itself.
4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via 4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via
`renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is `renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
`Article` from `frontend/shared/types/blog.ts`. `Article` from `frontend/shared/types/blog.ts`.
5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to 5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to
the client, and the article body is present in the server-rendered HTML for SEO/crawlers. the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
6. The cover image `<img>` tag points directly at `PUBLIC_STRAPI_URL/uploads/...` — the only 6. The cover image `<img>` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset
asset the browser fetches straight from Strapi. the browser fetches straight from PocketBase.
## Request flow: publishing content ## Request flow: publishing content
1. Editor logs into `/admin` (Strapi admin panel, authenticated, editor/admin only — no public 1. Editor goes to `PUBLIC_SITE_URL/admin` (redirects to `/_/`, PocketBase's admin UI —
sign-up, see [content-model.md](./content-model.md)). authenticated superuser only, no public sign-up, see [content-model.md](./content-model.md)).
2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish). 2. Editor writes/edits an Article (Markdown body) or Category, and sets `publishedAt` to publish
3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that it.
slug hits Strapi live through the Nitro endpoint. 3. PocketBase writes to its embedded SQLite database (on the `pocketbase-data` volume). No cache
to invalidate: the next public request for that slug hits PocketBase live through the Nitro
endpoint.
## Environment variables ## Environment variables
Defined in [.env.example](../.env.example) (root, drives docker-compose) and Defined in [.env.example](../.env.example) (root, drives docker-compose).
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
Docker).
| Variable | Consumed by | Purpose | | Variable | Consumed by | Purpose |
|---|---|---| |---|---|---|
| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. | | `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. | | `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. | | `POCKETBASE_ADMIN_EMAIL` | pocketbase | Bootstraps (and keeps up to date, on every restart) the initial superuser account. |
| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). | | `POCKETBASE_ADMIN_PASSWORD` | pocketbase | Password for the superuser above. Rotating it is a credential change — see `CLAUDE.md`. |
| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. | | `POCKETBASE_URL` | frontend (server-only) | Internal Docker address of PocketBase (`http://pocketbase:8090`); mapped to `NUXT_POCKETBASE_URL`. Never sent to the browser. |
| `ADMIN_JWT_SECRET` | cms | Signs Strapi admin panel JWTs. |
| `TRANSFER_TOKEN_SALT` | cms | Salt for Strapi data-transfer tokens. |
| `JWT_SECRET` | cms | Signs users-permissions (public API) JWTs. |
| `ENCRYPTION_KEY` | cms | Strapi's encrypted-field key. |
| `STRAPI_URL` | frontend (server-only) | Internal Docker address of Strapi (`http://cms:1337`); mapped to `NUXT_STRAPI_URL`. Never sent to the browser. |
| `NUXT_STRAPI_TOKEN` | frontend (server-only) | Optional Bearer token for Strapi requests; unset by default. |
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. | | `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
| `PUBLIC_STRAPI_URL` | frontend, browser | Public-facing Strapi origin for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_STRAPI_URL`. | | `PUBLIC_POCKETBASE_URL` | frontend, browser | Public-facing PocketBase origin (same domain as `PUBLIC_SITE_URL`; Caddy proxies `/_/*` and `/api/*` there) for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_POCKETBASE_URL`. |
All Strapi secrets have standalone copies in `cms/.env.example` for local development without
Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
only). only).
+51 -40
View File
@@ -1,63 +1,74 @@
# Content model & admin panel # Content model & admin panel
## Content types ## Collections
Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory Defined as code in `pocketbase/pb_migrations/*.js`, applied automatically the first time
defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers, PocketBase boots against an empty data directory (and on every subsequent deploy that adds new
routes, services, or policies exist for either type. migration files). No custom Go/JS hooks exist for either collection — schema and API rules only.
| Type | Fields | | Collection | Fields |
|---|---| |---|---|
| **Article** | `title` (string, required, max 160), `slug` (UID generated from title), `content` (richtext — Markdown source), `cover` (single media/image), `category` (many-to-one relation to Category) | | **articles** | `title` (text, required, max 160), `slug` (text, required, unique, kebab-case pattern), `content` (text — Markdown source, not the WYSIWYG `editor` field type), `cover` (single file, images only), `coverAlt` (text, alt text), `category` (relation to `categories`), `publishedAt` (date, nullable), `authorName` (text) |
| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) | | **categories** | `name` (text, required, unique), `slug` (text, required, unique, kebab-case pattern) |
Deliberately minimal: no Author (the only author is the admin account), no tags, no separate SEO Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete
fields. Don't add these back without a concrete need — see `CLAUDE.md`: need — see `CLAUDE.md`:
- Meta description is derived at request time from the article body (`summarise()` in - Meta description is derived at request time from the article body (`summarise()` in
`frontend/server/utils/strapi.ts`). `frontend/server/utils/pocketbase.ts`).
- Publish date is Draft & Publish's `publishedAt`. - Publish date is `publishedAt`.
- Social preview image is the cover. - Social preview image is the cover.
- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`), - The article byline is `authorName`, a plain field the editor fills in — PocketBase has no
not a content field. built-in "creator" metadata to auto-populate it from, unlike Strapi's `createdBy`.
Draft & Publish is enabled on Article (`draftAndPublish: true`) and disabled on Category There is no native Draft & Publish in PocketBase. `publishedAt` reproduces its semantics
(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the explicitly: empty = draft (never returned by the public API), set and not in the future =
numeric id. published. This is enforced by the collection's `listRule`/`viewRule`
(`publishedAt != '' && publishedAt <= @now`), not by application code — an unpublished or
future-dated article is invisible to `GET /api/collections/articles/records` regardless of what
Nitro asks for. Categories have no draft state (`slug` is unique so a category always exists or
doesn't). Public URLs always use the slug, never the PocketBase record id.
PocketBase file fields store only a filename, no width/height/alt metadata — that's why `coverAlt`
is a real field rather than upload metadata, and why the frontend's `MediaImage` type carries no
dimensions (see [frontend.md](./frontend.md)).
## Admin login & permissions ## Admin login & permissions
- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container - The admin panel lives at `/_/` (also reachable via `/admin`, which just redirects there),
([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard routed by Caddy straight to the `pocketbase` container
email/password admin authentication — no custom auth code in this repo. ([architecture.md](./architecture.md#path-routing-caddy)). It's PocketBase's standard
superuser email/password auth — no custom auth code in this repo.
- Public visitors **never** authenticate. There is no visitor account system, no comments, no - Public visitors **never** authenticate. There is no visitor account system, no comments, no
public write access of any kind. public write access of any kind.
- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start: - Each collection's `createRule`/`updateRule`/`deleteRule` is `null` — writes are superuser-only,
1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article full stop. There is no separate "editor" role: the site owner is the only superuser, matching
and Category, and nothing else (no create/update/delete, no other content type). This is "no front-end user accounts should ever exist."
what lets the Nitro endpoints read published content without a token. - `listRule`/`viewRule` on `articles` are `publishedAt != '' && publishedAt <= @now` (public read
2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration of published content only); on `categories` they're `''` (public read, unconditional — categories
(`allow_register: false`), since no front-end user accounts should ever exist. aren't drafted).
- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a - `cover`'s field config restricts uploads to images (PNG/JPEG/WEBP/AVIF/GIF), 10MB max — the
deliberate least-privilege boundary, not an oversight. equivalent of Strapi's old upload-plugin MIME allowlist, now expressed per-field instead of
- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images, globally.
video, audio, PDF, office docs, text/CSV) and explicitly denies executables. - Any rule change beyond this is a deliberate least-privilege boundary, not an oversight, and
needs to be justified explicitly.
## Editorial workflow ## Editorial workflow
1. Log into `/admin`. 1. Log into `PUBLIC_SITE_URL/admin` (redirects to `/_/`).
2. Create/edit a Category if needed (name → slug is generated automatically). 2. Create/edit a Category if needed.
3. Create/edit an Article: title (→ slug), Markdown content, cover image, category. 3. Create/edit an Article: title, slug, Markdown content, cover image + alt text, category,
4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne` author name.
permissions above — Strapi's default behavior already excludes non-published entries from the 4. Set `publishedAt` (to now, or a past/future date) to publish it. Leaving it empty keeps the
public API. article a draft, invisible to the public API.
5. The change is live immediately: the public site has no cache layer to invalidate (see 5. The change is live immediately: the public site has no cache layer to invalidate (see
[architecture.md](./architecture.md#request-flow-reading-an-article)). [architecture.md](./architecture.md#request-flow-reading-an-article)).
## Why Markdown, not a rich-text/WYSIWYG field ## Why Markdown, not a rich-text/WYSIWYG field
`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML `content` is a plain PocketBase `text` field, not the `editor` field type (which stores HTML).
happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never PocketBase stores the raw Markdown; conversion to HTML happens once, server-side, in the Nuxt
in Strapi and never in the browser. This keeps `marked` out of the client bundle and keeps HTML Nitro endpoint (`renderMarkdown()`, using `marked`) — never in PocketBase and never in the browser.
generation in one place. There is no sanitization step: this is intentional, since the only author This keeps `marked` out of the client bundle and keeps HTML generation in one place. There is no
is the trusted admin, not arbitrary users. sanitization step: this is intentional, since the only author is the trusted superuser, not
arbitrary users.
+37 -25
View File
@@ -4,44 +4,54 @@
| Route | File | Behavior | | Route | File | Behavior |
|---|---|---| |---|---|---|
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/api/articles` (page 1), shown as a featured block. | | `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/content/articles` (page 1), shown as a featured block. |
| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. | | `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/api/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. | | `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/content/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. | | `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no Strapi data. | | `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data. |
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered. All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
## Server (Nitro) endpoints — the only Strapi client ## Server (Nitro) endpoints — the only PocketBase client
`frontend/server/api/`: `frontend/server/routes/content/` (not `server/api/``/api/*` is reserved for PocketBase itself,
see [architecture.md](./architecture.md#path-routing-caddy); Nitro maps `server/routes/**` to the
matching path with no added prefix, unlike `server/api/**`):
| Endpoint | Purpose | | Endpoint | Purpose |
|---|---| |---|---|
| `GET /api/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Proxies to Strapi with `ARTICLE_SUMMARY_QUERY`. Returns `Paginated<ArticleSummary>`. | | `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Queries PocketBase with `ARTICLE_SUMMARY_FIELDS`. Returns `Paginated<ArticleSummary>`. |
| `GET /api/articles/:slug` | One article: fetches from Strapi with `ARTICLE_DETAIL_QUERY` (includes `content` + `createdBy`), converts Markdown to HTML, builds the meta description, derives the author byline. 400 without a slug, 404 if not found. Returns `Article`. | | `GET /content/articles/:slug` | One article: queries PocketBase with `ARTICLE_DETAIL_FIELDS` (includes `content` + `authorName`), converts Markdown to HTML, builds the meta description. 400 without a slug, 404 if not found or unpublished. Returns `Article`. |
| `GET /api/categories` | All categories (name + slug only), sorted by name. | | `GET /content/categories` | All categories (name + slug only), sorted by name. |
| `GET /api/categories/:slug` | One category by slug. 400/404 as above. | | `GET /content/categories/:slug` | One category by slug. 400/404 as above. |
This is the **single point of contact** with Strapi (`CLAUDE.md` rule): pages never call This is the **single point of contact** with PocketBase (`CLAUDE.md` rule): pages never call
`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client. `$fetch` against PocketBase directly, and `NUXT_POCKETBASE_URL` never reaches the client. If you
If you add a view that needs new data, add the endpoint here and type its return in add a view that needs new data, add the endpoint here (under `/content/*`) and type its return in
`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components. `frontend/shared/types/blog.ts` — don't scatter PocketBase calls into components.
`GET /admin` (`frontend/server/routes/admin.get.ts`) is the one other top-level server route: a
redirect to `${runtimeConfig.public.pocketbaseUrl}/_/`, PocketBase's own fixed dashboard path. It
lives in Nitro rather than Caddy so it works the same in dev (no Caddy in that stack) and
production — see [architecture.md](./architecture.md#path-routing-caddy).
## Supporting utilities ## Supporting utilities
- `frontend/server/utils/strapi.ts` - `frontend/server/utils/pocketbase.ts`
- `strapiFetch<T>(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional - `pbFetch<T>(path)` — server-only fetch against `runtimeConfig.pocketbaseUrl`; wraps failures
Bearer token; wraps failures as a 502 so internal details never leak to the client. as a 502 so internal details never leak to the client.
- `toMediaImage(collection, id, filename, alt)` — builds a PocketBase file URL
(`/api/files/{collection}/{id}/{filename}`) from a record; returns `null` when there's no
file.
- `renderMarkdown(source)``marked.parse()`, no sanitization (trusted, admin-only content). - `renderMarkdown(source)``marked.parse()`, no sanitization (trusted, admin-only content).
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta - `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
description, word-boundary clipped. description, word-boundary clipped.
- `frontend/server/utils/queries.ts`Strapi query-string builders kept intentionally minimal - `frontend/server/utils/queries.ts`PocketBase query-string builders kept intentionally minimal
(only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`, (only the fields each page actually renders): `ARTICLE_SUMMARY_FIELDS`, `ARTICLE_DETAIL_FIELDS`,
`PAGE_SIZE`, `pagination()`, `pageParam()`. `PAGE_SIZE`, `pagination()`, `pageParam()`, `quote()` (escapes a value for PocketBase's `filter`
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object DSL).
into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already - `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a `MediaImage` into an
absolute. absolute browser URL by prefixing `runtimeConfig.public.pocketbaseUrl` unless already absolute.
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`). - `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
- `frontend/shared/utils/format.ts``it-IT` date formatting (`formatDate`, `formatDateTime`, - `frontend/shared/utils/format.ts``it-IT` date formatting (`formatDate`, `formatDateTime`,
`isoDate`). `isoDate`).
@@ -49,15 +59,17 @@ If you add a view that needs new data, add the endpoint here and type its return
## Types (`frontend/shared/types/blog.ts`) ## Types (`frontend/shared/types/blog.ts`)
``` ```
StrapiImage { url, alternativeText, width, height } MediaImage { url, alt }
Category { name, slug } Category { name, slug }
ArticleSummary{ title, slug, publishedAt, cover: StrapiImage | null, category: Category | null } ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | null }
Article extends ArticleSummary { html, summary, author: string | null } Article extends ArticleSummary { html, summary, author: string | null }
Paginated<T> { items: T[], page, pageCount, total } Paginated<T> { items: T[], page, pageCount, total }
``` ```
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what `Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
listing pages use. listing pages use. `MediaImage` carries no width/height — PocketBase file fields don't store
dimensions, and the covers already reserve their aspect ratio via CSS (`aspect-ratio`), so no
layout shift results.
## Layout & shared components ## Layout & shared components
+1 -3
View File
@@ -21,9 +21,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
<img <img
class="cover" class="cover"
:src="cover" :src="cover"
:alt="article.cover?.alternativeText ?? ''" :alt="article.cover?.alt ?? ''"
:width="article.cover?.width ?? undefined"
:height="article.cover?.height ?? undefined"
loading="lazy" loading="lazy"
> >
</NuxtLink> </NuxtLink>
+5 -5
View File
@@ -1,14 +1,14 @@
import type { StrapiImage } from '#shared/types/blog' import type { MediaImage } from '#shared/types/blog'
/** /**
* Strapi returns media paths relative to its own origin; the browser needs * PocketBase returns file paths relative to its own origin; the browser
* absolute ones. Remote providers (S3) already return absolute URLs. * needs absolute ones.
*/ */
export function useMediaUrl() { export function useMediaUrl() {
const { public: config } = useRuntimeConfig() const { public: config } = useRuntimeConfig()
return (image: StrapiImage | null | undefined): string | null => { return (image: MediaImage | null | undefined): string | null => {
if (!image?.url) return null if (!image?.url) return null
return image.url.startsWith('http') ? image.url : `${config.strapiUrl}${image.url}` return image.url.startsWith('http') ? image.url : `${config.pocketbaseUrl}${image.url}`
} }
} }
+2 -4
View File
@@ -4,7 +4,7 @@ import type { Article } from '#shared/types/blog'
const route = useRoute() const route = useRoute()
const slug = route.params.slug as string const slug = route.params.slug as string
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`) const { data: article } = await useFetch<Article>(`/content/articles/${slug}`)
if (!article.value) { if (!article.value) {
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true }) throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
@@ -79,9 +79,7 @@ useHead({
v-if="cover" v-if="cover"
class="cover" class="cover"
:src="cover" :src="cover"
:alt="article.cover?.alternativeText ?? ''" :alt="article.cover?.alt ?? ''"
:width="article.cover?.width ?? undefined"
:height="article.cover?.height ?? undefined"
> >
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown --> <!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
+1 -1
View File
@@ -8,7 +8,7 @@ const page = computed(() => {
return Number.isInteger(value) && value > 0 ? value : 1 return Number.isInteger(value) && value > 0 ? value : 1
}) })
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', { const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
query: { page }, query: { page },
}) })
+2 -2
View File
@@ -9,13 +9,13 @@ const page = computed(() => {
return Number.isInteger(value) && value > 0 ? value : 1 return Number.isInteger(value) && value > 0 ? value : 1
}) })
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`) const { data: category } = await useFetch<Category>(`/content/categories/${slug}`)
if (!category.value) { if (!category.value) {
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true }) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
} }
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', { const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
query: { page, category: slug }, query: { page, category: slug },
}) })
+2 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog' import type { ArticleSummary, Paginated } from '#shared/types/blog'
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', { const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
key: 'home-articles', key: 'home-articles',
query: { page: 1 }, query: { page: 1 },
}) })
@@ -82,9 +82,7 @@ useSeoMeta({
> >
<img <img
:src="featuredCover" :src="featuredCover"
:alt="featured.cover?.alternativeText ?? ''" :alt="featured.cover?.alt ?? ''"
:width="featured.cover?.width ?? undefined"
:height="featured.cover?.height ?? undefined"
> >
</NuxtLink> </NuxtLink>
+4 -5
View File
@@ -20,14 +20,13 @@ export default defineNuxtConfig({
}, },
runtimeConfig: { runtimeConfig: {
// Server-only: internal Strapi address, never exposed to the browser. // Server-only: internal PocketBase address, never exposed to the browser.
strapiUrl: 'http://localhost:1337', pocketbaseUrl: 'http://localhost:8090',
strapiToken: '',
public: { public: {
// Canonical origin of the public website. // Canonical origin of the public website.
siteUrl: 'http://localhost:3000', siteUrl: 'http://localhost:3000',
// Browser-reachable Strapi origin, used to build absolute media URLs. // Browser-reachable PocketBase origin, used to build absolute media URLs.
strapiUrl: 'http://localhost:1337', pocketbaseUrl: 'http://localhost:8090',
}, },
}, },
@@ -1,43 +0,0 @@
import type { Article } from '#shared/types/blog'
interface AdminUser {
firstname: string | null
lastname: string | null
}
type RawArticle = Omit<Article, 'html' | 'summary' | 'author'> & {
content: string | null
createdBy?: AdminUser | null
}
/** Joins the admin user's name parts; returns null when neither is set. */
function byline(user: AdminUser | null | undefined): string | null {
const name = [user?.firstname, user?.lastname].filter(Boolean).join(' ').trim()
return name || null
}
export default defineEventHandler(async (event): Promise<Article> => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
}
const response = await strapiFetch<StrapiList<RawArticle>>(
`/api/articles?${ARTICLE_DETAIL_QUERY}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
)
const article = response.data[0]
if (!article) {
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}
const { content, createdBy, ...rest } = article
return {
...rest,
html: renderMarkdown(content),
summary: summarise(content),
author: byline(createdBy),
}
})
-22
View File
@@ -1,22 +0,0 @@
import type { ArticleSummary, Paginated } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
const query = getQuery(event)
const page = pageParam(query.page)
const category = typeof query.category === 'string' ? query.category : null
const filter = category
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
: ''
const response = await strapiFetch<StrapiList<ArticleSummary>>(
`/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
)
return {
items: response.data,
page: response.meta.pagination.page,
pageCount: response.meta.pagination.pageCount,
total: response.meta.pagination.total,
}
})
@@ -1,9 +0,0 @@
import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (): Promise<Category[]> => {
const response = await strapiFetch<StrapiList<Category>>(
'/api/categories?fields[0]=name&fields[1]=slug&sort[0]=name:asc&pagination[pageSize]=100'
)
return response.data
})
+9
View File
@@ -0,0 +1,9 @@
/**
* Vanity redirect to the PocketBase admin UI, whose own route is fixed at
* `/_/`. Handled here (not in Caddy) so it works the same in dev, where
* Caddy isn't in the stack, and in production.
*/
export default defineEventHandler((event) => {
const { public: config } = useRuntimeConfig()
return sendRedirect(event, `${config.pocketbaseUrl}/_/`, 302)
})
@@ -0,0 +1,42 @@
import type { Article, Category } from '#shared/types/blog'
interface RawArticle {
id: string
title: string
slug: string
publishedAt: string
content: string | null
cover: string
coverAlt: string | null
authorName: string | null
expand?: { category?: Category }
}
export default defineEventHandler(async (event): Promise<Article> => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
}
const response = await pbFetch<PbList<RawArticle>>(
`/api/collections/articles/records?fields=${ARTICLE_DETAIL_FIELDS}&expand=category&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&${pagination(1, 1)}`
)
const article = response.items[0]
if (!article) {
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}
return {
title: article.title,
slug: article.slug,
publishedAt: article.publishedAt,
cover: toMediaImage('articles', article.id, article.cover, article.coverAlt),
category: article.expand?.category ?? null,
html: renderMarkdown(article.content),
summary: summarise(article.content),
author: article.authorName || null,
}
})
@@ -0,0 +1,42 @@
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
interface RawArticleSummary {
id: string
title: string
slug: string
publishedAt: string
cover: string
coverAlt: string | null
expand?: { category?: Category }
}
function toSummary(raw: RawArticleSummary): ArticleSummary {
return {
title: raw.title,
slug: raw.slug,
publishedAt: raw.publishedAt,
cover: toMediaImage('articles', raw.id, raw.cover, raw.coverAlt),
category: raw.expand?.category ?? null,
}
}
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
const query = getQuery(event)
const page = pageParam(query.page)
const category = typeof query.category === 'string' ? query.category : null
const filter = category
? `&filter=${encodeURIComponent(`category.slug = ${quote(category)}`)}`
: ''
const response = await pbFetch<PbList<RawArticleSummary>>(
`/api/collections/articles/records?fields=${ARTICLE_SUMMARY_FIELDS}&expand=category&sort=-publishedAt&${pagination(page)}${filter}`
)
return {
items: response.items.map(toSummary),
page: response.page,
pageCount: response.totalPages,
total: response.totalItems,
}
})
@@ -7,11 +7,11 @@ export default defineEventHandler(async (event): Promise<Category> => {
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' }) throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
} }
const response = await strapiFetch<StrapiList<Category>>( const response = await pbFetch<PbList<Category>>(
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1` `/api/collections/categories/records?fields=name,slug&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
) )
const category = response.data[0] const category = response.items[0]
if (!category) { if (!category) {
throw createError({ statusCode: 404, statusMessage: 'Category not found' }) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
@@ -0,0 +1,9 @@
import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (): Promise<Category[]> => {
const response = await pbFetch<PbList<Category>>(
'/api/collections/categories/records?fields=name,slug&sort=name&perPage=100'
)
return response.items
})
@@ -1,19 +1,17 @@
import { marked } from 'marked' import { marked } from 'marked'
import type { MediaImage } from '#shared/types/blog'
/** /**
* Calls the Strapi REST API from the server only, so the internal URL and any * Calls the PocketBase REST API from the server only, so the internal URL
* future API token never reach the browser. * never reaches the browser.
*/ */
export async function strapiFetch<T>(path: string): Promise<T> { export async function pbFetch<T>(path: string): Promise<T> {
const { strapiUrl, strapiToken } = useRuntimeConfig() const { pocketbaseUrl } = useRuntimeConfig()
try { try {
return (await $fetch(path, { return (await $fetch(path, { baseURL: pocketbaseUrl })) as T
baseURL: strapiUrl,
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
})) as T
} catch (error) { } catch (error) {
console.error(`Strapi request failed: ${path}`, error) console.error(`PocketBase request failed: ${path}`, error)
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' }) throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
} }
} }
@@ -48,7 +46,24 @@ export function summarise(source: string | null | undefined, maxLength = 155): s
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}` return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}`
} }
export interface StrapiList<T> { export interface PbList<T> {
data: T[] page: number
meta: { pagination: { page: number; pageCount: number; total: number } } perPage: number
totalItems: number
totalPages: number
items: T[]
}
/**
* Builds a PocketBase file URL, relative to its own origin PocketBase file
* fields store only a filename, not a full path or dimensions.
*/
export function toMediaImage(
collection: string,
id: string,
filename: string | null | undefined,
alt: string | null | undefined
): MediaImage | null {
if (!filename) return null
return { url: `/api/files/${collection}/${id}/${filename}`, alt: alt || null }
} }
+9 -22
View File
@@ -1,33 +1,20 @@
/** Strapi query fragments. Only the fields the pages actually render are requested. */ /** PocketBase query fragments. Only the fields the pages actually render are requested. */
const list = (prefix: string, names: readonly string[]) => export const ARTICLE_SUMMARY_FIELDS =
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&') 'id,title,slug,publishedAt,cover,coverAlt,expand.category.name,expand.category.slug'
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const export const ARTICLE_DETAIL_FIELDS =
'id,title,slug,content,publishedAt,cover,coverAlt,authorName,expand.category.name,expand.category.slug'
export const ARTICLE_SUMMARY_QUERY = [
list('fields', ['title', 'slug', 'publishedAt']),
list('populate[cover][fields]', IMAGE_FIELDS),
list('populate[category][fields]', ['name', 'slug']),
'sort[0]=publishedAt:desc',
].join('&')
export const ARTICLE_DETAIL_QUERY = [
list('fields', ['title', 'slug', 'content', 'publishedAt']),
list('populate[cover][fields]', IMAGE_FIELDS),
list('populate[category][fields]', ['name', 'slug']),
// The byline is the admin user who created the entry; Strapi exposes the
// name fields only because the content type sets populateCreatorFields.
list('populate[createdBy][fields]', ['firstname', 'lastname']),
].join('&')
export const PAGE_SIZE = 12 export const PAGE_SIZE = 12
export const pagination = (page: number, pageSize = PAGE_SIZE) => export const pagination = (page: number, perPage = PAGE_SIZE) => `page=${page}&perPage=${perPage}`
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
/** Reads a positive integer query param, falling back to 1. */ /** Reads a positive integer query param, falling back to 1. */
export const pageParam = (value: unknown): number => { export const pageParam = (value: unknown): number => {
const page = Number(value) const page = Number(value)
return Number.isInteger(page) && page > 0 ? page : 1 return Number.isInteger(page) && page > 0 ? page : 1
} }
/** Quotes a value for PocketBase's `filter` DSL, escaping embedded quotes. */
export const quote = (value: string) => `"${value.replace(/"/g, '\\"')}"`
+5 -7
View File
@@ -1,10 +1,8 @@
/** Shape of the Strapi payloads, narrowed to what the site actually renders. */ /** Shape of the PocketBase payloads, narrowed to what the site actually renders. */
export interface StrapiImage { export interface MediaImage {
url: string url: string
alternativeText: string | null alt: string | null
width: number | null
height: number | null
} }
export interface Category { export interface Category {
@@ -17,7 +15,7 @@ export interface ArticleSummary {
title: string title: string
slug: string slug: string
publishedAt: string publishedAt: string
cover: StrapiImage | null cover: MediaImage | null
category: Category | null category: Category | null
} }
@@ -26,7 +24,7 @@ export interface Article extends ArticleSummary {
html: string html: string
/** Plain-text opening of the body, used as the meta description. */ /** Plain-text opening of the body, used as the meta description. */
summary: string summary: string
/** Byline: the full name of the admin user who wrote the article. */ /** Byline: the article's authorName field, filled in manually by the editor. */
author: string | null author: string | null
} }
+17
View File
@@ -0,0 +1,17 @@
FROM alpine:3.20 AS download
ARG PB_VERSION=0.40.3
RUN apk add --no-cache unzip curl ca-certificates
RUN curl -Lo /tmp/pb.zip \
https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
&& unzip /tmp/pb.zip -d /pb
FROM alpine:3.20 AS runtime
RUN apk add --no-cache ca-certificates wget
WORKDIR /pb
COPY --from=download /pb/pocketbase /usr/local/bin/pocketbase
COPY pb_migrations ./pb_migrations
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8090
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["pocketbase", "serve", "--http=0.0.0.0:8090"]
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# Idempotent: safe to run on every start, including restarts of an existing
# pb_data volume (upsert, not create).
if [ -n "$POCKETBASE_ADMIN_EMAIL" ] && [ -n "$POCKETBASE_ADMIN_PASSWORD" ]; then
pocketbase superuser upsert "$POCKETBASE_ADMIN_EMAIL" "$POCKETBASE_ADMIN_PASSWORD"
fi
exec "$@"
@@ -0,0 +1,26 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
type: 'base',
name: 'categories',
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: 'text', name: 'name', required: true, max: 160 },
{ type: 'text', name: 'slug', required: true, max: 160, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
{ type: 'autodate', name: 'created', onCreate: true },
{ type: 'autodate', name: 'updated', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE UNIQUE INDEX idx_categories_slug ON categories (slug)',
'CREATE UNIQUE INDEX idx_categories_name ON categories (name)',
],
})
app.save(collection)
}, (app) => {
app.delete(app.findCollectionByNameOrId('categories'))
})
@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
// publishedAt mirrors Strapi's Draft & Publish semantics: empty = draft, set
// (and not in the future) = published. listRule/viewRule enforce this, so an
// unpublished or scheduled article is invisible to the public API by
// construction, not by a check in application code.
migrate((app) => {
const categories = app.findCollectionByNameOrId('categories')
const collection = new Collection({
type: 'base',
name: 'articles',
listRule: "publishedAt != '' && publishedAt <= @now",
viewRule: "publishedAt != '' && publishedAt <= @now",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: 'text', name: 'title', required: true, max: 160 },
{ type: 'text', name: 'slug', required: true, max: 160, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
{ type: 'text', name: 'content', required: true },
{
type: 'file',
name: 'cover',
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif'],
},
{ type: 'text', name: 'coverAlt', max: 200 },
{ type: 'relation', name: 'category', collectionId: categories.id, maxSelect: 1 },
{ type: 'date', name: 'publishedAt' },
{ type: 'text', name: 'authorName', max: 160 },
{ type: 'autodate', name: 'created', onCreate: true },
{ type: 'autodate', name: 'updated', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE UNIQUE INDEX idx_articles_slug ON articles (slug)',
'CREATE INDEX idx_articles_category ON articles (category)',
'CREATE INDEX idx_articles_published ON articles (publishedAt)',
],
})
app.save(collection)
}, (app) => {
app.delete(app.findCollectionByNameOrId('articles'))
})