banyaro/backend/routes/push.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 — Push-Notification Routes"""
import os
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from pywebpush import webpush, WebPushException
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_CONTACT = os.getenv("VAPID_CONTACT", "mailto:admin@banyaro.app")
# ------------------------------------------------------------------
# GET /api/push/vapid-key — Public Key für Frontend
# ------------------------------------------------------------------
@router.get("/vapid-key")
async def get_vapid_key():
if not VAPID_PUBLIC_KEY:
raise HTTPException(503, "Push-Notifications nicht konfiguriert.")
return {"vapid_public_key": VAPID_PUBLIC_KEY}
# ------------------------------------------------------------------
# POST /api/push/subscribe — Subscription speichern
# ------------------------------------------------------------------
class PushSubscription(BaseModel):
endpoint: str = Field(..., max_length=2000)
keys: dict # { p256dh, auth }
expirationTime: Optional[int] = None
@router.post("/subscribe", status_code=201)
async def subscribe(data: PushSubscription, user=Depends(get_current_user)):
p256dh = data.keys.get("p256dh", "")
auth = data.keys.get("auth", "")
with db() as conn:
conn.execute("""
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
p256dh=excluded.p256dh,
auth=excluded.auth,
user_id=excluded.user_id
""", (user["id"], data.endpoint, p256dh, auth))
logger.info(f"Push-Subscription gespeichert für user {user['id']}")
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/push/subscribe — Subscription entfernen
# ------------------------------------------------------------------
@router.delete("/subscribe")
async def unsubscribe(user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"DELETE FROM push_subscriptions WHERE user_id=?", (user["id"],)
)
return {"ok": True}
# ------------------------------------------------------------------
# Interne Hilfsfunktion: Push an einen oder alle User schicken
# ------------------------------------------------------------------
def send_push(subscription_row, payload: dict) -> bool:
"""Schickt eine Push-Notification. Gibt True bei Erfolg zurück."""
if not VAPID_PRIVATE_KEY:
return False
try:
webpush(
subscription_info={
"endpoint": subscription_row["endpoint"],
"keys": {
"p256dh": subscription_row["p256dh"],
"auth": subscription_row["auth"],
},
},
data=json.dumps(payload),
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={"sub": VAPID_CONTACT},
)
return True
except WebPushException as e:
status = e.response.status_code if e.response else 0
logger.warning(f"Push fehlgeschlagen (HTTP {status}): {e}")
# 410 Gone = Subscription abgelaufen → aus DB löschen
if status == 410:
with db() as conn:
conn.execute(
"DELETE FROM push_subscriptions WHERE endpoint=?",
(subscription_row["endpoint"],)
)
return False
def send_push_to_user(user_id: int, payload: dict):
"""Schickt Push an alle Subscriptions eines Users und speichert Notification in DB."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM push_subscriptions WHERE user_id=?", (user_id,)
).fetchall()
sent = 0
for row in rows:
if send_push(row, payload):
sent += 1
# Notification in DB persistieren (unabhängig vom Push-Versand)
notif_type = payload.get("type", "info")
notif_title = payload.get("title", "Benachrichtigung")
notif_body = payload.get("body") or payload.get("message")
notif_data = json.dumps({k: v for k, v in payload.items()
if k not in ("type", "title", "body", "message")}) or None
with db() as conn:
conn.execute(
"""INSERT INTO notifications (user_id, type, title, body, data)
VALUES (?, ?, ?, ?, ?)""",
(user_id, notif_type, notif_title, notif_body, notif_data),
)
return sent
def send_push_to_all(payload: dict):
"""Schickt Push an alle abonnierten User (z.B. Giftköder-Alarm)."""
with db() as conn:
rows = conn.execute("SELECT * FROM push_subscriptions").fetchall()
sent = 0
for row in rows:
if send_push(row, payload):
sent += 1
logger.info(f"Push an {sent}/{len(rows)} Subscriptions gesendet.")
return sent
def send_push_nearby(lat: float, lon: float, radius_m: float, payload: dict):
"""Schickt Push nur an User deren letzter bekannter Standort innerhalb radius_m liegt.
User ohne gespeicherten Standort werden übersprungen."""
import math
def _dist(la1, lo1, la2, lo2):
R = 6_371_000
p1, p2 = math.radians(la1), math.radians(la2)
a = math.sin(math.radians(la2-la1)/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(math.radians(lo2-lo1)/2)**2
return 2*R*math.asin(math.sqrt(a))
with db() as conn:
rows = conn.execute(
"SELECT * FROM push_subscriptions WHERE last_lat IS NOT NULL"
).fetchall()
sent = 0
for row in rows:
if _dist(lat, lon, row["last_lat"], row["last_lon"]) <= radius_m:
if send_push(row, payload):
sent += 1
logger.info(f"Push nearby ({radius_m/1000:.0f}km): {sent}/{len(rows)} gesendet.")
return sent