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
165 lines
6 KiB
Python
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
|