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
|
PUBLIC_DOMAIN=blog.localhost
|
||||||
ACME_EMAIL=admin@example.com
|
ACME_EMAIL=admin@example.com
|
||||||
|
|
||||||
# --- Database ---
|
# --- PocketBase ---
|
||||||
POSTGRES_DB=blog
|
# Bootstraps (or updates, on restart) the initial superuser account — the
|
||||||
POSTGRES_USER=blog
|
# only login the admin UI accepts. Pick a real password (10+ chars); never
|
||||||
POSTGRES_PASSWORD=change-me
|
# committed as anything but this placeholder.
|
||||||
|
POCKETBASE_ADMIN_EMAIL=admin@example.com
|
||||||
# --- Strapi ---
|
POCKETBASE_ADMIN_PASSWORD=change-me-1234
|
||||||
# Generate each secret with: openssl rand -base64 32
|
|
||||||
APP_KEYS=change-me-1,change-me-2
|
|
||||||
API_TOKEN_SALT=change-me
|
|
||||||
ADMIN_JWT_SECRET=change-me
|
|
||||||
TRANSFER_TOKEN_SALT=change-me
|
|
||||||
JWT_SECRET=change-me
|
|
||||||
ENCRYPTION_KEY=change-me
|
|
||||||
|
|
||||||
# --- Frontend ---
|
# --- Frontend ---
|
||||||
# Server-side only: internal Docker address of Strapi.
|
# Server-side only: internal Docker address of PocketBase.
|
||||||
STRAPI_URL=http://cms:1337
|
POCKETBASE_URL=http://pocketbase:8090
|
||||||
# Public base URL of the website, used for canonical URLs and Open Graph.
|
# Public base URL of the website, used for canonical URLs and Open Graph.
|
||||||
PUBLIC_SITE_URL=https://blog.localhost
|
PUBLIC_SITE_URL=https://blog.localhost
|
||||||
# Public base URL of Strapi, used to build absolute media URLs in the browser.
|
# Public base URL of PocketBase, used to build absolute cover-image URLs.
|
||||||
# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi.
|
# Same origin as PUBLIC_SITE_URL: Caddy proxies /_/* and /api/* there (the
|
||||||
PUBLIC_STRAPI_URL=https://blog.localhost
|
# admin UI is at PUBLIC_POCKETBASE_URL/_/, reachable from PUBLIC_SITE_URL/admin
|
||||||
|
# too — see caddy/Caddyfile).
|
||||||
|
PUBLIC_POCKETBASE_URL=https://blog.localhost
|
||||||
|
|||||||
@@ -16,39 +16,45 @@ sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora conf
|
|||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser → Caddy ─┬─ /admin e i path dei plugin Strapi → Strapi 5 → PostgreSQL
|
Browser → Caddy ─┬─ /_/* e /api/* → PocketBase (admin UI + API)
|
||||||
└─ tutto il resto → Nuxt 4 (SSR) → REST Strapi
|
└─ tutto il resto → Nuxt 4 (SSR) → REST PocketBase (interno)
|
||||||
|
("/admin" fa redirect a /_/, gestito da Nitro)
|
||||||
```
|
```
|
||||||
|
|
||||||
Caddy instrada per **path**, non per sottodominio: `PUBLIC_DOMAIN` serve sia il sito che il
|
Caddy instrada per **path**, non per sottodominio o porta: `PUBLIC_DOMAIN` serve sia il sito
|
||||||
pannello Strapi. Ogni plugin Strapi monta la propria API admin sul proprio path di primo
|
che il pannello PocketBase. `/_/*` (dashboard) e `/api/*` (REST/file API) vanno **senza prefisso**
|
||||||
livello, non tutto sotto `/admin` (es. `/content-manager`, `/upload`, `/i18n`...): l'elenco
|
a PocketBase — la sua dashboard referenzia se stessa con quei path assoluti, quindi non si possono
|
||||||
completo dei prefissi da instradare a Strapi vive nel `Caddyfile`. Se aggiungi un plugin
|
instradare con uno strip-prefix (es. `/admin/*` riscritto): romperebbe gli asset/le chiamate della
|
||||||
Strapi, aggiungi il suo prefisso lì. Niente di questo tocca `/api`, riservato agli endpoint
|
dashboard. Per questo `/api` è riservato a PocketBase, non a Nitro: gli endpoint del frontend
|
||||||
Nitro del frontend. Un solo dominio, un solo certificato TLS.
|
vivono sotto `/content/*` (`frontend/server/routes/content/`, non `server/api/`). `/admin` è una
|
||||||
|
route Nitro (`frontend/server/routes/admin.get.ts`) che fa redirect a `/_/`, non una regola
|
||||||
|
Caddy: così funziona identico anche in sviluppo, dove Caddy non fa parte dello stack. Un solo
|
||||||
|
dominio, un solo certificato TLS. Se cambi questa scelta di routing, spiega il trade-off prima
|
||||||
|
(vedi [Vincoli](#vincoli)).
|
||||||
|
|
||||||
- Strapi è la **sola** fonte di verità editoriale. Niente altro backend (no Express/Nest/Fastify):
|
- PocketBase è la **sola** fonte di verità editoriale (CMS + database SQLite in un solo processo).
|
||||||
se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi.
|
Niente altro backend (no Express/Nest/Fastify): se serve logica server, sta in Nitro
|
||||||
- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi.
|
(`frontend/server/`) o in una regola/migration PocketBase.
|
||||||
- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli
|
- I visitatori pubblici non si autenticano mai. Solo il superuser usa l'auth PocketBase.
|
||||||
endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono
|
- **Il browser dei visitatori pubblici non parla mai con PocketBase per i contenuti.** Le pagine
|
||||||
query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e
|
chiamano gli endpoint Nitro in `frontend/server/routes/content/`, che sono l'unico posto dove si
|
||||||
niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il
|
costruiscono query PocketBase. Così `NUXT_POCKETBASE_URL` resta l'indirizzo interno Docker,
|
||||||
ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel
|
niente CORS e niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì (sotto
|
||||||
(`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica
|
`/content/*`, mai `/api/*`) e tipizza il ritorno in `shared/types/blog.ts`. Fanno eccezione, per
|
||||||
direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione
|
costruzione: l'admin panel (`/_/`, uso superuser autenticato, raggiungibile anche da `/admin`) e
|
||||||
richiesta né concessa).
|
le immagini cover, che il browser carica direttamente da `PUBLIC_POCKETBASE_URL`
|
||||||
|
(`/api/files/...`, sola lettura, nessun'autenticazione richiesta né concessa).
|
||||||
- Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il
|
- Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il
|
||||||
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
|
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
|
||||||
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
|
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
|
||||||
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
|
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
|
||||||
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
|
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
|
||||||
Google Translate del browser, non è gestita dall'app. I **contenuti** restano
|
Google Translate del browser, non è gestita dall'app. I **contenuti** restano monolingua.
|
||||||
monolingua — la localizzazione di Strapi è disattivata.
|
- `pocketbase/pb_migrations/*.js` definisce collection e regole: `listRule`/`viewRule` pubblici
|
||||||
- `cms/src/index.ts` (`bootstrap`) dà al ruolo Public solo `find`/`findOne` su Article e Category,
|
solo su `articles` (solo pubblicati, via `publishedAt`) e `categories`; `createRule`/
|
||||||
e disattiva la registrazione pubblica: non esistono utenti front-end, solo amministratori.
|
`updateRule`/`deleteRule` sempre `null` (solo superuser). Qualsiasi permesso in più va motivato.
|
||||||
Qualsiasi permesso in più va motivato.
|
- SQLite (PocketBase) non è esposto pubblicamente sulla rete — solo tramite l'API PocketBase
|
||||||
- Postgres non è esposto pubblicamente. Media su volume persistente, mai binari nel DB.
|
stessa. Media sul volume persistente `pocketbase-data`, mai binari fuori da lì.
|
||||||
|
|
||||||
## Comandi
|
## Comandi
|
||||||
|
|
||||||
@@ -61,17 +67,15 @@ npm run build # build produzione
|
|||||||
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
||||||
npm run lint
|
npm run lint
|
||||||
|
|
||||||
# cms/
|
# pocketbase/ (nessun npm script: binario singolo, le migration si applicano da sole all'avvio)
|
||||||
npm run develop # Strapi con content-type builder attivo
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||||
npm run build # admin panel
|
|
||||||
npm run start # produzione
|
|
||||||
|
|
||||||
# stack completo (con Caddy e domini reali)
|
# stack completo (con Caddy e domini reali)
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f cms
|
docker compose logs -f pocketbase
|
||||||
|
|
||||||
# stack locale senza domini né TLS: porte su localhost, niente Caddy
|
# stack locale senza domini né TLS: porte su localhost, niente Caddy
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
|
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
|
||||||
@@ -84,15 +88,16 @@ lanciare **un singolo test**.
|
|||||||
|
|
||||||
| Type | Campi |
|
| Type | Campi |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Article | title, slug (UID da title), content (Markdown), cover, category (rel) |
|
| Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName |
|
||||||
| Category | name, slug |
|
| Category | name, slug |
|
||||||
|
|
||||||
Deliberatamente minimale: **niente Author** (l'unico autore è l'admin), niente tag, nessun campo
|
Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata
|
||||||
SEO separato. La meta description è ricavata dall'inizio del body (`summarise` in
|
dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è
|
||||||
`frontend/server/utils/strapi.ts`), la data è il `publishedAt` di Draft & Publish, l'immagine
|
`publishedAt`, l'immagine social è la cover. Non reintrodurre campi senza che servano davvero.
|
||||||
social è la cover. Non reintrodurre questi campi senza che servano davvero.
|
|
||||||
|
|
||||||
Draft & Publish attivo su Article. Le URL pubbliche usano lo **slug**, mai l'id numerico.
|
PocketBase non ha Draft & Publish nativo: `publishedAt` vuoto = bozza, valorizzato (e non nel
|
||||||
|
futuro) = pubblicato, imposto dalla `listRule`/`viewRule` della collection `articles`, non da
|
||||||
|
codice applicativo. Le URL pubbliche usano lo **slug**, mai l'id del record.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
@@ -101,9 +106,9 @@ Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
|
|||||||
- SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto.
|
- SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto.
|
||||||
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
|
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
|
||||||
`composables/`, `layouts/`, `server/`).
|
`composables/`, `layouts/`, `server/`).
|
||||||
- Un solo punto di accesso a Strapi: un composable/util tipizzato. Non sparpagliare `$fetch` nelle pagine.
|
- Un solo punto di accesso a PocketBase: un composable/util tipizzato. Non sparpagliare `$fetch` nelle pagine.
|
||||||
- Tipizza esplicitamente il confine API. Niente `any` — `unknown` + narrowing.
|
- Tipizza esplicitamente il confine API. Niente `any` — `unknown` + narrowing.
|
||||||
- Query Strapi: richiedi solo i campi e le relazioni che servono (`fields`, `populate` mirati).
|
- Query PocketBase: richiedi solo i campi e le relazioni che servono (`fields`, `expand` mirati).
|
||||||
Gestisci sempre 404, lista vuota, errore API.
|
Gestisci sempre 404, lista vuota, errore API.
|
||||||
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
|
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
|
||||||
`BlogPosting`, gerarchia heading semantica. Il contenuto deve esistere nell'HTML server-rendered.
|
`BlogPosting`, gerarchia heading semantica. Il contenuto deve esistere nell'HTML server-rendered.
|
||||||
@@ -125,13 +130,14 @@ nuovo se non esiste una sezione adatta. Non lasciare `docs/` disallineata col co
|
|||||||
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
|
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
|
||||||
|
|
||||||
Nessuna dipendenza, astrazione o servizio senza un bisogno concreto e attuale. Preferisci i
|
Nessuna dipendenza, astrazione o servizio senza un bisogno concreto e attuale. Preferisci i
|
||||||
built-in Strapi al reimplementare funzioni CMS in Nuxt.
|
built-in PocketBase (regole per-collection, migration) al reimplementare funzioni CMS in Nuxt.
|
||||||
|
|
||||||
## Vincoli
|
## Vincoli
|
||||||
|
|
||||||
- Mai committare `.env`, segreti, token, credenziali, chiavi. Mantieni `.env.example` sanificato.
|
- Mai committare `.env`, segreti, token, credenziali, chiavi. Mantieni `.env.example` sanificato.
|
||||||
- Mai hard-codare domini di produzione, URL privilegiati o credenziali. Vanno in env var.
|
- Mai hard-codare domini di produzione, URL privilegiati o credenziali. Vanno in env var.
|
||||||
- Non indebolire auth, CORS, TLS o security header per comodità. Least privilege sui ruoli Strapi.
|
- Non indebolire auth, CORS, TLS o security header per comodità. Least privilege sulle regole
|
||||||
|
PocketBase.
|
||||||
- Richiedono **approvazione esplicita**: operazioni distruttive, migrazioni irreversibili, modifiche
|
- Richiedono **approvazione esplicita**: operazioni distruttive, migrazioni irreversibili, modifiche
|
||||||
a dati o configurazione di produzione, cambi di credenziali.
|
a dati o configurazione di produzione, cambi di credenziali.
|
||||||
- Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history,
|
- Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Blog
|
# Blog
|
||||||
|
|
||||||
Blog platform: a public website built with Nuxt, and a private Strapi CMS where the
|
Blog platform: a public website built with Nuxt, and a private PocketBase CMS where the
|
||||||
articles are written. Everything runs behind Caddy via Docker Compose.
|
articles are written. Everything runs behind Caddy via Docker Compose.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
|
Browser → Caddy ─┬─ /_/*, /api/* → PocketBase (CMS)
|
||||||
└─ everything else → Nuxt (website)
|
└─ everything else → Nuxt (website), which also redirects /admin → /_/
|
||||||
```
|
```
|
||||||
|
|
||||||
There are no front-end accounts: sign-up is disabled and only administrators write
|
There are no front-end accounts: only the superuser writes content. An article is a
|
||||||
content. An article is a title, a Markdown body, a cover image and a category.
|
title, a Markdown body, a cover image and a category.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -22,37 +22,32 @@ through Caddy. Requires Docker.
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Generate the secrets.** Every `change-me` must become a different random value —
|
**2. Set the admin credentials.** Replace `POCKETBASE_ADMIN_EMAIL` and
|
||||||
Strapi refuses to start otherwise. This fills them all:
|
`POCKETBASE_ADMIN_PASSWORD` in `.env` with your own — this is the superuser account
|
||||||
|
PocketBase creates (or updates) on every start. The domain and URL variables can stay as
|
||||||
|
they are for local use.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for var in POSTGRES_PASSWORD API_TOKEN_SALT ADMIN_JWT_SECRET TRANSFER_TOKEN_SALT JWT_SECRET ENCRYPTION_KEY; do
|
|
||||||
sed -i "s|^$var=.*|$var=$(openssl rand -base64 32)|" .env
|
|
||||||
done
|
|
||||||
sed -i "s|^APP_KEYS=.*|APP_KEYS=$(openssl rand -base64 32),$(openssl rand -base64 32)|" .env
|
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
`grep change-me .env` must print nothing. The domain and URL variables can stay as they
|
|
||||||
are for local use.
|
|
||||||
|
|
||||||
**3. Start the stack**
|
**3. Start the stack**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on
|
The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on
|
||||||
`127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be
|
`127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be
|
||||||
picked up by accident in production.
|
picked up by accident in production.
|
||||||
|
|
||||||
**4. Create the administrator account** at http://localhost:1337/admin (dev bypasses Caddy,
|
**4. Open the admin UI** at http://localhost:3000/admin (redirects to PocketBase's dashboard
|
||||||
so the CMS is reached directly on its port). This is the first
|
— this works in dev too, without Caddy, since the redirect is handled by the frontend itself)
|
||||||
run, so the form creates the account — pick your own credentials.
|
and log in with the credentials from `.env`.
|
||||||
|
|
||||||
**5. Write something.** In the admin panel: create a **Category**, then an **Article**
|
**5. Write something.** In the admin UI: create a **categories** record, then an
|
||||||
(the body field is Markdown), then press **Publish** — the website only shows published
|
**articles** record (the `content` field is Markdown), then set `publishedAt` — the
|
||||||
content.
|
website only shows articles whose `publishedAt` is set and not in the future.
|
||||||
|
|
||||||
**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and
|
**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and
|
||||||
`/category/<slug>`.
|
`/category/<slug>`.
|
||||||
@@ -60,16 +55,15 @@ content.
|
|||||||
Useful commands:
|
Useful commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f cms # follow the CMS logs
|
docker compose logs -f pocketbase # follow the CMS logs
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend
|
||||||
docker compose down # stop, keep the data
|
docker compose down # stop, keep the data
|
||||||
docker compose down -v # stop and WIPE the database and media
|
docker compose down -v # stop and WIPE the CMS database and media
|
||||||
```
|
```
|
||||||
|
|
||||||
To iterate on the code without rebuilding an image every time, run a package directly —
|
To iterate on the frontend without rebuilding an image every time: `cd frontend && npm run
|
||||||
`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to
|
dev`. It defaults to `http://localhost:8090` for PocketBase, so it works against the
|
||||||
`http://localhost:1337` for Strapi, so it works against the containerised CMS as is;
|
containerised CMS as is; override it with `NUXT_POCKETBASE_URL` if needed.
|
||||||
override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`.
|
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
@@ -78,14 +72,16 @@ public IP of the machine:
|
|||||||
|
|
||||||
| Record | Purpose |
|
| Record | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `example.com` | the website and, at `/admin`, the Strapi admin panel |
|
| `example.com` | the website, and, at `/admin`, the PocketBase admin UI |
|
||||||
|
|
||||||
Wait for the record to resolve before starting the stack — Caddy requests the
|
Wait for the record to resolve before starting the stack — Caddy requests the
|
||||||
certificate on the first boot and a failed challenge means a retry delay.
|
certificate on the first boot and a failed challenge means a retry delay.
|
||||||
|
|
||||||
**2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses
|
**2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses
|
||||||
it for the ACME challenge and to redirect to HTTPS. PostgreSQL, Strapi and Nuxt are only
|
it for the ACME challenge and to redirect to HTTPS. The admin UI shares port 443 with the
|
||||||
reachable inside the Docker network — do not publish their ports.
|
site (reachable by anyone who knows `/admin`, protected only by the superuser login, so
|
||||||
|
keep the password strong); PocketBase itself is never published directly, only Caddy's
|
||||||
|
proxy to it.
|
||||||
|
|
||||||
**3. Configure the environment.** Copy `.env.example` to `.env` on the server and set:
|
**3. Configure the environment.** Copy `.env.example` to `.env` on the server and set:
|
||||||
|
|
||||||
@@ -94,16 +90,15 @@ reachable inside the Docker network — do not publish their ports.
|
|||||||
| `PUBLIC_DOMAIN` | `example.com` |
|
| `PUBLIC_DOMAIN` | `example.com` |
|
||||||
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
|
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
|
||||||
| `PUBLIC_SITE_URL` | `https://example.com` |
|
| `PUBLIC_SITE_URL` | `https://example.com` |
|
||||||
| `PUBLIC_STRAPI_URL` | `https://example.com` — same origin, Caddy proxies `/admin` and Strapi's other plugin paths there (see `caddy/Caddyfile`) |
|
| `PUBLIC_POCKETBASE_URL` | `https://example.com` — same origin, Caddy proxies `/_/` and `/api/` there |
|
||||||
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public |
|
| `POCKETBASE_URL` | leave it as `http://pocketbase:8090` — internal address, never public |
|
||||||
|
| `POCKETBASE_ADMIN_EMAIL` / `POCKETBASE_ADMIN_PASSWORD` | your real superuser credentials |
|
||||||
|
|
||||||
Then generate **fresh** secrets on that machine with the same loop as in development —
|
Keep `.env` out of version control; it is already ignored.
|
||||||
different values from the ones you use locally. Keep `.env` out of version control; it is
|
|
||||||
already ignored.
|
|
||||||
|
|
||||||
> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out.
|
> Changing `POCKETBASE_ADMIN_PASSWORD` later and restarting rotates the superuser
|
||||||
> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values
|
> password immediately (the entrypoint upserts it on every boot) — a credential change,
|
||||||
> unreadable. Set them once, then back up the file somewhere safe.
|
> so treat it with the same care as any production credential rotation.
|
||||||
|
|
||||||
**4. Start everything**
|
**4. Start everything**
|
||||||
|
|
||||||
@@ -111,24 +106,22 @@ already ignored.
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
This time Caddy is included: it serves both the website and, under `/admin` and Strapi's
|
This time Caddy is included: it serves both the website and, under `/admin`, the CMS
|
||||||
other plugin paths, the CMS on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on
|
admin UI on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on its own, and adds
|
||||||
its own, and adds HSTS and the other security headers.
|
HSTS and the other security headers.
|
||||||
|
|
||||||
**5. Create the administrator account** at `https://example.com/admin`, immediately,
|
**5. Log into the admin UI** at `https://example.com/admin` with the credentials from
|
||||||
before anyone else finds the URL — the first visitor to that form is the one who gets the
|
`.env`, then publish as in development.
|
||||||
account. Then publish as in development.
|
|
||||||
|
|
||||||
**6. Back up what is not in git**: the `postgres-data` volume (all content) and the
|
**6. Back up what is not in git**: the `pocketbase-data` volume (database and uploaded
|
||||||
`cms-uploads` volume (all images). Nothing else on the server holds state.
|
media together). Nothing else on the server holds state.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
|
docker run --rm -v blog_pocketbase-data:/data -v "$PWD:/out" alpine tar czf /out/pocketbase-data.tar.gz -C /data .
|
||||||
docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Updating a running site**: pull the new code, then `docker compose up -d --build`.
|
**Updating a running site**: pull the new code, then `docker compose up -d --build`.
|
||||||
Strapi applies its own schema changes at startup; take a backup first.
|
PocketBase applies new `pb_migrations/` files at startup; take a backup first.
|
||||||
|
|
||||||
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).
|
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
|||||||
+14
-15
@@ -16,24 +16,23 @@
|
|||||||
import security_headers
|
import security_headers
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Strapi mounts each of its (and its plugins') admin APIs at its own
|
# /admin is a vanity redirect to /_/, PocketBase's own fixed dashboard
|
||||||
# top-level path, not all under /admin - the admin panel's dashboard
|
# route — handled by Nitro (frontend/server/routes/admin.get.ts), not
|
||||||
# widgets, media library, i18n, etc each call their own plugin prefix.
|
# here, so it also works in dev where Caddy isn't in the stack.
|
||||||
# This list is every @strapi/* package in cms/package.json plus the
|
|
||||||
# core-bundled plugins (content-manager, content-type-builder, upload,
|
# PocketBase's admin UI (/_/) and its own REST/file API (/api/*) both
|
||||||
# i18n, email, content-releases, review-workflows). Adding a new Strapi
|
# reference themselves with root-absolute paths, so they must be
|
||||||
# plugin later means adding its prefix here too.
|
# reachable unprefixed at the domain root — a path like /admin/api/...
|
||||||
# None of this touches /api, reserved for the frontend's own Nitro
|
# with a stripped prefix would break the dashboard's own asset and API
|
||||||
# endpoints.
|
# calls. This is why the frontend's Nitro endpoints live under
|
||||||
@cms path /admin* /content-manager* /content-type-builder* /upload*\
|
# /content/*, not /api/*: /api is reserved for PocketBase here.
|
||||||
/i18n* /email* /content-releases* /review-workflows*\
|
@pocketbase path /_/* /api/*
|
||||||
/users-permissions* /cloud*
|
handle @pocketbase {
|
||||||
handle @cms {
|
# Cover images and admin uploads pass through here too.
|
||||||
# Uploaded media can be large; Strapi's own limit still applies.
|
|
||||||
request_body {
|
request_body {
|
||||||
max_size 100MB
|
max_size 100MB
|
||||||
}
|
}
|
||||||
reverse_proxy cms:1337
|
reverse_proxy pocketbase:8090
|
||||||
}
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
|
|||||||
@@ -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
|
# Local testing without domains or TLS: publishes the app ports on localhost and
|
||||||
# leaves Caddy out. Never used in production — it must be passed explicitly:
|
# leaves Caddy out. Never used in production — it must be passed explicitly:
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build cms frontend
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||||
services:
|
services:
|
||||||
database:
|
pocketbase:
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:8090:8090"
|
||||||
|
|
||||||
cms:
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:1337:1337"
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
NUXT_PUBLIC_SITE_URL: http://localhost:3000
|
NUXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||||
NUXT_PUBLIC_STRAPI_URL: http://localhost:1337
|
NUXT_PUBLIC_POCKETBASE_URL: http://localhost:8090
|
||||||
|
|||||||
+12
-39
@@ -1,64 +1,38 @@
|
|||||||
services:
|
services:
|
||||||
database:
|
pocketbase:
|
||||||
image: postgres:17-alpine
|
build: ./pocketbase
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POCKETBASE_ADMIN_PASSWORD: ${POCKETBASE_ADMIN_PASSWORD}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- pocketbase-data:/pb/pb_data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8090/api/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
cms:
|
|
||||||
build: ./cms
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
database:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
HOST: 0.0.0.0
|
|
||||||
PORT: 1337
|
|
||||||
DATABASE_CLIENT: postgres
|
|
||||||
DATABASE_HOST: database
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_NAME: ${POSTGRES_DB}
|
|
||||||
DATABASE_USERNAME: ${POSTGRES_USER}
|
|
||||||
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
APP_KEYS: ${APP_KEYS}
|
|
||||||
API_TOKEN_SALT: ${API_TOKEN_SALT}
|
|
||||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
|
|
||||||
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
||||||
STRAPI_TELEMETRY_DISABLED: "true"
|
|
||||||
volumes:
|
|
||||||
- cms-uploads:/app/public/uploads
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- cms
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
NUXT_STRAPI_URL: ${STRAPI_URL}
|
NUXT_POCKETBASE_URL: ${POCKETBASE_URL}
|
||||||
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
||||||
NUXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL}
|
NUXT_PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
- frontend
|
||||||
- cms
|
- pocketbase
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -76,7 +50,6 @@ services:
|
|||||||
- caddy-config:/config
|
- caddy-config:/config
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
pocketbase-data:
|
||||||
cms-uploads:
|
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
caddy-config:
|
||||||
|
|||||||
+6
-6
@@ -6,7 +6,7 @@ the root [README.md](../README.md); for coding conventions and constraints, see
|
|||||||
depth than either of those.
|
depth than either of those.
|
||||||
|
|
||||||
- [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
|
- [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
|
||||||
- [content-model.md](./content-model.md) — Strapi content types, admin panel, permissions, editorial workflow.
|
- [content-model.md](./content-model.md) — PocketBase collections, admin panel, permissions, editorial workflow.
|
||||||
- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
|
- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
|
||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
@@ -15,9 +15,9 @@ A blog website with two faces:
|
|||||||
|
|
||||||
- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
|
- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
|
||||||
no login, no client-side calls to the CMS.
|
no login, no client-side calls to the CMS.
|
||||||
- **Admin panel** — the site owner logs into Strapi's admin UI (`/admin`) to write, edit and
|
- **Admin panel** — the site owner logs into PocketBase's admin UI (`/admin`, redirects to `/_/`)
|
||||||
publish articles and categories. This is the only way content changes; there is no other CMS
|
to write, edit and publish articles and categories. This is the only way content changes; there
|
||||||
and no public user accounts.
|
is no other CMS and no public user accounts.
|
||||||
|
|
||||||
One codebase, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a
|
One codebase, two runtime components (Nuxt frontend, PocketBase — CMS and database in one
|
||||||
single Caddy reverse proxy on one domain.
|
process), fronted by a single Caddy reverse proxy on one domain.
|
||||||
|
|||||||
+58
-53
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
|
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
|
||||||
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
|
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
|
||||||
|
("/admin" redirects to /_/, handled by Nitro)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **database** — `postgres:17-alpine`. Not exposed publicly; only reachable by `cms` on the
|
- **pocketbase** — a single PocketBase binary, the sole source of editorial truth. Embedded
|
||||||
Docker network. Data on the `postgres-data` volume.
|
SQLite, no separate database service. No other backend framework exists in this repo; any
|
||||||
- **cms** — Strapi 5, the sole source of editorial truth. No other backend framework exists in
|
server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new
|
||||||
this repo; any server-side logic that isn't content management belongs in Nuxt's Nitro server,
|
service.
|
||||||
not in a new service.
|
|
||||||
- **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints
|
- **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints
|
||||||
under `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi.
|
under `/content/*` (Nitro), which are the only code in the repo allowed to call PocketBase.
|
||||||
- **caddy** — single reverse proxy, single TLS certificate, single public domain
|
- **caddy** — single reverse proxy, single TLS certificate, single public domain
|
||||||
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain.
|
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain or port.
|
||||||
|
|
||||||
`docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy)
|
`docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy)
|
||||||
and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml` —
|
and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml` —
|
||||||
@@ -26,74 +26,79 @@ production.
|
|||||||
|
|
||||||
## Path routing (Caddy)
|
## Path routing (Caddy)
|
||||||
|
|
||||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`, one path matcher
|
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
|
||||||
`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
|
|
||||||
|
|
||||||
```
|
- `@pocketbase path /_/* /api/*` → `pocketbase:8090`, **unprefixed** (100MB body limit, for
|
||||||
/admin* /content-manager* /content-type-builder* /upload* /i18n*
|
uploads).
|
||||||
/email* /content-releases* /review-workflows* /users-permissions* /cloud*
|
- everything else → `frontend:3000`.
|
||||||
```
|
|
||||||
|
|
||||||
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
|
`/admin` is **not** a Caddy rule: it's a Nitro route
|
||||||
else — including `/api/*` — goes to `frontend:3000`.
|
(`frontend/server/routes/admin.get.ts`) that redirects to `${pocketbaseUrl}/_/` (PocketBase's own
|
||||||
|
fixed dashboard route, since it can't be told to serve elsewhere). Handling it in Nitro rather
|
||||||
|
than Caddy means it works identically in dev, where Caddy isn't part of the stack — `/admin`
|
||||||
|
redirects to `http://localhost:8090/_/` there — and in production, where it redirects to the same
|
||||||
|
origin's `/_/`, which Caddy then proxies to PocketBase.
|
||||||
|
|
||||||
**`/api/*` is reserved for Nuxt's own Nitro endpoints, never for Strapi.** Strapi's public REST
|
**Why unprefixed, not a stripped `/admin/*` prefix:** PocketBase's admin dashboard references its
|
||||||
API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the
|
own assets and API with paths rooted at `/_/` and `/api/`. A reverse-proxy rule that rewrites
|
||||||
Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content
|
`/admin/foo` → `/foo` before forwarding would serve the dashboard's HTML fine, but every asset and
|
||||||
— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated).
|
API call the dashboard's own JS makes afterwards targets `/_/...`/`/api/...` directly — those
|
||||||
|
requests would then miss the `/admin` prefix and never reach the rewrite rule, landing on Nuxt
|
||||||
|
instead and breaking the dashboard. Routing `/_/*` and `/api/*` at the domain root, unprefixed, is
|
||||||
|
the only configuration PocketBase's own code is written to expect (confirmed against a live
|
||||||
|
container: dashboard HTML, its JS/CSS assets under `/_/assets/...`, and REST calls under
|
||||||
|
`/api/...` all resolve correctly this way). This is also PocketBase's own documented recommendation
|
||||||
|
for reverse-proxy deployments.
|
||||||
|
|
||||||
If you add a Strapi plugin that mounts its own admin API path, add its prefix to `@cms` in the
|
**`/api/*` is reserved for PocketBase here — the inverse of the old Strapi setup.** Nuxt's own
|
||||||
Caddyfile — this is the one place that list is maintained.
|
Nitro endpoints live under `/content/*` instead (`frontend/server/routes/content/`, not
|
||||||
|
`frontend/server/api/`, since Nitro auto-prefixes anything under `server/api/` with `/api`).
|
||||||
|
PocketBase's public REST API is reached two ways: from inside the Docker network by the Nitro
|
||||||
|
server, over `POCKETBASE_URL=http://pocketbase:8090`; and directly by the browser for the two
|
||||||
|
things that don't go through Nitro — the admin UI and cover images.
|
||||||
|
|
||||||
## Request flow: reading an article
|
## Request flow: reading an article
|
||||||
|
|
||||||
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
|
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
|
||||||
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/api/articles/my-slug')` — a same-origin
|
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/content/articles/my-slug')` — a
|
||||||
call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip over the
|
same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip
|
||||||
network in production).
|
over the network in production).
|
||||||
3. `frontend/server/api/articles/[slug].get.ts` calls `strapiFetch()` (in
|
3. `frontend/server/routes/content/articles/[slug].get.ts` calls `pbFetch()` (in
|
||||||
`frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a
|
`frontend/server/utils/pocketbase.ts`), which hits `POCKETBASE_URL` (internal Docker address)
|
||||||
Strapi-specific query built by `frontend/server/utils/queries.ts`.
|
with a PocketBase filter/fields query built by `frontend/server/utils/queries.ts`. PocketBase's
|
||||||
|
`listRule`/`viewRule` on the `articles` collection already exclude unpublished entries — the
|
||||||
|
endpoint doesn't need to check that itself.
|
||||||
4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via
|
4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via
|
||||||
`renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
|
`renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
|
||||||
`Article` from `frontend/shared/types/blog.ts`.
|
`Article` from `frontend/shared/types/blog.ts`.
|
||||||
5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to
|
5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to
|
||||||
the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
|
the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
|
||||||
6. The cover image `<img>` tag points directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
|
6. The cover image `<img>` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset
|
||||||
asset the browser fetches straight from Strapi.
|
the browser fetches straight from PocketBase.
|
||||||
|
|
||||||
## Request flow: publishing content
|
## Request flow: publishing content
|
||||||
|
|
||||||
1. Editor logs into `/admin` (Strapi admin panel, authenticated, editor/admin only — no public
|
1. Editor goes to `PUBLIC_SITE_URL/admin` (redirects to `/_/`, PocketBase's admin UI —
|
||||||
sign-up, see [content-model.md](./content-model.md)).
|
authenticated superuser only, no public sign-up, see [content-model.md](./content-model.md)).
|
||||||
2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish).
|
2. Editor writes/edits an Article (Markdown body) or Category, and sets `publishedAt` to publish
|
||||||
3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that
|
it.
|
||||||
slug hits Strapi live through the Nitro endpoint.
|
3. PocketBase writes to its embedded SQLite database (on the `pocketbase-data` volume). No cache
|
||||||
|
to invalidate: the next public request for that slug hits PocketBase live through the Nitro
|
||||||
|
endpoint.
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
Defined in [.env.example](../.env.example) (root, drives docker-compose) and
|
Defined in [.env.example](../.env.example) (root, drives docker-compose).
|
||||||
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
|
|
||||||
Docker).
|
|
||||||
|
|
||||||
| Variable | Consumed by | Purpose |
|
| Variable | Consumed by | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
|
| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
|
||||||
| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
|
| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
|
||||||
| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. |
|
| `POCKETBASE_ADMIN_EMAIL` | pocketbase | Bootstraps (and keeps up to date, on every restart) the initial superuser account. |
|
||||||
| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). |
|
| `POCKETBASE_ADMIN_PASSWORD` | pocketbase | Password for the superuser above. Rotating it is a credential change — see `CLAUDE.md`. |
|
||||||
| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. |
|
| `POCKETBASE_URL` | frontend (server-only) | Internal Docker address of PocketBase (`http://pocketbase:8090`); mapped to `NUXT_POCKETBASE_URL`. Never sent to the browser. |
|
||||||
| `ADMIN_JWT_SECRET` | cms | Signs Strapi admin panel JWTs. |
|
|
||||||
| `TRANSFER_TOKEN_SALT` | cms | Salt for Strapi data-transfer tokens. |
|
|
||||||
| `JWT_SECRET` | cms | Signs users-permissions (public API) JWTs. |
|
|
||||||
| `ENCRYPTION_KEY` | cms | Strapi's encrypted-field key. |
|
|
||||||
| `STRAPI_URL` | frontend (server-only) | Internal Docker address of Strapi (`http://cms:1337`); mapped to `NUXT_STRAPI_URL`. Never sent to the browser. |
|
|
||||||
| `NUXT_STRAPI_TOKEN` | frontend (server-only) | Optional Bearer token for Strapi requests; unset by default. |
|
|
||||||
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
|
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
|
||||||
| `PUBLIC_STRAPI_URL` | frontend, browser | Public-facing Strapi origin for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_STRAPI_URL`. |
|
| `PUBLIC_POCKETBASE_URL` | frontend, browser | Public-facing PocketBase origin (same domain as `PUBLIC_SITE_URL`; Caddy proxies `/_/*` and `/api/*` there) for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_POCKETBASE_URL`. |
|
||||||
|
|
||||||
All Strapi secrets have standalone copies in `cms/.env.example` for local development without
|
|
||||||
Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
|
|
||||||
|
|
||||||
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
|
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
|
||||||
only).
|
only).
|
||||||
|
|||||||
+51
-40
@@ -1,63 +1,74 @@
|
|||||||
# Content model & admin panel
|
# Content model & admin panel
|
||||||
|
|
||||||
## Content types
|
## Collections
|
||||||
|
|
||||||
Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory
|
Defined as code in `pocketbase/pb_migrations/*.js`, applied automatically the first time
|
||||||
defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers,
|
PocketBase boots against an empty data directory (and on every subsequent deploy that adds new
|
||||||
routes, services, or policies exist for either type.
|
migration files). No custom Go/JS hooks exist for either collection — schema and API rules only.
|
||||||
|
|
||||||
| Type | Fields |
|
| Collection | Fields |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Article** | `title` (string, required, max 160), `slug` (UID generated from title), `content` (richtext — Markdown source), `cover` (single media/image), `category` (many-to-one relation to Category) |
|
| **articles** | `title` (text, required, max 160), `slug` (text, required, unique, kebab-case pattern), `content` (text — Markdown source, not the WYSIWYG `editor` field type), `cover` (single file, images only), `coverAlt` (text, alt text), `category` (relation to `categories`), `publishedAt` (date, nullable), `authorName` (text) |
|
||||||
| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) |
|
| **categories** | `name` (text, required, unique), `slug` (text, required, unique, kebab-case pattern) |
|
||||||
|
|
||||||
Deliberately minimal: no Author (the only author is the admin account), no tags, no separate SEO
|
Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete
|
||||||
fields. Don't add these back without a concrete need — see `CLAUDE.md`:
|
need — see `CLAUDE.md`:
|
||||||
|
|
||||||
- Meta description is derived at request time from the article body (`summarise()` in
|
- Meta description is derived at request time from the article body (`summarise()` in
|
||||||
`frontend/server/utils/strapi.ts`).
|
`frontend/server/utils/pocketbase.ts`).
|
||||||
- Publish date is Draft & Publish's `publishedAt`.
|
- Publish date is `publishedAt`.
|
||||||
- Social preview image is the cover.
|
- Social preview image is the cover.
|
||||||
- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`),
|
- The article byline is `authorName`, a plain field the editor fills in — PocketBase has no
|
||||||
not a content field.
|
built-in "creator" metadata to auto-populate it from, unlike Strapi's `createdBy`.
|
||||||
|
|
||||||
Draft & Publish is enabled on Article (`draftAndPublish: true`) and disabled on Category
|
There is no native Draft & Publish in PocketBase. `publishedAt` reproduces its semantics
|
||||||
(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the
|
explicitly: empty = draft (never returned by the public API), set and not in the future =
|
||||||
numeric id.
|
published. This is enforced by the collection's `listRule`/`viewRule`
|
||||||
|
(`publishedAt != '' && publishedAt <= @now`), not by application code — an unpublished or
|
||||||
|
future-dated article is invisible to `GET /api/collections/articles/records` regardless of what
|
||||||
|
Nitro asks for. Categories have no draft state (`slug` is unique so a category always exists or
|
||||||
|
doesn't). Public URLs always use the slug, never the PocketBase record id.
|
||||||
|
|
||||||
|
PocketBase file fields store only a filename, no width/height/alt metadata — that's why `coverAlt`
|
||||||
|
is a real field rather than upload metadata, and why the frontend's `MediaImage` type carries no
|
||||||
|
dimensions (see [frontend.md](./frontend.md)).
|
||||||
|
|
||||||
## Admin login & permissions
|
## Admin login & permissions
|
||||||
|
|
||||||
- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container
|
- The admin panel lives at `/_/` (also reachable via `/admin`, which just redirects there),
|
||||||
([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard
|
routed by Caddy straight to the `pocketbase` container
|
||||||
email/password admin authentication — no custom auth code in this repo.
|
([architecture.md](./architecture.md#path-routing-caddy)). It's PocketBase's standard
|
||||||
|
superuser email/password auth — no custom auth code in this repo.
|
||||||
- Public visitors **never** authenticate. There is no visitor account system, no comments, no
|
- Public visitors **never** authenticate. There is no visitor account system, no comments, no
|
||||||
public write access of any kind.
|
public write access of any kind.
|
||||||
- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start:
|
- Each collection's `createRule`/`updateRule`/`deleteRule` is `null` — writes are superuser-only,
|
||||||
1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article
|
full stop. There is no separate "editor" role: the site owner is the only superuser, matching
|
||||||
and Category, and nothing else (no create/update/delete, no other content type). This is
|
"no front-end user accounts should ever exist."
|
||||||
what lets the Nitro endpoints read published content without a token.
|
- `listRule`/`viewRule` on `articles` are `publishedAt != '' && publishedAt <= @now` (public read
|
||||||
2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration
|
of published content only); on `categories` they're `''` (public read, unconditional — categories
|
||||||
(`allow_register: false`), since no front-end user accounts should ever exist.
|
aren't drafted).
|
||||||
- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a
|
- `cover`'s field config restricts uploads to images (PNG/JPEG/WEBP/AVIF/GIF), 10MB max — the
|
||||||
deliberate least-privilege boundary, not an oversight.
|
equivalent of Strapi's old upload-plugin MIME allowlist, now expressed per-field instead of
|
||||||
- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images,
|
globally.
|
||||||
video, audio, PDF, office docs, text/CSV) and explicitly denies executables.
|
- Any rule change beyond this is a deliberate least-privilege boundary, not an oversight, and
|
||||||
|
needs to be justified explicitly.
|
||||||
|
|
||||||
## Editorial workflow
|
## Editorial workflow
|
||||||
|
|
||||||
1. Log into `/admin`.
|
1. Log into `PUBLIC_SITE_URL/admin` (redirects to `/_/`).
|
||||||
2. Create/edit a Category if needed (name → slug is generated automatically).
|
2. Create/edit a Category if needed.
|
||||||
3. Create/edit an Article: title (→ slug), Markdown content, cover image, category.
|
3. Create/edit an Article: title, slug, Markdown content, cover image + alt text, category,
|
||||||
4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne`
|
author name.
|
||||||
permissions above — Strapi's default behavior already excludes non-published entries from the
|
4. Set `publishedAt` (to now, or a past/future date) to publish it. Leaving it empty keeps the
|
||||||
public API.
|
article a draft, invisible to the public API.
|
||||||
5. The change is live immediately: the public site has no cache layer to invalidate (see
|
5. The change is live immediately: the public site has no cache layer to invalidate (see
|
||||||
[architecture.md](./architecture.md#request-flow-reading-an-article)).
|
[architecture.md](./architecture.md#request-flow-reading-an-article)).
|
||||||
|
|
||||||
## Why Markdown, not a rich-text/WYSIWYG field
|
## Why Markdown, not a rich-text/WYSIWYG field
|
||||||
|
|
||||||
`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML
|
`content` is a plain PocketBase `text` field, not the `editor` field type (which stores HTML).
|
||||||
happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never
|
PocketBase stores the raw Markdown; conversion to HTML happens once, server-side, in the Nuxt
|
||||||
in Strapi and never in the browser. This keeps `marked` out of the client bundle and keeps HTML
|
Nitro endpoint (`renderMarkdown()`, using `marked`) — never in PocketBase and never in the browser.
|
||||||
generation in one place. There is no sanitization step: this is intentional, since the only author
|
This keeps `marked` out of the client bundle and keeps HTML generation in one place. There is no
|
||||||
is the trusted admin, not arbitrary users.
|
sanitization step: this is intentional, since the only author is the trusted superuser, not
|
||||||
|
arbitrary users.
|
||||||
|
|||||||
+37
-25
@@ -4,44 +4,54 @@
|
|||||||
|
|
||||||
| Route | File | Behavior |
|
| Route | File | Behavior |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/api/articles` (page 1), shown as a featured block. |
|
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/content/articles` (page 1), shown as a featured block. |
|
||||||
| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
|
| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
|
||||||
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/api/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
|
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/content/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
|
||||||
| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
|
| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
|
||||||
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no Strapi data. |
|
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data. |
|
||||||
|
|
||||||
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
|
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
|
||||||
|
|
||||||
## Server (Nitro) endpoints — the only Strapi client
|
## Server (Nitro) endpoints — the only PocketBase client
|
||||||
|
|
||||||
`frontend/server/api/`:
|
`frontend/server/routes/content/` (not `server/api/` — `/api/*` is reserved for PocketBase itself,
|
||||||
|
see [architecture.md](./architecture.md#path-routing-caddy); Nitro maps `server/routes/**` to the
|
||||||
|
matching path with no added prefix, unlike `server/api/**`):
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
| Endpoint | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Proxies to Strapi with `ARTICLE_SUMMARY_QUERY`. Returns `Paginated<ArticleSummary>`. |
|
| `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Queries PocketBase with `ARTICLE_SUMMARY_FIELDS`. Returns `Paginated<ArticleSummary>`. |
|
||||||
| `GET /api/articles/:slug` | One article: fetches from Strapi with `ARTICLE_DETAIL_QUERY` (includes `content` + `createdBy`), converts Markdown to HTML, builds the meta description, derives the author byline. 400 without a slug, 404 if not found. Returns `Article`. |
|
| `GET /content/articles/:slug` | One article: queries PocketBase with `ARTICLE_DETAIL_FIELDS` (includes `content` + `authorName`), converts Markdown to HTML, builds the meta description. 400 without a slug, 404 if not found or unpublished. Returns `Article`. |
|
||||||
| `GET /api/categories` | All categories (name + slug only), sorted by name. |
|
| `GET /content/categories` | All categories (name + slug only), sorted by name. |
|
||||||
| `GET /api/categories/:slug` | One category by slug. 400/404 as above. |
|
| `GET /content/categories/:slug` | One category by slug. 400/404 as above. |
|
||||||
|
|
||||||
This is the **single point of contact** with Strapi (`CLAUDE.md` rule): pages never call
|
This is the **single point of contact** with PocketBase (`CLAUDE.md` rule): pages never call
|
||||||
`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client.
|
`$fetch` against PocketBase directly, and `NUXT_POCKETBASE_URL` never reaches the client. If you
|
||||||
If you add a view that needs new data, add the endpoint here and type its return in
|
add a view that needs new data, add the endpoint here (under `/content/*`) and type its return in
|
||||||
`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components.
|
`frontend/shared/types/blog.ts` — don't scatter PocketBase calls into components.
|
||||||
|
|
||||||
|
`GET /admin` (`frontend/server/routes/admin.get.ts`) is the one other top-level server route: a
|
||||||
|
redirect to `${runtimeConfig.public.pocketbaseUrl}/_/`, PocketBase's own fixed dashboard path. It
|
||||||
|
lives in Nitro rather than Caddy so it works the same in dev (no Caddy in that stack) and
|
||||||
|
production — see [architecture.md](./architecture.md#path-routing-caddy).
|
||||||
|
|
||||||
## Supporting utilities
|
## Supporting utilities
|
||||||
|
|
||||||
- `frontend/server/utils/strapi.ts`
|
- `frontend/server/utils/pocketbase.ts`
|
||||||
- `strapiFetch<T>(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional
|
- `pbFetch<T>(path)` — server-only fetch against `runtimeConfig.pocketbaseUrl`; wraps failures
|
||||||
Bearer token; wraps failures as a 502 so internal details never leak to the client.
|
as a 502 so internal details never leak to the client.
|
||||||
|
- `toMediaImage(collection, id, filename, alt)` — builds a PocketBase file URL
|
||||||
|
(`/api/files/{collection}/{id}/{filename}`) from a record; returns `null` when there's no
|
||||||
|
file.
|
||||||
- `renderMarkdown(source)` — `marked.parse()`, no sanitization (trusted, admin-only content).
|
- `renderMarkdown(source)` — `marked.parse()`, no sanitization (trusted, admin-only content).
|
||||||
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
|
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
|
||||||
description, word-boundary clipped.
|
description, word-boundary clipped.
|
||||||
- `frontend/server/utils/queries.ts` — Strapi query-string builders kept intentionally minimal
|
- `frontend/server/utils/queries.ts` — PocketBase query-string builders kept intentionally minimal
|
||||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`,
|
(only the fields each page actually renders): `ARTICLE_SUMMARY_FIELDS`, `ARTICLE_DETAIL_FIELDS`,
|
||||||
`PAGE_SIZE`, `pagination()`, `pageParam()`.
|
`PAGE_SIZE`, `pagination()`, `pageParam()`, `quote()` (escapes a value for PocketBase's `filter`
|
||||||
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object
|
DSL).
|
||||||
into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already
|
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a `MediaImage` into an
|
||||||
absolute.
|
absolute browser URL by prefixing `runtimeConfig.public.pocketbaseUrl` unless already absolute.
|
||||||
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
|
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
|
||||||
- `frontend/shared/utils/format.ts` — `it-IT` date formatting (`formatDate`, `formatDateTime`,
|
- `frontend/shared/utils/format.ts` — `it-IT` date formatting (`formatDate`, `formatDateTime`,
|
||||||
`isoDate`).
|
`isoDate`).
|
||||||
@@ -49,15 +59,17 @@ If you add a view that needs new data, add the endpoint here and type its return
|
|||||||
## Types (`frontend/shared/types/blog.ts`)
|
## Types (`frontend/shared/types/blog.ts`)
|
||||||
|
|
||||||
```
|
```
|
||||||
StrapiImage { url, alternativeText, width, height }
|
MediaImage { url, alt }
|
||||||
Category { name, slug }
|
Category { name, slug }
|
||||||
ArticleSummary{ title, slug, publishedAt, cover: StrapiImage | null, category: Category | null }
|
ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | null }
|
||||||
Article extends ArticleSummary { html, summary, author: string | null }
|
Article extends ArticleSummary { html, summary, author: string | null }
|
||||||
Paginated<T> { items: T[], page, pageCount, total }
|
Paginated<T> { items: T[], page, pageCount, total }
|
||||||
```
|
```
|
||||||
|
|
||||||
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
|
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
|
||||||
listing pages use.
|
listing pages use. `MediaImage` carries no width/height — PocketBase file fields don't store
|
||||||
|
dimensions, and the covers already reserve their aspect ratio via CSS (`aspect-ratio`), so no
|
||||||
|
layout shift results.
|
||||||
|
|
||||||
## Layout & shared components
|
## Layout & shared components
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
|||||||
<img
|
<img
|
||||||
class="cover"
|
class="cover"
|
||||||
:src="cover"
|
:src="cover"
|
||||||
:alt="article.cover?.alternativeText ?? ''"
|
:alt="article.cover?.alt ?? ''"
|
||||||
:width="article.cover?.width ?? undefined"
|
|
||||||
:height="article.cover?.height ?? undefined"
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
>
|
>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { StrapiImage } from '#shared/types/blog'
|
import type { MediaImage } from '#shared/types/blog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strapi returns media paths relative to its own origin; the browser needs
|
* PocketBase returns file paths relative to its own origin; the browser
|
||||||
* absolute ones. Remote providers (S3) already return absolute URLs.
|
* needs absolute ones.
|
||||||
*/
|
*/
|
||||||
export function useMediaUrl() {
|
export function useMediaUrl() {
|
||||||
const { public: config } = useRuntimeConfig()
|
const { public: config } = useRuntimeConfig()
|
||||||
|
|
||||||
return (image: StrapiImage | null | undefined): string | null => {
|
return (image: MediaImage | null | undefined): string | null => {
|
||||||
if (!image?.url) return null
|
if (!image?.url) return null
|
||||||
return image.url.startsWith('http') ? image.url : `${config.strapiUrl}${image.url}`
|
return image.url.startsWith('http') ? image.url : `${config.pocketbaseUrl}${image.url}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Article } from '#shared/types/blog'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = route.params.slug as string
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
|
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`)
|
||||||
|
|
||||||
if (!article.value) {
|
if (!article.value) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||||
@@ -79,9 +79,7 @@ useHead({
|
|||||||
v-if="cover"
|
v-if="cover"
|
||||||
class="cover"
|
class="cover"
|
||||||
:src="cover"
|
:src="cover"
|
||||||
:alt="article.cover?.alternativeText ?? ''"
|
:alt="article.cover?.alt ?? ''"
|
||||||
:width="article.cover?.width ?? undefined"
|
|
||||||
:height="article.cover?.height ?? undefined"
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const page = computed(() => {
|
|||||||
return Number.isInteger(value) && value > 0 ? value : 1
|
return Number.isInteger(value) && value > 0 ? value : 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||||
query: { page },
|
query: { page },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ const page = computed(() => {
|
|||||||
return Number.isInteger(value) && value > 0 ? value : 1
|
return Number.isInteger(value) && value > 0 ? value : 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
|
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`)
|
||||||
|
|
||||||
if (!category.value) {
|
if (!category.value) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||||
query: { page, category: slug },
|
query: { page, category: slug },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||||
|
|
||||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||||
key: 'home-articles',
|
key: 'home-articles',
|
||||||
query: { page: 1 },
|
query: { page: 1 },
|
||||||
})
|
})
|
||||||
@@ -82,9 +82,7 @@ useSeoMeta({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="featuredCover"
|
:src="featuredCover"
|
||||||
:alt="featured.cover?.alternativeText ?? ''"
|
:alt="featured.cover?.alt ?? ''"
|
||||||
:width="featured.cover?.width ?? undefined"
|
|
||||||
:height="featured.cover?.height ?? undefined"
|
|
||||||
>
|
>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,17 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
// Server-only: internal Strapi address, never exposed to the browser.
|
// Server-only: internal PocketBase address, never exposed to the browser.
|
||||||
strapiUrl: 'http://localhost:1337',
|
pocketbaseUrl: 'http://localhost:8090',
|
||||||
strapiToken: '',
|
|
||||||
public: {
|
public: {
|
||||||
// Canonical origin of the public website.
|
// Canonical origin of the public website.
|
||||||
siteUrl: 'http://localhost:3000',
|
siteUrl: 'http://localhost:3000',
|
||||||
// Browser-reachable Strapi origin, used to build absolute media URLs.
|
// Browser-reachable PocketBase origin, used to build absolute media URLs.
|
||||||
strapiUrl: 'http://localhost:1337',
|
pocketbaseUrl: 'http://localhost:8090',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
strict: true,
|
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' })
|
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<Category>>(
|
const response = await pbFetch<PbList<Category>>(
|
||||||
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
`/api/collections/categories/records?fields=name,slug&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
|
||||||
)
|
)
|
||||||
|
|
||||||
const category = response.data[0]
|
const category = response.items[0]
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Category } from '#shared/types/blog'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (): Promise<Category[]> => {
|
||||||
|
const response = await pbFetch<PbList<Category>>(
|
||||||
|
'/api/collections/categories/records?fields=name,slug&sort=name&perPage=100'
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.items
|
||||||
|
})
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
|
import type { MediaImage } from '#shared/types/blog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls the Strapi REST API from the server only, so the internal URL and any
|
* Calls the PocketBase REST API from the server only, so the internal URL
|
||||||
* future API token never reach the browser.
|
* never reaches the browser.
|
||||||
*/
|
*/
|
||||||
export async function strapiFetch<T>(path: string): Promise<T> {
|
export async function pbFetch<T>(path: string): Promise<T> {
|
||||||
const { strapiUrl, strapiToken } = useRuntimeConfig()
|
const { pocketbaseUrl } = useRuntimeConfig()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return (await $fetch(path, {
|
return (await $fetch(path, { baseURL: pocketbaseUrl })) as T
|
||||||
baseURL: strapiUrl,
|
|
||||||
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
|
|
||||||
})) as T
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Strapi request failed: ${path}`, error)
|
console.error(`PocketBase request failed: ${path}`, error)
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
|
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +46,24 @@ export function summarise(source: string | null | undefined, maxLength = 155): s
|
|||||||
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}…`
|
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrapiList<T> {
|
export interface PbList<T> {
|
||||||
data: T[]
|
page: number
|
||||||
meta: { pagination: { page: number; pageCount: number; total: number } }
|
perPage: number
|
||||||
|
totalItems: number
|
||||||
|
totalPages: number
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a PocketBase file URL, relative to its own origin — PocketBase file
|
||||||
|
* fields store only a filename, not a full path or dimensions.
|
||||||
|
*/
|
||||||
|
export function toMediaImage(
|
||||||
|
collection: string,
|
||||||
|
id: string,
|
||||||
|
filename: string | null | undefined,
|
||||||
|
alt: string | null | undefined
|
||||||
|
): MediaImage | null {
|
||||||
|
if (!filename) return null
|
||||||
|
return { url: `/api/files/${collection}/${id}/${filename}`, alt: alt || null }
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,20 @@
|
|||||||
/** Strapi query fragments. Only the fields the pages actually render are requested. */
|
/** PocketBase query fragments. Only the fields the pages actually render are requested. */
|
||||||
|
|
||||||
const list = (prefix: string, names: readonly string[]) =>
|
export const ARTICLE_SUMMARY_FIELDS =
|
||||||
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
|
'id,title,slug,publishedAt,cover,coverAlt,expand.category.name,expand.category.slug'
|
||||||
|
|
||||||
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const
|
export const ARTICLE_DETAIL_FIELDS =
|
||||||
|
'id,title,slug,content,publishedAt,cover,coverAlt,authorName,expand.category.name,expand.category.slug'
|
||||||
export const ARTICLE_SUMMARY_QUERY = [
|
|
||||||
list('fields', ['title', 'slug', 'publishedAt']),
|
|
||||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
|
||||||
list('populate[category][fields]', ['name', 'slug']),
|
|
||||||
'sort[0]=publishedAt:desc',
|
|
||||||
].join('&')
|
|
||||||
|
|
||||||
export const ARTICLE_DETAIL_QUERY = [
|
|
||||||
list('fields', ['title', 'slug', 'content', 'publishedAt']),
|
|
||||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
|
||||||
list('populate[category][fields]', ['name', 'slug']),
|
|
||||||
// The byline is the admin user who created the entry; Strapi exposes the
|
|
||||||
// name fields only because the content type sets populateCreatorFields.
|
|
||||||
list('populate[createdBy][fields]', ['firstname', 'lastname']),
|
|
||||||
].join('&')
|
|
||||||
|
|
||||||
export const PAGE_SIZE = 12
|
export const PAGE_SIZE = 12
|
||||||
|
|
||||||
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
|
export const pagination = (page: number, perPage = PAGE_SIZE) => `page=${page}&perPage=${perPage}`
|
||||||
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
|
|
||||||
|
|
||||||
/** Reads a positive integer query param, falling back to 1. */
|
/** Reads a positive integer query param, falling back to 1. */
|
||||||
export const pageParam = (value: unknown): number => {
|
export const pageParam = (value: unknown): number => {
|
||||||
const page = Number(value)
|
const page = Number(value)
|
||||||
return Number.isInteger(page) && page > 0 ? page : 1
|
return Number.isInteger(page) && page > 0 ? page : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quotes a value for PocketBase's `filter` DSL, escaping embedded quotes. */
|
||||||
|
export const quote = (value: string) => `"${value.replace(/"/g, '\\"')}"`
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
/** Shape of the Strapi payloads, narrowed to what the site actually renders. */
|
/** Shape of the PocketBase payloads, narrowed to what the site actually renders. */
|
||||||
|
|
||||||
export interface StrapiImage {
|
export interface MediaImage {
|
||||||
url: string
|
url: string
|
||||||
alternativeText: string | null
|
alt: string | null
|
||||||
width: number | null
|
|
||||||
height: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -17,7 +15,7 @@ export interface ArticleSummary {
|
|||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
publishedAt: string
|
publishedAt: string
|
||||||
cover: StrapiImage | null
|
cover: MediaImage | null
|
||||||
category: Category | null
|
category: Category | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +24,7 @@ export interface Article extends ArticleSummary {
|
|||||||
html: string
|
html: string
|
||||||
/** Plain-text opening of the body, used as the meta description. */
|
/** Plain-text opening of the body, used as the meta description. */
|
||||||
summary: string
|
summary: string
|
||||||
/** Byline: the full name of the admin user who wrote the article. */
|
/** Byline: the article's authorName field, filled in manually by the editor. */
|
||||||
author: string | null
|
author: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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