From bfec3a25f37d0b9cebee2c28e7cc3ecabfa75cce Mon Sep 17 00:00:00 2001 From: Jordy Hoogeveen Date: Fri, 4 Sep 2026 11:51:29 +0200 Subject: [PATCH] Migratie naar 2-container opzet (app + database) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De Express-app serveert nu zelf de gebouwde Vite-frontend op / met een SPA-fallback naar index.html; de aparte nginx-container is vervallen. Bedoeld voor self-hosting op een Raspberry Pi: één image, één origin, geen interne proxy en geen CORS tussen frontend en API. Docker - Nieuwe multi-stage Dockerfile in de root met targets test, development en production; frontend/Dockerfile, frontend/nginx-spa.conf en backend/Dockerfile verwijderd - docker-compose.yml: alleen app + postgres, app publiceert APP_PORT (standaard 3400) naar containerpoort 3001 - docker-compose.dev.yml: dev-container draait API (node --watch) en de Vite dev-server samen via docker/dev-entrypoint.sh, hot reload intact - .dockerignore, .env.example, .gitignore en .gitattributes toegevoegd Backend - express.static + SPA-fallback op STATIC_DIR (default /app/public), met immutable caching voor gehashte assets en no-store voor index.html - Rate limiting alleen nog op /api — statische bestanden liepen anders na 200 requests tegen de limiet aan - Helmet CSP toegespitst op wat de frontend laadt (Google Fonts, inline styles, base64-avatars); upgrade-insecure-requests uit voor intern http - Schema-init verplaatst naar initDb(), niet meer bij import van db.js Tests en CI/CD - backend/test/app.test.js: 11 tests (node --test) voor API-routes, SPA-fallback, cache- en security-headers, zonder database - test.ps1 / test.sh: backend-tests, productiebuild en smoketest op de echte app + postgres stack - azure-pipelines.yml: Test -> Build (incl. smoketest) -> Publish (multi-arch amd64 + arm64, alleen op main/master of een tag) Scripts en documentatie - dev.ps1/dev.sh met targets app en postgres, acties logs en test; dev.ps1 up wist de dev-database niet langer (alleen dev.ps1 down) - release.ps1/release.sh bouwen één image voor amd64 + arm64 en draaien de testsuite vóór het publiceren - README.md en BEHEER.md bijgewerkt voor de nieuwe structuur Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 18 ++++ .env.example | 21 ++++ .gitattributes | 11 ++ .gitignore | 13 +++ BEHEER.md | 123 +++++++++++++++++------ Changelog.md | 38 +++++++ Dockerfile | 80 +++++++++++++++ README.md | 133 +++++++++++++++++++----- azure-pipelines.yml | 195 ++++++++++++++++++++++++++++++++++++ backend/Dockerfile | 41 -------- backend/package.json | 1 + backend/src/config/index.js | 10 ++ backend/src/db.js | 49 +++++---- backend/src/server.js | 77 +++++++++++--- backend/test/app.test.js | 117 ++++++++++++++++++++++ dev.ps1 | 115 ++++++++++----------- dev.sh | 21 ++-- docker-compose.dev.yml | 55 ++++------ docker-compose.yml | 38 ++++--- docker/dev-entrypoint.sh | 23 +++++ frontend/Dockerfile | 42 -------- frontend/nginx-spa.conf | 32 ------ frontend/package.json | 2 +- frontend/vite.config.js | 4 +- package.json | 2 +- release.ps1 | 150 +++++++++++++-------------- release.sh | 15 +-- test.ps1 | 145 +++++++++++++++++++++++++++ test.sh | 25 +++++ 29 files changed, 1168 insertions(+), 428 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 azure-pipelines.yml delete mode 100644 backend/Dockerfile create mode 100644 backend/test/app.test.js create mode 100644 docker/dev-entrypoint.sh delete mode 100644 frontend/Dockerfile delete mode 100644 frontend/nginx-spa.conf create mode 100644 test.ps1 create mode 100644 test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e31b63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# Build-context zo klein mogelijk houden +**/node_modules +**/dist +.git +.gitignore +.claude +*.md +!README.md +.env +.env.* +docker-compose*.yml +dev.ps1 +dev.sh +release.ps1 +release.sh +test.ps1 +test.sh +azure-pipelines.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b027ae --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# ── VibeFinance omgevingsvariabelen ────────────────────────────────────────── +# Kopieer naar .env en vul in: cp .env.example .env + +# Database (postgres-container) +POSTGRES_DB=vibefinance +POSTGRES_USER=vibefinance +POSTGRES_PASSWORD=kies-een-sterk-wachtwoord + +# App +# Poort waarop de app op de host draait (container luistert intern op 3001) +APP_PORT=3400 +# Geheim voor het ondertekenen van JWT's — verplicht in productie +JWT_SECRET=vervang-dit-door-een-lange-random-string +JWT_EXPIRES=7d +# Origin die de API mag aanspreken; bij één container is dat de app zelf +CORS_ORIGIN=http://localhost:3400 + +# Update-notificaties (leeg = melding uit) +UPDATE_CHECK_URL=https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest +# Alleen nodig als de Gitea-repo niet publiek leesbaar is +# UPDATE_CHECK_TOKEN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a657db7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Zorg dat shell-scripts en Docker-bestanden altijd met LF worden uitgechecked: +# met CRLF faalt het shebang in de container ("bad interpreter"). +* text=auto + +*.sh text eol=lf +Dockerfile text eol=lf +*.yml text eol=lf +docker/* text eol=lf + +# PowerShell mag CRLF houden (Windows-tooling) +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac5226a --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Dependencies +node_modules/ + +# Build-output +frontend/dist/ + +# Omgevingsvariabelen (gebruik .env.example als sjabloon) +.env +.env.local + +# Logs en rommel +*.log +.DS_Store diff --git a/BEHEER.md b/BEHEER.md index 5a82fb9..f41a9be 100644 --- a/BEHEER.md +++ b/BEHEER.md @@ -1,28 +1,59 @@ # VibeFinance — Beheer +De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en +`postgres`. Eén image, één origin — geen aparte webserver en geen interne proxy. + --- ## Ontwikkeling ```powershell -.\dev.ps1 # build + start (standaard) -.\dev.ps1 build # alleen bouwen -.\dev.ps1 build -Target backend # alleen backend bouwen -.\dev.ps1 build -Target frontend # alleen frontend bouwen -.\dev.ps1 up -Target backend # alleen backend herbouwen + herstarten -.\dev.ps1 up -Target frontend # alleen frontend herbouwen + herstarten -.\dev.ps1 push # dev-images naar registry pushen -.\dev.ps1 push -Target backend # alleen backend pushen -.\dev.ps1 push -Target frontend # alleen frontend pushen +.\dev.ps1 # build + start (standaard) +.\dev.ps1 build # alleen bouwen +.\dev.ps1 up -Target app # alleen de app herbouwen + herstarten +.\dev.ps1 up -Target postgres # alleen de database herstarten +.\dev.ps1 logs # logs van de app volgen +.\dev.ps1 test # testsuite draaien +.\dev.ps1 down # stoppen én volumes wissen +.\dev.ps1 push # dev-image naar registry pushen ``` > Op macOS/Linux: gebruik `./dev.sh` met dezelfde argumenten. -> ⚠️ `.\dev.ps1 up` (beide services) en `.\dev.ps1 down` voeren intern `docker compose down -v` uit — dit wist de development-database. Gebruik voor een herstart zonder dataverlies: +In dev draait één container beide processen (zie `docker/dev-entrypoint.sh`): + +| Adres | Wat | +|---|---| +| http://localhost | Vite dev-server met hot reload | +| http://localhost:3001/api/health | de API rechtstreeks | + +Vite proxyt `/api` naar `localhost:3001` binnen dezelfde container. + +> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`). +> `.\dev.ps1 up` doet een gewone `down` en laat het volume staan. + +--- + +## Testen + +```powershell +.\test.ps1 # backend-tests + productiebuild + smoketest +.\test.ps1 -Unit # alleen de backend-tests +.\test.ps1 -NoSmoke # tests + build, zonder de stack te starten +``` + +Alles loopt in Docker; Node op de host is niet nodig. + +| Stap | Wat er gebeurt | +|---|---| +| Backend-tests | `Dockerfile` target `test` → `node --test` in `backend/test/`, zonder database | +| Productiebuild | `Dockerfile` target `production` — faalt de Vite-build, faalt deze stap | +| Smoketest | app + postgres starten en `/api/health` en `/` opvragen | + +Losse backend-tests draaien in een lopende dev-container: ```bash -docker compose -f docker-compose.dev.yml down -docker compose -f docker-compose.dev.yml up -d --build +docker compose -f docker-compose.dev.yml exec app npm test ``` --- @@ -31,16 +62,38 @@ docker compose -f docker-compose.dev.yml up -d --build ```powershell # 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x" -# 2. -.\release.ps1 # bouw, push naar registry, maak Git-tag aan -.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren -.\release.ps1 -NoBuild # sla docker build over, push bestaande images +# 2. Changelog.md bijwerken — die sectie wordt de release-notes +# 3. Token met release-rechten zetten +$env:GITEA_TOKEN = "" -# 3. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus +.\release.ps1 # test, bouw (amd64+arm64), push, tag, Gitea-release +.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren +.\release.ps1 -NoBuild # sla docker build over, alleen tag + release +.\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen) + +# 4. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus ``` +Het script bouwt één image voor `linux/amd64` en `linux/arm64`, zodat dezelfde +tag op een Raspberry Pi en op een x86-server werkt. + --- +## CI/CD (Azure DevOps) + +`azure-pipelines.yml` doet in drie stages hetzelfde als de lokale scripts: + +| Stage | Wat | +|---|---| +| `Test` | `docker build --target test` + `docker run` → backend-tests | +| `Build` | productie-image bouwen en smoketesten tegen een postgres-container | +| `Publish` | multi-arch image (amd64 + arm64) pushen naar de registry | + +`Publish` loopt alleen op `main`/`master` of een tag. Vul vóór het eerste gebruik +de variabele `dockerRegistryServiceConnection` in met de naam van je Docker +Registry service connection in Azure DevOps. + +--- ## Docker ```bash @@ -71,29 +124,37 @@ docker exec -i vibefinance_postgres psql -U vibefinance -d vibefinance < backup- --- -## Architectuur — nginx proxy +## Architectuur — één app-container -De frontend-container (nginx) serveert de React-app en proxyt `/api/`-verzoeken intern door naar de backend-container op poort 3001. Dit is geconfigureerd in `frontend/nginx-spa.conf`: +De Express-app serveert zowel de API als de gebouwde frontend: -```nginx -location /api/ { - proxy_pass http://backend:3001; -} -``` +| Verzoek | Wat de app doet | +|---|---| +| `/api/...` | REST API (rate limited) | +| `/assets/...` | statische build uit `STATIC_DIR`, 1 jaar `immutable` gecached | +| al het overige | `index.html` — fallback voor React-routing | -> Ontbreekt dit blok, dan krijgen gebruikers "Kan geen verbinding maken met de server" bij het inloggen — ook al draaien alle containers. +`STATIC_DIR` staat standaard op `/app/public`; daar zet de `Dockerfile` de +Vite-build neer. Bestaat die map niet (bijv. in dev), dan serveert de app +alleen de API en logt dat bij het starten. + +> Ontbreekt de build in het image, dan krijg je op `/` een 404 en werkt alleen +> `/api/...`. Controleer dan de `frontend-build` stage in de `Dockerfile`. --- ## Logs en diagnose ```bash -# Live logs (dev) -docker compose -f docker-compose.dev.yml logs -f +# Live logs (dev) — beide processen van de app-container +docker compose -f docker-compose.dev.yml logs -f app # Live logs (productie) docker compose logs -f +# Shell in de app-container (dev) +docker compose -f docker-compose.dev.yml exec app sh + # Status containers docker compose ps @@ -157,7 +218,8 @@ Rollen: - [ ] `POSTGRES_PASSWORD` instellen op een sterk wachtwoord - [ ] `JWT_SECRET` aanpassen (genereer met `openssl rand -hex 64`) - [ ] `CORS_ORIGIN` instellen op je domeinnaam -- [ ] `FRONTEND_PORT` aanpassen indien poort 3300 al in gebruik is +- [ ] `APP_PORT` aanpassen indien poort 3400 al in gebruik is +- [ ] Testsuite groen (`test.ps1` / `./test.sh`) - [ ] TLS afhandelen via een externe reverse proxy (bijv. Nginx Proxy Manager, Caddy, Traefik) --- @@ -165,8 +227,7 @@ Rollen: ## Registry ``` -vibehoogie.duckdns.org:3000/vibe/vibefinance-backend -vibehoogie.duckdns.org:3000/vibe/vibefinance-frontend +vibehoogie.duckdns.org/vibe/vibefinance-app (frontend + backend in één image) Tags: latest productie — meest recente release @@ -177,7 +238,7 @@ Tags: Inloggen op registry: ```bash -docker login vibehoogie.duckdns.org:3000 +docker login vibehoogie.duckdns.org ``` --- diff --git a/Changelog.md b/Changelog.md index bc25065..408c0fe 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,43 @@ # Changelog +## [0.1.6] - 2026-09-04 + +### Gewijzigd +- **Migratie naar 2 containers**: de Express-app serveert nu zelf de gebouwde + frontend op `/` (met SPA-fallback naar `index.html`); de aparte nginx-container + is vervallen. Eén image, één origin, geen interne proxy — bedoeld voor + self-hosting op een Raspberry Pi +- Nieuwe multi-stage `Dockerfile` in de root met targets `test`, `development` + en `production`; `frontend/Dockerfile`, `frontend/nginx-spa.conf` en + `backend/Dockerfile` zijn verwijderd +- `docker-compose.yml` bevat alleen nog `app` en `postgres`; de app publiceert + `APP_PORT` (standaard 3400) naar containerpoort 3001 +- Dev-stack draait beide processen in één container (`docker/dev-entrypoint.sh`): + API met `node --watch` en de Vite dev-server met hot reload +- Rate limiting geldt alleen nog voor `/api` — statische bestanden werden anders + na 200 requests geblokkeerd +- Content-Security-Policy via Helmet toegespitst op wat de frontend laadt + (Google Fonts, inline styles, base64-avatars) +- Assets met hash worden een jaar `immutable` gecached, `index.html` nooit +- Schema-initialisatie zit nu in `initDb()` in plaats van bij het importeren van + `db.js`, zodat de app zonder database te importeren is (tests) +- `dev.ps1`, `release.ps1` en de shell-wrappers werken met één app-image; + `dev.ps1 up` wist de dev-database niet langer (alleen `dev.ps1 down`) +- `release.ps1` draait de testsuite vóór het publiceren en bouwt voor + `linux/amd64` + `linux/arm64` + +### Toegevoegd +- Testsuite: `backend/test/app.test.js` (11 tests met `node --test`) voor de + API-routes, SPA-fallback, cache-headers en security-headers +- `test.ps1` / `test.sh`: backend-tests, productiebuild en smoketest op de + echte 2-container stack +- `azure-pipelines.yml`: CI/CD met stages Test → Build (incl. smoketest) → + Publish (multi-arch push, alleen op main/master of een tag) +- `.env.example`, `.dockerignore` en `.gitignore` +- `STATIC_DIR` als omgevingsvariabele (map met de frontend-build) + +--- + ## [0.1.5] - 2026-09-04 ### Toegevoegd diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..028bfac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ VibeFinance — één app-image: Express API + gebouwde Vite-frontend ║ +# ║ ║ +# ║ Targets: ║ +# ║ test backend-tests (npm test, geen database nodig) ║ +# ║ development API (node --watch) + Vite dev-server, hot reload ║ +# ║ production API die de statische build op / serveert ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + +# ── Stage 1: frontend dependencies ──────────────────────────────────────────── +FROM node:22-alpine AS frontend-deps +WORKDIR /build/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install && npm cache clean --force + +# ── Stage 2: frontend bouwen (Vite → dist) ──────────────────────────────────── +FROM frontend-deps AS frontend-build +ARG VITE_API_URL=/api +ARG VITE_APP_VERSION=dev +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_APP_VERSION=$VITE_APP_VERSION +COPY frontend/ ./ +RUN npm run build + +# ── Stage 3: backend dependencies ───────────────────────────────────────────── +FROM node:22-alpine AS backend-deps +WORKDIR /app +COPY backend/package.json backend/package-lock.json* ./ +RUN npm install && npm cache clean --force + +# ── Stage 4: productie-dependencies (zonder devDependencies) ────────────────── +FROM backend-deps AS backend-prod-deps +RUN npm prune --omit=dev + +# ── Stage 5: tests (backend, zonder database) ───────────────────────────────── +FROM backend-deps AS test +WORKDIR /app +ENV NODE_ENV=test +COPY backend/ ./ +CMD ["npm", "test"] + +# ── Stage 6: development (API + Vite samen, hot reload) ─────────────────────── +FROM node:22-alpine AS development +WORKDIR /app +ENV NODE_ENV=development +COPY --from=backend-deps /app/node_modules ./node_modules +COPY --from=frontend-deps /build/frontend/node_modules ./frontend/node_modules +COPY backend/ ./ +COPY frontend/ ./frontend/ +COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh +RUN chmod +x /usr/local/bin/dev-entrypoint.sh +# 5173 = Vite dev-server (browser), 3001 = API +EXPOSE 5173 3001 +CMD ["/usr/local/bin/dev-entrypoint.sh"] + +# ── Stage 7: production (minimale image) ────────────────────────────────────── +FROM node:22-alpine AS production +LABEL maintainer="VibeFinance" +ARG APP_VERSION=dev +ENV NODE_ENV=production +ENV APP_VERSION=$APP_VERSION + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +WORKDIR /app + +COPY --from=backend-prod-deps /app/node_modules ./node_modules +COPY backend/package.json ./ +COPY backend/src/ ./src/ +# De frontend-build wordt door de backend geserveerd (config.STATIC_DIR) +COPY --from=frontend-build /build/frontend/dist ./public/ + +RUN chown -R appuser:appgroup /app +USER appuser + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://localhost:3001/api/health || exit 1 + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md index a5b198f..2fa1189 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,16 @@ Deze applicatie is volledig ontworpen en ontwikkeld met behulp van [Claude.ai](h > Vereisten: Docker Engine 24+ en Docker Compose v2+ +De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en +`postgres` (database). + **`.env`** — kopieer `.env.example` en vul in: ```env POSTGRES_USER=vibefinance POSTGRES_PASSWORD=kies_een_sterk_wachtwoord JWT_SECRET=minimaal_64_willekeurige_tekens -FRONTEND_PORT=3300 +APP_PORT=3400 ``` ```bash @@ -42,10 +45,14 @@ openssl rand -hex 64 ``` ```bash +cp .env.example .env # en vul de waarden in docker compose pull && docker compose up -d ``` -Navigeer naar `http://:3300` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol. +Navigeer naar `http://:3400` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol. + +De container luistert intern op poort 3001 en serveert daar zowel de frontend +(op `/`) als de API (op `/api`). `APP_PORT` bepaalt de poort op de host. ### Development (hot reload) @@ -53,9 +60,45 @@ Navigeer naar `http://:3300` en registreer je account. De **eerste** .\dev.ps1 ``` -Frontend bereikbaar op **http://localhost** (poort 80 → Vite dev server op 5173). +Eén dev-container draait de API (`node --watch`) én de Vite dev-server samen: -> **Let op:** `.\dev.ps1 up` en `.\dev.ps1 down` wissen de development-database (volumes). Gebruik voor een gewone herstart `docker compose -f docker-compose.dev.yml down` gevolgd door `docker compose -f docker-compose.dev.yml up -d --build`. +| Adres | Wat | +|---|---| +| http://localhost | de app in de browser (Vite, hot reload) | +| http://localhost:3001/api/health | de API rechtstreeks | + +Vite proxyt `/api` naar de API in dezelfde container, dus je hebt geen CORS- +uitzonderingen nodig. Handige acties: + +```powershell +.\dev.ps1 up -Target app # alleen de app herbouwen en herstarten +.\dev.ps1 logs # logs van de app volgen +.\dev.ps1 down # stoppen én volumes wissen (dev-database weg) +.\dev.ps1 test # testsuite draaien +``` + +> `.\dev.ps1 up` laat de dev-database staan; alleen `.\dev.ps1 down` wist de volumes. + +### Testen + +```powershell +.\test.ps1 # backend-tests + productiebuild + smoketest +.\test.ps1 -Unit # alleen de backend-tests +.\test.ps1 -NoSmoke # tests + build, zonder de stack te starten +``` + +Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien +zonder database via het `test`-target in de `Dockerfile`. + +### Productie release + +```powershell +$env:GITEA_TOKEN = "" +.\release.ps1 +``` + +Draait de tests, bouwt het app-image voor `amd64` + `arm64` (Raspberry Pi), +pusht naar de registry, zet de Git-tag en publiceert de Gitea-release. --- @@ -75,22 +118,28 @@ Frontend bereikbaar op **http://localhost** (poort 80 → Vite dev server op 517 ``` vibefinance/ -├── docker-compose.yml # Productie orchestratie (pre-built images) -├── docker-compose.dev.yml # Standalone dev stack (hot reload) +├── Dockerfile # Multi-stage: frontend build → app-image +├── .dockerignore +├── docker-compose.yml # Productie: app + postgres +├── docker-compose.dev.yml # Dev: app (API + Vite) + postgres +├── azure-pipelines.yml # CI/CD: test → build+smoketest → publish ├── .env.example # Kopieer naar .env en vul in ├── package.json # Versienummer — single source of truth -├── dev.ps1 # Dev script (bouwen, starten, pushen) +├── dev.ps1 / dev.sh # Dev script (bouwen, starten, logs, pushen) +├── test.ps1 / test.sh # Testsuite (unit + build + smoketest) +├── release.ps1 / release.sh # Release (test, push, tag, Gitea-release) │ -├── frontend/ # React / Vite SPA -│ ├── Dockerfile # Multi-stage: dev → build → nginx -│ ├── nginx-spa.conf # SPA fallback + /api proxy naar backend -│ ├── vite.config.js +├── docker/ +│ └── dev-entrypoint.sh # Start API + Vite samen in de dev-container +│ +├── frontend/ # React / Vite SPA (build → dist) +│ ├── vite.config.js # /api proxy naar de API in dezelfde container │ └── src/ │ ├── App.jsx # Root – wiring + idle-logout │ ├── main.jsx │ ├── constants/ # Kleuren, tabs, begindata │ ├── utils/ # Formatters, helpers -│ ├── hooks/ # useTheme +│ ├── hooks/ # useTheme, useUpdateCheck │ ├── context/ # AppContext (global state + API sync) │ ├── components/ │ │ ├── ui/ # Primitieve UI (VCard, DonutChart…) @@ -104,25 +153,39 @@ vibefinance/ │ ├── SchuldTab.jsx │ └── VoortgangTab.jsx │ -└── backend/ # Express REST API - ├── Dockerfile # Multi-stage: dev → production (non-root) +└── backend/ # Express REST API + statische frontend + ├── test/ # node --test integratietests └── src/ - ├── server.js # Entry point - ├── db.js # PostgreSQL connectie + schema migraties - ├── config/ # Omgevingsvariabelen + ├── server.js # Entry point: API op /api, SPA op / + ├── db.js # PostgreSQL pool + initDb() schema-migraties + ├── config/ # Omgevingsvariabelen (incl. STATIC_DIR) ├── constants/ # Begindata (initialData.js) ├── middleware/ # auth.js, errorHandler.js - ├── routes/ # auth, data, users, health + ├── routes/ # auth, data, users, health, update-check └── stores/ # userStore, dataStore ``` **Stack:** React 18 · Vite · Node.js 22 · Express 4 · PostgreSQL 18 · JWT · bcrypt · Docker Compose v2 +### Waarom 2 containers + +De frontend werd eerder door een eigen nginx-container geserveerd. Voor +self-hosting op een Raspberry Pi is dat een container te veel: de Express-app +serveert de statische build nu zelf, dus er is geen aparte webserver en geen +interne proxy meer nodig. Dat betekent ook: één origin, dus geen CORS tussen +frontend en API. + +| Verzoek | Wat de app doet | +|---|---| +| `/api/...` | REST API (rate limited) | +| `/assets/...` | Statische build, 1 jaar `immutable` gecached | +| al het overige | `index.html` — fallback voor React-routing | + --- ## 🔌 API -Beveiligde endpoints vereisen `Authorization: Bearer `. Rate limiting: 20 req/15 min op auth-routes, 200 req/15 min globaal. +Beveiligde endpoints vereisen `Authorization: Bearer `. Rate limiting: 20 req/15 min op auth-routes, 200 req/15 min op de rest van `/api` (statische bestanden zijn uitgezonderd). | Route | Methoden | Omschrijving | |---|---|---| @@ -133,6 +196,7 @@ Beveiligde endpoints vereisen `Authorization: Bearer `. Rate limiting: 20 | `/api/data` | GET, PUT, DELETE | Financiële data per gebruiker | | `/api/users` | GET, POST, PATCH, DELETE | Gebruikersbeheer (Admin only) | | `/api/health` | GET | Status + uptime | +| `/api/update-check` | GET | Laatste release (voor de update-melding) | --- @@ -150,6 +214,9 @@ Beveiligde endpoints vereisen `Authorization: Bearer `. Rate limiting: 20 > PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker` — het volume is daarom gemount op `/var/lib/postgresql` (niet `/data`). +Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via +`initDb()` in `backend/src/db.js`. + --- ## ⚙️ Omgevingsvariabelen @@ -161,8 +228,12 @@ Beveiligde endpoints vereisen `Authorization: Bearer `. Rate limiting: 20 | `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam | | `POSTGRES_DB` | `vibefinance` | Database naam | | `JWT_EXPIRES` | `7d` | Geldigheidsduur token | -| `CORS_ORIGIN` | — | Externe domeinnaam | -| `FRONTEND_PORT` | `3300` | Externe poort frontend | +| `CORS_ORIGIN` | `http://localhost` | Externe domeinnaam (één origin, dus zelden nodig) | +| `APP_PORT` | `3400` | Poort op de host (container luistert op 3001) | +| `PORT` | `3001` | Poort binnen de container | +| `STATIC_DIR` | `/app/public` | Map met de frontend-build | +| `UPDATE_CHECK_URL` | Gitea releases-API | Bron update-melding (leeg = uit) | +| `UPDATE_CHECK_TOKEN` | — | Alleen voor een niet-publieke repo | --- @@ -170,11 +241,23 @@ Beveiligde endpoints vereisen `Authorization: Bearer `. Rate limiting: 20 - Wachtwoorden gehasht met bcrypt (cost factor 12) - JWT authenticatie met instelbare vervaltijd -- Backend uitsluitend bereikbaar via nginx proxy (`/api/` in `nginx-spa.conf`) +- Frontend en API op één origin — geen interne proxy, geen CORS-uitzonderingen +- Content-Security-Policy via Helmet, toegespitst op wat de app laadt - Database bereikbaar alleen binnen het interne Docker netwerk -- Containers draaien als non-root -- Helmet security headers -- Rate limiting op alle endpoints +- App-container draait als non-root +- Rate limiting op `/api` (statische bestanden uitgezonderd) + +--- + +## 🧪 Testen + +| Laag | Wat | Commando | +|---|---|---| +| Backend | API-routes, SPA-fallback, cache- en security-headers | `.\test.ps1 -Unit` | +| Build | Vite-build + productie-image | `.\test.ps1 -NoSmoke` | +| Integratie | Smoketest op de echte app + postgres stack | `.\test.ps1` | + +In CI draaien dezelfde stappen via `azure-pipelines.yml`. --- diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..3e5a0cd --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,195 @@ +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ VibeFinance CI/CD — 2-container opzet ║ +# ║ ║ +# ║ Eén app-image (Express API + gebouwde Vite-frontend) + postgres. ║ +# ║ ║ +# ║ Stages: ║ +# ║ Test backend-tests (Dockerfile target 'test') ║ +# ║ Build productie-image bouwen + smoketest op de echte stack ║ +# ║ Publish multi-arch image (amd64 + arm64) naar de registry pushen ║ +# ║ ║ +# ║ Vereist in Azure DevOps: ║ +# ║ - Service connection (type: Docker Registry) naar de registry; vul ║ +# ║ de naam in bij variabele dockerRegistryServiceConnection ║ +# ║ - Agent met Docker en buildx (ubuntu-latest heeft dit) ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + +trigger: + branches: + include: + - main + - master + - feature/* + tags: + include: + - "*" + +pr: + branches: + include: + - main + - master + +variables: + # ── Aanpassen per omgeving ────────────────────────────────────────────── + dockerRegistryServiceConnection: "vibefinance-registry" + registryHost: "vibehoogie.duckdns.org/vibe" + imageName: "vibefinance-app" + # ── Afgeleid ──────────────────────────────────────────────────────────── + imageRepo: "$(registryHost)/$(imageName)" + +pool: + vmImage: ubuntu-latest + +stages: + + # ── Stage 1: tests ────────────────────────────────────────────────────── + - stage: Test + displayName: Tests + jobs: + - job: BackendTests + displayName: Backend-tests (node --test) + steps: + - checkout: self + fetchDepth: 1 + + - script: | + set -euo pipefail + docker build --target test -t vibefinance/app:test . + docker run --rm vibefinance/app:test + displayName: npm test (in Docker, zonder database) + + # ── Stage 2: image bouwen en smoketesten ──────────────────────────────── + - stage: Build + displayName: Build en smoketest + dependsOn: Test + jobs: + - job: BuildImage + displayName: Productie-image + smoketest + steps: + - checkout: self + fetchDepth: 1 + + - script: | + set -euo pipefail + APP_VERSION=$(node -p "require('./package.json').version") + echo "##vso[task.setvariable variable=appVersion]$APP_VERSION" + echo "Versie: $APP_VERSION" + displayName: Versienummer uit package.json + + - script: | + set -euo pipefail + docker build --target production \ + --build-arg APP_VERSION=$(appVersion) \ + --build-arg VITE_APP_VERSION=$(appVersion) \ + --build-arg VITE_API_URL=/api \ + -t vibefinance/app:ci . + displayName: Productie-image bouwen (incl. Vite-build) + + - script: | + set -euo pipefail + docker network create vibefinance-ci + + docker run -d --name ci-postgres --network vibefinance-ci \ + -e POSTGRES_DB=vibefinance \ + -e POSTGRES_USER=vibefinance \ + -e POSTGRES_PASSWORD=cipassword \ + postgres:18-alpine + + for i in $(seq 1 30); do + if docker exec ci-postgres pg_isready -U vibefinance >/dev/null 2>&1; then break; fi + sleep 2 + done + + docker run -d --name ci-app --network vibefinance-ci \ + -e NODE_ENV=production \ + -e JWT_SECRET=ci-secret \ + -e UPDATE_CHECK_URL= \ + -e DATABASE_URL=postgresql://vibefinance:cipassword@ci-postgres:5432/vibefinance \ + -p 3001:3001 \ + vibefinance/app:ci + + gezond=0 + for i in $(seq 1 30); do + if curl -fsS http://localhost:3001/api/health | grep -q ok; then gezond=1; break; fi + sleep 2 + done + if [ "$gezond" -ne 1 ]; then + echo "App werd niet gezond. Logs:" + docker logs ci-app + exit 1 + fi + echo "/api/health : ok" + + curl -fsS http://localhost:3001/ | grep -q root + echo "/ : index.html" + + curl -fsS http://localhost:3001/voortgang | grep -q root + echo "/voortgang : index.html (SPA-fallback)" + + code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/api/bestaat-niet) + test "$code" = "404" + echo "/api/... : 404 JSON" + displayName: Smoketest op app + postgres + + - script: | + docker logs ci-app || true + docker rm -f ci-app ci-postgres || true + docker network rm vibefinance-ci || true + displayName: Opruimen + condition: always() + + # ── Stage 3: publiceren ───────────────────────────────────────────────── + # Alleen vanaf main/master of een tag; feature-branches en PRs stoppen na Build. + - stage: Publish + displayName: Publiceren naar registry + dependsOn: Build + condition: | + and(succeeded(), + or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), + eq(variables['Build.SourceBranch'], 'refs/heads/master'), + startsWith(variables['Build.SourceBranch'], 'refs/tags/'))) + jobs: + - job: PushImage + displayName: Multi-arch image pushen + steps: + - checkout: self + fetchDepth: 1 + + - script: | + set -euo pipefail + APP_VERSION=$(node -p "require('./package.json').version") + echo "##vso[task.setvariable variable=appVersion]$APP_VERSION" + displayName: Versienummer bepalen + + - task: Docker@2 + displayName: Inloggen op de registry + inputs: + command: login + containerRegistry: $(dockerRegistryServiceConnection) + + - script: | + set -euo pipefail + docker run --privileged --rm tonistiigi/binfmt --install arm64 + docker buildx create --name vibefinance --use + docker buildx inspect --bootstrap + displayName: buildx voor arm64 (Raspberry Pi) klaarzetten + + - script: | + set -euo pipefail + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target production \ + --build-arg APP_VERSION=$(appVersion) \ + --build-arg VITE_APP_VERSION=$(appVersion) \ + --build-arg VITE_API_URL=/api \ + -t $(imageRepo):$(appVersion) \ + -t $(imageRepo):latest \ + --push . + displayName: Bouwen en pushen (amd64 + arm64) + + - script: | + echo "Gepubliceerd:" + echo " $(imageRepo):$(appVersion)" + echo " $(imageRepo):latest" + displayName: Samenvatting diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index f2179e6..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# ╔══════════════════════════════════════════════╗ -# ║ Backend Dockerfile – multi-stage ║ -# ╚══════════════════════════════════════════════╝ - -# ── Stage 1: deps (incl. native build tools) ───── -FROM node:22-alpine AS deps -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install && npm cache clean --force - -# ── Stage 2: development (hot reload via --watch) ─ -FROM deps AS development -COPY . . -EXPOSE 3001 -CMD ["node", "--watch", "src/server.js"] - -# ── Stage 3: productie-deps (apart gebouwd) ─────── -FROM deps AS prod-deps -RUN npm prune --omit=dev - -# ── Stage 4: production (minimale image) ────────── -FROM node:22-alpine AS production -LABEL maintainer="VibeFinance" - -RUN addgroup -S appgroup && adduser -S appuser -G appgroup - -WORKDIR /app - -COPY --from=prod-deps /app/node_modules ./node_modules -COPY package.json ./ -COPY src/ ./src/ - -RUN chown -R appuser:appgroup /app -USER appuser - -EXPOSE 3001 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:3001/api/health || exit 1 - -CMD ["node", "src/server.js"] diff --git a/backend/package.json b/backend/package.json index 7a56287..f120cce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,7 @@ "scripts": { "start": "node src/server.js", "dev": "node --watch src/server.js", + "test": "node --test", "lint": "eslint src --ext .js" }, "dependencies": { diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 20f0568..5dc7f82 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -1,4 +1,8 @@ import "dotenv/config"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export const config = { NODE_ENV: process.env.NODE_ENV || "development", @@ -9,6 +13,12 @@ export const config = { DATABASE_URL: process.env.DATABASE_URL, REDIS_URL: process.env.REDIS_URL, + // Map met de gebouwde frontend (Vite dist). In het productie-image staat die + // op /app/public; bestaat de map niet, dan serveert de app alleen de API. + STATIC_DIR: process.env.STATIC_DIR + ? resolve(process.env.STATIC_DIR) + : join(__dirname, "..", "..", "public"), + // Update-notificaties: welke release-API wordt gepolld (Gitea). // Leeg laten schakelt de melding in de app uit. UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL diff --git a/backend/src/db.js b/backend/src/db.js index 0fd6abb..fdccef5 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -7,27 +7,34 @@ export const db = new Pool({ connectionString: config.DATABASE_URL, }); -await db.query(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - naam TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - wachtwoord TEXT NOT NULL, - rol TEXT NOT NULL DEFAULT 'Viewer', - actief BOOLEAN NOT NULL DEFAULT true, - aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); +/** + * Zet het schema klaar. Wordt bij het starten van de server aangeroepen — + * niet bij het importeren, zodat de app ook zonder database te importeren is + * (tests, health checks tijdens de build). + */ +export async function initDb() { + await db.query(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + naam TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + wachtwoord TEXT NOT NULL, + rol TEXT NOT NULL DEFAULT 'Viewer', + actief BOOLEAN NOT NULL DEFAULT true, + aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); - CREATE TABLE IF NOT EXISTS user_data ( - user_id TEXT PRIMARY KEY, - data TEXT NOT NULL - ); -`); + CREATE TABLE IF NOT EXISTS user_data ( + user_id TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + `); -await db.query(` - ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW(); -`); + await db.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW(); + `); -await db.query(` - ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT; -`); + await db.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT; + `); +} diff --git a/backend/src/server.js b/backend/src/server.js index 4e0570b..619261f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,9 +3,12 @@ import cors from "cors"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; import cookieParser from "cookie-parser"; -import pino from "pino"; import pinoHttp from "pino-http"; +import { existsSync } from "fs"; +import { join } from "path"; import { config } from "./config/index.js"; +import { logger } from "./logger.js"; +import { initDb } from "./db.js"; import authRouter from "./routes/auth.js"; import dataRouter from "./routes/data.js"; import usersRouter from "./routes/users.js"; @@ -13,23 +16,36 @@ import healthRouter from "./routes/health.js"; import updateRouter from "./routes/update.js"; import { errorHandler } from "./middleware/errorHandler.js"; -export const logger = pino({ - level: config.NODE_ENV === "production" ? "info" : "debug", - ...(config.NODE_ENV !== "production" && { - transport: { target: "pino-pretty", options: { colorize: true } }, - }), -}); +export { logger }; const app = express(); // ── Security ────────────────────────────────────────────────────────────────── -app.use(helmet()); +// De app serveert nu ook de frontend, dus het CSP-beleid moet passen bij wat +// die pagina laadt: Google Fonts, inline