From 61b1249afd3820a713ed474a4c5d85cdd8c438c3 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Sep 2026 00:09:03 +0200 Subject: [PATCH] build: dockerfile, compose-stack, entrypoint und update-skript Multi-Stage-Build mit standalone-Output, non-root Nutzer node (UID 1000) und Healthcheck auf /api/health. Der Entrypoint prueft zuerst, ob /data beschreibbar ist, und sagt bei einem Rechteproblem genau, was zu tun ist; danach laufen die Migrationen und erst dann startet die App. Der Migrations-Runner wird beim Build mit esbuild zu einer einzelnen CJS-Datei gebuendelt. Damit braucht das Runtime-Image weder drizzle-kit noch tsx - nur das native better-sqlite3, das ohnehin dabei ist. update.sh sichert data und .env, holt den neuen Stand, baut, startet und wartet auf den Healthcheck. Wird der Container nicht gesund, rollt es Code und Container auf den vorherigen Commit zurueck und nennt den Pfad der Sicherung. .gitattributes erzwingt LF: eine entrypoint.sh mit CRLF scheitert im Container mit "no such file or directory" auf der Shebang-Zeile. Verifiziert: Image gebaut (358 MB), Container healthy, Migration laeuft, Registrierung des ersten Nutzers funktioniert, sharp arbeitet unter musl. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 20 +++ .gitattributes | 17 +++ .gitignore | 3 + Dockerfile | 86 ++++++++++++ QUICKSTART.md | 210 ++++++++++++++++++++++++++++ README.md | 233 ++++++++++++++++++++++++++++--- docker-compose.yml | 46 ++++++ docker/entrypoint.sh | 30 ++++ e2e/screenshots-setup.ts | 9 ++ e2e/screenshots.spec.ts | 124 ++++++++++++++++ package.json | 3 +- playwright.screenshots.config.ts | 44 ++++++ pnpm-lock.yaml | 31 ++-- scripts/reset-shots.mjs | 7 + update.sh | 149 ++++++++++++++++++++ 15 files changed, 976 insertions(+), 36 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 Dockerfile create mode 100644 QUICKSTART.md create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 e2e/screenshots-setup.ts create mode 100644 e2e/screenshots.spec.ts create mode 100644 playwright.screenshots.config.ts create mode 100644 scripts/reset-shots.mjs create mode 100644 update.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..713a692 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.next +.git +.github +data +data-e2e +test-results +playwright-report +blob-report +coverage +.env +.env.*.local +*.log +docs +README.md +QUICKSTART.md +CHANGELOG.md +CLAUDE.md +.vscode +.idea diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..015ea15 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Normalise line endings so files written on Windows still work in the +# Linux container. A shell script with CRLF fails with the confusing +# "no such file or directory" on its shebang line. +* text=auto eol=lf + +*.sh text eol=lf +Dockerfile text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Binary files must never be touched. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.pdf binary +*.ico binary diff --git a/.gitignore b/.gitignore index e5e8b98..23c007c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ next-env.d.ts # runtime data (database, uploads) - never in git, this is the backup target /data /data-e2e +/data-shots +/data-docker +/backups # production *.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ca9e32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Bricktrack - Multi-Stage-Build +# +# Ergebnis ist ein schlankes Runtime-Image mit Next.js im Standalone-Modus, +# das als Nutzer "node" laeuft. Alle Daten liegen ausserhalb des Images unter +# /data, damit ein Update nichts anfasst, was dir gehoert. +# --------------------------------------------------------------------------- + +FROM node:24-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +ENV NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable + +# --- Abhaengigkeiten ------------------------------------------------------- +# better-sqlite3 und sharp bringen Prebuilds mit; die Build-Werkzeuge sind der +# Rueckfallweg, falls fuer die Plattform keines existiert. +FROM base AS deps +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +# --- Build ----------------------------------------------------------------- +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Platzhalter nur fuer den Build: src/env.ts validiert beim Import, und die +# echten Werte kommen zur Laufzeit aus der .env. Keiner dieser Werte landet im +# Image-Zustand, weil keine Seite vorgerendert wird. +ENV NODE_ENV=production +ENV BASE_URL=http://localhost:3000 +ENV BETTER_AUTH_SECRET=build-platzhalter-0123456789-abcdefghij +ENV DATA_DIR=/tmp/build-data + +RUN pnpm build + +# Der Migrations-Runner wird zu einer einzelnen Datei gebuendelt. So braucht +# das Runtime-Image kein drizzle-kit und kein tsx - nur das native +# better-sqlite3, das ohnehin dabei ist. Das Buendel ist CommonJS, weil +# mitgebuendelte CJS-Abhaengigkeiten sonst am require() der Node-Builtins +# scheitern. +RUN pnpm exec esbuild scripts/migrate.ts \ + --bundle \ + --platform=node \ + --format=cjs \ + --target=node22 \ + --external:better-sqlite3 \ + --outfile=dist/migrate.cjs + +# --- Laufzeit -------------------------------------------------------------- +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV DATA_DIR=/data +ENV MIGRATIONS_DIR=/app/drizzle + +# Standalone bringt seine eigenen node_modules mit - kein pnpm install noetig. +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/drizzle ./drizzle +COPY --from=builder --chown=node:node /app/dist/migrate.cjs ./migrate.cjs +COPY --chown=node:node docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Anlaufstelle fuer Datenbank und Uploads. Wird per Bind-Mount ueberdeckt; +# das Anlegen hier sorgt nur dafuer, dass der Pfad auch ohne Mount existiert. +RUN mkdir -p /data/db /data/files && chown -R node:node /data + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..3c544f2 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,210 @@ +# Bricktrack – Schnellstart + +Von null auf laufende Instanz. Zeitbedarf: etwa 15 Minuten, davon 10 für den +ersten Docker-Build. + +**Voraussetzungen:** Ein Linux-Host (z. B. eine LXC oder VM auf Proxmox) mit +Docker und dem Compose-Plugin, ein Reverse Proxy mit gültigem TLS-Zertifikat +und eine Subdomain, die auf den Proxy zeigt. + +> **Warum HTTPS Pflicht ist:** Passkeys (WebAuthn) funktionieren +> ausschließlich über HTTPS und sind fest an den Hostnamen gebunden. Ohne +> Zertifikat bleibt nur die Anmeldung per Passwort. Wird die Domain später +> geändert, sind **alle** Passkeys ungültig und müssen neu angelegt werden. + +--- + +## 1. Projekt holen + +```bash +sudo mkdir -p /opt/bricktrack +sudo chown "$USER" /opt/bricktrack +git clone https://git.wilde-server.de/florian.wilde/bricktrack.git /opt/bricktrack +cd /opt/bricktrack +``` + +## 2. Konfiguration anlegen + +```bash +cp .env.example .env +``` + +Jetzt `.env` öffnen und zwei Werte setzen: + +```bash +# Die Adresse, unter der du die App im Browser aufrufst. +BASE_URL=https://bricktrack.example.de + +# Signiergeheimnis - hiermit erzeugen: +# openssl rand -base64 48 +BETTER_AUTH_SECRET= +``` + +Alles andere kann so bleiben. `DATA_DIR=/data` ist der Pfad **im Container** +und wird nicht angefasst. + +## 3. Datenverzeichnis vorbereiten + +Der Container läuft aus Sicherheitsgründen nicht als root, sondern als Nutzer +mit der UID 1000. Das gemountete Verzeichnis muss ihm gehören: + +```bash +mkdir -p ./data +sudo chown -R 1000:1000 ./data +``` + +Diesen Schritt vergisst man gern – der Container sagt dir dann aber beim Start +genau das. + +## 4. Starten + +```bash +docker compose up -d --build +``` + +Der erste Build dauert einige Minuten. Danach: + +```bash +docker compose ps # Status "healthy" abwarten +docker compose logs -f app # Log mitlesen, mit Strg+C beenden +curl -s http://127.0.0.1:3000/api/health +# {"status":"ok","time":"..."} +``` + +## 5. Reverse Proxy einrichten (Nginx Proxy Manager) + +In NPM unter **Hosts → Proxy Hosts → Add Proxy Host**: + +**Reiter „Details"** + +| Feld | Wert | +|---|---| +| Domain Names | `bricktrack.example.de` | +| Scheme | `http` | +| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | +| Forward Port | `3000` | +| Cache Assets | **aus** (Next.js setzt seine Cache-Header selbst) | +| Block Common Exploits | an | +| Websockets Support | **an** | + +**Reiter „SSL"** + +| Feld | Wert | +|---|---| +| SSL Certificate | Let's Encrypt anfordern oder vorhandenes wählen | +| Force SSL | **an** | +| HTTP/2 Support | an | +| HSTS Enabled | an (optional, aber sinnvoll) | + +**Reiter „Advanced"** – hier hinein, sonst scheitern große Anleitungs-PDFs am +Standardlimit von 1 MB: + +```nginx +# Anleitungs-PDFs koennen mehrere Megabyte gross sein. +client_max_body_size 64m; + +# Uploads und PDF-Auslieferung duerfen etwas dauern. +proxy_read_timeout 300; +proxy_send_timeout 300; + +# Die App leitet ihre oeffentliche Adresse und die Client-IP hieraus ab. +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Real-IP $remote_addr; +``` + +`client_max_body_size` muss mindestens so groß sein wie `MAX_UPLOAD_MB` in +deiner `.env` (Standard 25 MB); 64m gibt Luft. + +> **Läuft NPM im selben Docker-Netz?** Dann in `docker-compose.yml` das +> Port-Mapping auf `- "3000:3000"` ändern oder ganz entfernen und beide +> Container in ein gemeinsames Netzwerk hängen. Als Forward Hostname trägst du +> dann `bricktrack` ein (den Container-Namen), Port `3000`. +> +> Die Voreinstellung `127.0.0.1:3000:3000` bindet den Port nur an localhost – +> die App ist dann ausschließlich über den Proxy erreichbar. Sitzt NPM auf +> einem **anderen** Host, muss das Mapping auf `3000:3000` geändert und der +> Zugriff per Firewall beschränkt werden. + +## 6. Ersten Nutzer anlegen + +`https://bricktrack.example.de` aufrufen. Die App leitet direkt auf die +Registrierung. + +**Der erste registrierte Nutzer wird automatisch Administrator.** Danach ist +die Registrierung geschlossen – weitere Konten entstehen nur über einen +Einladungslink (ab Version 0.2.0). + +Also: gleich beim ersten Aufruf selbst registrieren, bevor es jemand anderes +tut. + +## 7. Passkey hinterlegen + +Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per +Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein +eigener Passkey – Handy, Tablet, Laptop. + +## 8. Loslegen + +1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. +2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … +3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … +4. **Katalog → Set anlegen**: Setnummer, Name, Bild. +5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. +6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen, + **„Zur Wunschliste hinzufügen"**. +7. **Wunschlisten → Liste öffnen → Link kopieren** und verschicken. + +Zum Ausprobieren gibt es Beispieldaten: + +```bash +docker compose exec app node -e "process.exit(0)" # laeuft der Container? +docker compose exec app sh -c 'echo "Seed nur in der Entwicklung"' +``` + +Das Seed-Skript ist bewusst nicht im Produktions-Image. Zum Ausprobieren +lokal: `pnpm db:seed`. + +--- + +## Sichern + +Alles, was zählt, liegt in **einem** Verzeichnis: + +```bash +docker compose stop app +tar czf bricktrack-$(date +%F).tar.gz data .env +docker compose start app +``` + +Zurückspielen: + +```bash +docker compose down +tar xzf bricktrack-2026-09-06.tar.gz +sudo chown -R 1000:1000 ./data +docker compose up -d +``` + +## Aktualisieren + +```bash +./update.sh +``` + +Das Skript sichert vorher automatisch, baut neu, wartet auf den Healthcheck +und rollt bei einem Fehlschlag zurück. Details in der [README](README.md). + +--- + +## Wenn etwas klemmt + +| Symptom | Ursache und Lösung | +|---|---| +| `FEHLER: In /data darf nicht geschrieben werden` | `sudo chown -R 1000:1000 ./data` | +| Container startet, aber `unhealthy` | `docker compose logs app` – meist eine fehlende Variable in `.env` | +| `Ungueltige Umgebungsvariablen: BETTER_AUTH_SECRET` | Das Geheimnis braucht mindestens 32 Zeichen | +| Passkey lässt sich nicht anlegen | Kein HTTPS, oder `BASE_URL` passt nicht zur aufgerufenen Domain | +| Passkeys funktionieren nach einem Domainwechsel nicht mehr | Erwartet – Passkeys sind an die Domain gebunden, neu anlegen | +| `413 Request Entity Too Large` beim PDF-Upload | `client_max_body_size` im Proxy erhöhen (Schritt 5) | +| Anmeldung meldet „Zu viele Versuche" | Rate-Limit: fünf Fehlversuche pro Minute, danach kurz warten | diff --git a/README.md b/README.md index e215bc4..f12075f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,227 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Bricktrack -## Getting Started +Self-hosted Verwaltung für die Spielzeug-Set-Sammlung deines Kindes – Lego, +Playmobil, Cobi, BlueBrixx, Mega Bloks und jeden weiteren Hersteller, den du +selbst anlegst. -First, run the development server: +Die App beantwortet zwei Fragen sofort: + +- **Welche Sets hat er?** → die Sammlung, mit Standort, Zustand und Statistik. +- **Welche Sets hat er noch nicht?** → alles im Katalog ohne Sammlungseintrag. + +Aus den fehlenden Sets entsteht in wenigen Handgriffen eine Wunschliste, die +du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk +– **ohne sich zu registrieren**, und ohne dass jemand doppelt kauft. + +> **Neu hier?** → [QUICKSTART.md](QUICKSTART.md) führt dich in 15 Minuten von +> null zur laufenden Instanz, inklusive Nginx Proxy Manager. +> **Bedienung?** → [docs/ANLEITUNG.md](docs/ANLEITUNG.md), bebildert. + +--- + +## Was drin ist + +| | | +|---|---| +| **Katalog** | Sets aller Hersteller, Suche nach Nummer, Name oder EAN, Filter nach Hersteller, Thema, Jahr und Besitzstatus | +| **Sammlung** | Was tatsächlich da ist: Status, Vollständigkeit, Zustand, Standort, Kaufpreis, „geschenkt von" | +| **Anleitungen** | PDFs hochladen und im Browser blättern (pdf.js, ohne externe Dienste) oder auf die Hersteller-Seite verlinken | +| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera | +| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin | +| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus | +| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto | + +Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht +am Schreibtisch. + +## Technik + +Next.js 15 (App Router, Server Actions, TypeScript strict) · Tailwind CSS 4 · +shadcn/ui · SQLite über Drizzle ORM und better-sqlite3 · better-auth mit +Passkey-Plugin · alles in **einem** Docker-Container. + +Keine Cloud, keine externen Dienste, keine Telemetrie. Der Browser lädt nichts +von fremden Servern außer den Bild-URLs, die du selbst hinterlegt hast. + +Details zu Stack, Konventionen und Datenmodell: [CLAUDE.md](CLAUDE.md). + +--- + +## Installation + +Ausführlich in [QUICKSTART.md](QUICKSTART.md). Die Kurzfassung: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +git clone https://git.wilde-server.de/florian.wilde/bricktrack.git +cd bricktrack + +cp .env.example .env +# BASE_URL und BETTER_AUTH_SECRET setzen: +# openssl rand -base64 48 + +mkdir -p ./data +sudo chown -R 1000:1000 ./data # der Container laeuft als UID 1000 + +docker compose up -d --build ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Danach `https://deine-domain` aufrufen. **Der erste registrierte Nutzer wird +Administrator**, danach ist die Registrierung geschlossen. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Konfiguration -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Alle Werte kommen aus `.env` (Vorlage: `.env.example`). -## Learn More +| Variable | Pflicht | Standard | Bedeutung | +|---|---|---|---| +| `BASE_URL` | ja | `http://localhost:3000` | Öffentliche Adresse. Bestimmt die Passkey-Domain (RP-ID). | +| `BETTER_AUTH_SECRET` | ja | – | Signiergeheimnis, mindestens 32 Zeichen. | +| `DATA_DIR` | nein | `/data` | Datenverzeichnis im Container. Nicht ändern. | +| `MAX_UPLOAD_MB` | nein | `25` | Größenlimit je Datei. Muss zum Proxy passen. | +| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. | +| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. | -To learn more about Next.js, take a look at the following resources: +### Reverse Proxy -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +TLS wird außerhalb terminiert, der Container spricht nur HTTP auf Port 3000. +Drei Dinge sind wichtig: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +1. **HTTPS mit Force-SSL** – ohne das keine Passkeys. +2. **`client_max_body_size`** mindestens so groß wie `MAX_UPLOAD_MB`, sonst + scheitern große Anleitungs-PDFs mit „413". +3. **`X-Forwarded-For`** durchreichen, damit das Rate-Limit auf dem + Login-Endpunkt die echte Client-IP sieht. -## Deploy on Vercel +Die vollständige Nginx-Proxy-Manager-Konfiguration steht in +[QUICKSTART.md, Schritt 5](QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager). -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +--- -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Backup und Restore + +**Ein Verzeichnis reicht.** `./data` enthält die Datenbank *und* alle +hochgeladenen Dateien: + +``` +data/ + db/bricktrack.db SQLite (dazu -wal und -shm) + files/images/sets/… Set-Bilder + files/photos/… eigene Fotos + files/instructions/… Anleitungs-PDFs +``` + +**Sichern** (Container kurz anhalten, damit die Datenbank nicht mitten im +Schreiben eingepackt wird): + +```bash +docker compose stop app +tar czf bricktrack-$(date +%F).tar.gz data .env +docker compose start app +``` + +**Zurückspielen:** + +```bash +docker compose down +rm -rf data +tar xzf bricktrack-2026-09-06.tar.gz +sudo chown -R 1000:1000 ./data +docker compose up -d +``` + +Migrationen laufen bei jedem Start automatisch – ein altes Backup wird beim +Zurückspielen also auf den aktuellen Schemastand gehoben. + +## Aktualisieren + +```bash +./update.sh +``` + +Das Skript sichert `./data` und `.env`, holt den neuen Stand, baut das Image, +startet neu und wartet auf den Healthcheck. Wird der Container nicht gesund, +rollt es Code **und** Container automatisch auf den vorherigen Commit zurück +und sagt dir, wo die Sicherung liegt. + +```bash +./update.sh --no-pull # nur neu bauen, ohne git pull +./update.sh --no-backup # ohne Sicherung (nicht empfohlen) +``` + +Die letzten zehn Sicherungen bleiben unter `./backups` liegen. + +--- + +## Entwicklung + +```bash +corepack enable pnpm # pnpm 10 +pnpm install + +cp .env.example .env # DATA_DIR=./data, BASE_URL=http://localhost:3000 +pnpm db:migrate # Datenbank anlegen +pnpm db:seed # Beispieldaten: 3 Hersteller, 10 Sets, 1 Wunschliste +pnpm dev # http://localhost:3000 +``` + +| Befehl | Wirkung | +|---|---| +| `pnpm dev` | Entwicklungsserver | +| `pnpm build` | Produktionsbuild (standalone) | +| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein | +| `pnpm test` | Unit-Tests (Vitest) | +| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` | +| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen | +| `pnpm db:migrate` | Migrationen anwenden | +| `pnpm db:studio` | Drizzle Studio | + +> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der +> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder +> administrative Rechte braucht. Der Docker-Build unter Linux ist davon nicht +> betroffen. Zum Prüfen lokal: `docker build -t bricktrack:test .` + +Die Passkey-Anmeldung funktioniert lokal auch über `http://localhost` – +WebAuthn behandelt localhost als sicheren Kontext. Auf jeder anderen Adresse +braucht es HTTPS. + +--- + +## Datenmodell in einem Absatz + +Ein **`set`** ist ein Katalogeintrag: Das Set existiert in der Welt, +unabhängig davon, ob wir es besitzen. Ein **`collection_item`** verknüpft ein +Set mit einem tatsächlich vorhandenen Exemplar – dasselbe Set zweimal im Regal +sind zwei Einträge. Daraus folgt die zentrale Regel: + +> „Hat er nicht" = Sets im Katalog, zu denen kein `collection_item` mit einem +> Status ungleich `sold` existiert. + +Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht +wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese +Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`. + +Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell). + +## Sicherheit + +- Jede Eingabe wird serverseitig mit zod validiert. +- Uploads sind auf PDF, JPEG, PNG und WebP begrenzt – geprüft an den **Magic + Bytes**, nicht am gemeldeten MIME-Typ. Dateinamen erzeugt immer der Server. +- Die öffentliche Wunschliste nutzt ausschließlich den Share-Token als + Schlüssel und gibt nichts außer den Wunschlisten-Daten preis. Sie ist + `noindex`. +- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank. +- Session-Cookies sind `httpOnly`, `sameSite=lax` und im Betrieb `secure`. + Auf den Login-Endpunkten liegt ein Rate-Limit (5 Versuche pro Minute). +- Set-Bilder sind ohne Anmeldung abrufbar – die geteilte Wunschliste braucht + sie. Anleitungs-PDFs und eigene Fotos **nie**. + +## Stand und Pläne + +Aktuelle Version und Änderungen: [CHANGELOG.md](CHANGELOG.md). +Was als Nächstes kommt: [docs/ROADMAP.md](docs/ROADMAP.md). +Wie lange was gedauert hat: [docs/DEVLOG.md](docs/DEVLOG.md). + +## Lizenz + +Privates Projekt, keine Lizenz vergeben. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a07102c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +# --------------------------------------------------------------------------- +# Bricktrack - ein Service, ein Datenverzeichnis. +# +# TLS terminiert der Reverse Proxy (z. B. Nginx Proxy Manager). Der Container +# spricht nur HTTP und muss nicht nach aussen erreichbar sein. +# --------------------------------------------------------------------------- + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: bricktrack:latest + container_name: bricktrack + restart: unless-stopped + + env_file: + - .env + + environment: + # Im Container liegen die Daten immer unter /data - der Pfad auf dem Host + # wird ueber den Bind-Mount unten bestimmt. + DATA_DIR: /data + PORT: 3000 + + ports: + # Nur an localhost binden: erreichbar wird die App ueber den Reverse + # Proxy. Laeuft der Proxy in einem anderen Docker-Netz, siehe README. + - "127.0.0.1:3000:3000" + + volumes: + # Das eine Verzeichnis, das gesichert werden muss. + - ./data:/data + + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..cd7e63c --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Startet den Container: Verzeichnisse pruefen, Migrationen anwenden, App starten. +set -e + +DATA_DIR="${DATA_DIR:-/data}" + +echo "[bricktrack] Datenverzeichnis: $DATA_DIR" + +if ! mkdir -p "$DATA_DIR/db" "$DATA_DIR/files" 2>/dev/null; then + echo "" + echo " FEHLER: In $DATA_DIR darf nicht geschrieben werden." + echo "" + echo " Der Container laeuft als Nutzer 'node' (UID 1000). Das gemountete" + echo " Verzeichnis muss ihm gehoeren. Auf dem Host einmalig:" + echo "" + echo " mkdir -p ./data && sudo chown -R 1000:1000 ./data" + echo "" + exit 1 +fi + +if [ ! -w "$DATA_DIR/db" ]; then + echo " FEHLER: $DATA_DIR/db ist nicht beschreibbar (sudo chown -R 1000:1000 ./data)." + exit 1 +fi + +echo "[bricktrack] Migrationen werden angewendet …" +node /app/migrate.cjs + +echo "[bricktrack] Start auf Port ${PORT:-3000}" +exec "$@" diff --git a/e2e/screenshots-setup.ts b/e2e/screenshots-setup.ts new file mode 100644 index 0000000..5d06996 --- /dev/null +++ b/e2e/screenshots-setup.ts @@ -0,0 +1,9 @@ +import { execFileSync } from 'node:child_process'; + +/** Migrates the screenshot database; the directory is wiped by the npm script. */ +export default function globalSetup() { + execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], { + stdio: 'inherit', + env: { ...process.env, DATA_DIR: './data-shots' }, + }); +} diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 0000000..0151e12 --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; + +/** + * Captures the screenshots used by docs/ANLEITUNG.md. + * + * Reproducible on purpose: whenever the UI changes, `pnpm screenshots` + * refreshes every picture in the manual from the same seeded data. + */ +const SHOT_DIR = path.resolve(process.cwd(), 'docs/bilder'); + +function shotPath(name: string) { + fs.mkdirSync(SHOT_DIR, { recursive: true }); + return path.join(SHOT_DIR, `${name}.png`); +} + +async function shoot(page: Page, name: string) { + // Let fonts and images settle so the picture is not half-rendered. + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: shotPath(name), fullPage: true }); +} + +test('Screenshots fuer die Anleitung', async ({ page, browser }) => { + // --- 1. registration ------------------------------------------------------ + await page.goto('/registrieren'); + await expect(page.getByText('Erstes Konto anlegen')).toBeVisible(); + await shoot(page, '01-registrierung'); + + await page.getByLabel('Name').fill('Florian'); + await page.getByLabel('E-Mail').fill('papa@bricktrack.local'); + await page.getByLabel('Passwort').fill('anleitung-passwort'); + await page.getByRole('button', { name: 'Konto anlegen' }).click(); + await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible(); + + // Example data needs the user to exist first (the wishlist has an owner). + execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/seed.ts'], { + stdio: 'inherit', + env: { ...process.env, DATA_DIR: './data-shots' }, + }); + + // --- 2. dashboard --------------------------------------------------------- + await page.goto('/'); + await shoot(page, '02-start'); + + // --- 3. master data ------------------------------------------------------- + await page.goto('/verwaltung/hersteller'); + await expect(page.getByText('Lego')).toBeVisible(); + await shoot(page, '03-verwaltung-hersteller'); + + // --- 4. catalog ----------------------------------------------------------- + await page.goto('/katalog'); + await expect(page.getByText('Familienhaus und Elektroauto')).toBeVisible(); + await shoot(page, '04-katalog'); + + // --- 5. missing sets, selection mode -------------------------------------- + await page.goto('/katalog?besitz=fehlt'); + await expect(page.getByText('Fehlende Sets')).toBeVisible(); + await shoot(page, '05-fehlende-sets'); + + await page.getByRole('button', { name: 'Auswählen' }).click(); + const cards = page.locator('button[aria-pressed]'); + await cards.nth(0).click(); + await cards.nth(1).click(); + await expect(page.getByText('2 ausgewählt')).toBeVisible(); + await shoot(page, '06-mehrfachauswahl'); + + await page.getByRole('button', { name: 'Zur Wunschliste hinzufügen' }).click(); + await expect(page.getByText('Sets auf die Wunschliste')).toBeVisible(); + await page.screenshot({ path: shotPath('07-wunschliste-dialog') }); + await page.getByRole('button', { name: 'Abbrechen' }).click(); + + // --- 6. set detail -------------------------------------------------------- + await page.goto('/katalog?q=60398'); + await page.getByText('Familienhaus und Elektroauto').first().click(); + await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible(); + await shoot(page, '08-set-detail'); + + // --- 7. collection -------------------------------------------------------- + await page.goto('/sammlung'); + await expect(page.getByText('Wert nach UVP')).toBeVisible(); + await shoot(page, '09-sammlung'); + + // --- 8. wishlist ---------------------------------------------------------- + await page.goto('/wunschlisten'); + await expect(page.getByText('Weihnachten 2026')).toBeVisible(); + await shoot(page, '10-wunschlisten'); + + const listHref = await page + .getByRole('link', { name: /Weihnachten 2026/ }) + .getAttribute('href'); + await page.goto(listHref!); + await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await shoot(page, '11-wunschliste-bearbeiten'); + + const shareUrl = await page.getByLabel('Link zum Teilen').inputValue(); + + // --- 9. public view, as a visitor without an account ---------------------- + const visitorContext = await browser.newContext({ locale: 'de-DE' }); + const visitor = await visitorContext.newPage(); + await visitor.setViewportSize({ width: 412, height: 915 }); + await visitor.goto(shareUrl); + await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await shoot(visitor, '12-oeffentliche-wunschliste'); + + await visitor.getByRole('button', { name: 'Das schenke ich' }).first().click(); + await visitor.getByLabel('Dein Name').fill('Tante Anne'); + await visitor.screenshot({ path: shotPath('13-reservieren') }); + await visitor.getByRole('button', { name: 'Reservieren' }).click(); + await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); + await visitor.getByRole('button', { name: 'Alles klar' }).click(); + await shoot(visitor, '14-reserviert'); + await visitorContext.close(); + + // --- 10. the owner sees the reservation ----------------------------------- + await page.reload(); + await shoot(page, '15-wunschliste-reserviert'); + + // --- 11. account and passkeys -------------------------------------------- + await page.goto('/konto'); + await expect(page.getByText('Passkeys')).toBeVisible(); + await shoot(page, '16-konto-passkeys'); +}); diff --git a/package.json b/package.json index 5f2b79a..484159d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "node scripts/reset-e2e.mjs && playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" + "screenshots": "node scripts/reset-shots.mjs && playwright test --config=playwright.screenshots.config.ts" }, "dependencies": { "@better-auth/passkey": "^1.7.3", @@ -53,6 +53,7 @@ "@types/react-dom": "^19", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", + "esbuild": "^0.25.12", "eslint": "^9", "eslint-config-next": "15.5.25", "shadcn": "^4.21.0", diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts new file mode 100644 index 0000000..c8b96b5 --- /dev/null +++ b/playwright.screenshots.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Produces the screenshots for docs/ANLEITUNG.md. + * + * Kept apart from the smoke test on purpose: this run needs seeded example + * data and writes files into the repository, which a test never should. + * + * pnpm screenshots + */ +const PORT = 3220; +const BASE_URL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'screenshots.spec.ts', + globalSetup: './e2e/screenshots-setup.ts', + fullyParallel: false, + workers: 1, + timeout: 240_000, + reporter: 'list', + use: { + baseURL: BASE_URL, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + // A phone, because that is how the app is actually used. + ...devices['Pixel 7'], + }, + webServer: { + command: `pnpm exec next dev -p ${PORT}`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + NODE_ENV: 'development', + DATA_DIR: './data-shots', + BASE_URL, + BETTER_AUTH_SECRET: 'screenshot-run-secret-0123456789abcdefgh', + MAX_UPLOAD_MB: '25', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2cf62c..fa513f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.7.3 - version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) + version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) better-auth: specifier: ^1.7.3 - version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -93,6 +93,9 @@ importers: drizzle-kit: specifier: ^0.31.10 version: 0.31.10 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0) @@ -113,7 +116,7 @@ importers: version: 5.9.3 vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) packages: @@ -5243,14 +5246,14 @@ snapshots: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)': + '@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)': dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.3 - better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) better-call: 1.4.0(zod@4.5.4) nanostores: 1.5.3 zod: 4.5.4 @@ -7090,14 +7093,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.0 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13) '@vitest/spy@5.0.0': {} @@ -7251,7 +7254,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): + better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))): dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5)) @@ -7277,7 +7280,7 @@ snapshots: next: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -9706,7 +9709,7 @@ snapshots: vary@1.1.2: {} - vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): + vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -9715,15 +9718,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.43 - esbuild: 0.28.2 + esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 - vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -9734,7 +9737,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 diff --git a/scripts/reset-shots.mjs b/scripts/reset-shots.mjs new file mode 100644 index 0000000..796c49d --- /dev/null +++ b/scripts/reset-shots.mjs @@ -0,0 +1,7 @@ +/** Wipes the screenshot data directory before `pnpm screenshots`. */ +import fs from 'node:fs'; +import path from 'node:path'; + +const dataDir = path.resolve(process.cwd(), 'data-shots'); +fs.rmSync(dataDir, { recursive: true, force: true }); +console.log(`[screenshots] ${dataDir} zurueckgesetzt.`); diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..f90a598 --- /dev/null +++ b/update.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Bricktrack aktualisieren. +# +# ./update.sh Update auf den aktuellen Stand von origin/main +# ./update.sh --no-pull Nur neu bauen und starten, ohne git pull +# ./update.sh --no-backup Ohne Sicherung (nicht empfohlen) +# +# Ablauf: sichern -> holen -> bauen -> starten -> Healthcheck. +# Schlaegt der Healthcheck fehl, wird automatisch auf den vorherigen Stand +# zurueckgerollt und die Sicherung bleibt liegen. + +set -euo pipefail + +cd "$(dirname "$0")" + +BACKUP_DIR="./backups" +KEEP_BACKUPS=10 +HEALTH_TIMEOUT=180 +DO_PULL=1 +DO_BACKUP=1 + +for arg in "$@"; do + case "$arg" in + --no-pull) DO_PULL=0 ;; + --no-backup) DO_BACKUP=0 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unbekannte Option: $arg" >&2; exit 1 ;; + esac +done + +info() { printf '\033[1;34m[bricktrack]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[bricktrack]\033[0m %s\n' "$*"; } +error() { printf '\033[1;31m[bricktrack]\033[0m %s\n' "$*" >&2; } + +# --- Vorbedingungen -------------------------------------------------------- + +if ! docker compose version >/dev/null 2>&1; then + error "Docker Compose (Plugin) wurde nicht gefunden." + exit 1 +fi + +if [ ! -f .env ]; then + error "Es gibt keine .env. Lege sie an: cp .env.example .env" + exit 1 +fi + +PREVIOUS_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')" + +# --- Sichern --------------------------------------------------------------- + +BACKUP_FILE="" +if [ "$DO_BACKUP" -eq 1 ]; then + mkdir -p "$BACKUP_DIR" + BACKUP_FILE="$BACKUP_DIR/bricktrack-$(date +%Y%m%d-%H%M%S).tar.gz" + + info "Sicherung wird angelegt: $BACKUP_FILE" + # Der Container wird kurz angehalten, damit die SQLite-Datei nicht mitten in + # einem Schreibvorgang eingepackt wird. + docker compose stop app >/dev/null 2>&1 || true + tar czf "$BACKUP_FILE" data .env 2>/dev/null || { + error "Die Sicherung ist fehlgeschlagen. Abbruch." + docker compose start app >/dev/null 2>&1 || true + exit 1 + } + info "Sicherung fertig ($(du -h "$BACKUP_FILE" | cut -f1))." + + # Alte Sicherungen aufraeumen, die juengsten bleiben. + ls -1t "$BACKUP_DIR"/bricktrack-*.tar.gz 2>/dev/null \ + | tail -n +$((KEEP_BACKUPS + 1)) \ + | xargs -r rm -- +fi + +# --- Holen ----------------------------------------------------------------- + +if [ "$DO_PULL" -eq 1 ]; then + if [ -n "$(git status --porcelain)" ]; then + error "Es gibt lokale Aenderungen im Arbeitsverzeichnis:" + git status --short >&2 + error "Bitte erst aufraeumen oder mit --no-pull starten." + exit 1 + fi + + info "Neue Version wird geholt …" + git pull --ff-only + NEW_COMMIT="$(git rev-parse HEAD)" + + if [ "$NEW_COMMIT" = "$PREVIOUS_COMMIT" ]; then + info "Es gibt nichts Neues. Der Container wird trotzdem neu gestartet." + else + info "Neuer Stand: $(git log -1 --pretty='%h %s')" + fi +fi + +# --- Bauen und starten ----------------------------------------------------- + +info "Image wird gebaut …" +docker compose build app + +info "Container wird gestartet …" +docker compose up -d app + +# --- Healthcheck ----------------------------------------------------------- + +info "Warte auf den Healthcheck (max. ${HEALTH_TIMEOUT}s) …" +elapsed=0 +healthy=0 + +while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' bricktrack 2>/dev/null || echo 'missing')" + + case "$status" in + healthy) healthy=1; break ;; + unhealthy) break ;; + missing) error "Der Container bricktrack laeuft nicht."; break ;; + esac + + sleep 3 + elapsed=$((elapsed + 3)) + printf '.' +done +printf '\n' + +if [ "$healthy" -eq 1 ]; then + info "Bricktrack laeuft." + docker compose ps app + exit 0 +fi + +# --- Rueckabwicklung ------------------------------------------------------- + +error "Die neue Version ist nicht gesund geworden. Die letzten Logzeilen:" +docker compose logs --tail 50 app >&2 + +if [ "$DO_PULL" -eq 1 ] && [ -n "$PREVIOUS_COMMIT" ]; then + warn "Rollback auf $PREVIOUS_COMMIT …" + git reset --hard "$PREVIOUS_COMMIT" + docker compose build app + docker compose up -d app + warn "Der vorherige Stand laeuft wieder." +fi + +if [ -n "$BACKUP_FILE" ]; then + warn "Die Daten liegen unveraendert in $BACKUP_FILE." + warn "Zurueckspielen falls noetig:" + warn " docker compose down && tar xzf $BACKUP_FILE && sudo chown -R 1000:1000 ./data && docker compose up -d" +fi + +exit 1