Private
Public Access
Backend: PushSubscription model/migration, VAPID config + `cli.py generate-vapid-keys`, push_service.send_push_to_user (upsert-by-endpoint subscribe/unsubscribe, auto-cleanup of expired 404/410 subscriptions), /api/push/* router, and ConnectionManager now tracks connected user IDs per room so chat.py can push only to offline members after broadcasting to online ones. Two test-infra bugs found and fixed along the way: send_push_to_user takes the caller's AsyncSession and is awaited inline rather than fired via asyncio.create_task with its own session (background tasks were outliving the test event loop); and the ws_client fixture now uses NullPool to eliminate a connection-pool checkout race that was failing WS tests intermittently. Frontend: service worker rebuilt with vite-plugin-pwa's injectManifest strategy (custom src/sw.ts) so it can add push/notificationclick handlers alongside the existing precaching and StaleWhileRevalidate routes ported over from generateSW. New subscribe/unsubscribe flow (lib/push.ts, api/push.ts) with a toggle in the account menu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
99 lines
3.6 KiB
Python
99 lines
3.6 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
from pywebpush import WebPushException, webpush
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.models import PushSubscription
|
|
from app.schemas.push import PushSubscriptionCreate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def subscribe(
|
|
db: AsyncSession, user_id: uuid.UUID, data: PushSubscriptionCreate
|
|
) -> PushSubscription:
|
|
# Upsert by endpoint: the same device/browser re-subscribing (e.g. after
|
|
# a key rotation, or logging in as someone else on a shared device)
|
|
# updates the existing row rather than erroring on the unique constraint.
|
|
stmt = (
|
|
pg_insert(PushSubscription)
|
|
.values(
|
|
user_id=user_id,
|
|
endpoint=data.endpoint,
|
|
p256dh_key=data.keys.p256dh,
|
|
auth_key=data.keys.auth,
|
|
)
|
|
.on_conflict_do_update(
|
|
index_elements=[PushSubscription.endpoint],
|
|
set_={
|
|
"user_id": user_id,
|
|
"p256dh_key": data.keys.p256dh,
|
|
"auth_key": data.keys.auth,
|
|
},
|
|
)
|
|
.returning(PushSubscription)
|
|
)
|
|
result = await db.execute(stmt)
|
|
await db.commit()
|
|
return result.scalar_one()
|
|
|
|
|
|
async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> None:
|
|
await db.execute(
|
|
delete(PushSubscription).where(
|
|
PushSubscription.user_id == user_id, PushSubscription.endpoint == endpoint
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
def _send_one(subscription: PushSubscription, payload: dict) -> None:
|
|
webpush(
|
|
subscription_info={
|
|
"endpoint": subscription.endpoint,
|
|
"keys": {"p256dh": subscription.p256dh_key, "auth": subscription.auth_key},
|
|
},
|
|
data=json.dumps(payload),
|
|
vapid_private_key=settings.vapid_private_key,
|
|
vapid_claims={"sub": settings.vapid_subject},
|
|
)
|
|
|
|
|
|
async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict) -> None:
|
|
"""Called (awaited) from the WS handler after broadcasting to connected
|
|
clients, so it never delays delivery to anyone actually online. Runs
|
|
sequentially against the caller's session rather than firing background
|
|
asyncio.create_task()s -- those can easily outlive the request/test event
|
|
loop they were created on, and AsyncSession isn't safe to touch from two
|
|
coroutines concurrently, so a fire-and-forget task per subscription would
|
|
risk exactly that. Each webpush() call itself still runs off the event
|
|
loop via asyncio.to_thread (pywebpush is synchronous)."""
|
|
if not settings.vapid_private_key:
|
|
logger.debug("VAPID keys not configured; skipping push to %s", user_id)
|
|
return
|
|
|
|
result = await db.execute(
|
|
select(PushSubscription).where(PushSubscription.user_id == user_id)
|
|
)
|
|
subscriptions = list(result.scalars().all())
|
|
|
|
for subscription in subscriptions:
|
|
try:
|
|
await asyncio.to_thread(_send_one, subscription, payload)
|
|
except WebPushException as exc:
|
|
status = exc.response.status_code if exc.response is not None else None
|
|
if status in (404, 410):
|
|
# Subscription is gone (browser unsubscribed, expired, etc.)
|
|
await db.execute(
|
|
delete(PushSubscription).where(PushSubscription.id == subscription.id)
|
|
)
|
|
await db.commit()
|
|
else:
|
|
logger.warning("Push delivery failed for %s: %s", subscription.id, exc)
|