diff --git a/CLAUDE.md b/CLAUDE.md index 44df2ae..a57ff76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,17 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Stato -Repository a **stato bootstrap**: contiene solo questo file. Tutto ciò che segue descrive il -target, non l'esistente. Verifica sempre cosa esiste prima di assumere. - -Ordine di costruzione: - -1. `frontend/` — Nuxt 4 (`npx nuxi@latest init frontend --package-manager npm --no-install-git`) -2. `cms/` — Strapi 5 (`npx create-strapi@latest cms --typescript --dbclient=postgres --no-example --no-git-init`) -3. `docker-compose.yml` + `.env.example` — Postgres, cms, frontend -4. Content types Strapi + permessi ruolo Public (solo `find`/`findOne`) -5. Client Strapi in Nuxt + pagine blog -6. `caddy/Caddyfile` + hardening +Struttura, content type, pagine blog, Docker e Caddy sono in piedi. Restano da fare: +sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora configurato). ## Architettura @@ -32,8 +23,15 @@ Browser → Caddy ─┬─ dominio pubblico → Nuxt 4 (SSR) → REST Strapi - 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. -- Nuxt parla con Strapi **server-side** (`STRAPI_URL` interno Docker). Il token privilegiato non - raggiunge mai il browser: se serve, sta in `runtimeConfig` (non `public`). +- **Il browser 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`. +- 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. +- `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. ## Comandi @@ -52,11 +50,17 @@ npm run develop # Strapi con content-type builder attivo npm run build # admin panel npm run start # produzione -# stack +# stack completo (con Caddy e domini reali) docker compose up -d --build docker compose logs -f cms + +# 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.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio +per non finire per sbaglio in produzione esponendo le porte. + Nessun test framework è ancora configurato. Se ne aggiungi uno, documenta qui il comando per lanciare **un singolo test**. @@ -64,10 +68,13 @@ lanciare **un singolo test**. | Type | Campi | |---|---| -| Article | title, slug (UID da title), excerpt, content (rich text), cover, author (rel), category (rel), tags (rel n:n), publishedDate, seoTitle, seoDescription, seoImage | +| Article | title, slug (UID da title), content (Markdown), cover, category (rel) | | Category | name, slug | -| Tag | name, slug | -| Author | name, biography, image | + +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. Draft & Publish attivo su Article. Le URL pubbliche usano lo **slug**, mai l'id numerico. diff --git a/README.md b/README.md index dc068bc..0d25f9c 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,132 @@ # Blog -Blog platform: a public website built with Nuxt, and a private Strapi CMS where -editors write the articles. Everything runs behind Caddy via Docker Compose. +Blog platform: a public website built with Nuxt, and a private Strapi CMS where the +articles are written. Everything runs behind Caddy via Docker Compose. ```text Browser → Caddy ─┬─ public domain → Nuxt (website) └─ cms subdomain → Strapi (CMS) → PostgreSQL ``` -> Status: bootstrap. The application code is not in place yet. +There are no front-end accounts: sign-up is disabled and only administrators write +content. An article is a title, a Markdown body, a cover image and a category. -## Getting started +## Development -Requires Docker and Node.js 20+. +No domain and no TLS needed — the services are published on localhost instead of going +through Caddy. Requires Docker. + +**1. Create the environment file** + +```bash +cp .env.example .env +``` + +**2. Generate the secrets** and paste them into `.env`. Strapi refuses to start with the +placeholder values. + +```bash +openssl rand -base64 32 # run once per secret +``` + +Fill in `POSTGRES_PASSWORD`, both values of `APP_KEYS`, `API_TOKEN_SALT`, +`ADMIN_JWT_SECRET`, `TRANSFER_TOKEN_SALT`, `JWT_SECRET` and `ENCRYPTION_KEY`. The domain +and URL variables can stay as they are for local use. + +**3. Start the stack** + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend +``` + +The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on +`127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be +picked up by accident in production. + +**4. Create the administrator account** at http://localhost:1337/admin. This is the first +run, so the form creates the account — pick your own credentials. + +**5. Write something.** In the admin panel: create a **Category**, then an **Article** +(the body field is Markdown), then press **Publish** — the website only shows published +content. + +**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/` and +`/category/`. + +Useful commands: + +```bash +docker compose logs -f cms # follow the CMS logs +docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend +docker compose down # stop, keep the data +docker compose down -v # stop and WIPE the database and media +``` + +To iterate on the code without rebuilding an image every time, run a package directly — +`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to +`http://localhost:1337` for Strapi, so it works against the containerised CMS as is; +override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`. + +## Production + +**1. Point the DNS at the server.** Two `A` records (and `AAAA` if you have IPv6) on the +public IP of the machine: + +| Record | Purpose | +|---|---| +| `example.com` | the website | +| `cms.example.com` | the Strapi admin panel | + +Wait for the records to resolve before starting the stack — Caddy requests the +certificates on the first boot and a failed challenge means a retry delay. + +**2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses +it for the ACME challenge and to redirect to HTTPS. PostgreSQL, Strapi and Nuxt are only +reachable inside the Docker network — do not publish their ports. + +**3. Configure the environment.** Copy `.env.example` to `.env` on the server and set: + +| Variable | Value | +|---|---| +| `PUBLIC_DOMAIN` | `example.com` | +| `CMS_DOMAIN` | `cms.example.com` | +| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there | +| `PUBLIC_SITE_URL` | `https://example.com` | +| `PUBLIC_STRAPI_URL` | `https://cms.example.com` | +| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public | + +Then generate **fresh** secrets for that machine (`openssl rand -base64 32`), different +from the development ones. Keep `.env` out of version control; it is already ignored. + +> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out. +> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values +> unreadable. Set them once, then back up the file somewhere safe. + +**4. Start everything** ```bash -cp .env.example .env # fill in the secrets, never commit this file docker compose up -d --build ``` -Then open the CMS URL to create the first admin account and start publishing. -The public site picks up published articles automatically. +This time Caddy is included: it serves the website on `PUBLIC_DOMAIN`, the CMS on +`CMS_DOMAIN`, obtains and renews the TLS certificates on its own, and adds HSTS and the +other security headers. -## Working on it +**5. Create the administrator account** at `https://cms.example.com/admin`, immediately, +before anyone else finds the URL — the first visitor to that form is the one who gets the +account. Then publish as in development. + +**6. Back up what is not in git**: the `postgres-data` volume (all content) and the +`cms-uploads` volume (all images). Nothing else on the server holds state. ```bash -cd frontend && npm run dev # website -cd cms && npm run develop # CMS +docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql +docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data . ``` +**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. + Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md). ## License