banyaro/backend/routes/lost.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

165 lines
6 KiB
Python

"""BAN YARO — Verlorener Hund Routes"""
import os, uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from routes.push import send_push_to_all
from media_utils import convert_media
from math_utils import haversine_m
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LostDogCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
beschreibung: str = Field(..., min_length=3, max_length=5000)
lat: float
lon: float
dog_id: Optional[int] = None
client_time: Optional[str] = Field(None, max_length=64)
# ------------------------------------------------------------------
# GET /api/lost — aktive Meldungen (optional nach Distanz gefiltert)
# ------------------------------------------------------------------
@router.get("")
async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
radius_km: float = 25):
with db() as conn:
rows = conn.execute(
"""SELECT l.*, u.name AS melder_name
FROM lost_dogs l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.is_active = 1
ORDER BY l.created_at DESC"""
).fetchall()
results = []
for r in rows:
entry = dict(r)
if lat is not None and lon is not None:
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
if dist > radius_km * 1000:
continue
entry["distanz_m"] = round(dist)
results.append(entry)
if lat is not None and lon is not None:
results.sort(key=lambda x: x.get("distanz_m", 0))
return results
# ------------------------------------------------------------------
# POST /api/lost — Hund vermisst melden (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_lost(data: LostDogCreate, user=Depends(get_current_user)):
with db() as conn:
ct = safe_client_time(data.client_time)
conn.execute(
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.name, data.rasse,
data.beschreibung, data.lat, data.lon, ct)
)
row = conn.execute(
"SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
entry = dict(row)
send_push_to_all({
"type": "lost_dog_alert",
"title": f"🔍 {data.name} wird vermisst!",
"body": f"{data.rasse or 'Hund'} in deiner Nähe vermisst. Hilf bei der Suche!",
"tag": f"lost-{entry['id']}",
"data": {"page": "lost"},
})
return entry
# ------------------------------------------------------------------
# POST /api/lost/{id}/foto — Foto hochladen (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/foto")
async def upload_foto(
lost_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
entry = conn.execute(
"SELECT id FROM lost_dogs WHERE id=? AND user_id=?",
(lost_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"lost_{lost_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "lost", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
foto_url = f"/media/lost/{filename}"
with db() as conn:
conn.execute("UPDATE lost_dogs SET foto_url=? WHERE id=?", (foto_url, lost_id))
return {"foto_url": foto_url}
# ------------------------------------------------------------------
# POST /api/lost/{id}/found — als gefunden markieren (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/found")
async def mark_found(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_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.")
conn.execute(
"""UPDATE lost_dogs
SET is_active=0, gefunden_at=datetime('now')
WHERE id=?""",
(lost_id,)
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/lost/{id} — eigene Meldung löschen (Login)
# ------------------------------------------------------------------
@router.delete("/{lost_id}", status_code=204)
async def delete_lost(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_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.")
conn.execute("DELETE FROM lost_dogs WHERE id=?", (lost_id,))
return None