Der bisherige Schritt hängte das Arbeitsverzeichnis mit -v "$PWD":/app in
einen Container. Läuft der Gitea-Runner selbst in einem Container, zeigt
$PWD auf einen Pfad, den der Docker-Daemon des Hosts nicht kennt: der
Mount wäre leer, pytest fände keine Tests und meldete Erfolg. Wieder ein
Test, der nicht hätte fehlschlagen können.
Jetzt über Dockerfile.test - der Code kommt über den Build-Kontext hinein,
unabhängig davon, wie der Runner aufgebaut ist. tests/ bleibt dafür im
Kontext; das Anwendungs-Image kopiert sie weiterhin nicht mit.
Lokal genauso ausgeführt wie die CI es tun wird: 81 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Farbschema folgt zunächst der Systemeinstellung, lässt sich per Knopf in
der Kopfzeile umstellen und wird dann gemerkt. Bei gesperrtem Speicher
(privates Fenster) greift wieder die Systemeinstellung.
theme.js steht bewusst OHNE defer und vor dem Inhalt: es setzt die
Farbklasse, bevor gezeichnet wird. Mit defer blitzte beim Laden kurz die
helle Fassung auf. Eine eigene Datei statt eines <script>-Blocks, weil die
CSP nur script-src 'self' erlaubt - inline wäre blockiert.
Fusszeile zeigt Version oder Commit, gleiches Muster wie die Kantone-App:
die CI reicht beides als Build-Argument durch, lokal erscheint der
Build-Zeitpunkt. Der ändert sich bei jedem Neubau und zeigt damit sofort,
ob wirklich der neue Stand läuft.
Beim Ergänzen der Dunkel-Varianten hatte mein Ersetzer die eben
eingefügten dark:-Klassen selbst nochmal getroffen und daraus Paare wie
"dark:text-slate-300 dark:text-slate-600" gemacht - bereinigt.
81 Tests, alle grün. Neu darunter: die Fusszeile zeigt Tag bzw. Commit,
theme.js steht vor <body> und ohne defer, der Umschalter ist da.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Jinja2 mit HTMX, Tailwind über die Standalone-Binärdatei im Docker-Build -
kein Node.js, kein npm. Mobile-First, Tippflächen mindestens 44 px hoch.
Seiten: Galerie mit Filterleiste, Detailseite, Erfassungsformular,
Stapel-Import mit Entwurfsliste, Anmeldung. HTMX tauscht beim Filtern nur
die Liste aus statt die ganze Seite neu zu laden.
Aus den Entscheidungen im Plan folgt konkret:
- Der Stapel-Import steht ZUERST im Formular: wer zwanzig Teile hat, will
nicht zwanzigmal ein Formular ausfüllen. Bewusst ohne capture-Attribut,
damit sich wahlweise fotografieren oder aus der Mediathek wählen lässt -
mit capture ginge nur je ein Foto pro Vorgang.
- Grösse, Kategorie, Geschlecht, Saison und Zustand werden vom zuletzt
gespeicherten Teil übernommen. Beim Abarbeiten einer Kiste sind das fünf
Felder weniger pro Stück.
- Nach dem Nachtragen eines Entwurfs geht es direkt zum nächsten, nicht auf
die Detailseite - das ist der eigentliche Arbeitsablauf.
- Titel bleibt leer erlaubt; angezeigt wird dann Kategorie und Grösse.
- reserved_by erscheint öffentlich nur als "reserviert". Den Namen sieht
ausschliesslich der angemeldete Betreiber.
- Nach dem Reservieren wird der Freigabe-Link angezeigt, als vollständige
Adresse zum Aufbewahren.
- Post/Redirect/Get überall, damit ein Neuladen nicht erneut abschickt.
- CSRF-Token in jedem Formular, zusätzlich zu SameSite=Lax.
HTMX liegt lokal im Repository, nicht vom CDN: die CSP erlaubt nur
script-src 'self', und ein CDN-Aufruf gäbe die IP jedes Besuchers weiter -
dieselbe Überlegung wie bei Swagger und bei den Schriften der Kantone-App.
Ein Test prüft für alle Seiten, dass nichts von aussen geladen wird.
Nebenbei TemplateResponse auf die aktuelle Signatur umgestellt (64
Deprecation-Warnungen weniger).
77 Tests, alle grün. Zusätzlich der ganze Weg gegen den Container:
anmelden, drei Fotos als Stapel, Entwurf nachtragen, Werte werden für das
nächste Teil übernommen, als Gast reservieren, Name bleibt verborgen,
Selbst-Freigabe über den Link.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Zwei Fehler, beide erst im Browser sichtbar:
1. /docs blieb weiss. FastAPI holt Swagger von cdn.jsdelivr.net, und die
in Phase 3 eingeführte CSP (script-src 'self') blockiert das zu Recht.
Die Dateien liegen jetzt unter app/static/swagger/ im Repository - das
funktioniert auch ohne Internet und ruft keinen fremden Server auf, aus
demselben Grund, aus dem die Kantone-App ihre Schriften lokal ausliefert.
Das Favicon holte FastAPI ebenfalls von aussen; auch das ist jetzt lokal.
2. Die Wurzel / lieferte ein nacktes 404-JSON - im Browser sieht das aus
wie eine kaputte Anwendung. Jetzt eine schlichte Platzhalter-Seite, bis
die Galerie in Phase 4 steht.
Ausserdem beim Testen aufgefallen: eine lokale .env-Datei reicht
ADMIN_PASSWORD auch dann herein, wenn die Umgebungsvariable gelöscht wird -
bei pydantic-settings haben Umgebungsvariablen Vorrang, ein leerer Wert
überstimmt die Datei, ein Löschen nicht. Der Test setzt jetzt leer statt zu
löschen; im Betrieb ist das dieselbe Falle.
Der neue Test prüft, dass /docs keinen einzigen Verweis auf einen fremden
Host enthält - genau das würde sonst unbemerkt wieder hereinrutschen.
61 Tests, alle grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Lesen darf jeder, schreiben nur der Betreiber - mit der bewussten Ausnahme
des Reservierens. Dazu Dockerfile und Compose-Datei, damit sich das lokal
ausprobieren lässt.
Beim Bauen sind zwei Fehler aufgefallen, die ohne Test nicht aufgefallen
wären:
1. slowapi zählt pro URL-Pfad. Weil jedes Kleidungsstück eine eigene URL
hat, bekam jedes seinen eigenen Zähler - ein Skript hätte also den
gesamten Bestand reservieren können, ohne je an ein Limit zu stossen.
Genau der Missbrauch, gegen den das Limit gedacht ist. Behoben mit
shared_limit und festem scope.
2. Der erste Anlauf des Tests machte fünf Anfragen gegen ein Limit von
fünf und konnte damit gar nichts zeigen. Geprüft wird jetzt der
tatsächlich ausgelieferte Standardwert, mit mehr Anfragen als erlaubt.
Weiter umgesetzt:
- Passwort als bcrypt-Hash aus der Umgebung, einmal beim Start gebildet
und gemerkt. Bei jeder Anfrage neu gehasht liesse sich die Anwendung
sonst ohne Anmeldung lahmlegen - bcrypt ist absichtlich langsam.
- Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt (503)
statt offen zu stehen. Kein mitgeliefertes Standardpasswort.
- Sitzung als signiertes Cookie, HttpOnly, SameSite=Lax (blockt
seitenfremde POSTs), Secure abschaltbar nur fürs lokale Testen,
Abmeldung nach zwei Stunden Ruhe.
- CSP mit script-src 'self', nosniff, frame-ancestors none, dazu noindex
und robots.txt: die Galerie ist frei zugänglich, soll aber nicht
dauerhaft im Suchindex stehen.
- Der Betreiber darf Reservierungen ohne Token aufheben, damit sich eine
Missbrauchswelle aufräumen lässt.
- uvicorn mit --proxy-headers: hinter einem Reverse-Proxy zählte sonst
alles auf dessen IP, und ein einzelner Besucher sperrte alle aus.
- Container läuft nicht als root; Code gehört root, nur Daten und Bilder
dem Dienstbenutzer.
Umgebungsvariablen heissen jetzt ausdrücklich englisch (ADMIN_PASSWORD,
SECRET_KEY, ...), passend zur Anleitung und zur Kantone-App.
59 Tests, alle grün. Zusätzlich gegen den laufenden Container geprüft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Uploads werden geprüft, nach WebP gewandelt (max. 1200 px Breite) und über
eine eigene Route ausgeliefert. Dazu der Stapel-Import: viele Fotos auf
einmal hochladen, pro Foto entsteht ein Entwurf.
Der Upload ist die grösste Angriffsfläche der Anwendung - die Galerie ist
frei zugänglich, und hier landet fremdes Binärmaterial auf der Platte.
Entsprechend:
- Der Dateiname wird selbst erzeugt (UUID + .webp), nie aus dem Upload
übernommen. Beim Ausliefern wird er zusätzlich gegen ein Muster geprüft:
er kommt dort aus der URL und ist damit Nutzereingabe, auch wenn wir ihn
ursprünglich vergeben haben.
- Der Inhalt entscheidet, nicht die Endung - Pillow muss das Bild öffnen
können. Eine als .jpg getarnte PHP-Datei fällt durch.
- Pixelmasse ausdrücklich begrenzt, VOR dem Dekodieren. Pillow allein
wirft erst bei der doppelten Zahl einen Fehler; dazwischen gibt es nur
eine Warnung, die niemand sieht.
- Gespeichert wird nur das Ergebnis. Das entfernt eingebettete
Fremdinhalte und die EXIF-Daten - bei Handyfotos oft samt
GPS-Koordinaten der Wohnung. Die Drehung wird vorher auf die Bildpunkte
angewendet, sonst lägen Hochkant-Fotos quer.
- Bilder liegen nicht unter static/, sondern werden mit festem
Content-Type und nosniff ausgeliefert.
HEIC wird unterstützt (pillow-heif): iPhones nehmen so auf, und ohne das
fiele beim Stapel-Import jedes iPhone-Foto durch. Ein unlesbares Bild
lässt bewusst den ganzen Stapel scheitern - bei zwanzig Fotos fiele sonst
niemandem auf, dass eines fehlt.
Beim Löschen werden die Dateien mitentfernt (ON DELETE CASCADE räumt nur
die Zeilen weg), und wird das Vorschaubild gelöscht, rückt das nächste
nach.
42 Tests, alle grün. Zusätzlich von Hand gegen die laufende API geprüft:
3000x2200-Foto kommt als WebP 1200x880 an, Pfadmanipulation und getarnte
Dateien werden abgewiesen, Entwürfe bleiben aus der Galerie draussen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
FastAPI mit SQLAlchemy und SQLite, Migrationen über Alembic. Die
Endpunkte aus projekt.md für items, categories und sizes stehen samt
Reservierung; Bilder und Anmeldung folgen in Phase 2 und 3.
Umgesetzt wie in Plan.md festgelegt:
- Feste Wertelisten als Enum UND als CHECK in der Datenbank. Die
CHECK-Bedingung ist der eigentliche Schutz: an SQLAlchemy vorbei (Import,
sqlite3 von Hand) käme sonst "Gril" durch, und die Filter griffen still
nicht mehr.
- Status "draft" für den Stapel-Import. Entwürfe erscheinen weder in der
Galerie noch in GET /items, solange nicht ausdrücklich status=draft
angefragt wird, und lassen sich nicht reservieren.
- Titel ist freiwillig und wird sonst beim ANZEIGEN aus Kategorie und
Grösse gebildet ("Jacken 98/104") - nicht beim Speichern, damit er einer
späteren Korrektur der Grösse folgt.
- reservation_token: nur wer es hat, kann die eigene Reservierung aufheben.
Verglichen mit compare_digest, gelöscht beim Freigeben und beim
Erledigen, damit ein alter Link nicht später eine fremde Reservierung
aufhebt.
- Eigene sizes-Tabelle mit sort_order statt SELECT DISTINCT: sonst stünde
"104" vor "56" und jeder Tippfehler würde zur Filteroption.
- Pagination auf GET /items, in projekt.md nicht vorgesehen.
Zwei SQLite-Eigenheiten, die leicht untergehen: foreign_keys ist
standardmässig AUS (ohne PRAGMA greift ON DELETE CASCADE nicht), und
check_same_thread muss für FastAPI abgeschaltet werden. Beides in
database.py, dazu WAL fürs gleichzeitige Lesen.
24 Tests, alle grün. Zusätzlich von Hand geprüft: Migration anwenden,
Eintrag anlegen, reservieren, ohne Token freigeben (403), mit Token
freigeben (200), Swagger UI erreichbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
- Aufheben darf der Reservierende selbst (per Token-Link) und der
Betreiber. Ein offener release-Endpunkt wäre die naheliegende, aber
falsche Variante: dann löscht jeder Besucher fremde Reservierungen, und
weil reserved_by mitgeht, bleibt nicht mal nachvollziehbar, dass jemand
reserviert hatte. Token wird beim Aufheben und beim Erledigen gelöscht,
Vergleich mit compare_digest.
- Die Galerie bleibt frei zugänglich, ohne Zugangscode. Damit wird das
Rate-Limit zur einzigen Bremse vor dem Reservieren-Endpunkt, und zwei
Dinge gehören zwingend dazu: reserved_by erscheint öffentlich nur als
"reserviert", und noindex/robots.txt verhindern, dass Fotos und Texte
dauerhaft im Suchindex landen.
projekt.md bei /release entsprechend präzisiert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Beim Erstbestand sind viele Teile auf einmal zu erfassen. Der Aufwand
steckt nicht im Fotografieren, sondern im Rundlauf pro Stück. Darum:
- Stapel-Import: erst alles mit der Kamera-App fotografieren, dann alle
Fotos auf einmal hochladen. Pro Foto entsteht ein Entwurf, die Details
kommen später. Bewusst ohne capture="environment" - das erzwingt ein
Foto pro Vorgang und schliesst multiple aus.
- Titel wird optional und sonst aus Kategorie und Grösse gebildet
("Hose 98/104"). Erzeugt beim Anzeigen, nicht beim Speichern, damit er
einer späteren Korrektur der Grösse folgt.
Daraus folgt ein neuer Status "draft" im Datenmodell; title und size
müssen NULL erlauben. Entwürfe erscheinen nicht in der Galerie.
Ausserdem HEIC in Phase 2 aufgenommen: iPhones nehmen so auf, und beim
Stapel-Import fiele ein nicht lesbares Bild sonst still hinten runter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Fehlte in Phase 4: das Image soll wie bei der Kantone-App über einen
Versions-Tag gebaut, vor dem Veröffentlichen getestet und in die Gitea-
Registry gestellt werden.
Der Plan hält dazu die Punkte fest, die dort nachträglich korrigiert werden
mussten - vor allem, Werte aus ${{ ... }} nicht direkt in die Shell zu
schreiben, weil Tag-Namen Sonderzeichen enthalten dürfen und der Job ein
Registry-Token hält.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
Gilt unabhängig vom lokalen core.autocrlf und damit auch für Klone auf
anderen Rechnern. Wichtig, sobald Shell-Skripte im Container laufen: mit
CRLF scheitern die dort an "bad interpreter: /bin/sh^M".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
projekt.md beschreibt die Kinderkleider-Börse (FastAPI, SQLite, Bild-Upload
mit WebP-Konvertierung). Plan.md ergänzt die dort offenen Entscheidungen:
Frontend-Variante A (Jinja2 + HTMX, kein Node.js), Erfassen hinter Passwort,
Reservieren ohne Anmeldung, erreichbar aus dem Internet.
Aus dieser Kombination folgt der Schwerpunkt des Plans - ein offener,
zustandsändernder Endpunkt im Internet und ein Bild-Upload sind die beiden
Angriffsflächen, die den Ausschlag geben.
Noch kein Code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o