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

114 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BAN YARO — Hilfe / FAQ Routes"""
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user_optional, require_admin
from cache import ttl_cache
router = APIRouter()
# ------------------------------------------------------------------
# Öffentliche, aktive FAQ-Liste statisch, 1h TTL-Cache.
# Admin-Pfad (?all=1) wird NICHT gecached.
# Wird bei jedem schreibenden Admin-Endpoint unten invalidiert.
# ------------------------------------------------------------------
@ttl_cache(ttl=3600)
def _load_active_help_articles() -> list[dict]:
with db() as conn:
rows = conn.execute(
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
"FROM help_articles "
"WHERE aktiv = 1 "
"ORDER BY kategorie, sort_order, id"
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ArticleCreate(BaseModel):
kategorie: str = Field(..., max_length=100)
frage: str = Field(..., min_length=3, max_length=500)
antwort: str = Field(..., min_length=3, max_length=10000)
sort_order: int = 0
aktiv: int = 1
class ArticleUpdate(BaseModel):
kategorie: Optional[str] = Field(None, max_length=100)
frage: Optional[str] = Field(None, max_length=500)
antwort: Optional[str] = Field(None, max_length=10000)
sort_order: Optional[int] = None
aktiv: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/help — öffentlich (nur aktive); ?all=1 für Admins
# ------------------------------------------------------------------
@router.get("")
def get_help(
all: int = Query(0),
user=Depends(get_current_user_optional),
):
is_admin = user and user.get("rolle") == "admin"
show_all = all == 1 and is_admin
if show_all:
with db() as conn:
rows = conn.execute(
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
"FROM help_articles "
"ORDER BY kategorie, sort_order, id"
).fetchall()
return [dict(r) for r in rows]
# Öffentliche, aktive Artikel kommen aus dem Cache
return _load_active_help_articles()
# ------------------------------------------------------------------
# POST /api/help — Admin: neuen Artikel anlegen
# ------------------------------------------------------------------
@router.post("", status_code=201)
def create_article(body: ArticleCreate, admin=Depends(require_admin)):
with db() as conn:
cur = conn.execute(
"INSERT INTO help_articles (kategorie, frage, antwort, sort_order, aktiv) "
"VALUES (?, ?, ?, ?, ?)",
(body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv),
)
_load_active_help_articles.cache_clear()
return {"ok": True, "id": cur.lastrowid}
# ------------------------------------------------------------------
# PATCH /api/help/{article_id} — Admin: Artikel bearbeiten
# ------------------------------------------------------------------
@router.patch("/{article_id}")
def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_admin)):
updates = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not updates:
return {"ok": True}
set_clause = ", ".join(f"{k}=?" for k in updates)
with db() as conn:
conn.execute(
f"UPDATE help_articles SET {set_clause} WHERE id=?",
(*updates.values(), article_id),
)
_load_active_help_articles.cache_clear()
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/help/{article_id} — Admin: Artikel löschen
# ------------------------------------------------------------------
@router.delete("/{article_id}")
def delete_article(article_id: int, admin=Depends(require_admin)):
with db() as conn:
conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,))
_load_active_help_articles.cache_clear()
return {"ok": True}