forked from retoor/devplacepy
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>
77 lines
1.8 KiB
Python
77 lines
1.8 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from devplacepy.push.providers.apns import ApnsProvider
|
|
from devplacepy.push.providers.base import (
|
|
ACCEPTED,
|
|
DEAD,
|
|
REJECTED,
|
|
Delivery,
|
|
PushProvider,
|
|
)
|
|
from devplacepy.push.providers.webpush import WebPushProvider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_PROVIDER = WebPushProvider.name
|
|
|
|
PROVIDERS: dict[str, PushProvider] = {
|
|
provider.name: provider for provider in (WebPushProvider(), ApnsProvider())
|
|
}
|
|
|
|
__all__ = [
|
|
"ACCEPTED",
|
|
"DEAD",
|
|
"DEFAULT_PROVIDER",
|
|
"Delivery",
|
|
"PROVIDERS",
|
|
"PushProvider",
|
|
"REJECTED",
|
|
"active",
|
|
"admin_fields",
|
|
"client_config",
|
|
"get",
|
|
"is_active",
|
|
"names",
|
|
]
|
|
|
|
|
|
def get(name: str | None) -> PushProvider | None:
|
|
if not isinstance(name, str):
|
|
name = ""
|
|
return PROVIDERS.get(name.strip().lower() or DEFAULT_PROVIDER)
|
|
|
|
|
|
def names() -> list[str]:
|
|
return list(PROVIDERS)
|
|
|
|
|
|
def active() -> list[PushProvider]:
|
|
return [provider for provider in PROVIDERS.values() if is_active(provider)]
|
|
|
|
|
|
def admin_fields() -> list:
|
|
return [field for provider in PROVIDERS.values() for field in provider.all_fields()]
|
|
|
|
|
|
def client_config() -> dict[str, Any]:
|
|
return {provider.name: _client_config(provider) for provider in active()}
|
|
|
|
|
|
def is_active(provider: PushProvider) -> bool:
|
|
try:
|
|
return provider.is_active()
|
|
except Exception as exc:
|
|
logger.error("Push provider %s failed its readiness check: %s", provider.name, exc)
|
|
return False
|
|
|
|
|
|
def _client_config(provider: PushProvider) -> dict[str, Any]:
|
|
try:
|
|
return provider.client_config()
|
|
except Exception as exc:
|
|
logger.error("Push provider %s failed to describe itself: %s", provider.name, exc)
|
|
return {}
|