Feat: Push-Notifications vollständig implementiert

- push.py: /vapid-key, /subscribe, /unsubscribe + send_push_to_user/all Helpers
- VAPID Keys in docker-compose.yml (Public + Private)
- Giftköder-Meldung löst automatisch Push an alle Subscriber aus
- ON CONFLICT(endpoint) für idempotentes Re-Subscribe
- Abgelaufene Subscriptions werden bei HTTP 410 auto-gelöscht
This commit is contained in:
rene 2026-04-13 20:47:51 +02:00
parent b8a5dc7a66
commit c721d051c8
3 changed files with 142 additions and 3 deletions

View file

@ -7,6 +7,7 @@ from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_all
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -84,7 +85,17 @@ async def report_poison(data: PoisonCreate, user=Depends(get_current_user)):
"SELECT * FROM poison WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
return dict(row)
entry = dict(row)
# Push-Notification an alle User
send_push_to_all({
"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
# ------------------------------------------------------------------

View file

@ -1,3 +1,128 @@
"""BAN YARO — push Routes (Stub, wird ausgebaut)"""
from fastapi import APIRouter
"""BAN YARO — Push-Notification Routes"""
import os
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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
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."""
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
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

View file

@ -12,6 +12,9 @@ services:
environment:
- DB_PATH=/data/banyaro.db
- MEDIA_DIR=/data/media
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
- VAPID_CONTACT=mailto:admin@banyaro.app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s