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:
+13
-18
@@ -4,25 +4,20 @@
|
||||
PUBLIC_DOMAIN=blog.localhost
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# --- Database ---
|
||||
POSTGRES_DB=blog
|
||||
POSTGRES_USER=blog
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
# --- Strapi ---
|
||||
# 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
|
||||
# --- PocketBase ---
|
||||
# Bootstraps (or updates, on restart) the initial superuser account — the
|
||||
# only login the admin UI accepts. Pick a real password (10+ chars); never
|
||||
# committed as anything but this placeholder.
|
||||
POCKETBASE_ADMIN_EMAIL=admin@example.com
|
||||
POCKETBASE_ADMIN_PASSWORD=change-me-1234
|
||||
|
||||
# --- Frontend ---
|
||||
# Server-side only: internal Docker address of Strapi.
|
||||
STRAPI_URL=http://cms:1337
|
||||
# Server-side only: internal Docker address of PocketBase.
|
||||
POCKETBASE_URL=http://pocketbase:8090
|
||||
# Public base URL of the website, used for canonical URLs and Open Graph.
|
||||
PUBLIC_SITE_URL=https://blog.localhost
|
||||
# Public base URL of Strapi, used to build absolute media URLs in the browser.
|
||||
# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi.
|
||||
PUBLIC_STRAPI_URL=https://blog.localhost
|
||||
# Public base URL of PocketBase, used to build absolute cover-image URLs.
|
||||
# Same origin as PUBLIC_SITE_URL: Caddy proxies /_/* and /api/* there (the
|
||||
# admin UI is at PUBLIC_POCKETBASE_URL/_/, reachable from PUBLIC_SITE_URL/admin
|
||||
# too — see caddy/Caddyfile).
|
||||
PUBLIC_POCKETBASE_URL=https://blog.localhost
|
||||
|
||||
@@ -16,39 +16,45 @@ sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora conf
|
||||
## Architettura
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin e i path dei plugin Strapi → Strapi 5 → PostgreSQL
|
||||
└─ tutto il resto → Nuxt 4 (SSR) → REST Strapi
|
||||
Browser → Caddy ─┬─ /_/* e /api/* → PocketBase (admin UI + API)
|
||||
└─ 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
|
||||
pannello Strapi. Ogni plugin Strapi monta la propria API admin sul proprio path di primo
|
||||
livello, non tutto sotto `/admin` (es. `/content-manager`, `/upload`, `/i18n`...): l'elenco
|
||||
completo dei prefissi da instradare a Strapi vive nel `Caddyfile`. Se aggiungi un plugin
|
||||
Strapi, aggiungi il suo prefisso lì. Niente di questo tocca `/api`, riservato agli endpoint
|
||||
Nitro del frontend. Un solo dominio, un solo certificato TLS.
|
||||
Caddy instrada per **path**, non per sottodominio o porta: `PUBLIC_DOMAIN` serve sia il sito
|
||||
che il pannello PocketBase. `/_/*` (dashboard) e `/api/*` (REST/file API) vanno **senza prefisso**
|
||||
a PocketBase — la sua dashboard referenzia se stessa con quei path assoluti, quindi non si possono
|
||||
instradare con uno strip-prefix (es. `/admin/*` riscritto): romperebbe gli asset/le chiamate della
|
||||
dashboard. Per questo `/api` è riservato a PocketBase, non a Nitro: gli endpoint del frontend
|
||||
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):
|
||||
se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi.
|
||||
- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi.
|
||||
- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli
|
||||
endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono
|
||||
query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e
|
||||
niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il
|
||||
ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel
|
||||
(`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica
|
||||
direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione
|
||||
richiesta né concessa).
|
||||
- PocketBase è la **sola** fonte di verità editoriale (CMS + database SQLite in un solo processo).
|
||||
Niente altro backend (no Express/Nest/Fastify): se serve logica server, sta in Nitro
|
||||
(`frontend/server/`) o in una regola/migration PocketBase.
|
||||
- I visitatori pubblici non si autenticano mai. Solo il superuser usa l'auth PocketBase.
|
||||
- **Il browser dei visitatori pubblici non parla mai con PocketBase per i contenuti.** Le pagine
|
||||
chiamano gli endpoint Nitro in `frontend/server/routes/content/`, che sono l'unico posto dove si
|
||||
costruiscono query PocketBase. Così `NUXT_POCKETBASE_URL` resta l'indirizzo interno Docker,
|
||||
niente CORS e niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì (sotto
|
||||
`/content/*`, mai `/api/*`) e tipizza il ritorno in `shared/types/blog.ts`. Fanno eccezione, per
|
||||
costruzione: l'admin panel (`/_/`, uso superuser autenticato, raggiungibile anche da `/admin`) e
|
||||
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
|
||||
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
|
||||
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
|
||||
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
|
||||
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
|
||||
Google Translate del browser, non è gestita dall'app. I **contenuti** restano
|
||||
monolingua — la localizzazione di Strapi è disattivata.
|
||||
- `cms/src/index.ts` (`bootstrap`) dà al ruolo Public solo `find`/`findOne` su Article e Category,
|
||||
e disattiva la registrazione pubblica: non esistono utenti front-end, solo amministratori.
|
||||
Qualsiasi permesso in più va motivato.
|
||||
- Postgres non è esposto pubblicamente. Media su volume persistente, mai binari nel DB.
|
||||
Google Translate del browser, non è gestita dall'app. I **contenuti** restano monolingua.
|
||||
- `pocketbase/pb_migrations/*.js` definisce collection e regole: `listRule`/`viewRule` pubblici
|
||||
solo su `articles` (solo pubblicati, via `publishedAt`) e `categories`; `createRule`/
|
||||
`updateRule`/`deleteRule` sempre `null` (solo superuser). Qualsiasi permesso in più va motivato.
|
||||
- SQLite (PocketBase) non è esposto pubblicamente sulla rete — solo tramite l'API PocketBase
|
||||
stessa. Media sul volume persistente `pocketbase-data`, mai binari fuori da lì.
|
||||
|
||||
## Comandi
|
||||
|
||||
@@ -61,17 +67,15 @@ npm run build # build produzione
|
||||
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
||||
npm run lint
|
||||
|
||||
# cms/
|
||||
npm run develop # Strapi con content-type builder attivo
|
||||
npm run build # admin panel
|
||||
npm run start # produzione
|
||||
# pocketbase/ (nessun npm script: binario singolo, le migration si applicano da sole all'avvio)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||
|
||||
# stack completo (con Caddy e domini reali)
|
||||
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
|
||||
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
|
||||
@@ -84,15 +88,16 @@ lanciare **un singolo test**.
|
||||
|
||||
| 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 |
|
||||
|
||||
Deliberatamente minimale: **niente Author** (l'unico autore è l'admin), niente tag, nessun campo
|
||||
SEO separato. La meta description è ricavata dall'inizio del body (`summarise` in
|
||||
`frontend/server/utils/strapi.ts`), la data è il `publishedAt` di Draft & Publish, l'immagine
|
||||
social è la cover. Non reintrodurre questi campi senza che servano davvero.
|
||||
Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata
|
||||
dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è
|
||||
`publishedAt`, l'immagine social è la cover. Non reintrodurre 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
|
||||
|
||||
@@ -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.
|
||||
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
|
||||
`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.
|
||||
- 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.
|
||||
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
|
||||
`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.
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
a dati o configurazione di produzione, cambi di credenziali.
|
||||
- Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# 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.
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
|
||||
└─ everything else → Nuxt (website)
|
||||
Browser → Caddy ─┬─ /_/*, /api/* → PocketBase (CMS)
|
||||
└─ everything else → Nuxt (website), which also redirects /admin → /_/
|
||||
```
|
||||
|
||||
There are no front-end accounts: sign-up is disabled and only administrators write
|
||||
content. An article is a title, a Markdown body, a cover image and a category.
|
||||
There are no front-end accounts: only the superuser writes content. An article is a
|
||||
title, a Markdown body, a cover image and a category.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -22,37 +22,32 @@ through Caddy. Requires Docker.
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**2. Generate the secrets.** Every `change-me` must become a different random value —
|
||||
Strapi refuses to start otherwise. This fills them all:
|
||||
**2. Set the admin credentials.** Replace `POCKETBASE_ADMIN_EMAIL` and
|
||||
`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
|
||||
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
|
||||
```
|
||||
|
||||
`grep change-me .env` must print nothing. The domain and URL variables can stay as they
|
||||
are for local use.
|
||||
|
||||
**3. Start the stack**
|
||||
|
||||
```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
|
||||
`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.
|
||||
|
||||
**4. Create the administrator account** at http://localhost:1337/admin (dev bypasses Caddy,
|
||||
so the CMS is reached directly on its port). This is the first
|
||||
run, so the form creates the account — pick your own credentials.
|
||||
**4. Open the admin UI** at http://localhost:3000/admin (redirects to PocketBase's dashboard
|
||||
— this works in dev too, without Caddy, since the redirect is handled by the frontend itself)
|
||||
and log in with the credentials from `.env`.
|
||||
|
||||
**5. Write something.** In the admin panel: create a **Category**, then an **Article**
|
||||
(the body field is Markdown), then press **Publish** — the website only shows published
|
||||
content.
|
||||
**5. Write something.** In the admin UI: create a **categories** record, then an
|
||||
**articles** record (the `content` field is Markdown), then set `publishedAt` — the
|
||||
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
|
||||
`/category/<slug>`.
|
||||
@@ -60,16 +55,15 @@ content.
|
||||
Useful commands:
|
||||
|
||||
```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 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 —
|
||||
`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to
|
||||
`http://localhost:1337` for Strapi, so it works against the containerised CMS as is;
|
||||
override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`.
|
||||
To iterate on the frontend without rebuilding an image every time: `cd frontend && npm run
|
||||
dev`. It defaults to `http://localhost:8090` for PocketBase, so it works against the
|
||||
containerised CMS as is; override it with `NUXT_POCKETBASE_URL` if needed.
|
||||
|
||||
## Production
|
||||
|
||||
@@ -78,14 +72,16 @@ public IP of the machine:
|
||||
|
||||
| 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
|
||||
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
|
||||
it for the ACME challenge and to redirect to HTTPS. PostgreSQL, Strapi and Nuxt are only
|
||||
reachable inside the Docker network — do not publish their ports.
|
||||
it for the ACME challenge and to redirect to HTTPS. The admin UI shares port 443 with the
|
||||
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:
|
||||
|
||||
@@ -94,16 +90,15 @@ reachable inside the Docker network — do not publish their ports.
|
||||
| `PUBLIC_DOMAIN` | `example.com` |
|
||||
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
|
||||
| `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`) |
|
||||
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public |
|
||||
| `PUBLIC_POCKETBASE_URL` | `https://example.com` — same origin, Caddy proxies `/_/` and `/api/` there |
|
||||
| `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 —
|
||||
different values from the ones you use locally. Keep `.env` out of version control; it is
|
||||
already ignored.
|
||||
Keep `.env` out of version control; it is already ignored.
|
||||
|
||||
> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out.
|
||||
> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values
|
||||
> unreadable. Set them once, then back up the file somewhere safe.
|
||||
> Changing `POCKETBASE_ADMIN_PASSWORD` later and restarting rotates the superuser
|
||||
> password immediately (the entrypoint upserts it on every boot) — a credential change,
|
||||
> so treat it with the same care as any production credential rotation.
|
||||
|
||||
**4. Start everything**
|
||||
|
||||
@@ -111,24 +106,22 @@ already ignored.
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This time Caddy is included: it serves both the website and, under `/admin` and Strapi's
|
||||
other plugin paths, the CMS on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on
|
||||
its own, and adds HSTS and the other security headers.
|
||||
This time Caddy is included: it serves both the website and, under `/admin`, the CMS
|
||||
admin UI on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on its own, and adds
|
||||
HSTS and the other security headers.
|
||||
|
||||
**5. Create the administrator account** at `https://example.com/admin`, immediately,
|
||||
before anyone else finds the URL — the first visitor to that form is the one who gets the
|
||||
account. Then publish as in development.
|
||||
**5. Log into the admin UI** at `https://example.com/admin` with the credentials from
|
||||
`.env`, then publish as in development.
|
||||
|
||||
**6. Back up what is not in git**: the `postgres-data` volume (all content) and the
|
||||
`cms-uploads` volume (all images). Nothing else on the server holds state.
|
||||
**6. Back up what is not in git**: the `pocketbase-data` volume (database and uploaded
|
||||
media together). Nothing else on the server holds state.
|
||||
|
||||
```bash
|
||||
docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
|
||||
docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data .
|
||||
docker run --rm -v blog_pocketbase-data:/data -v "$PWD:/out" alpine tar czf /out/pocketbase-data.tar.gz -C /data .
|
||||
```
|
||||
|
||||
**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).
|
||||
|
||||
|
||||
+14
-15
@@ -16,24 +16,23 @@
|
||||
import security_headers
|
||||
encode zstd gzip
|
||||
|
||||
# Strapi mounts each of its (and its plugins') admin APIs at its own
|
||||
# top-level path, not all under /admin - the admin panel's dashboard
|
||||
# widgets, media library, i18n, etc each call their own plugin prefix.
|
||||
# This list is every @strapi/* package in cms/package.json plus the
|
||||
# core-bundled plugins (content-manager, content-type-builder, upload,
|
||||
# i18n, email, content-releases, review-workflows). Adding a new Strapi
|
||||
# plugin later means adding its prefix here too.
|
||||
# None of this touches /api, reserved for the frontend's own Nitro
|
||||
# endpoints.
|
||||
@cms path /admin* /content-manager* /content-type-builder* /upload*\
|
||||
/i18n* /email* /content-releases* /review-workflows*\
|
||||
/users-permissions* /cloud*
|
||||
handle @cms {
|
||||
# Uploaded media can be large; Strapi's own limit still applies.
|
||||
# /admin is a vanity redirect to /_/, PocketBase's own fixed dashboard
|
||||
# route — handled by Nitro (frontend/server/routes/admin.get.ts), not
|
||||
# here, so it also works in dev where Caddy isn't in the stack.
|
||||
|
||||
# PocketBase's admin UI (/_/) and its own REST/file API (/api/*) both
|
||||
# reference themselves with root-absolute paths, so they must be
|
||||
# reachable unprefixed at the domain root — a path like /admin/api/...
|
||||
# with a stripped prefix would break the dashboard's own asset and API
|
||||
# calls. This is why the frontend's Nitro endpoints live under
|
||||
# /content/*, not /api/*: /api is reserved for PocketBase here.
|
||||
@pocketbase path /_/* /api/*
|
||||
handle @pocketbase {
|
||||
# Cover images and admin uploads pass through here too.
|
||||
request_body {
|
||||
max_size 100MB
|
||||
}
|
||||
reverse_proxy cms:1337
|
||||
reverse_proxy pocketbase:8090
|
||||
}
|
||||
|
||||
handle {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.strapi
|
||||
.tmp
|
||||
.env
|
||||
.git
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 497 B |
Generated
-21551
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
|
||||
# User-Agent: *
|
||||
# Disallow: /
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::article.article');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
# 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:
|
||||
# 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:
|
||||
database:
|
||||
pocketbase:
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
cms:
|
||||
ports:
|
||||
- "127.0.0.1:1337:1337"
|
||||
- "127.0.0.1:8090:8090"
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
NUXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
NUXT_PUBLIC_STRAPI_URL: http://localhost:1337
|
||||
NUXT_PUBLIC_POCKETBASE_URL: http://localhost:8090
|
||||
|
||||
+12
-39
@@ -1,64 +1,38 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:17-alpine
|
||||
pocketbase:
|
||||
build: ./pocketbase
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL}
|
||||
POCKETBASE_ADMIN_PASSWORD: ${POCKETBASE_ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- pocketbase-data:/pb/pb_data
|
||||
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
|
||||
timeout: 5s
|
||||
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:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cms
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
NUXT_STRAPI_URL: ${STRAPI_URL}
|
||||
NUXT_POCKETBASE_URL: ${POCKETBASE_URL}
|
||||
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
||||
NUXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL}
|
||||
NUXT_PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
- cms
|
||||
- pocketbase
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -76,7 +50,6 @@ services:
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
cms-uploads:
|
||||
pocketbase-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
+6
-6
@@ -6,7 +6,7 @@ the root [README.md](../README.md); for coding conventions and constraints, see
|
||||
depth than either of those.
|
||||
|
||||
- [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.
|
||||
|
||||
## 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,
|
||||
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
|
||||
publish articles and categories. This is the only way content changes; there is no other CMS
|
||||
and no public user accounts.
|
||||
- **Admin panel** — the site owner logs into PocketBase's admin UI (`/admin`, redirects to `/_/`)
|
||||
to write, edit and publish articles and categories. This is the only way content changes; there
|
||||
is no other CMS and no public user accounts.
|
||||
|
||||
One codebase, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a
|
||||
single Caddy reverse proxy on one domain.
|
||||
One codebase, two runtime components (Nuxt frontend, PocketBase — CMS and database in one
|
||||
process), fronted by a single Caddy reverse proxy on one domain.
|
||||
|
||||
+58
-53
@@ -2,22 +2,22 @@
|
||||
|
||||
## Services
|
||||
|
||||
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
|
||||
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
|
||||
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
|
||||
└─ 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
|
||||
Docker network. Data on the `postgres-data` volume.
|
||||
- **cms** — Strapi 5, the sole source of editorial truth. No other backend framework exists in
|
||||
this repo; any server-side logic that isn't content management belongs in Nuxt's Nitro server,
|
||||
not in a new service.
|
||||
- **pocketbase** — a single PocketBase binary, the sole source of editorial truth. Embedded
|
||||
SQLite, no separate database service. No other backend framework exists in this repo; any
|
||||
server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new
|
||||
service.
|
||||
- **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
|
||||
(`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)
|
||||
and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml` —
|
||||
@@ -26,74 +26,79 @@ production.
|
||||
|
||||
## Path routing (Caddy)
|
||||
|
||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`, one path matcher
|
||||
`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
|
||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
|
||||
|
||||
```
|
||||
/admin* /content-manager* /content-type-builder* /upload* /i18n*
|
||||
/email* /content-releases* /review-workflows* /users-permissions* /cloud*
|
||||
```
|
||||
- `@pocketbase path /_/* /api/*` → `pocketbase:8090`, **unprefixed** (100MB body limit, for
|
||||
uploads).
|
||||
- everything else → `frontend:3000`.
|
||||
|
||||
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
|
||||
else — including `/api/*` — goes to `frontend:3000`.
|
||||
`/admin` is **not** a Caddy rule: it's a Nitro route
|
||||
(`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
|
||||
API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the
|
||||
Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content
|
||||
— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated).
|
||||
**Why unprefixed, not a stripped `/admin/*` prefix:** PocketBase's admin dashboard references its
|
||||
own assets and API with paths rooted at `/_/` and `/api/`. A reverse-proxy rule that rewrites
|
||||
`/admin/foo` → `/foo` before forwarding would serve the dashboard's HTML fine, but every asset and
|
||||
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
|
||||
Caddyfile — this is the one place that list is maintained.
|
||||
**`/api/*` is reserved for PocketBase here — the inverse of the old Strapi setup.** Nuxt's own
|
||||
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
|
||||
|
||||
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
|
||||
call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip over the
|
||||
network in production).
|
||||
3. `frontend/server/api/articles/[slug].get.ts` calls `strapiFetch()` (in
|
||||
`frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a
|
||||
Strapi-specific query built by `frontend/server/utils/queries.ts`.
|
||||
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/content/articles/my-slug')` — a
|
||||
same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip
|
||||
over the network in production).
|
||||
3. `frontend/server/routes/content/articles/[slug].get.ts` calls `pbFetch()` (in
|
||||
`frontend/server/utils/pocketbase.ts`), which hits `POCKETBASE_URL` (internal Docker address)
|
||||
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
|
||||
`renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
|
||||
`Article` from `frontend/shared/types/blog.ts`.
|
||||
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.
|
||||
6. The cover image `<img>` tag points directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
|
||||
asset the browser fetches straight from Strapi.
|
||||
6. The cover image `<img>` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset
|
||||
the browser fetches straight from PocketBase.
|
||||
|
||||
## Request flow: publishing content
|
||||
|
||||
1. Editor logs into `/admin` (Strapi admin panel, authenticated, editor/admin 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).
|
||||
3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that
|
||||
slug hits Strapi live through the Nitro endpoint.
|
||||
1. Editor goes to `PUBLIC_SITE_URL/admin` (redirects to `/_/`, PocketBase's admin UI —
|
||||
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 sets `publishedAt` to publish
|
||||
it.
|
||||
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
|
||||
|
||||
Defined in [.env.example](../.env.example) (root, drives docker-compose) and
|
||||
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
|
||||
Docker).
|
||||
Defined in [.env.example](../.env.example) (root, drives docker-compose).
|
||||
|
||||
| Variable | Consumed by | Purpose |
|
||||
|---|---|---|
|
||||
| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
|
||||
| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
|
||||
| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. |
|
||||
| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). |
|
||||
| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. |
|
||||
| `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. |
|
||||
| `POCKETBASE_ADMIN_EMAIL` | pocketbase | Bootstraps (and keeps up to date, on every restart) the initial superuser account. |
|
||||
| `POCKETBASE_ADMIN_PASSWORD` | pocketbase | Password for the superuser above. Rotating it is a credential change — see `CLAUDE.md`. |
|
||||
| `POCKETBASE_URL` | frontend (server-only) | Internal Docker address of PocketBase (`http://pocketbase:8090`); mapped to `NUXT_POCKETBASE_URL`. Never sent to the browser. |
|
||||
| `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`. |
|
||||
|
||||
All Strapi secrets have standalone copies in `cms/.env.example` for local development without
|
||||
Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
|
||||
| `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`. |
|
||||
|
||||
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
|
||||
only).
|
||||
|
||||
+51
-40
@@ -1,63 +1,74 @@
|
||||
# Content model & admin panel
|
||||
|
||||
## Content types
|
||||
## Collections
|
||||
|
||||
Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory
|
||||
defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers,
|
||||
routes, services, or policies exist for either type.
|
||||
Defined as code in `pocketbase/pb_migrations/*.js`, applied automatically the first time
|
||||
PocketBase boots against an empty data directory (and on every subsequent deploy that adds new
|
||||
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) |
|
||||
| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) |
|
||||
| **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) |
|
||||
| **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
|
||||
fields. Don't add these back without a concrete need — see `CLAUDE.md`:
|
||||
Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete
|
||||
need — see `CLAUDE.md`:
|
||||
|
||||
- Meta description is derived at request time from the article body (`summarise()` in
|
||||
`frontend/server/utils/strapi.ts`).
|
||||
- Publish date is Draft & Publish's `publishedAt`.
|
||||
`frontend/server/utils/pocketbase.ts`).
|
||||
- Publish date is `publishedAt`.
|
||||
- Social preview image is the cover.
|
||||
- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`),
|
||||
not a content field.
|
||||
- The article byline is `authorName`, a plain field the editor fills in — PocketBase has no
|
||||
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
|
||||
(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the
|
||||
numeric id.
|
||||
There is no native Draft & Publish in PocketBase. `publishedAt` reproduces its semantics
|
||||
explicitly: empty = draft (never returned by the public API), set and not in the future =
|
||||
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
|
||||
|
||||
- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container
|
||||
([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard
|
||||
email/password admin authentication — no custom auth code in this repo.
|
||||
- The admin panel lives at `/_/` (also reachable via `/admin`, which just redirects there),
|
||||
routed by Caddy straight to the `pocketbase` container
|
||||
([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 write access of any kind.
|
||||
- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start:
|
||||
1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article
|
||||
and Category, and nothing else (no create/update/delete, no other content type). This is
|
||||
what lets the Nitro endpoints read published content without a token.
|
||||
2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration
|
||||
(`allow_register: false`), since no front-end user accounts should ever exist.
|
||||
- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a
|
||||
deliberate least-privilege boundary, not an oversight.
|
||||
- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images,
|
||||
video, audio, PDF, office docs, text/CSV) and explicitly denies executables.
|
||||
- Each collection's `createRule`/`updateRule`/`deleteRule` is `null` — writes are superuser-only,
|
||||
full stop. There is no separate "editor" role: the site owner is the only superuser, matching
|
||||
"no front-end user accounts should ever exist."
|
||||
- `listRule`/`viewRule` on `articles` are `publishedAt != '' && publishedAt <= @now` (public read
|
||||
of published content only); on `categories` they're `''` (public read, unconditional — categories
|
||||
aren't drafted).
|
||||
- `cover`'s field config restricts uploads to images (PNG/JPEG/WEBP/AVIF/GIF), 10MB max — the
|
||||
equivalent of Strapi's old upload-plugin MIME allowlist, now expressed per-field instead of
|
||||
globally.
|
||||
- Any rule change beyond this is a deliberate least-privilege boundary, not an oversight, and
|
||||
needs to be justified explicitly.
|
||||
|
||||
## Editorial workflow
|
||||
|
||||
1. Log into `/admin`.
|
||||
2. Create/edit a Category if needed (name → slug is generated automatically).
|
||||
3. Create/edit an Article: title (→ slug), Markdown content, cover image, category.
|
||||
4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne`
|
||||
permissions above — Strapi's default behavior already excludes non-published entries from the
|
||||
public API.
|
||||
1. Log into `PUBLIC_SITE_URL/admin` (redirects to `/_/`).
|
||||
2. Create/edit a Category if needed.
|
||||
3. Create/edit an Article: title, slug, Markdown content, cover image + alt text, category,
|
||||
author name.
|
||||
4. Set `publishedAt` (to now, or a past/future date) to publish it. Leaving it empty keeps the
|
||||
article a draft, invisible to the public API.
|
||||
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)).
|
||||
|
||||
## Why Markdown, not a rich-text/WYSIWYG field
|
||||
|
||||
`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML
|
||||
happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never
|
||||
in Strapi and never in the browser. This keeps `marked` out of the client bundle and keeps HTML
|
||||
generation in one place. There is no sanitization step: this is intentional, since the only author
|
||||
is the trusted admin, not arbitrary users.
|
||||
`content` is a plain PocketBase `text` field, not the `editor` field type (which stores HTML).
|
||||
PocketBase stores the raw Markdown; conversion to HTML happens once, server-side, in the Nuxt
|
||||
Nitro endpoint (`renderMarkdown()`, using `marked`) — never in PocketBase and never in the browser.
|
||||
This keeps `marked` out of the client bundle and keeps HTML generation in one place. There is no
|
||||
sanitization step: this is intentional, since the only author is the trusted superuser, not
|
||||
arbitrary users.
|
||||
|
||||
+37
-25
@@ -4,44 +4,54 @@
|
||||
|
||||
| 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/[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. |
|
||||
| `/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.
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `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 /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 /api/categories` | All categories (name + slug only), sorted by name. |
|
||||
| `GET /api/categories/:slug` | One category by slug. 400/404 as above. |
|
||||
| `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Queries PocketBase with `ARTICLE_SUMMARY_FIELDS`. Returns `Paginated<ArticleSummary>`. |
|
||||
| `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 /content/categories` | All categories (name + slug only), sorted by name. |
|
||||
| `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
|
||||
`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client.
|
||||
If you add a view that needs new data, add the endpoint here and type its return in
|
||||
`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components.
|
||||
This is the **single point of contact** with PocketBase (`CLAUDE.md` rule): pages never call
|
||||
`$fetch` against PocketBase directly, and `NUXT_POCKETBASE_URL` never reaches the client. If you
|
||||
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 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
|
||||
|
||||
- `frontend/server/utils/strapi.ts`
|
||||
- `strapiFetch<T>(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional
|
||||
Bearer token; wraps failures as a 502 so internal details never leak to the client.
|
||||
- `frontend/server/utils/pocketbase.ts`
|
||||
- `pbFetch<T>(path)` — server-only fetch against `runtimeConfig.pocketbaseUrl`; wraps failures
|
||||
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).
|
||||
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
|
||||
description, word-boundary clipped.
|
||||
- `frontend/server/utils/queries.ts` — Strapi query-string builders kept intentionally minimal
|
||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`,
|
||||
`PAGE_SIZE`, `pagination()`, `pageParam()`.
|
||||
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object
|
||||
into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already
|
||||
absolute.
|
||||
- `frontend/server/utils/queries.ts` — PocketBase query-string builders kept intentionally minimal
|
||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_FIELDS`, `ARTICLE_DETAIL_FIELDS`,
|
||||
`PAGE_SIZE`, `pagination()`, `pageParam()`, `quote()` (escapes a value for PocketBase's `filter`
|
||||
DSL).
|
||||
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a `MediaImage` into an
|
||||
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/format.ts` — `it-IT` date formatting (`formatDate`, `formatDateTime`,
|
||||
`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`)
|
||||
|
||||
```
|
||||
StrapiImage { url, alternativeText, width, height }
|
||||
MediaImage { url, alt }
|
||||
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 }
|
||||
Paginated<T> { items: T[], page, pageCount, total }
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
<img
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alternativeText ?? ''"
|
||||
:width="article.cover?.width ?? undefined"
|
||||
:height="article.cover?.height ?? undefined"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
loading="lazy"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -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
|
||||
* absolute ones. Remote providers (S3) already return absolute URLs.
|
||||
* PocketBase returns file paths relative to its own origin; the browser
|
||||
* needs absolute ones.
|
||||
*/
|
||||
export function useMediaUrl() {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
|
||||
return (image: StrapiImage | null | undefined): string | null => {
|
||||
return (image: MediaImage | null | undefined): string | 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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Article } from '#shared/types/blog'
|
||||
const route = useRoute()
|
||||
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) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
@@ -79,9 +79,7 @@ useHead({
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alternativeText ?? ''"
|
||||
:width="article.cover?.width ?? undefined"
|
||||
:height="article.cover?.height ?? undefined"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
|
||||
@@ -8,7 +8,7 @@ const page = computed(() => {
|
||||
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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ const page = computed(() => {
|
||||
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) {
|
||||
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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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',
|
||||
query: { page: 1 },
|
||||
})
|
||||
@@ -82,9 +82,7 @@ useSeoMeta({
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alternativeText ?? ''"
|
||||
:width="featured.cover?.width ?? undefined"
|
||||
:height="featured.cover?.height ?? undefined"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
@@ -20,18 +20,17 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
// Server-only: internal Strapi address, never exposed to the browser.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
strapiToken: '',
|
||||
// Server-only: internal PocketBase address, never exposed to the browser.
|
||||
pocketbaseUrl: 'http://localhost:8090',
|
||||
public: {
|
||||
// Canonical origin of the public website.
|
||||
siteUrl: 'http://localhost:3000',
|
||||
// Browser-reachable Strapi origin, used to build absolute media URLs.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
// Browser-reachable PocketBase origin, used to build absolute media URLs.
|
||||
pocketbaseUrl: 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
|
||||
typescript: {
|
||||
strict: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
+3
-3
@@ -7,11 +7,11 @@ export default defineEventHandler(async (event): Promise<Category> => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
const response = await pbFetch<PbList<Category>>(
|
||||
`/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) {
|
||||
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 type { MediaImage } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Calls the Strapi REST API from the server only, so the internal URL and any
|
||||
* future API token never reach the browser.
|
||||
* Calls the PocketBase REST API from the server only, so the internal URL
|
||||
* never reaches the browser.
|
||||
*/
|
||||
export async function strapiFetch<T>(path: string): Promise<T> {
|
||||
const { strapiUrl, strapiToken } = useRuntimeConfig()
|
||||
export async function pbFetch<T>(path: string): Promise<T> {
|
||||
const { pocketbaseUrl } = useRuntimeConfig()
|
||||
|
||||
try {
|
||||
return (await $fetch(path, {
|
||||
baseURL: strapiUrl,
|
||||
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
|
||||
})) as T
|
||||
return (await $fetch(path, { baseURL: pocketbaseUrl })) as T
|
||||
} catch (error) {
|
||||
console.error(`Strapi request failed: ${path}`, error)
|
||||
console.error(`PocketBase request failed: ${path}`, error)
|
||||
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()}…`
|
||||
}
|
||||
|
||||
export interface StrapiList<T> {
|
||||
data: T[]
|
||||
meta: { pagination: { page: number; pageCount: number; total: number } }
|
||||
export interface PbList<T> {
|
||||
page: 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 }
|
||||
}
|
||||
@@ -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[]) =>
|
||||
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
|
||||
export const ARTICLE_SUMMARY_FIELDS =
|
||||
'id,title,slug,publishedAt,cover,coverAlt,expand.category.name,expand.category.slug'
|
||||
|
||||
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const
|
||||
|
||||
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 ARTICLE_DETAIL_FIELDS =
|
||||
'id,title,slug,content,publishedAt,cover,coverAlt,authorName,expand.category.name,expand.category.slug'
|
||||
|
||||
export const PAGE_SIZE = 12
|
||||
|
||||
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
|
||||
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
|
||||
export const pagination = (page: number, perPage = PAGE_SIZE) => `page=${page}&perPage=${perPage}`
|
||||
|
||||
/** Reads a positive integer query param, falling back to 1. */
|
||||
export const pageParam = (value: unknown): number => {
|
||||
const page = Number(value)
|
||||
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, '\\"')}"`
|
||||
|
||||
@@ -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
|
||||
alternativeText: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
alt: string | null
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -17,7 +15,7 @@ export interface ArticleSummary {
|
||||
title: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: StrapiImage | null
|
||||
cover: MediaImage | null
|
||||
category: Category | null
|
||||
}
|
||||
|
||||
@@ -26,7 +24,7 @@ export interface Article extends ArticleSummary {
|
||||
html: string
|
||||
/** Plain-text opening of the body, used as the meta description. */
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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'))
|
||||
})
|
||||
Reference in New Issue
Block a user