From 31f59fd3c8be31c8ba2e43d9e9e9481a2624afdc Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 30 Aug 2026 08:40:57 +0200 Subject: [PATCH] Phase 5: Gitea Actions, Betriebs-Compose und README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Versions-Tag (v*) löst den Bau aus, ein Push auf main nicht - so bleibt das Zusammenführen von Branches folgenlos und ein Release ist ein bewusster Schritt. Die CI baut, testet das FERTIGE Image und veröffentlicht erst danach; "latest" nur bei echten Tags. Beim Durchspielen des Prüfschritts zwei Fehler gefunden, die beide erst in der CI aufgefallen wären - und einer davon gar nicht: 1. "docker exec" ohne -i hängt kein stdin an. Das Here-Dokument lief damit ins Leere, python bekam ein leeres Programm, tat nichts und meldete Erfolg. Ein Test, der nie hätte fehlschlagen können. Lokal nachgestellt und mit -i behoben. 2. Die YAML war ungültig: eingebettete Python-Blöcke standen links vom Einzug und brachen den run-Block auf. Das Prüfskript liegt jetzt als scripts/image-pruefen.py vor - dadurch lässt es sich auch lokal gegen ein gebautes Image laufen lassen - und die Warteschleife ist einzeilig. Aus der Kantone-App übernommen: github.ref_name geht über env in die Shell statt direkt als Ausdruck (Tag-Namen dürfen " und $ enthalten, und der Job hält ein Registry-Token), Anmeldung per --password-stdin, docker logout mit if: always(), Rechte auf contents:read und packages:write. docker-compose.betrieb.yml liefert bewusst KEIN Passwort mit: ohne eigenes bleibt der Erfassungsbereich gesperrt. Genau das war die schwerste Lücke der Kantone-App. Ausserdem fehlte eine .dockerignore - der ganze Kontext samt .git und .env ging an den Docker-Daemon, obwohl das Dockerfile nur gezielt kopiert. 81 Tests grün, der CI-Prüfschritt lokal gegen das gebaute Image durchgespielt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o --- .dockerignore | 31 +++++ .env-example | 12 ++ .gitea/workflows/docker-image.yml | 152 +++++++++++++++++++++++++ README.md | 182 ++++++++++++++++++++++++++++++ docker-compose.betrieb.yml | 53 +++++++++ scripts/image-pruefen.py | 57 ++++++++++ 6 files changed, 487 insertions(+) create mode 100644 .dockerignore create mode 100644 .env-example create mode 100644 .gitea/workflows/docker-image.yml create mode 100644 README.md create mode 100644 docker-compose.betrieb.yml create mode 100644 scripts/image-pruefen.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9720fef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Was nicht in den Build-Kontext gehört. +# +# Das Dockerfile kopiert ohnehin nur gezielt einzelne Verzeichnisse, es +# landet also nichts davon im Image. Aber der gesamte Kontext wird an den +# Docker-Daemon übertragen - und .env enthält das Admin-Passwort. + +.env +.git +.gitea +.github +.claude + +# Laufzeitdaten (im Betrieb Volumes) +data/ +uploads/ +*.sqlite +*.sqlite3 + +# Entwicklung +tests/ +.pytest_cache/ +.venv/ +venv/ +__pycache__/ +*.py[cod] + +# Wird im Build erzeugt bzw. nur dort gebraucht +tailwindcss +Plan.md +projekt.md +README.md diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..e3fc965 --- /dev/null +++ b/.env-example @@ -0,0 +1,12 @@ +# Vorlage für die lokale Entwicklung: als ".env" kopieren und anpassen. +# Die echte .env gehört nicht ins Repository (siehe .gitignore). + +# Passwort fürs Erfassen. Leer = Erfassungsbereich gesperrt. +ADMIN_PASSWORD=bitte-eigenes-setzen + +# Signiert das Sitzungs-Cookie. Erzeugen mit: +# python -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=nur-lokal-bitte-im-betrieb-ersetzen + +# Nur fürs Testen über http://localhost. Im Betrieb hinter HTTPS: true +COOKIE_HTTPS_ONLY=false diff --git a/.gitea/workflows/docker-image.yml b/.gitea/workflows/docker-image.yml new file mode 100644 index 0000000..3f63e61 --- /dev/null +++ b/.gitea/workflows/docker-image.yml @@ -0,0 +1,152 @@ +name: Docker-Image bauen + +# Baut das Image, sobald ein Versions-Tag (v1.2.3) gepusht wird, und legt es +# in der eingebauten Container-Registry von Gitea ab. Bewusst NICHT bei +# jedem Push auf main: so bleibt das Zusammenführen von Branches folgenlos +# und ein Release ist ein bewusster Schritt. +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: gitea.boing86.myds.me + IMAGE_NAME: docker/kleiderboerse + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Quellcode auschecken + uses: actions/checkout@v4 + + - name: Tests + run: | + set -eu + docker run --rm -v "$PWD":/app -w /app python:3.11-slim bash -c ' + pip install --quiet -r requirements-dev.txt + python -m pytest -q + ' + + - name: Version bestimmen + id: meta + env: + # Bewusst über die Umgebung statt direkt als ${{ ... }} im Skript: + # Ausdrücke werden vor der Shell eingesetzt, und Tag-Namen dürfen + # Zeichen wie " oder $ enthalten. Als Variable kann daraus kein + # zusätzlicher Befehl werden. + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + set -eu + if [ "$REF_TYPE" = "tag" ]; then + VERSION="$REF_NAME" + VERSION="${VERSION#v}" # v1.2.3 -> 1.2.3 + IS_RELEASE=true + else + VERSION="manual-$(date +%Y%m%d-%H%M%S)" + IS_RELEASE=false + fi + # Die Registry behandelt Namen case-insensitiv -> konsequent klein. + VERSION="$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + # Nur bei echten Tags eine Version in die Fusszeile schreiben; bei + # manuellen Läufen bleibt sie leer und es erscheint der Commit. + if [ "$IS_RELEASE" = "true" ]; then + echo "tag_version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "tag_version=" >> "$GITHUB_OUTPUT" + fi + echo "Version: $VERSION (Release: $IS_RELEASE)" + + - name: Image bauen + run: | + set -eu + docker build \ + --tag "$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" \ + --build-arg "APP_VERSION=${{ steps.meta.outputs.tag_version }}" \ + --build-arg "APP_REVISION=${{ github.sha }}" \ + --label "org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" \ + --label "org.opencontainers.image.revision=${{ github.sha }}" \ + --label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \ + . + + # Prüft das fertige Image, bevor irgendetwas veröffentlicht wird. + # Geprüft wird im Container selbst (kein Port-Mapping nötig, damit der + # Test unabhängig vom Netzwerk-Setup des Runners funktioniert). + - name: Image testen + run: | + set -eu + BILD="$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" + CID=$(docker run -d -e ADMIN_PASSWORD=pruef -e SECRET_KEY=pruef "$BILD") + trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT + + for i in $(seq 1 30); do + # Einzeilig: ein mehrzeiliger String bräche den YAML-Block auf, + # weil dessen Fortsetzung links vom Einzug stünde. + if docker exec "$CID" python -c "import urllib.request as u; u.urlopen('http://localhost:8000/health')" 2>/dev/null; then + break + fi + if [ "$i" = "30" ]; then + echo "Anwendung im Container nicht erreichbar geworden:" + docker logs "$CID" + exit 1 + fi + sleep 1 + done + + # Stammdaten: ohne Kategorien und Grössen lässt sich nichts + # erfassen. Untergrenzen bewusst unter dem Ist-Stand, damit der + # Test nicht bei jeder Ergänzung anschlägt. + # -i ist zwingend: ohne angehängtes stdin bekommt "python -" ein + # leeres Programm, tut nichts und meldet Erfolg. Der Test könnte + # dann nie fehlschlagen. + docker exec -i "$CID" python - < scripts/image-pruefen.py + + # Das CSS muss im Image liegen - es entsteht in einer eigenen + # Build-Stufe und würde bei einem Fehler dort still fehlen. + docker exec "$CID" test -s /app/app/static/style.css + echo "style.css vorhanden." + + - name: An Registry anmelden + env: + # Optional: eigenes Token/Konto als Repo-Secret hinterlegen, falls + # der automatische GITEA_TOKEN nicht in die Registry schreiben darf. + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + AUTO_TOKEN: ${{ secrets.GITEA_TOKEN }} + AKTEUR: ${{ github.actor }} + run: | + set -eu + TOKEN="${REGISTRY_TOKEN:-$AUTO_TOKEN}" + BENUTZER="${REGISTRY_USER:-$AKTEUR}" + if [ -z "$TOKEN" ]; then + echo "Kein Token verfügbar: REGISTRY_TOKEN als Repo-Secret hinterlegen." + exit 1 + fi + echo "Anmeldung an $REGISTRY als $BENUTZER" + # --password-stdin: so steht das Token weder in der Prozessliste + # noch im Protokoll. + echo "$TOKEN" | docker login "$REGISTRY" -u "$BENUTZER" --password-stdin + + - name: Image veröffentlichen + run: | + set -eu + docker push "$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" + + # "latest" nur für echte Versions-Tags, nicht für manuelle Läufe. + if [ "${{ steps.meta.outputs.is_release }}" = "true" ]; then + docker tag "$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" \ + "$REGISTRY/$IMAGE_NAME:latest" + docker push "$REGISTRY/$IMAGE_NAME:latest" + fi + + - name: Abmelden + if: always() + run: docker logout "$REGISTRY" || true diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fb7eda --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Kinderkleider-Börse + +Zu klein gewordene Kinderkleidung fotografieren, in einer Galerie zeigen und +an Familie, Freunde und Nachbarn weitergeben. Wer etwas brauchen kann, +reserviert es mit einem Klick – ohne Konto, ohne Anmeldung. + +Fürs Erfassen genügt das Smartphone: alles fotografieren, alle Fotos auf +einmal hochladen, die Angaben in Ruhe nachtragen. + +--- + +## In vier Schritten + +**1.** Einen leeren Ordner anlegen, zum Beispiel `kleiderboerse`. + +**2.** Darin eine Datei namens `docker-compose.yml` anlegen und den Inhalt +von [`docker-compose.betrieb.yml`](docker-compose.betrieb.yml) hineinkopieren. + +**3.** Zwei Werte eintragen: + +```yaml +ADMIN_PASSWORD: "dein-passwort" +SECRET_KEY: "..." +``` + +Den `SECRET_KEY` erzeugen: + +```bash +docker run --rm python:3.11-slim python -c "import secrets; print(secrets.token_urlsafe(48))" +``` + +Bleibt `ADMIN_PASSWORD` leer, ist der Erfassungsbereich gesperrt – die +Galerie funktioniert trotzdem. Absichtlich ist kein Passwort vorgegeben: +ein mitgeliefertes stünde in dieser Anleitung und wäre damit bekannt. + +**4.** Starten und öffnen: + +```bash +docker compose up -d +``` + +→ + +--- + +## Erfassen + +Nach dem Anmelden gibt es zwei Wege: + +**Viele auf einmal** – der Weg für den Erstbestand. Zuerst alles mit der +Kamera-App fotografieren, dann alle Fotos zusammen hochladen. Pro Foto +entsteht ein Entwurf; unter *Entwürfe* trägst du danach Grösse und Kategorie +nach. Grösse, Kategorie, Saison und Zustand werden dabei vom zuletzt +gespeicherten Teil übernommen – beim Abarbeiten einer Kiste sind das vier +Felder weniger pro Stück. + +**Einzeln** – Formular mit Foto, für spätere Nachträge. + +Der Titel ist freiwillig. Bleibt er leer, steht dort automatisch Kategorie +und Grösse, also zum Beispiel „Jacken 98/104". Wird die Grösse später +korrigiert, ändert sich der Titel mit. + +## Reservieren + +Besucher brauchen kein Konto. Nach dem Reservieren erscheint ein Link, mit +dem sich die eigene Reservierung wieder aufheben lässt – dieser Link sollte +aufbewahrt werden. Ohne ihn kann nur der Betreiber die Reservierung lösen. + +Der Name der reservierenden Person erscheint **nicht** in der Galerie, dort +steht nur „reserviert". Sichtbar ist er ausschliesslich nach der Anmeldung. + +--- + +## Betrieb + +### Aus dem Internet erreichbar machen + +Die App gehört hinter einen Reverse-Proxy, der HTTPS beendet. Wichtig dabei: + +- `X-Forwarded-Proto` und `X-Forwarded-For` weiterreichen. Ohne das erkennt + die App nicht, dass der Browser verschlüsselt verbunden ist, und alle + Rate-Limits zählen auf die Adresse des Proxys – ein einzelner Besucher + sperrte damit alle anderen aus. +- `COOKIE_HTTPS_ONLY` auf `"true"` lassen. + +Die Galerie ist frei zugänglich, wird aber per `noindex` und `robots.txt` +von Suchmaschinen ferngehalten: sonst wären Fotos und Texte noch auffindbar, +wenn die Börse längst abgeräumt ist. + +### Sicherung + +Zwei Volumes enthalten alles, was nicht im Code steht: + +| Volume | Inhalt | +|---|---| +| `kleiderboerse-db` | Datenbank (`/data`) | +| `kleiderboerse-bilder` | hochgeladene Fotos (`/uploads`) | + +```bash +docker run --rm -v kleiderboerse-db:/d -v "$PWD":/sicherung alpine \ + tar czf /sicherung/db.tar.gz -C /d . +docker run --rm -v kleiderboerse-bilder:/b -v "$PWD":/sicherung alpine \ + tar czf /sicherung/bilder.tar.gz -C /b . +``` + +### Einstellungen + +Alle über Umgebungsvariablen, keine im Image: + +| Variable | Standard | Bedeutung | +|---|---|---| +| `ADMIN_PASSWORD` | – | Passwort fürs Erfassen. Leer = gesperrt. | +| `ADMIN_PASSWORD_HASH` | – | Alternative: fertiger bcrypt-Hash. Hat Vorrang. | +| `SECRET_KEY` | – | Signiert das Sitzungs-Cookie. Ohne festen Wert nach jedem Neustart abgemeldet. | +| `COOKIE_HTTPS_ONLY` | `true` | Sitzungs-Cookie nur über HTTPS. | +| `RATE_LIMIT_RESERVE` | `5/hour` | Reservierungen pro IP. | +| `RATE_LIMIT_LOGIN` | `10/hour` | Anmeldeversuche pro IP. | +| `SESSION_IDLE_SECONDS` | `7200` | Abmeldung nach Ruhezeit. | +| `MAX_UPLOAD_BYTES` | `10485760` | Grösse eines einzelnen Fotos. | +| `DATABASE_PATH` | `/data/kleiderboerse.sqlite` | Ort der Datenbank. | +| `UPLOAD_PATH` | `/uploads` | Ort der Fotos. | + +--- + +## Entwickeln + +```bash +cp .env-example .env # dann ADMIN_PASSWORD darin anpassen +docker compose up -d --build +``` + +→ , API-Oberfläche unter `/docs` + +Tests: + +```bash +docker run --rm -v "$PWD":/app -w /app python:3.11-slim bash -c \ + 'pip install -q -r requirements-dev.txt && python -m pytest -q' +``` + +Datenbank-Migration nach einer Modelländerung: + +```bash +alembic revision --autogenerate -m "Beschreibung" +``` + +### Aufbau + +Ein Dienst, eine SQLite-Datei. FastAPI liefert sowohl die REST-API unter +`/api/v1/…` als auch die Seiten aus; HTMX tauscht beim Filtern nur die Liste +aus. Tailwind wird im Docker-Build über die Standalone-Binärdatei übersetzt – +kein Node.js nötig. + +``` +app/ +├── main.py Einstiegspunkt, Middleware, Sicherheits-Header +├── config.py Einstellungen aus Umgebungsvariablen +├── models.py Datenmodelle +├── crud.py Datenbankzugriffe +├── security.py Anmeldung, Sitzung, CSRF, Rate-Limits +├── images.py Upload prüfen und nach WebP wandeln +├── routers/ API- und Seiten-Routen +└── templates/ Jinja2-Vorlagen +``` + +Fremde Dateien (HTMX, Swagger, Schriften) liegen bewusst lokal im +Repository statt von einem CDN geladen zu werden: die Content-Security-Policy +erlaubt nur `script-src 'self'`, und ein CDN-Aufruf gäbe die IP-Adresse jedes +Besuchers an einen Dritten weiter. + +## Veröffentlichen + +Ein Versions-Tag löst den Bau des Images aus: + +```bash +git tag -a v1.0.0 -m "Version 1.0.0 – ..." +git push origin v1.0.0 +``` + +Die CI baut, **testet das fertige Image** und stellt es erst danach in die +Registry. `latest` wird nur bei echten Versions-Tags gesetzt. Ein Push auf +`main` löst nichts aus. diff --git a/docker-compose.betrieb.yml b/docker-compose.betrieb.yml new file mode 100644 index 0000000..6328a07 --- /dev/null +++ b/docker-compose.betrieb.yml @@ -0,0 +1,53 @@ +# Fertiges Image starten – ohne Quellcode, ohne selbst zu bauen. +# +# So geht's: +# 1. Diese Datei als "docker-compose.yml" in einen leeren Ordner legen +# 2. Unten ein eigenes Passwort und einen eigenen SECRET_KEY eintragen +# 3. Im Ordner ausführen: docker compose up -d +# 4. Im Browser öffnen: http://localhost:8090 +# +# Beenden: docker compose down +# Aktualisieren: docker compose pull && docker compose up -d + +services: + app: + image: gitea.boing86.myds.me/docker/kleiderboerse:latest + restart: unless-stopped + + ports: + # links der Port am eigenen Rechner, rechts der im Container. + # Ist 8090 schon belegt, einfach auf z.B. "8091:8000" ändern. + - "8090:8000" + + environment: + # Passwort fürs Erfassen. Solange hier nichts steht, ist der + # Erfassungsbereich gesperrt; Galerie und Reservieren funktionieren + # trotzdem. Bewusst leer ausgeliefert: ein mitgeliefertes Passwort + # stünde in dieser Anleitung und wäre damit allgemein bekannt. + ADMIN_PASSWORD: "" + + # Signiert das Sitzungs-Cookie. Ohne festen Wert ist man nach jedem + # Neustart abgemeldet. Einen erzeugen mit: + # docker run --rm python:3.11-slim python -c \ + # "import secrets; print(secrets.token_urlsafe(48))" + SECRET_KEY: "" + + # Die App läuft hinter einem Reverse-Proxy mit HTTPS. Auf "false" nur + # zum Testen über http://localhost - sonst ginge das Sitzungs-Cookie + # auch über unverschlüsselte Verbindungen mit. + COOKIE_HTTPS_ONLY: "true" + + # Reservieren ist ohne Anmeldung möglich. Diese Grenze ist damit das + # Einzige, was zwischen einem Skript und dem ganzen Bestand steht. + RATE_LIMIT_RESERVE: "5/hour" + RATE_LIMIT_LOGIN: "10/hour" + + volumes: + # Hier liegen Datenbank und Bilder. Ohne diese beiden Zeilen wäre nach + # einem Neustart alles weg. + - kleiderboerse-db:/data + - kleiderboerse-bilder:/uploads + +volumes: + kleiderboerse-db: + kleiderboerse-bilder: diff --git a/scripts/image-pruefen.py b/scripts/image-pruefen.py new file mode 100644 index 0000000..0536e9f --- /dev/null +++ b/scripts/image-pruefen.py @@ -0,0 +1,57 @@ +"""Prüft das fertige Image, bevor es veröffentlicht wird. + +Wird von der CI in den Container gepipt: + docker exec -i "$CID" python - < scripts/image-pruefen.py + +Bewusst eine eigene Datei statt eines Here-Dokuments im Workflow: ein +unindentierter Block bricht den YAML-Block auf, und als Datei lässt sich +das Ganze auch lokal gegen ein gebautes Image laufen lassen. + +Die Untergrenzen liegen bewusst unter dem Ist-Stand, damit der Test nicht +bei jeder zusätzlichen Kategorie anschlägt. +""" + +import json +import sys +import urllib.error +import urllib.request + +BASIS = "http://localhost:8000" + + +def hole(pfad: str): + with urllib.request.urlopen(BASIS + pfad) as antwort: + return json.load(antwort) + + +def main() -> None: + kategorien = hole("/api/v1/categories") + groessen = hole("/api/v1/sizes") + print(f"Kategorien: {len(kategorien)}, Grössen: {len(groessen)}") + assert len(kategorien) >= 5, "zu wenige Kategorien - Stammdaten fehlen?" + assert len(groessen) >= 20, "zu wenige Grössen - Stammdaten fehlen?" + + with urllib.request.urlopen(BASIS + "/") as antwort: + seite = antwort.read().decode() + assert "Kleider-Börse" in seite, "Galerie wird nicht ausgeliefert" + # Das CSS entsteht in einer eigenen Build-Stufe und würde bei einem + # Fehler dort still fehlen - die Seite käme dann ohne Gestaltung. + assert "/static/style.css" in seite, "kein CSS eingebunden" + print("Galerie und CSS: ok") + + # Ohne Anmeldung darf nichts geschrieben werden. + try: + urllib.request.urlopen(urllib.request.Request( + BASIS + "/api/v1/items", method="POST", + data=b'{"size":"80"}', + headers={"Content-Type": "application/json"}, + )) + except urllib.error.HTTPError as fehler: + assert fehler.code == 401, f"ergab {fehler.code}, erwartet 401" + else: + sys.exit("Schreiben war ohne Anmeldung möglich!") + print("Schreibschutz greift.") + + +if __name__ == "__main__": + main()