Private
Public Access
Phase 4: Web Push notifications (pywebpush + VAPID)
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>
This commit is contained in:
@@ -6,6 +6,7 @@ created by an operator running this script directly on the app server.
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -33,6 +34,34 @@ async def _create_user(username: str, email: str, password: str, is_admin: bool)
|
||||
print(f"Created user {username!r} (id={user.id}, admin={is_admin})")
|
||||
|
||||
|
||||
def _generate_vapid_keys() -> None:
|
||||
# py_vapid works in DER/PEM internally, but both pywebpush's
|
||||
# vapid_private_key argument and the browser's PushManager
|
||||
# applicationServerKey expect base64url-encoded *raw* key bytes -- the
|
||||
# format used in every Web Push tutorial/example. Encode explicitly
|
||||
# rather than relying on py_vapid's own (PEM-oriented) save helpers.
|
||||
from py_vapid import Vapid02
|
||||
|
||||
vapid = Vapid02()
|
||||
vapid.generate_keys()
|
||||
|
||||
private_raw = vapid.private_key.private_numbers().private_value.to_bytes(32, "big")
|
||||
private_b64 = base64.urlsafe_b64encode(private_raw).decode().rstrip("=")
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
public_raw = vapid.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
public_b64 = base64.urlsafe_b64encode(public_raw).decode().rstrip("=")
|
||||
|
||||
print("Add these to backend/.env:")
|
||||
print(f"VAPID_PUBLIC_KEY={public_b64}")
|
||||
print(f"VAPID_PRIVATE_KEY={private_b64}")
|
||||
print("VAPID_SUBJECT=mailto:you@example.com")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.cli")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -43,10 +72,14 @@ def main() -> None:
|
||||
create_user.add_argument("password")
|
||||
create_user.add_argument("--admin", action="store_true", help="Grant is_site_admin")
|
||||
|
||||
subparsers.add_parser("generate-vapid-keys", help="Generate a VAPID key pair for push notifications")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "create-user":
|
||||
asyncio.run(_create_user(args.username, args.email, args.password, args.admin))
|
||||
elif args.command == "generate-vapid-keys":
|
||||
_generate_vapid_keys()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,5 +9,12 @@ class Settings(BaseSettings):
|
||||
session_https_only: bool = True
|
||||
session_max_age_seconds: int = 60 * 60 * 24 * 14
|
||||
|
||||
# Optional: push notifications are skipped (logged, not an error) if
|
||||
# unset, so existing deployments don't have to configure this to keep
|
||||
# running. Generate a pair with `python -m app.cli generate-vapid-keys`.
|
||||
vapid_public_key: str | None = None
|
||||
vapid_private_key: str | None = None
|
||||
vapid_subject: str = "mailto:admin@example.com"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, health, invites, rooms
|
||||
from app.routers import auth, health, invites, push, rooms
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
|
||||
@@ -24,6 +24,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(auth.router)
|
||||
app.include_router(rooms.router)
|
||||
app.include_router(invites.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -2,6 +2,7 @@ from app.models.base import Base
|
||||
from app.models.invite import InviteStatus, RoomInvite
|
||||
from app.models.membership import RoomMembership, RoomRole
|
||||
from app.models.message import Message
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.user import User
|
||||
|
||||
@@ -14,4 +15,5 @@ __all__ = [
|
||||
"Message",
|
||||
"RoomInvite",
|
||||
"InviteStatus",
|
||||
"PushSubscription",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
endpoint: Mapped[str] = mapped_column(String(1024), unique=True, index=True, nullable=False)
|
||||
p256dh_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
auth_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user = relationship("User")
|
||||
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.push import (
|
||||
PushSubscriptionCreate,
|
||||
PushUnsubscribeRequest,
|
||||
VapidPublicKeyRead,
|
||||
)
|
||||
from app.services.push_service import subscribe, unsubscribe
|
||||
|
||||
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||
|
||||
|
||||
@router.get("/vapid-public-key", response_model=VapidPublicKeyRead)
|
||||
async def get_vapid_public_key(current_user: User = Depends(get_current_user)):
|
||||
return VapidPublicKeyRead(public_key=settings.vapid_public_key)
|
||||
|
||||
|
||||
@router.post("/subscribe", status_code=204)
|
||||
async def subscribe_endpoint(
|
||||
data: PushSubscriptionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
await subscribe(db, current_user.id, data)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.delete("/subscribe", status_code=204)
|
||||
async def unsubscribe_endpoint(
|
||||
data: PushUnsubscribeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
await unsubscribe(db, current_user.id, data.endpoint)
|
||||
return Response(status_code=204)
|
||||
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PushSubscriptionKeys(BaseModel):
|
||||
p256dh: str
|
||||
auth: str
|
||||
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str
|
||||
keys: PushSubscriptionKeys
|
||||
|
||||
|
||||
class PushUnsubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
|
||||
|
||||
class VapidPublicKeyRead(BaseModel):
|
||||
public_key: str | None
|
||||
@@ -0,0 +1,98 @@
|
||||
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)
|
||||
+32
-2
@@ -6,8 +6,10 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, User
|
||||
from app.models import Room, RoomMembership, User
|
||||
from app.services.message_service import create_message
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
@@ -29,6 +31,31 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession,
|
||||
manager: ConnectionManager,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
content: str,
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
offline_ids = member_ids - manager.connected_user_ids(room_id)
|
||||
if not offline_ids:
|
||||
return
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
payload = {
|
||||
"title": f"#{room.name}" if room else "New message",
|
||||
"body": f"{sender.username}: {content}"[:120],
|
||||
"room_id": str(room_id),
|
||||
}
|
||||
for user_id in offline_ids:
|
||||
await send_push_to_user(db, user_id, payload)
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
@@ -63,7 +90,7 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
manager.join(envelope.room_id, websocket)
|
||||
manager.join(envelope.room_id, websocket, user.id)
|
||||
joined_rooms.add(envelope.room_id)
|
||||
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
||||
|
||||
@@ -100,6 +127,9 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
"created_at": message.created_at.isoformat(),
|
||||
},
|
||||
)
|
||||
await _notify_offline_members(
|
||||
db, manager, envelope.room_id, user, envelope.content
|
||||
)
|
||||
|
||||
else:
|
||||
await websocket.send_json(
|
||||
|
||||
@@ -13,9 +13,14 @@ class ConnectionManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
|
||||
# A single connection can be joined to multiple rooms at once (one
|
||||
# `join` message per room over the same socket), so this is keyed on
|
||||
# the socket alone, not per-room.
|
||||
self._ws_user: dict[WebSocket, uuid.UUID] = {}
|
||||
|
||||
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||
def join(self, room_id: uuid.UUID, websocket: WebSocket, user_id: uuid.UUID) -> None:
|
||||
self._rooms[room_id].add(websocket)
|
||||
self._ws_user[websocket] = user_id
|
||||
|
||||
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||
self._rooms[room_id].discard(websocket)
|
||||
@@ -25,6 +30,15 @@ class ConnectionManager:
|
||||
def leave_all(self, websocket: WebSocket) -> None:
|
||||
for room_id in list(self._rooms.keys()):
|
||||
self.leave(room_id, websocket)
|
||||
self._ws_user.pop(websocket, None)
|
||||
|
||||
def connected_user_ids(self, room_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
"""Users (not just sockets) with an active connection to this room --
|
||||
used to skip push notifications for anyone already watching, per
|
||||
ARCHITECTURE.md's "members with no active connection" push flow."""
|
||||
return {
|
||||
self._ws_user[ws] for ws in self._rooms.get(room_id, ()) if ws in self._ws_user
|
||||
}
|
||||
|
||||
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||
for websocket in list(self._rooms.get(room_id, ())):
|
||||
|
||||
Reference in New Issue
Block a user