From 49c42ecc96f11a5f26326d51430303d616ad2d76 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 11 Sep 2026 11:25:14 +0200 Subject: [PATCH] Replace Strapi CMS with PocketBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 31 +- CLAUDE.md | 88 +- README.md | 91 +- caddy/Caddyfile | 29 +- cms/.dockerignore | 9 - cms/.env.example | 9 - cms/.gitignore | 131 - cms/Dockerfile | 19 - cms/README.md | 61 - cms/config/admin.ts | 25 - cms/config/api.ts | 16 - cms/config/database.ts | 72 - cms/config/middlewares.ts | 16 - cms/config/plugins.ts | 44 - cms/config/server.ts | 14 - cms/database/migrations/.gitkeep | 0 cms/favicon.png | Bin 497 -> 0 bytes cms/package-lock.json | 21551 ---------------- cms/package.json | 41 - cms/public/robots.txt | 3 - cms/public/uploads/.gitkeep | 0 cms/src/admin/app.example.tsx | 37 - cms/src/admin/tsconfig.json | 20 - cms/src/admin/vite.config.example.ts | 12 - cms/src/api/.gitkeep | 0 .../article/content-types/article/schema.json | 41 - cms/src/api/article/controllers/article.ts | 3 - cms/src/api/article/routes/article.ts | 3 - cms/src/api/article/services/article.ts | 3 - .../content-types/category/schema.json | 31 - cms/src/api/category/controllers/category.ts | 3 - cms/src/api/category/routes/category.ts | 3 - cms/src/api/category/services/category.ts | 3 - cms/src/extensions/.gitkeep | 0 cms/src/index.ts | 55 - cms/tsconfig.json | 44 - docker-compose.dev.yml | 12 +- docker-compose.yml | 51 +- docs/README.md | 12 +- docs/architecture.md | 111 +- docs/content-model.md | 91 +- docs/frontend.md | 62 +- frontend/app/components/ArticleCard.vue | 4 +- frontend/app/composables/useMediaUrl.ts | 10 +- frontend/app/pages/blog/[slug].vue | 6 +- frontend/app/pages/blog/index.vue | 2 +- frontend/app/pages/category/[slug].vue | 4 +- frontend/app/pages/index.vue | 6 +- frontend/nuxt.config.ts | 11 +- frontend/server/api/articles/[slug].get.ts | 43 - frontend/server/api/articles/index.get.ts | 22 - frontend/server/api/categories/index.get.ts | 9 - frontend/server/routes/admin.get.ts | 9 + .../routes/content/articles/[slug].get.ts | 42 + .../routes/content/articles/index.get.ts | 42 + .../content}/categories/[slug].get.ts | 6 +- .../routes/content/categories/index.get.ts | 9 + .../server/utils/{strapi.ts => pocketbase.ts} | 39 +- frontend/server/utils/queries.ts | 31 +- frontend/shared/types/blog.ts | 12 +- pocketbase/Dockerfile | 17 + pocketbase/docker-entrypoint.sh | 10 + .../1757500000_create_categories.js | 26 + .../1757500001_create_articles.js | 45 + 64 files changed, 546 insertions(+), 22706 deletions(-) delete mode 100644 cms/.dockerignore delete mode 100644 cms/.env.example delete mode 100644 cms/.gitignore delete mode 100644 cms/Dockerfile delete mode 100644 cms/README.md delete mode 100644 cms/config/admin.ts delete mode 100644 cms/config/api.ts delete mode 100644 cms/config/database.ts delete mode 100644 cms/config/middlewares.ts delete mode 100644 cms/config/plugins.ts delete mode 100644 cms/config/server.ts delete mode 100644 cms/database/migrations/.gitkeep delete mode 100644 cms/favicon.png delete mode 100644 cms/package-lock.json delete mode 100644 cms/package.json delete mode 100644 cms/public/robots.txt delete mode 100644 cms/public/uploads/.gitkeep delete mode 100644 cms/src/admin/app.example.tsx delete mode 100644 cms/src/admin/tsconfig.json delete mode 100644 cms/src/admin/vite.config.example.ts delete mode 100644 cms/src/api/.gitkeep delete mode 100644 cms/src/api/article/content-types/article/schema.json delete mode 100644 cms/src/api/article/controllers/article.ts delete mode 100644 cms/src/api/article/routes/article.ts delete mode 100644 cms/src/api/article/services/article.ts delete mode 100644 cms/src/api/category/content-types/category/schema.json delete mode 100644 cms/src/api/category/controllers/category.ts delete mode 100644 cms/src/api/category/routes/category.ts delete mode 100644 cms/src/api/category/services/category.ts delete mode 100644 cms/src/extensions/.gitkeep delete mode 100644 cms/src/index.ts delete mode 100644 cms/tsconfig.json delete mode 100644 frontend/server/api/articles/[slug].get.ts delete mode 100644 frontend/server/api/articles/index.get.ts delete mode 100644 frontend/server/api/categories/index.get.ts create mode 100644 frontend/server/routes/admin.get.ts create mode 100644 frontend/server/routes/content/articles/[slug].get.ts create mode 100644 frontend/server/routes/content/articles/index.get.ts rename frontend/server/{api => routes/content}/categories/[slug].get.ts (64%) create mode 100644 frontend/server/routes/content/categories/index.get.ts rename frontend/server/utils/{strapi.ts => pocketbase.ts} (56%) create mode 100644 pocketbase/Dockerfile create mode 100644 pocketbase/docker-entrypoint.sh create mode 100644 pocketbase/pb_migrations/1757500000_create_categories.js create mode 100644 pocketbase/pb_migrations/1757500001_create_articles.js diff --git a/.env.example b/.env.example index 501b4b8..e3a6478 100644 --- a/.env.example +++ b/.env.example @@ -4,25 +4,20 @@ PUBLIC_DOMAIN=blog.localhost ACME_EMAIL=admin@example.com -# --- Database --- -POSTGRES_DB=blog -POSTGRES_USER=blog -POSTGRES_PASSWORD=change-me - -# --- Strapi --- -# Generate each secret with: openssl rand -base64 32 -APP_KEYS=change-me-1,change-me-2 -API_TOKEN_SALT=change-me -ADMIN_JWT_SECRET=change-me -TRANSFER_TOKEN_SALT=change-me -JWT_SECRET=change-me -ENCRYPTION_KEY=change-me +# --- PocketBase --- +# Bootstraps (or updates, on restart) the initial superuser account — the +# only login the admin UI accepts. Pick a real password (10+ chars); never +# committed as anything but this placeholder. +POCKETBASE_ADMIN_EMAIL=admin@example.com +POCKETBASE_ADMIN_PASSWORD=change-me-1234 # --- Frontend --- -# Server-side only: internal Docker address of Strapi. -STRAPI_URL=http://cms:1337 +# Server-side only: internal Docker address of PocketBase. +POCKETBASE_URL=http://pocketbase:8090 # Public base URL of the website, used for canonical URLs and Open Graph. PUBLIC_SITE_URL=https://blog.localhost -# Public base URL of Strapi, used to build absolute media URLs in the browser. -# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi. -PUBLIC_STRAPI_URL=https://blog.localhost +# Public base URL of PocketBase, used to build absolute cover-image URLs. +# Same origin as PUBLIC_SITE_URL: Caddy proxies /_/* and /api/* there (the +# admin UI is at PUBLIC_POCKETBASE_URL/_/, reachable from PUBLIC_SITE_URL/admin +# too — see caddy/Caddyfile). +PUBLIC_POCKETBASE_URL=https://blog.localhost diff --git a/CLAUDE.md b/CLAUDE.md index ac2b4f6..dce63e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,39 +16,45 @@ sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora conf ## Architettura ```text -Browser → Caddy ─┬─ /admin e i path dei plugin Strapi → Strapi 5 → PostgreSQL - └─ tutto il resto → Nuxt 4 (SSR) → REST Strapi +Browser → Caddy ─┬─ /_/* e /api/* → PocketBase (admin UI + API) + └─ tutto il resto → Nuxt 4 (SSR) → REST PocketBase (interno) + ("/admin" fa redirect a /_/, gestito da Nitro) ``` -Caddy instrada per **path**, non per sottodominio: `PUBLIC_DOMAIN` serve sia il sito che il -pannello Strapi. Ogni plugin Strapi monta la propria API admin sul proprio path di primo -livello, non tutto sotto `/admin` (es. `/content-manager`, `/upload`, `/i18n`...): l'elenco -completo dei prefissi da instradare a Strapi vive nel `Caddyfile`. Se aggiungi un plugin -Strapi, aggiungi il suo prefisso lì. Niente di questo tocca `/api`, riservato agli endpoint -Nitro del frontend. Un solo dominio, un solo certificato TLS. +Caddy instrada per **path**, non per sottodominio o porta: `PUBLIC_DOMAIN` serve sia il sito +che il pannello PocketBase. `/_/*` (dashboard) e `/api/*` (REST/file API) vanno **senza prefisso** +a PocketBase — la sua dashboard referenzia se stessa con quei path assoluti, quindi non si possono +instradare con uno strip-prefix (es. `/admin/*` riscritto): romperebbe gli asset/le chiamate della +dashboard. Per questo `/api` è riservato a PocketBase, non a Nitro: gli endpoint del frontend +vivono sotto `/content/*` (`frontend/server/routes/content/`, non `server/api/`). `/admin` è una +route Nitro (`frontend/server/routes/admin.get.ts`) che fa redirect a `/_/`, non una regola +Caddy: così funziona identico anche in sviluppo, dove Caddy non fa parte dello stack. Un solo +dominio, un solo certificato TLS. Se cambi questa scelta di routing, spiega il trade-off prima +(vedi [Vincoli](#vincoli)). -- Strapi è la **sola** fonte di verità editoriale. Niente altro backend (no Express/Nest/Fastify): - se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi. -- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi. -- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli - endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono - query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e - niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il - ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel - (`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica - direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione - richiesta né concessa). +- PocketBase è la **sola** fonte di verità editoriale (CMS + database SQLite in un solo processo). + Niente altro backend (no Express/Nest/Fastify): se serve logica server, sta in Nitro + (`frontend/server/`) o in una regola/migration PocketBase. +- I visitatori pubblici non si autenticano mai. Solo il superuser usa l'auth PocketBase. +- **Il browser dei visitatori pubblici non parla mai con PocketBase per i contenuti.** Le pagine + chiamano gli endpoint Nitro in `frontend/server/routes/content/`, che sono l'unico posto dove si + costruiscono query PocketBase. Così `NUXT_POCKETBASE_URL` resta l'indirizzo interno Docker, + niente CORS e niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì (sotto + `/content/*`, mai `/api/*`) e tipizza il ritorno in `shared/types/blog.ts`. Fanno eccezione, per + costruzione: l'admin panel (`/_/`, uso superuser autenticato, raggiungibile anche da `/admin`) e + le immagini cover, che il browser carica direttamente da `PUBLIC_POCKETBASE_URL` + (`/api/files/...`, sola lettura, nessun'autenticazione richiesta né concessa). - Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client. - L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente `@nuxtjs/i18n`, niente file di traduzione): `` è fisso in `nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione - Google Translate del browser, non è gestita dall'app. I **contenuti** restano - monolingua — la localizzazione di Strapi è disattivata. -- `cms/src/index.ts` (`bootstrap`) dà al ruolo Public solo `find`/`findOne` su Article e Category, - e disattiva la registrazione pubblica: non esistono utenti front-end, solo amministratori. - Qualsiasi permesso in più va motivato. -- Postgres non è esposto pubblicamente. Media su volume persistente, mai binari nel DB. + Google Translate del browser, non è gestita dall'app. I **contenuti** restano monolingua. +- `pocketbase/pb_migrations/*.js` definisce collection e regole: `listRule`/`viewRule` pubblici + solo su `articles` (solo pubblicati, via `publishedAt`) e `categories`; `createRule`/ + `updateRule`/`deleteRule` sempre `null` (solo superuser). Qualsiasi permesso in più va motivato. +- SQLite (PocketBase) non è esposto pubblicamente sulla rete — solo tramite l'API PocketBase + stessa. Media sul volume persistente `pocketbase-data`, mai binari fuori da lì. ## Comandi @@ -61,17 +67,15 @@ npm run build # build produzione npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto npm run lint -# cms/ -npm run develop # Strapi con content-type builder attivo -npm run build # admin panel -npm run start # produzione +# pocketbase/ (nessun npm script: binario singolo, le migration si applicano da sole all'avvio) +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend # stack completo (con Caddy e domini reali) docker compose up -d --build -docker compose logs -f cms +docker compose logs -f pocketbase # stack locale senza domini né TLS: porte su localhost, niente Caddy -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend ``` `docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio @@ -84,15 +88,16 @@ lanciare **un singolo test**. | Type | Campi | |---|---| -| Article | title, slug (UID da title), content (Markdown), cover, category (rel) | +| Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName | | Category | name, slug | -Deliberatamente minimale: **niente Author** (l'unico autore è l'admin), niente tag, nessun campo -SEO separato. La meta description è ricavata dall'inizio del body (`summarise` in -`frontend/server/utils/strapi.ts`), la data è il `publishedAt` di Draft & Publish, l'immagine -social è la cover. Non reintrodurre questi campi senza che servano davvero. +Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata +dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è +`publishedAt`, l'immagine social è la cover. Non reintrodurre campi senza che servano davvero. -Draft & Publish attivo su Article. Le URL pubbliche usano lo **slug**, mai l'id numerico. +PocketBase non ha Draft & Publish nativo: `publishedAt` vuoto = bozza, valorizzato (e non nel +futuro) = pubblicato, imposto dalla `listRule`/`viewRule` della collection `articles`, non da +codice applicativo. Le URL pubbliche usano lo **slug**, mai l'id del record. ## Frontend @@ -101,9 +106,9 @@ Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`. - SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto. - `