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) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 00:09:03 +02:00
co-authored by Claude Opus 5
parent b0152b8667
commit 61b1249afd
15 changed files with 976 additions and 36 deletions
+20
View File
@@ -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
+17
View File
@@ -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
+3
View File
@@ -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
+86
View File
@@ -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"]
+210
View File
@@ -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=<dein-erzeugter-wert>
```
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 |
+212 -21
View File
@@ -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.
+46
View File
@@ -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"
+30
View File
@@ -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 "$@"
+9
View File
@@ -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' },
});
}
+124
View File
@@ -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');
});
+2 -1
View File
@@ -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",
+44
View File
@@ -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',
},
},
});
+17 -14
View File
@@ -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
+7
View File
@@ -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.`);
+149
View File
@@ -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