banyaro/backend/routes/poison.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00

183 lines
6.6 KiB
Python

"""BAN YARO — Giftköder-Alarm Routes"""
import os, uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_nearby
from media_utils import convert_media
from ratelimit import check as rl_check
from math_utils import haversine_m
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PoisonCreate(BaseModel):
lat: float
lon: float
beschreibung: Optional[str] = Field(None, max_length=2000)
typ: str = Field("unbekannt", max_length=50)
class PoisonResolve(BaseModel):
grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes
# ------------------------------------------------------------------
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
# ------------------------------------------------------------------
@router.get("")
async def list_poison(lat: float, lon: float, radius: int = 5000):
now = datetime.utcnow().isoformat()
with db() as conn:
rows = conn.execute(
"""SELECT p.*, u.name AS melder_name
FROM poison p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.geloest = 0
AND p.expires_at > ?
ORDER BY p.created_at DESC""",
(now,)
).fetchall()
results = []
for r in rows:
entry = dict(r)
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
if dist <= radius:
entry["distanz_m"] = round(dist)
results.append(entry)
results.sort(key=lambda x: x["distanz_m"])
return results
# ------------------------------------------------------------------
# POST /api/poison — neue Meldung (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_poison(data: PoisonCreate, request: Request,
user=Depends(get_current_user)):
rl_check(request, max_requests=3, window_seconds=3600, key="poison")
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
with db() as conn:
conn.execute(
"""INSERT INTO poison (user_id, lat, lon, beschreibung, typ, expires_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], data.lat, data.lon, data.beschreibung, data.typ, expires_at)
)
row = conn.execute(
"SELECT * FROM poison WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
entry = dict(row)
# Push nur an User im Umkreis von 30 km
send_push_nearby(data.lat, data.lon, 30_000, {
"type": "poison_alert",
"title": "⚠️ Giftköder gemeldet!",
"body": f"{data.typ or 'Verdächtiger Fund'} in deiner Nähe — bitte vorsichtig sein.",
"data": {"page": "poison", "id": entry["id"]},
})
return entry
# ------------------------------------------------------------------
# POST /api/poison/{id}/confirm — Community-Bestätigung
# Verlängert die Laufzeit um 7 weitere Tage ab jetzt
# ------------------------------------------------------------------
@router.post("/{poison_id}/confirm")
async def confirm_poison(poison_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
if dict(entry)["user_id"] == user["id"]:
raise HTTPException(400, "Eigene Meldung kann nicht bestätigt werden.")
new_expires = (datetime.utcnow() + timedelta(days=7)).isoformat()
conn.execute(
"""UPDATE poison
SET bestaetigt=1, bestaetigt_von=?, expires_at=?
WHERE id=?""",
(user["id"], new_expires, poison_id)
)
row = conn.execute("SELECT * FROM poison WHERE id=?", (poison_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /api/poison/{id}/resolve — als erledigt markieren
# Nur der Melder selbst oder ein Admin
# ------------------------------------------------------------------
@router.post("/{poison_id}/resolve")
async def resolve_poison(
poison_id: int,
data: PoisonResolve = PoisonResolve(),
user=Depends(get_current_user)
):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
# Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten
conn.execute(
"""UPDATE poison
SET geloest=1,
geloest_von=?,
geloest_at=datetime('now'),
geloest_grund=?
WHERE id=?""",
(user["id"], data.grund, poison_id)
)
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/poison/{id}/photo — Foto nachträglich anhängen
# Nur vom Melder selbst
# ------------------------------------------------------------------
@router.post("/{poison_id}/photo")
async def upload_photo(
poison_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
entry = conn.execute(
"SELECT id FROM poison WHERE id=? AND user_id=?",
(poison_id, user["id"])
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
data, ext = convert_media(await file.read(), file.filename or "")
if not ext:
ext = ".jpg"
filename = f"poison_{poison_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "poison", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
foto_url = f"/media/poison/{filename}"
with db() as conn:
conn.execute("UPDATE poison SET foto_url=? WHERE id=?", (foto_url, poison_id))
return {"foto_url": foto_url}