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:
@@ -1,3 +1,9 @@
|
|||||||
DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp
|
DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp
|
||||||
SESSION_SECRET=change-me-to-a-long-random-string
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
SESSION_HTTPS_ONLY=false
|
SESSION_HTTPS_ONLY=false
|
||||||
|
|
||||||
|
# Optional: push notifications are skipped if unset. Generate with:
|
||||||
|
# python -m app.cli generate-vapid-keys
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|||||||
+35
-9
@@ -1,9 +1,10 @@
|
|||||||
# KeepItTalking backend (Phase 1 + 2)
|
# KeepItTalking backend (Phase 1 + 2 + 4)
|
||||||
|
|
||||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, room CRUD
|
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, room CRUD
|
||||||
(open and private), room roles (owner/admin/member) and invites, and a
|
(open and private), room roles (owner/admin/member) and invites, a
|
||||||
single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
|
single-instance WebSocket chat endpoint, and Web Push notifications for
|
||||||
full system design and the phased build plan.
|
offline room members. See `../ARCHITECTURE.md` for the full system design
|
||||||
|
and the phased build plan.
|
||||||
|
|
||||||
This is an **invite-only site**: there is no public registration endpoint.
|
This is an **invite-only site**: there is no public registration endpoint.
|
||||||
Accounts are created by an operator on the app server — see step 4 below.
|
Accounts are created by an operator on the app server — see step 4 below.
|
||||||
@@ -55,7 +56,17 @@ There's no public sign-up. Create accounts directly with the CLI (add
|
|||||||
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
|
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Run the dev server
|
### 5. (Optional) Set up push notifications
|
||||||
|
|
||||||
|
Push works without any setup — `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` are
|
||||||
|
unset by default and push delivery is silently skipped. To enable it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m app.cli generate-vapid-keys
|
||||||
|
# paste the three printed lines into backend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Run the dev server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/uvicorn app.main:app --reload
|
.venv/bin/uvicorn app.main:app --reload
|
||||||
@@ -63,7 +74,7 @@ There's no public sign-up. Create accounts directly with the CLI (add
|
|||||||
|
|
||||||
API docs: http://localhost:8000/docs. WebSocket chat endpoint: `ws://localhost:8000/ws/chat`.
|
API docs: http://localhost:8000/docs. WebSocket chat endpoint: `ws://localhost:8000/ws/chat`.
|
||||||
|
|
||||||
### 6. Run tests
|
### 7. Run tests
|
||||||
|
|
||||||
Tests run against a real Postgres database (`chatapp_test` by default — native
|
Tests run against a real Postgres database (`chatapp_test` by default — native
|
||||||
`ENUM`/`UUID` types aren't faithfully reproduced by SQLite), with each test
|
`ENUM`/`UUID` types aren't faithfully reproduced by SQLite), with each test
|
||||||
@@ -82,17 +93,32 @@ app/
|
|||||||
database.py async engine/session, get_db() dependency
|
database.py async engine/session, get_db() dependency
|
||||||
dependencies.py get_current_user, require_room_member, require_room_role
|
dependencies.py get_current_user, require_room_member, require_room_role
|
||||||
security.py argon2 password hashing
|
security.py argon2 password hashing
|
||||||
cli.py `python -m app.cli create-user` (account provisioning)
|
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||||
messages, room_invites)
|
messages, room_invites, push_subscriptions)
|
||||||
schemas/ Pydantic request/response models
|
schemas/ Pydantic request/response models
|
||||||
routers/ auth, rooms, invites, health
|
routers/ auth, rooms, invites, push, health
|
||||||
services/ business logic called by routers
|
services/ business logic called by routers
|
||||||
ws/ WebSocket connection manager + /ws/chat handler
|
ws/ WebSocket connection manager + /ws/chat handler
|
||||||
alembic/ migrations
|
alembic/ migrations
|
||||||
tests/ pytest + httpx/TestClient tests
|
tests/ pytest + httpx/TestClient tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Push notifications (Phase 4)
|
||||||
|
|
||||||
|
`POST /api/push/subscribe` (upserts by `endpoint`) / `DELETE /api/push/subscribe`
|
||||||
|
manage a user's `push_subscriptions` rows; `GET /api/push/vapid-public-key` gives
|
||||||
|
the frontend the key it needs for `PushManager.subscribe()`. On every chat
|
||||||
|
message, `app/ws/chat.py` computes `room members - ConnectionManager.
|
||||||
|
connected_user_ids(room_id)` (who's actually connected to *that room* right
|
||||||
|
now, tracked alongside the existing WebSocket registry) and sends each
|
||||||
|
offline member a push via `pywebpush`, awaited inline against the same
|
||||||
|
request-scoped session rather than fired as a background task — the
|
||||||
|
broadcast to online members already happened by that point, so nothing
|
||||||
|
online-facing is delayed, and it sidesteps `asyncio.create_task()`s outliving
|
||||||
|
the session/event loop they were created on. An expired/invalid subscription
|
||||||
|
(pywebpush 404/410) is deleted automatically.
|
||||||
|
|
||||||
## Room roles and invites (Phase 2)
|
## Room roles and invites (Phase 2)
|
||||||
|
|
||||||
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
|
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""push subscriptions
|
||||||
|
|
||||||
|
Revision ID: 8a55c5254edb
|
||||||
|
Revises: 0699d20789b3
|
||||||
|
Create Date: 2026-08-14 06:24:35.973594
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8a55c5254edb'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '0699d20789b3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('push_subscriptions',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('endpoint', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('p256dh_key', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('auth_key', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_push_subscriptions_endpoint'), 'push_subscriptions', ['endpoint'], unique=True)
|
||||||
|
op.create_index(op.f('ix_push_subscriptions_user_id'), 'push_subscriptions', ['user_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_push_subscriptions_user_id'), table_name='push_subscriptions')
|
||||||
|
op.drop_index(op.f('ix_push_subscriptions_endpoint'), table_name='push_subscriptions')
|
||||||
|
op.drop_table('push_subscriptions')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -6,6 +6,7 @@ created by an operator running this script directly on the app server.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
|
||||||
from pydantic import ValidationError
|
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})")
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(prog="python -m app.cli")
|
parser = argparse.ArgumentParser(prog="python -m app.cli")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
@@ -43,10 +72,14 @@ def main() -> None:
|
|||||||
create_user.add_argument("password")
|
create_user.add_argument("password")
|
||||||
create_user.add_argument("--admin", action="store_true", help="Grant is_site_admin")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "create-user":
|
if args.command == "create-user":
|
||||||
asyncio.run(_create_user(args.username, args.email, args.password, args.admin))
|
asyncio.run(_create_user(args.username, args.email, args.password, args.admin))
|
||||||
|
elif args.command == "generate-vapid-keys":
|
||||||
|
_generate_vapid_keys()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -9,5 +9,12 @@ class Settings(BaseSettings):
|
|||||||
session_https_only: bool = True
|
session_https_only: bool = True
|
||||||
session_max_age_seconds: int = 60 * 60 * 24 * 14
|
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()
|
settings = Settings()
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
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.chat import router as ws_router
|
||||||
from app.ws.connection_manager import ConnectionManager
|
from app.ws.connection_manager import ConnectionManager
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(rooms.router)
|
app.include_router(rooms.router)
|
||||||
app.include_router(invites.router)
|
app.include_router(invites.router)
|
||||||
|
app.include_router(push.router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from app.models.base import Base
|
|||||||
from app.models.invite import InviteStatus, RoomInvite
|
from app.models.invite import InviteStatus, RoomInvite
|
||||||
from app.models.membership import RoomMembership, RoomRole
|
from app.models.membership import RoomMembership, RoomRole
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
|
from app.models.push_subscription import PushSubscription
|
||||||
from app.models.room import Room
|
from app.models.room import Room
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
@@ -14,4 +15,5 @@ __all__ = [
|
|||||||
"Message",
|
"Message",
|
||||||
"RoomInvite",
|
"RoomInvite",
|
||||||
"InviteStatus",
|
"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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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.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"])
|
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
|
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")
|
@router.websocket("/ws/chat")
|
||||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||||
user_id_raw = websocket.session.get("user_id")
|
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"}
|
{"type": "error", "detail": "Not a member of this room"}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
manager.join(envelope.room_id, websocket)
|
manager.join(envelope.room_id, websocket, user.id)
|
||||||
joined_rooms.add(envelope.room_id)
|
joined_rooms.add(envelope.room_id)
|
||||||
await websocket.send_json({"type": "joined", "room_id": str(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(),
|
"created_at": message.created_at.isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await _notify_offline_members(
|
||||||
|
db, manager, envelope.room_id, user, envelope.content
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ class ConnectionManager:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
|
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._rooms[room_id].add(websocket)
|
||||||
|
self._ws_user[websocket] = user_id
|
||||||
|
|
||||||
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||||
self._rooms[room_id].discard(websocket)
|
self._rooms[room_id].discard(websocket)
|
||||||
@@ -25,6 +30,15 @@ class ConnectionManager:
|
|||||||
def leave_all(self, websocket: WebSocket) -> None:
|
def leave_all(self, websocket: WebSocket) -> None:
|
||||||
for room_id in list(self._rooms.keys()):
|
for room_id in list(self._rooms.keys()):
|
||||||
self.leave(room_id, websocket)
|
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:
|
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||||
for websocket in list(self._rooms.get(room_id, ())):
|
for websocket in list(self._rooms.get(room_id, ())):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.6",
|
"pydantic-settings>=2.6",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
"itsdangerous>=2.2",
|
"itsdangerous>=2.2",
|
||||||
|
"pywebpush>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from alembic.config import Config
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
@@ -84,8 +85,17 @@ def ws_client():
|
|||||||
# the `db_session`/`app` fixtures' engine, which belongs to pytest's
|
# the `db_session`/`app` fixtures' engine, which belongs to pytest's
|
||||||
# loop. No per-test rollback here (see test_ws_chat.py for the
|
# loop. No per-test rollback here (see test_ws_chat.py for the
|
||||||
# unique-name convention that keeps tests independent without it).
|
# unique-name convention that keeps tests independent without it).
|
||||||
|
#
|
||||||
|
# poolclass=NullPool: with pooling, a WS test that does more than one
|
||||||
|
# DB round trip per message (e.g. the offline-push lookup) can hit a
|
||||||
|
# race where the pooled connection returned by the WS handler's session
|
||||||
|
# close hasn't finished being checked back in before the test's next
|
||||||
|
# (synchronous, same-portal) REST call checks a connection back out --
|
||||||
|
# surfaces as "connection is closed". A fresh connection per session
|
||||||
|
# sidesteps it; fine for tests, not something prod needs (prod isn't
|
||||||
|
# juggling a background portal thread against the main test thread).
|
||||||
application = create_app()
|
application = create_app()
|
||||||
test_engine = create_async_engine(TEST_DATABASE_URL)
|
test_engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
|
||||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|
||||||
async def _get_db():
|
async def _get_db():
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pywebpush import WebPushException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import PushSubscription
|
||||||
|
from tests.conftest import login_as, register_and_login
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_payload(suffix: str = "a") -> dict:
|
||||||
|
return {
|
||||||
|
"endpoint": f"https://push.example.com/ep-{suffix}",
|
||||||
|
"keys": {"p256dh": f"p256dh-{suffix}", "auth": f"auth-{suffix}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_vapid_public_key_endpoint(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
resp = await client.get("/api/push/vapid-public-key")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "public_key" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_suffix() -> str:
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subscribe_creates_row(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
payload = _subscription_payload(_unique_suffix())
|
||||||
|
resp = await client.post("/api/push/subscribe", json=payload)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# The chatapp_test database is shared across the whole suite and the
|
||||||
|
# ws_client-based tests below intentionally don't roll back (see
|
||||||
|
# conftest.ws_client), so a unique endpoint keeps this test independent
|
||||||
|
# of leftover rows from those instead of asserting on the total count.
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subscribe_upserts_by_endpoint(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
payload = _subscription_payload(_unique_suffix())
|
||||||
|
assert (await client.post("/api/push/subscribe", json=payload)).status_code == 204
|
||||||
|
|
||||||
|
updated = {**payload, "keys": {"p256dh": "new-p256dh", "auth": "new-auth"}}
|
||||||
|
assert (await client.post("/api/push/subscribe", json=updated)).status_code == 204
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].p256dh_key == "new-p256dh"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unsubscribe_removes_row(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
payload = _subscription_payload(_unique_suffix())
|
||||||
|
await client.post("/api/push/subscribe", json=payload)
|
||||||
|
|
||||||
|
resp = await client.request(
|
||||||
|
"DELETE", "/api/push/subscribe", json={"endpoint": payload["endpoint"]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
|
||||||
|
)
|
||||||
|
assert result.scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def _register_ws(ws_client, username: str) -> dict:
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
from app.services.auth_service import register_user
|
||||||
|
|
||||||
|
async def _seed():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
await register_user(
|
||||||
|
session,
|
||||||
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_client.portal.call(_seed)
|
||||||
|
resp = ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_subscriptions(ws_client, user_id: str) -> list[PushSubscription]:
|
||||||
|
async def _query():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(PushSubscription).where(PushSubscription.user_id == uuid.UUID(user_id))
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
return ws_client.portal.call(_query)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_message_pushes_offline_member_only(ws_client, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_webpush(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
||||||
|
|
||||||
|
alice = _register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bob = _register_ws(ws_client, _unique("bob"))
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||||
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||||
|
|
||||||
|
login_resp = ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||||
|
)
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
assert ws.receive_json()["type"] == "message"
|
||||||
|
# The handler processes frames strictly sequentially, so a second
|
||||||
|
# (idempotent) join only gets acked once the "message" frame's full
|
||||||
|
# handling -- including the offline-push step -- has completed. A
|
||||||
|
# plain `with` block exit doesn't guarantee that: closing can race
|
||||||
|
# ahead of (and cancel) still-in-flight server-side work.
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0]["subscription_info"]["endpoint"].startswith("https://push.example.com/ep-bob")
|
||||||
|
assert "hello" in calls[0]["data"]
|
||||||
|
assert alice["username"] in calls[0]["data"] # sender attribution in the payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_message_no_push_when_member_connected(ws_client, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||||
|
|
||||||
|
alice = _register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bob = _register_ws(ws_client, _unique("bob"))
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||||
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||||
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert bob_ws.receive_json()["type"] == "joined"
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username_or_email": alice["username"], "password": "password123"},
|
||||||
|
)
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||||
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert alice_ws.receive_json()["type"] == "joined"
|
||||||
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||||
|
assert alice_ws.receive_json()["type"] == "message"
|
||||||
|
# bob is connected too -- he should get the broadcast, not a push
|
||||||
|
assert bob_ws.receive_json()["type"] == "message"
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_subscription_is_cleaned_up(ws_client, monkeypatch):
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 410
|
||||||
|
|
||||||
|
def fake_webpush(**kwargs):
|
||||||
|
raise WebPushException("gone", response=FakeResponse())
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
||||||
|
|
||||||
|
alice = _register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bob = _register_ws(ws_client, _unique("bob"))
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||||
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||||
|
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||||
|
)
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
assert ws.receive_json()["type"] == "message"
|
||||||
|
# See test_ws_message_pushes_offline_member_only for why this sync
|
||||||
|
# barrier is needed before checking server-side push side effects.
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
|
||||||
|
assert _fetch_subscriptions(ws_client, bob["id"]) == []
|
||||||
Generated
+6
-1
@@ -20,7 +20,12 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"oxlint": "^1.75.0",
|
"oxlint": "^1.75.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.0"
|
"vite": "^8.2.0",
|
||||||
|
"workbox-cacheable-response": "^7.4.1",
|
||||||
|
"workbox-expiration": "^7.4.1",
|
||||||
|
"workbox-precaching": "^7.4.1",
|
||||||
|
"workbox-routing": "^7.4.1",
|
||||||
|
"workbox-strategies": "^7.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@apideck/better-ajv-errors": {
|
"node_modules/@apideck/better-ajv-errors": {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"oxlint": "^1.75.0",
|
"oxlint": "^1.75.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.0"
|
"vite": "^8.2.0",
|
||||||
|
"workbox-cacheable-response": "^7.4.1",
|
||||||
|
"workbox-expiration": "^7.4.1",
|
||||||
|
"workbox-precaching": "^7.4.1",
|
||||||
|
"workbox-routing": "^7.4.1",
|
||||||
|
"workbox-strategies": "^7.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
|
||||||
|
export interface PushSubscriptionPayload {
|
||||||
|
endpoint: string
|
||||||
|
keys: { p256dh: string; auth: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VapidPublicKeyResponse {
|
||||||
|
public_key: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVapidPublicKey(): Promise<VapidPublicKeyResponse> {
|
||||||
|
return apiFetch<VapidPublicKeyResponse>('/api/push/vapid-public-key')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePush(subscription: PushSubscriptionPayload): Promise<void> {
|
||||||
|
return apiFetch<void>('/api/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsubscribePush(endpoint: string): Promise<void> {
|
||||||
|
return apiFetch<void>('/api/push/subscribe', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -94,3 +94,15 @@
|
|||||||
.top-bar-menu button[role='menuitem']:hover {
|
.top-bar-menu button[role='menuitem']:hover {
|
||||||
background: var(--ds-surface-2);
|
background: var(--ds-surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-bar-menu button[role='menuitem']:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-menu-error {
|
||||||
|
color: var(--ds-danger);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
padding: 2px 8px 6px;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import logo from '../assets/logo.png'
|
import logo from '../assets/logo.png'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { initials } from '../lib/avatar'
|
import { initials } from '../lib/avatar'
|
||||||
|
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
|
||||||
import './TopBar.css'
|
import './TopBar.css'
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [pushSubscribed, setPushSubscribed] = useState(false)
|
||||||
|
const [pushBusy, setPushBusy] = useState(false)
|
||||||
|
const [pushError, setPushError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPushSubscriptionStatus().then(setPushSubscribed)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleTogglePush() {
|
||||||
|
setPushBusy(true)
|
||||||
|
setPushError(null)
|
||||||
|
try {
|
||||||
|
if (pushSubscribed) {
|
||||||
|
await unsubscribeFromPush()
|
||||||
|
setPushSubscribed(false)
|
||||||
|
} else {
|
||||||
|
await subscribeToPush()
|
||||||
|
setPushSubscribed(true)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setPushError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setPushBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
@@ -32,6 +58,17 @@ export function TopBar() {
|
|||||||
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
|
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
|
||||||
<div className="top-bar-menu" role="menu">
|
<div className="top-bar-menu" role="menu">
|
||||||
<div className="top-bar-menu-username">{user.username}</div>
|
<div className="top-bar-menu-username">{user.username}</div>
|
||||||
|
{isPushSupported() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={handleTogglePush}
|
||||||
|
disabled={pushBusy}
|
||||||
|
>
|
||||||
|
{pushSubscribed ? 'Disable notifications' : 'Enable notifications'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pushError && <div className="top-bar-menu-error">{pushError}</div>}
|
||||||
<button type="button" role="menuitem" onClick={() => logout()}>
|
<button type="button" role="menuitem" onClick={() => logout()}>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { getVapidPublicKey, subscribePush, unsubscribePush } from '../api/push'
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const rawData = atob(base64)
|
||||||
|
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length))
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return outputArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPushSupported(): boolean {
|
||||||
|
return 'serviceWorker' in navigator && 'PushManager' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPushSubscriptionStatus(): Promise<boolean> {
|
||||||
|
if (!isPushSupported()) return false
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
return subscription !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeToPush(): Promise<void> {
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
throw new Error('Push notifications are not supported in this browser')
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission was not granted')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { public_key } = await getVapidPublicKey()
|
||||||
|
if (!public_key) {
|
||||||
|
throw new Error('Push notifications are not configured on the server')
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = subscription.toJSON()
|
||||||
|
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||||
|
throw new Error('Push subscription is missing required fields')
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscribePush({
|
||||||
|
endpoint: json.endpoint,
|
||||||
|
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeFromPush(): Promise<void> {
|
||||||
|
if (!isPushSupported()) return
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
if (!subscription) return
|
||||||
|
await unsubscribePush(subscription.endpoint)
|
||||||
|
await subscription.unsubscribe()
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||||||
|
import { ExpirationPlugin } from 'workbox-expiration'
|
||||||
|
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||||
|
import { NetworkOnly, StaleWhileRevalidate } from 'workbox-strategies'
|
||||||
|
|
||||||
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
self.skipWaiting()
|
||||||
|
cleanupOutdatedCaches()
|
||||||
|
|
||||||
|
// The app shell -- same effect generateSW gave us automatically in Phase 3.
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(createHandlerBoundToURL('index.html'), {
|
||||||
|
denylist: [/^\/api/, /^\/ws/],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const READ_CACHE_EXPIRATION = { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }
|
||||||
|
const cacheableResponse = new CacheableResponsePlugin({ statuses: [0, 200] })
|
||||||
|
|
||||||
|
// Ported from Phase 3's vite.config.ts `workbox.runtimeCaching` -- that
|
||||||
|
// option only applies to the generateSW strategy, so with a hand-written
|
||||||
|
// service worker (required below for the push/notificationclick handlers)
|
||||||
|
// these routes have to be registered explicitly instead.
|
||||||
|
registerRoute(({ url }) => url.pathname.startsWith('/api/auth/'), new NetworkOnly())
|
||||||
|
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => url.pathname === '/api/rooms/mine',
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'api-rooms-mine',
|
||||||
|
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => url.pathname === '/api/rooms',
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'api-rooms-open',
|
||||||
|
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => /^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'api-room-messages',
|
||||||
|
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => /^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'api-room-members',
|
||||||
|
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => url.pathname === '/api/invites/mine',
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'api-invites-mine',
|
||||||
|
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// Defensive default: anything else under /api/ stays network-only until
|
||||||
|
// explicitly opted in above.
|
||||||
|
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly())
|
||||||
|
|
||||||
|
interface PushPayload {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
room_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return
|
||||||
|
let payload: PushPayload
|
||||||
|
try {
|
||||||
|
payload = event.data.json()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(payload.title, {
|
||||||
|
body: payload.body,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/icon-192.png',
|
||||||
|
data: { room_id: payload.room_id },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close()
|
||||||
|
const roomId = (event.notification.data as { room_id?: string } | undefined)?.room_id
|
||||||
|
const targetUrl = roomId ? `/rooms/${roomId}` : '/rooms'
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
for (const client of clientsList) {
|
||||||
|
if ('focus' in client) {
|
||||||
|
await client.navigate(targetUrl)
|
||||||
|
return client.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(targetUrl)
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -22,5 +22,6 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/sw.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.sw.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.sw.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "WebWorker"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"erasableSyntaxOnly": true
|
||||||
|
},
|
||||||
|
"include": ["src/sw.ts"]
|
||||||
|
}
|
||||||
+14
-76
@@ -2,16 +2,25 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
const READ_CACHE_EXPIRATION = {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
// generateSW (Phase 3) can't add custom event listeners, and push /
|
||||||
|
// notificationclick need exactly that -- injectManifest means we hand-
|
||||||
|
// write the service worker (src/sw.ts); its runtime-caching routes are
|
||||||
|
// registered there directly instead of via the `workbox` option below
|
||||||
|
// (which only applies to generateSW).
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
injectManifest: {
|
||||||
|
// Workbox's default globPatterns exclude the manifest's own output
|
||||||
|
// dir, which is fine, but be explicit about what the app shell
|
||||||
|
// precache should contain.
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest}'],
|
||||||
|
},
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'KeepItTalking',
|
name: 'KeepItTalking',
|
||||||
@@ -31,77 +40,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
|
||||||
// urlPattern uses function matchers against url.pathname rather than
|
|
||||||
// RegExp (which Workbox tests against the *full href*, origin
|
|
||||||
// included -- a `^/api/` anchor would silently never match).
|
|
||||||
runtimeCaching: [
|
|
||||||
// Never serve a stale cached "who am I" response.
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/auth/'),
|
|
||||||
handler: 'NetworkOnly',
|
|
||||||
},
|
|
||||||
// Cache-and-refresh: show the last known list/history immediately,
|
|
||||||
// update from the network in the background. Routes default to
|
|
||||||
// matching GET only, so mutations to these same paths are
|
|
||||||
// untouched and still go straight to network.
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) => url.pathname === '/api/rooms/mine',
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-rooms-mine',
|
|
||||||
expiration: READ_CACHE_EXPIRATION,
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) => url.pathname === '/api/rooms',
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-rooms-open',
|
|
||||||
expiration: READ_CACHE_EXPIRATION,
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) =>
|
|
||||||
/^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-room-messages',
|
|
||||||
expiration: READ_CACHE_EXPIRATION,
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) =>
|
|
||||||
/^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-room-members',
|
|
||||||
expiration: READ_CACHE_EXPIRATION,
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) => url.pathname === '/api/invites/mine',
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-invites-mine',
|
|
||||||
expiration: READ_CACHE_EXPIRATION,
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Defensive default: anything else under /api/ (including any
|
|
||||||
// future GET endpoint) stays network-only until explicitly opted
|
|
||||||
// in above.
|
|
||||||
{
|
|
||||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
|
||||||
handler: 'NetworkOnly',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user