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>
84 lines
2.9 KiB
Python
84 lines
2.9 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from devplacepy import stealth
|
|
from devplacepy.database import get_int_setting
|
|
from devplacepy.push import providers, store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TIMEOUT_KEY = "push_delivery_timeout_seconds"
|
|
DEFAULT_TIMEOUT_SECONDS = 10
|
|
MIN_TIMEOUT_SECONDS = 1
|
|
MAX_TIMEOUT_SECONDS = 120
|
|
|
|
|
|
def timeout_seconds() -> float:
|
|
seconds = get_int_setting(TIMEOUT_KEY, DEFAULT_TIMEOUT_SECONDS)
|
|
return float(min(max(seconds, MIN_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS))
|
|
|
|
|
|
def group_by_provider(
|
|
registrations: list[dict[str, Any]],
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
for registration in registrations:
|
|
grouped.setdefault(store.provider_of(registration), []).append(registration)
|
|
return grouped
|
|
|
|
|
|
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
|
registrations = store.active_for_user(user_uid)
|
|
if not registrations:
|
|
logger.debug("No active push subscriptions for user %s", user_uid)
|
|
return
|
|
|
|
grouped = group_by_provider(registrations)
|
|
async with stealth.stealth_async_client(timeout=timeout_seconds()) as client:
|
|
for name, rows in grouped.items():
|
|
provider = providers.PROVIDERS.get(name)
|
|
if provider is None:
|
|
logger.warning(
|
|
"Unknown push provider %s on %s subscriptions of user %s",
|
|
name,
|
|
len(rows),
|
|
user_uid,
|
|
)
|
|
continue
|
|
if not providers.is_active(provider):
|
|
logger.debug(
|
|
"Push provider %s is not active; skipping %s subscriptions",
|
|
name,
|
|
len(rows),
|
|
)
|
|
continue
|
|
try:
|
|
prepared = provider.prepare(payload)
|
|
except Exception as exc:
|
|
logger.error("Push provider %s could not build a payload: %s", name, exc)
|
|
continue
|
|
for registration in rows:
|
|
await _deliver_one(provider, client, registration, prepared, user_uid)
|
|
|
|
|
|
async def _deliver_one(provider, client, registration, prepared, user_uid) -> None:
|
|
try:
|
|
outcome = await provider.deliver(client, registration, prepared)
|
|
except Exception as exc:
|
|
logger.error("Push provider %s raised for %s: %s", provider.name, user_uid, exc)
|
|
return
|
|
if outcome.status == providers.ACCEPTED:
|
|
logger.debug("Push delivered to %s via %s", user_uid, provider.name)
|
|
return
|
|
if outcome.status == providers.DEAD:
|
|
try:
|
|
store.mark_dead(registration["id"])
|
|
except Exception as exc:
|
|
logger.error("Could not soft-delete push subscription: %s", exc)
|
|
return
|
|
logger.warning(
|
|
"Push rejected by %s for %s: %s", provider.name, user_uid, outcome.detail
|
|
)
|