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>
80 lines
2.7 KiB
Python
80 lines
2.7 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from devplacepy.database import get_int_setting
|
|
from devplacepy.push import providers, store
|
|
from devplacepy.push.delivery import (
|
|
DEFAULT_TIMEOUT_SECONDS,
|
|
MAX_TIMEOUT_SECONDS,
|
|
MIN_TIMEOUT_SECONDS,
|
|
TIMEOUT_KEY,
|
|
)
|
|
from devplacepy.services.base import BaseService, ConfigField
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
RETENTION_KEY = "push_dead_retention_days"
|
|
DEFAULT_RETENTION_DAYS = 30
|
|
|
|
|
|
class PushService(BaseService):
|
|
title = "Push notifications"
|
|
description = (
|
|
"Configures the push providers and prunes dead subscriptions. Delivery is "
|
|
"independent of this service and continues while it is stopped."
|
|
)
|
|
details = (
|
|
"Notifications reach a user through every provider they have a live subscription "
|
|
"for. A provider delivers only while its own Enabled toggle is on and its "
|
|
"configuration is complete, so an unconfigured provider is inert."
|
|
)
|
|
default_enabled = True
|
|
min_interval = 3600
|
|
METRICS_SECONDS = 300
|
|
config_fields = [
|
|
ConfigField(
|
|
RETENTION_KEY,
|
|
"Dead subscription retention (days)",
|
|
type="int",
|
|
default=DEFAULT_RETENTION_DAYS,
|
|
minimum=0,
|
|
help="Subscriptions the push services rejected as gone are removed after this many days. 0 disables pruning.",
|
|
group="General",
|
|
),
|
|
ConfigField(
|
|
TIMEOUT_KEY,
|
|
"Delivery timeout (seconds)",
|
|
type="int",
|
|
default=DEFAULT_TIMEOUT_SECONDS,
|
|
minimum=MIN_TIMEOUT_SECONDS,
|
|
maximum=MAX_TIMEOUT_SECONDS,
|
|
help="Per request timeout used for every push provider.",
|
|
group="General",
|
|
),
|
|
*providers.admin_fields(),
|
|
]
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("push", interval_seconds=86400)
|
|
|
|
async def run_once(self) -> None:
|
|
days = get_int_setting(RETENTION_KEY, DEFAULT_RETENTION_DAYS)
|
|
if days <= 0:
|
|
self.log("Retention disabled (0 days); nothing pruned")
|
|
return
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
removed = store.prune(cutoff)
|
|
self.log(f"Pruned {removed} dead push subscriptions older than {days}d")
|
|
|
|
def collect_metrics(self) -> dict:
|
|
totals = store.counts()
|
|
metrics = {"dead": totals.get("dead", 0)}
|
|
for provider in providers.PROVIDERS.values():
|
|
metrics[f"{provider.name}_active"] = totals.get(provider.name, 0)
|
|
metrics[f"{provider.name}_ready"] = (
|
|
1 if providers.is_active(provider) else 0
|
|
)
|
|
return metrics
|