Files
devplacepy/devplacepy/routers/push.py
T
retoorandClaude Opus 5 53ddf4f233 Add push provider architecture with Apple Push Notification support
Split the push delivery library into a provider architecture. devplacepy/push
becomes a package: a PushProvider protocol with a registry, the existing Web
Push implementation moved unchanged behind it, a new APNs provider, a store
owning every push_registration access, and a delivery loop that groups a user's
subscriptions by provider, prepares each provider's payload once and sends over
a single shared client.

APNs delivers over HTTP/2 with an ES256 provider token cached per credential
fingerprint, so a worker signs at most one token per 45 minutes. Registrations
carry a hexadecimal device token; 410 and the Unregistered class of reasons soft
delete the subscription exactly like a gone Web Push endpoint.

All provider configuration is edited at /admin/services/push through the same
ConfigField surface every other subsystem uses, assembled from the registry so a
future provider needs no edit to the service. A provider that is disabled,
unconfigured or holding an unusable credential accepts no registrations and is
skipped during delivery, never failing the other providers.

POST /push.json accepts a registration for any active provider; a body without a
provider field is a Web Push body, so existing clients are unchanged. GET
/push.json keeps publicKey at the top level and adds the active providers.
push_registration gains provider and token columns, ensured in init_db with a
converging backfill; existing rows are never rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:43:10 +02:00

94 lines
2.9 KiB
Python

# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push
from devplacepy.config import STATIC_DIR
from devplacepy.push import providers
from devplacepy.utils import require_user_api
from urllib.parse import urlparse
from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
router = APIRouter()
WELCOME_PAYLOAD = {
"title": "DevPlace",
"message": "Push notifications enabled.",
"icon": "/static/apple-touch-icon.png",
"url": "/notifications",
}
@router.get("/push.json")
async def push_public_key() -> JSONResponse:
configs = providers.client_config()
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
return JSONResponse(
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
)
@router.post("/push.json")
async def push_register(request: Request) -> JSONResponse:
user = require_user_api(request)
try:
body = await request.json()
except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
if not isinstance(body, dict):
return JSONResponse({"error": "Invalid request"}, status_code=400)
provider = providers.get(body.get("provider"))
if provider is None or not providers.is_active(provider):
return JSONResponse({"error": "Unknown provider"}, status_code=400)
fields = provider.parse_registration(body)
if fields is None:
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = push.register(user["uid"], provider.name, fields)
if created:
try:
await push.notify_user(user["uid"], WELCOME_PAYLOAD)
except Exception as exc:
logger.warning("Welcome push failed for %s: %s", user["uid"], exc)
audit.record(
request,
"push.subscribe" if created else "push.update",
user=user,
target_type="user",
target_uid=user["uid"],
target_label=user.get("username"),
metadata={
"provider": provider.name,
"endpoint_host": urlparse(fields["endpoint"]).hostname
if fields.get("endpoint")
else None,
"created": created,
},
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
links=[audit.target("user", user["uid"], user.get("username"))],
)
return JSONResponse({"registered": True})
@router.get("/service-worker.js")
async def service_worker() -> FileResponse:
return FileResponse(
STATIC_DIR / "service-worker.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@router.get("/manifest.json")
async def manifest() -> FileResponse:
return FileResponse(
STATIC_DIR / "manifest.json", media_type="application/manifest+json"
)