Skip to content

Commit dbed6f1

Browse files
phashManuel Rödigclaude
authored
fix(review): Intensiv-Review-Findings bis Low (Security/DSGVO/UI-UX/Tests) (#12)
5 parallele Reviews (Security, DSGVO, Backend-Code, Frontend-UI/UX, Testabdeckung). Alle Findings bis einschliesslich LOW umgesetzt. Backend: - PATCH /me/profile: exclude_unset (kein NULL-Ueberschreiben, Datenverlust) - GET /me/export: Feedback + Edit-State + Preset-Geometrie ergaenzt (Art. 15/20) - marketplace fork kopiert geometry; report_count via FOR UPDATE (Lost-Update) - delete_image: 502 statt 500, Janitor raeumt failed; confirm idempotent - Marketplace-Preview nur fuer ready-Bilder; Pending-Upload-Soft-Quota (429) - Modell-Indizes gespiegelt; 002-Downgrade-Datenqualitaet kommentiert Frontend: - Geteilte Modal-Komponente (role/aria-modal/Escape/Focus-Trap/-Restore) fuer Marketplace-Detail, PresetDialog, ShortcutCheatsheet, BatchApplyModal; ExportDialog Escape+role - Library-Bild-Loeschen zweistufig (Datenverlust-Schutz) - Bypass-Shortcut-Text, Umlaut-Fixes, Login-Fehler sichtbar, Compare-Race, Smart-Suggestion-Hinweis, Account-Lade-Hinweis, aria-labels/aria-controls DSGVO-Texte: Matomo-DNT relativiert, tfhub.dev/kaggle.com + buy-me-a-coffee offengelegt, Feedback-Datenkategorie + Retention, RAW-EXIF-Hinweis. Infra: security_headers-Snippet versioniert; CSP-Trade-off dokumentiert. Tests: +20 Backend (AuthZ-Negativ, Moderation, S3-Fehlerpfad, Honeypot, Bounds, Merge-Gruppen, Quota), +Frontend (Modal-A11y, exifStrip-Gate, transform-Degenerate). backend 182 / frontend 381 / lint / tsc / build gruen. Co-authored-by: Manuel Rödig <m.roedig@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b3d5b3 commit dbed6f1

37 files changed

Lines changed: 1167 additions & 148 deletions

backend/alembic/versions/002_keycloak.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ def downgrade() -> None:
5353
op.create_index("idx_refresh_tokens_token_hash", "refresh_tokens", ["token_hash"])
5454

5555
# users: password_hash wieder rein, keycloak_sub raus
56+
# ACHTUNG: Dieser Downgrade ist NICHT datenneutral. Bestehende User
57+
# bekommen einen leeren password_hash (server_default="") — Keycloak-only-
58+
# Accounts haben kein lokales Passwort, das wiederhergestellt werden
59+
# koennte. Der Reverse-Pfad ist nur fuer leere/Dev-DBs gedacht; in Prod
60+
# nie ausfuehren.
5661
op.add_column(
5762
"users",
5863
sa.Column("password_hash", sa.String(length=255), nullable=False, server_default=""),

backend/app/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class Settings(BaseSettings):
3232
garage_s3_secret_access_key: str = ""
3333
presigned_url_expires_in: int = 900
3434
max_image_size_bytes: int = 200 * 1024 * 1024 # 200 MB
35+
# Soft-Quota: max. gleichzeitig offene (pending) Uploads pro User. Begrenzt
36+
# zusammen mit Rate-Limit + Janitor das Anlegen vieler Zombie-Rows/Objekte
37+
# (DB-/Bucket-DoS gegen den gemeinsamen Bucket).
38+
max_pending_uploads_per_user: int = 50
3539

3640
cors_origin: str = "http://localhost:5173"
3741

backend/app/janitor.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
"""Janitor — periodische Cleanup-Tasks fuer pending Uploads.
1+
"""Janitor — periodische Cleanup-Tasks fuer haengengebliebene Uploads.
22
33
Pre-Signed-URLs haben keine Content-Length-Range-Constraint, also kann
44
ein Browser einen `init_upload` aufrufen und den `confirm` nie senden.
55
Die DB-Row und ein potentiell schon hochgeladenes S3-Object bleiben
66
dann zurueck (Bucket-Bloat). Dieser Janitor raeumt sie ab einem
77
Schwellenalter (Default: 15 min) weg.
88
9+
Geraeumt werden zwei Zustaende:
10+
- `pending`: nie bestaetigte Uploads (Browser hat confirm nie geschickt).
11+
- `failed`: DELETE-Versuche, bei denen das S3-Delete fehlschlug und die
12+
Row als `failed` markiert wurde (Object evtl. noch da). Sonst bliebe ein
13+
verwaistes Object ohne DB-Referenz dauerhaft liegen.
14+
915
Das Modul stellt eine reine async-Funktion `prune_pending_uploads()`
1016
bereit; aufgerufen wird sie von `backend/scripts/janitor.py` (Cron).
1117
"""
@@ -26,7 +32,7 @@
2632

2733
@dataclass
2834
class PruneResult:
29-
candidates: int # gefundene pending-Rows aelter als TTL
35+
candidates: int # gefundene pending/failed-Rows aelter als TTL
3036
storage_deleted: int # erfolgreich aus S3 entfernt
3137
storage_errors: int # S3-Fehler (best effort, DB-Row bleibt drin)
3238
db_deleted: int # tatsaechlich aus DB entfernt
@@ -39,7 +45,8 @@ async def prune_pending_uploads(
3945
ttl: timedelta = PENDING_TTL,
4046
now: datetime | None = None,
4147
) -> PruneResult:
42-
"""Loescht pending Uploads aelter als ttl aus S3 und DB.
48+
"""Loescht haengengebliebene Uploads (pending + failed) aelter als ttl
49+
aus S3 und DB.
4350
4451
Reihenfolge: erst S3-Object weg, dann DB-Row. Wenn S3 wirft, bleibt
4552
die DB-Row stehen (naechster Lauf versucht's wieder). Das ist
@@ -49,7 +56,7 @@ async def prune_pending_uploads(
4956
threshold = (now or datetime.now(timezone.utc)) - ttl
5057
result = await db.execute(
5158
select(Image).where(
52-
Image.upload_state == "pending",
59+
Image.upload_state.in_(("pending", "failed")),
5360
Image.created_at < threshold,
5461
)
5562
)

backend/app/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ class Preset(Base):
5353
"visibility IN ('private','public')",
5454
name="ck_presets_visibility",
5555
),
56+
# In Migration 001 angelegt — hier gespiegelt, damit Modell == Schema
57+
# und `alembic revision --autogenerate` den Index nicht faelschlich
58+
# als "zu droppen" erkennt.
59+
Index("idx_presets_user_id", "user_id"),
5660
)
5761

5862
id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
@@ -107,6 +111,9 @@ class Image(Base):
107111
"upload_state IN ('pending','ready','failed')",
108112
name="ck_images_upload_state",
109113
),
114+
# In Migration 003 angelegt — hier gespiegelt (Modell == Schema).
115+
Index("idx_images_user_id", "user_id"),
116+
Index("idx_images_state", "upload_state"),
110117
)
111118

112119
id: Mapped[UUID] = mapped_column(

backend/app/routers/auth.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from app.auth import current_user
1717
from app.database import get_db
1818
from app.keycloak_admin import delete_user as kc_delete_user
19-
from app.models import Image, Preset, PresetReport, User
19+
from app.models import Feedback, Image, ImageEdit, Preset, PresetReport, User
2020
from app.rate_limit import limiter
2121
from app.schemas import (
2222
CAMEL_BASE_CONFIG,
@@ -48,8 +48,15 @@ async def update_profile(
4848
user: User = Depends(current_user),
4949
db: AsyncSession = Depends(get_db),
5050
) -> ProfileOut:
51-
user.handle = payload.handle
52-
user.bio = payload.bio
51+
# PATCH ist partiell: nur tatsaechlich gesendete Felder anfassen, sonst
52+
# loescht ein PATCH, der z.B. nur bio schickt, stillschweigend den handle
53+
# (= oeffentlicher Marketplace-Creator-Name). exclude_unset trennt
54+
# "nicht gesendet" sauber von "explizit auf null gesetzt".
55+
data = payload.model_dump(exclude_unset=True)
56+
if "handle" in data:
57+
user.handle = data["handle"]
58+
if "bio" in data:
59+
user.bio = data["bio"]
5360
try:
5461
await db.commit()
5562
except IntegrityError:
@@ -137,6 +144,10 @@ class ImageExport(BaseModel):
137144
confirmed_at: datetime | None
138145
download_url: str
139146
download_url_expires_in: int
147+
# Persistierter Editor-Bearbeitungsstand (C1) — User-erzeugter Inhalt,
148+
# gehoert in einen vollstaendigen Art.-15/20-Export. None, wenn das Bild
149+
# nie im Editor bearbeitet/gespeichert wurde.
150+
edit_state: dict | None = None
140151

141152

142153
class PresetExport(BaseModel):
@@ -147,6 +158,9 @@ class PresetExport(BaseModel):
147158
name: str
148159
adjustments: dict
149160
masks: list
161+
# Crop/Straighten/Lens-Geometrie ist User-erzeugter Inhalt (Migration
162+
# 009) und gehoert in den Export.
163+
geometry: dict | None
150164
visibility: str
151165
genre: str | None
152166
description: str | None
@@ -169,6 +183,19 @@ class ReportExport(BaseModel):
169183
created_at: datetime
170184

171185

186+
class FeedbackExport(BaseModel):
187+
"""Vom User abgegebenes Feedback. Der Message-Text ist freier User-Inhalt
188+
und auskunfts-/uebertragbarkeitspflichtig (Art. 15 + 20) — und bleibt bei
189+
Loeschung nur anonymisiert erhalten, ist also gerade vorher exportwichtig."""
190+
model_config = CAMEL_OUT_CONFIG
191+
id: UUID
192+
kind: str
193+
message: str
194+
page: str | None
195+
status: str
196+
created_at: datetime
197+
198+
172199
class MeExport(BaseModel):
173200
model_config = CAMEL_BASE_CONFIG
174201
id: UUID
@@ -179,6 +206,7 @@ class MeExport(BaseModel):
179206
presets: list[PresetExport]
180207
images: list[ImageExport]
181208
submitted_reports: list[ReportExport]
209+
feedbacks: list[FeedbackExport]
182210

183211

184212
@router.get("/me/export", response_model=MeExport)
@@ -202,8 +230,20 @@ async def export_me(
202230
images_result = await db.execute(
203231
select(Image).where(Image.user_id == user.id).order_by(Image.created_at)
204232
)
233+
image_rows = images_result.scalars().all()
234+
235+
# Edit-States der eigenen Bilder in einer Query (image_id -> state).
236+
edits_by_image: dict[UUID, dict] = {}
237+
if image_rows:
238+
edit_rows = await db.execute(
239+
select(ImageEdit).where(
240+
ImageEdit.image_id.in_([img.id for img in image_rows])
241+
)
242+
)
243+
edits_by_image = {e.image_id: e.state for e in edit_rows.scalars().all()}
244+
205245
images: list[ImageExport] = []
206-
for img in images_result.scalars().all():
246+
for img in image_rows:
207247
url, expires = storage.presign_get(img.bucket_key)
208248
images.append(ImageExport(
209249
id=img.id,
@@ -215,6 +255,7 @@ async def export_me(
215255
confirmed_at=img.confirmed_at,
216256
download_url=url,
217257
download_url_expires_in=expires,
258+
edit_state=edits_by_image.get(img.id),
218259
))
219260

220261
reports_result = await db.execute(
@@ -224,6 +265,15 @@ async def export_me(
224265
)
225266
reports = [ReportExport.model_validate(r) for r in reports_result.scalars().all()]
226267

268+
feedback_result = await db.execute(
269+
select(Feedback)
270+
.where(Feedback.user_id == user.id)
271+
.order_by(Feedback.created_at)
272+
)
273+
feedbacks = [
274+
FeedbackExport.model_validate(f) for f in feedback_result.scalars().all()
275+
]
276+
227277
return MeExport(
228278
id=user.id,
229279
email=user.email,
@@ -233,4 +283,5 @@ async def export_me(
233283
presets=presets,
234284
images=images,
235285
submitted_reports=reports,
286+
feedbacks=feedbacks,
236287
)

backend/app/routers/images.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
66
from fastapi.concurrency import run_in_threadpool
7-
from sqlalchemy import select
7+
from sqlalchemy import func, select
88
from sqlalchemy.ext.asyncio import AsyncSession
99

1010
from app.auth import current_user
@@ -96,6 +96,24 @@ async def init_upload(
9696
detail=f"size_bytes ueber Maximum {settings.max_image_size_bytes}.",
9797
)
9898

99+
# Soft-Quota gegen Zombie-Pending-Rows: ein Client koennte bis zum
100+
# Rate-Limit pending Uploads anlegen, die erst der Janitor (>15 min) wegraeumt.
101+
pending_count = (
102+
await db.execute(
103+
select(func.count())
104+
.select_from(Image)
105+
.where(Image.user_id == user.id, Image.upload_state == "pending")
106+
)
107+
).scalar_one()
108+
if pending_count >= settings.max_pending_uploads_per_user:
109+
raise HTTPException(
110+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
111+
detail=(
112+
"Zu viele offene Uploads — bitte bestehende bestaetigen oder "
113+
"kurz warten."
114+
),
115+
)
116+
99117
image_id = uuid4()
100118
bucket_key = storage.make_key(user.id, image_id)
101119

@@ -133,6 +151,11 @@ async def confirm_upload(
133151
raise HTTPException(status_code=404, detail="Image nicht gefunden.")
134152
_ensure_owns_key(image, user)
135153

154+
# Bereits bestaetigt -> idempotent zurueckgeben, ohne erneute S3-Roundtrips
155+
# (HEAD + Magic-Byte-Read) und ohne confirmed_at zu ueberschreiben.
156+
if image.upload_state == "ready":
157+
return ImageOut.model_validate(image)
158+
136159
try:
137160
# boto3 ist blockierend -> Threadpool, sonst steht der Event-Loop.
138161
size = await run_in_threadpool(storage.head, image.bucket_key)
@@ -308,12 +331,19 @@ async def delete_image(
308331

309332
try:
310333
await run_in_threadpool(storage.delete, image.bucket_key)
311-
except Exception:
312-
# S3-Fehler: DB-Row als 'failed' markieren, kein Hard-Fail —
313-
# erneute DELETE-Calls funktionieren idempotent.
334+
except Exception as exc:
335+
# S3-Fehler: DB-Row als 'failed' markieren statt sie zu loeschen —
336+
# sonst entstuende ein verwaistes S3-Object ohne DB-Referenz, das nie
337+
# wieder aufgeraeumt wird. Der Janitor sammelt 'failed'-Rows aelter als
338+
# TTL nachtraeglich ein. Kontrolliertes 502 (statt nacktem 500), damit
339+
# der Client einen Retry-Hinweis bekommt; erneute DELETE-Calls
340+
# funktionieren idempotent.
314341
image.upload_state = "failed"
315342
await db.commit()
316-
raise
343+
raise HTTPException(
344+
status_code=status.HTTP_502_BAD_GATEWAY,
345+
detail="Speicher-Backend nicht erreichbar — bitte erneut versuchen.",
346+
) from exc
317347

318348
await db.delete(image)
319349
await db.commit()

backend/app/routers/marketplace.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ def _preview_url(
6767
db_session: AsyncSession, # noqa: ARG001 — Hook fuer spaetere Caches
6868
image: Image | None,
6969
) -> str | None:
70-
if image is None:
70+
# Nur bestaetigte Uploads als Vorschau ausliefern. Ein pending/failed
71+
# Object koennte (vor confirm) beliebige, nicht magic-byte-gepruefte Bytes
72+
# enthalten — die werden im Marketplace same-origin im <img> gerendert.
73+
if image is None or image.upload_state != "ready":
7174
return None
7275
url, _ = storage.presign_get(image.bucket_key)
7376
return url
@@ -237,6 +240,10 @@ async def fork_marketplace_preset(
237240
name=name,
238241
adjustments=dict(src.adjustments),
239242
masks=list(src.masks),
243+
# Geometrie (Crop/Straighten/Lens) ist Teil des Preset-Inhalts und
244+
# muss mitkopiert werden — sonst verliert der Fork stillschweigend
245+
# die Crop/Lens-Einstellungen des Originals.
246+
geometry=dict(src.geometry) if src.geometry else None,
240247
visibility="private",
241248
# Genre/Description/Preview NICHT mitkopieren — der Fork ist privat.
242249
)
@@ -271,19 +278,28 @@ async def report_marketplace_preset(
271278
await db.rollback()
272279
raise HTTPException(status_code=409, detail="Du hast dieses Preset bereits gemeldet.")
273280

274-
# Auto-Hide-Trigger.
281+
# Auto-Hide-Trigger. Die Preset-Row sperren (FOR UPDATE), bevor wir den
282+
# denormalisierten report_count read-modify-write aktualisieren — sonst
283+
# ueberschreiben zwei parallele Reports verschiedener Reporter den Counter
284+
# mit einem veralteten Snapshot (Lost-Update), und die Auto-Hide-Schwelle
285+
# kann verpasst werden, obwohl genug echte Reports existieren.
286+
locked = (
287+
await db.execute(
288+
select(Preset).where(Preset.id == preset.id).with_for_update()
289+
)
290+
).scalar_one()
275291
count = (
276292
await db.execute(
277293
select(func.count())
278294
.select_from(PresetReport)
279-
.where(PresetReport.preset_id == preset.id)
295+
.where(PresetReport.preset_id == locked.id)
280296
)
281297
).scalar_one()
282-
preset.report_count = int(count)
283-
if count >= REPORT_AUTOHIDE_THRESHOLD and preset.visibility == "public":
284-
preset.visibility = "private"
285-
preset.published_at = None
298+
locked.report_count = int(count)
299+
if count >= REPORT_AUTOHIDE_THRESHOLD and locked.visibility == "public":
300+
locked.visibility = "private"
301+
locked.published_at = None
286302
logger.warning(
287-
"preset %s auto-hidden after %d reports", preset.id, count
303+
"preset %s auto-hidden after %d reports", locked.id, count
288304
)
289305
await db.commit()

0 commit comments

Comments
 (0)