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>
This commit is contained in:
retoor 2026-07-31 22:43:10 +02:00
parent 5079f40f46
commit 53ddf4f233
14 changed files with 839 additions and 99 deletions

View File

@ -134,7 +134,28 @@ def init_db():
) )
_index(db, "notifications", "idx_notifications_user", ["user_uid"]) _index(db, "notifications", "idx_notifications_user", ["user_uid"])
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"]) _index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
push_registration = get_table("push_registration")
for column, example in (
("uid", ""),
("user_uid", ""),
("provider", "webpush"),
("endpoint", ""),
("key_auth", ""),
("key_p256dh", ""),
("token", ""),
("created_at", ""),
("deleted_at", ""),
):
if not push_registration.has_column(column):
push_registration.create_column_by_example(column, example)
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"]) _index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
if "push_registration" in db.tables:
with db:
db.query(
"UPDATE push_registration SET provider = 'webpush' "
"WHERE provider IS NULL OR provider = ''"
)
_index(db, "sessions", "idx_sessions_token", ["session_token"]) _index(db, "sessions", "idx_sessions_token", ["session_token"])
projects = get_table("projects") projects = get_table("projects")
for column, example in ( for column, example in (

View File

@ -8,8 +8,15 @@ GROUP = {
"intro": """ "intro": """
# Web Push # Web Push
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then Push notifications are delivered by one or more providers. `webpush` is the default and
register a `PushSubscription` obtained from the browser's `PushManager`. implements the Web Push protocol: fetch the public VAPID key, then register a
`PushSubscription` obtained from the browser's `PushManager`. `apns` delivers to an Apple
Push Notification service device token and is only offered when an administrator has
configured it.
`GET /push.json` lists the providers that currently accept registrations. A registration
body without a `provider` field is a `webpush` registration, so existing clients need no
change.
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
@ -26,39 +33,60 @@ four ways to sign requests.
method="GET", method="GET",
path="/push.json", path="/push.json",
title="Get the public key", title="Get the public key",
summary="Return the VAPID public key for subscribing.", summary="Return the VAPID public key and the providers that accept registrations.",
auth="public", auth="public",
sample_response={"publicKey": "BASE64_VAPID_KEY"}, sample_response={
"publicKey": "BASE64_VAPID_KEY",
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
},
), ),
endpoint( endpoint(
id="push-register", id="push-register",
method="POST", method="POST",
path="/push.json", path="/push.json",
title="Register a subscription", title="Register a subscription",
summary="Register a browser push subscription. Sends a welcome notification.", summary="Register a push subscription. Sends a welcome notification.",
auth="user", auth="user",
encoding="json", encoding="json",
interactive=False, interactive=False,
params=[ params=[
field(
"provider",
"json",
"string",
False,
"webpush",
"Provider to register with. Omit for webpush.",
),
field( field(
"endpoint", "endpoint",
"json", "json",
"string", "string",
True, False,
"https://fcm.googleapis.com/...", "https://fcm.googleapis.com/...",
"Subscription endpoint URL.", "Subscription endpoint URL. Required for webpush.",
), ),
field( field(
"keys", "keys",
"json", "json",
"string", "string",
True, False,
'{"p256dh":"...","auth":"..."}', '{"p256dh":"...","auth":"..."}',
"Subscription keys object.", "Subscription keys object. Required for webpush.",
),
field(
"token",
"json",
"string",
False,
"a1b2c3...",
"Hexadecimal device token. Required for apns.",
), ),
], ],
notes=[ notes=[
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.' 'A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.',
'An APNs body is JSON: `{"provider": "apns", "token": "..."}`.',
"A provider that is unknown, disabled or unconfigured returns 400.",
], ],
sample_response={"registered": True}, sample_response={"registered": True},
), ),

View File

@ -115,6 +115,7 @@ from devplacepy.services.containers.service import ContainerService
from devplacepy.services.xmlrpc import XmlrpcService from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit from devplacepy.services.audit import record as audit
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService from devplacepy.services.telegram.outbox_service import TelegramOutboxService
@ -275,6 +276,7 @@ async def lifespan(app: FastAPI):
service_manager.register(ContainerService()) service_manager.register(ContainerService())
service_manager.register(XmlrpcService()) service_manager.register(XmlrpcService())
service_manager.register(AuditService()) service_manager.register(AuditService())
service_manager.register(PushService())
service_manager.register(TelegramService()) service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService()) service_manager.register(TelegramOutboxService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"): if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):

View File

@ -0,0 +1,29 @@
# retoor <retoor@molodetz.nl>
from devplacepy.push.delivery import notify_user
from devplacepy.push.providers.webpush import (
browser_base64,
create_notification_authorization,
create_notification_info_with_payload,
ensure_certificates,
generate_pkcs8_private_key,
generate_private_key,
generate_public_key,
hkdf,
public_key_standard_b64,
)
from devplacepy.push.store import register
__all__ = [
"browser_base64",
"create_notification_authorization",
"create_notification_info_with_payload",
"ensure_certificates",
"generate_pkcs8_private_key",
"generate_private_key",
"generate_public_key",
"hkdf",
"notify_user",
"public_key_standard_b64",
"register",
]

View File

@ -0,0 +1,83 @@
# 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
)

View File

@ -0,0 +1,76 @@
# 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 {}

View File

@ -0,0 +1,243 @@
# retoor <retoor@molodetz.nl>
import hashlib
import json
import logging
import string
import time
from typing import Any
import httpx
import jwt
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_setting
from devplacepy.push.providers.base import (
ACCEPTED,
DEAD,
REJECTED,
Delivery,
PushProvider,
)
from devplacepy.services.base import ConfigField
from devplacepy.utils import DEFAULT_PUSH_URL, PUSH_ICON, generate_uid
logger = logging.getLogger(__name__)
TEAM_ID_KEY = "push_apns_team_id"
KEY_ID_KEY = "push_apns_key_id"
AUTH_KEY_KEY = "push_apns_auth_key"
TOPIC_KEY = "push_apns_topic"
ENVIRONMENT_KEY = "push_apns_environment"
PROVIDER_LABEL = "Apple Push (APNs)"
DEFAULT_ENVIRONMENT = "production"
HOSTS = {
"production": "api.push.apple.com",
"sandbox": "api.sandbox.push.apple.com",
}
ENVIRONMENT_OPTIONS = [
{"value": "production", "label": "Production"},
{"value": "sandbox", "label": "Sandbox"},
]
TOKEN_REFRESH_SECONDS = 45 * 60
TOKEN_MIN_LENGTH = 64
TOKEN_MAX_LENGTH = 200
THREAD_ID = "devplace-notification"
PUSH_TYPE = "alert"
PRIORITY = "10"
DEAD_REASONS = frozenset(
{
"BadDeviceToken",
"DeviceTokenNotForTopic",
"ExpiredToken",
"Unregistered",
"TopicDisallowed",
}
)
_token_state: dict[str, Any] = {}
def _setting(key: str) -> str:
return get_setting(key, "").strip()
def _environment() -> str:
value = _setting(ENVIRONMENT_KEY) or DEFAULT_ENVIRONMENT
return value if value in HOSTS else DEFAULT_ENVIRONMENT
def host() -> str:
return HOSTS[_environment()]
def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()
def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
fingerprint = _fingerprint(team_id, key_id, auth_key)
issued_at = int(time.time())
state = _token_state.get("current")
if (
state
and state["fingerprint"] == fingerprint
and issued_at - state["issued_at"] < TOKEN_REFRESH_SECONDS
):
if state["token"] is None:
raise ValueError(state["error"])
return state["token"]
try:
token = jwt.encode(
{"iss": team_id, "iat": issued_at},
auth_key,
algorithm="ES256",
headers={"kid": key_id},
)
except Exception as exc:
message = f"APNs auth key is not usable: {exc}"
_token_state["current"] = {
"token": None,
"error": message,
"issued_at": issued_at,
"fingerprint": fingerprint,
}
logger.error(message)
raise ValueError(message) from exc
_token_state["current"] = {
"token": token,
"error": "",
"issued_at": issued_at,
"fingerprint": fingerprint,
}
return token
def _reason(response: httpx.Response) -> str:
try:
body = response.json()
except ValueError:
return ""
if isinstance(body, dict):
return str(body.get("reason", ""))
return ""
class ApnsProvider(PushProvider):
name = "apns"
label = PROVIDER_LABEL
config_fields = [
ConfigField(
TEAM_ID_KEY,
"Team ID",
type="str",
default="",
help="Ten character Apple Developer team identifier, used as the token iss claim.",
group=PROVIDER_LABEL,
),
ConfigField(
KEY_ID_KEY,
"Key ID",
type="str",
default="",
help="Ten character identifier of the APNs auth key, sent as the token kid header.",
group=PROVIDER_LABEL,
),
ConfigField(
AUTH_KEY_KEY,
"Auth key (.p8)",
type="text",
default="",
secret=True,
help="Contents of the APNs .p8 signing key, including the BEGIN and END lines. Leave blank to keep the stored key.",
group=PROVIDER_LABEL,
),
ConfigField(
TOPIC_KEY,
"Topic",
type="str",
default="",
help="Bundle identifier of the receiving app, sent as the apns-topic header.",
group=PROVIDER_LABEL,
),
ConfigField(
ENVIRONMENT_KEY,
"Environment",
type="select",
default=DEFAULT_ENVIRONMENT,
options=ENVIRONMENT_OPTIONS,
help="Production delivers to App Store builds, sandbox to development builds.",
group=PROVIDER_LABEL,
),
]
def is_configured(self) -> bool:
return bool(
_setting(TEAM_ID_KEY)
and _setting(KEY_ID_KEY)
and _setting(AUTH_KEY_KEY)
and _setting(TOPIC_KEY)
)
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
token = body.get("token")
if not isinstance(token, str):
return None
token = token.strip()
if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
return None
if any(character not in string.hexdigits for character in token):
return None
return {"token": token}
def prepare(self, payload: dict[str, Any]) -> str:
return json.dumps(
{
"aps": {
"alert": {
"title": payload.get("title") or "DevPlace",
"body": payload.get("message") or "",
},
"sound": "default",
"thread-id": THREAD_ID,
},
"url": payload.get("url") or DEFAULT_PUSH_URL,
"icon": payload.get("icon") or PUSH_ICON,
}
)
def headers(self) -> dict[str, str]:
return {
"authorization": f"bearer {provider_token(_setting(TEAM_ID_KEY), _setting(KEY_ID_KEY), _setting(AUTH_KEY_KEY))}",
"apns-topic": _setting(TOPIC_KEY),
"apns-push-type": PUSH_TYPE,
"apns-priority": PRIORITY,
"apns-expiration": str(int(time.time()) + SECONDS_PER_DAY),
"apns-id": generate_uid(),
"content-type": "application/json",
}
async def deliver(
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
) -> Delivery:
token = (registration.get("token") or "").strip()
if not token:
return Delivery(DEAD, "missing device token")
try:
headers = self.headers()
response = await client.post(
f"https://{host()}/3/device/{token}",
headers=headers,
content=prepared.encode("utf-8"),
)
except (httpx.HTTPError, ValueError) as exc:
return Delivery(REJECTED, str(exc))
if response.status_code == 200:
return Delivery(ACCEPTED)
reason = _reason(response)
detail = f"{response.status_code} {reason}".strip()
if response.status_code == 410 or reason in DEAD_REASONS:
return Delivery(DEAD, detail)
return Delivery(REJECTED, detail)

View File

@ -0,0 +1,66 @@
# retoor <retoor@molodetz.nl>
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import httpx
from devplacepy.database import get_setting
from devplacepy.services.base import ConfigField
ACCEPTED = "accepted"
DEAD = "dead"
REJECTED = "rejected"
@dataclass(frozen=True)
class Delivery:
status: str
detail: str = ""
class PushProvider(ABC):
name = ""
label = ""
config_fields: list[ConfigField] = []
@property
def enabled_key(self) -> str:
return f"push_{self.name}_enabled"
def enabled_field(self) -> ConfigField:
return ConfigField(
self.enabled_key,
"Enabled",
type="bool",
default=True,
help=f"Deliver notifications through {self.label}.",
group=self.label,
)
def all_fields(self) -> list[ConfigField]:
return [self.enabled_field(), *self.config_fields]
def is_enabled(self) -> bool:
return get_setting(self.enabled_key, "1") == "1"
def is_active(self) -> bool:
return self.is_enabled() and self.is_configured()
def client_config(self) -> dict[str, Any]:
return {}
@abstractmethod
def is_configured(self) -> bool: ...
@abstractmethod
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None: ...
@abstractmethod
def prepare(self, payload: dict[str, Any]) -> str: ...
@abstractmethod
async def deliver(
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
) -> Delivery: ...

View File

@ -7,7 +7,6 @@ import logging
import os import os
import random import random
import time import time
from datetime import datetime, timezone
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
@ -20,7 +19,6 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from devplacepy import stealth
from devplacepy.config import ( from devplacepy.config import (
SECONDS_PER_DAY, SECONDS_PER_DAY,
VAPID_PRIVATE_KEY_FILE, VAPID_PRIVATE_KEY_FILE,
@ -28,7 +26,15 @@ from devplacepy.config import (
VAPID_PUBLIC_KEY_FILE, VAPID_PUBLIC_KEY_FILE,
VAPID_SUB, VAPID_SUB,
) )
from devplacepy.database import get_table from devplacepy.database import get_setting
from devplacepy.push.providers.base import (
ACCEPTED,
DEAD,
REJECTED,
Delivery,
PushProvider,
)
from devplacepy.services.base import ConfigField
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,6 +43,8 @@ JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY) PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
DEAD_SUBSCRIPTION_STATUSES = (404, 410) DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201) ACCEPTED_STATUSES = (200, 201)
SUBJECT_KEY = "push_webpush_subject"
PROVIDER_LABEL = "Web Push (VAPID)"
def generate_private_key() -> None: def generate_private_key() -> None:
@ -149,13 +157,17 @@ def public_key_standard_b64() -> str:
return base64.b64encode(point).decode("utf-8").rstrip("=") return base64.b64encode(point).decode("utf-8").rstrip("=")
def subject() -> str:
return get_setting(SUBJECT_KEY, "").strip() or VAPID_SUB
def create_notification_authorization(push_url: str) -> str: def create_notification_authorization(push_url: str) -> str:
target = urlparse(push_url) target = urlparse(push_url)
audience = f"{target.scheme}://{target.netloc}" audience = f"{target.scheme}://{target.netloc}"
issued_at = int(time.time()) issued_at = int(time.time())
return jwt.encode( return jwt.encode(
{ {
"sub": VAPID_SUB, "sub": subject(),
"aud": audience, "aud": audience,
"exp": issued_at + JWT_LIFETIME_SECONDS, "exp": issued_at + JWT_LIFETIME_SECONDS,
"nbf": issued_at, "nbf": issued_at,
@ -223,78 +235,76 @@ def create_notification_info_with_payload(
} }
def _mark_subscription_dead(subscription_id: int) -> None: class WebPushProvider(PushProvider):
get_table("push_registration").update( name = "webpush"
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()}, label = PROVIDER_LABEL
["id"], config_fields = [
) ConfigField(
logger.info("Soft-deleted dead push subscription id=%s", subscription_id) SUBJECT_KEY,
"VAPID subject",
type="str",
default=VAPID_SUB,
help="Contact sent as the JWT sub claim, a mailto: or https: URL. Blank uses the built-in default.",
group=PROVIDER_LABEL,
)
]
def is_configured(self) -> bool:
return True
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None: def client_config(self) -> dict[str, Any]:
registrations = list( try:
get_table("push_registration").find(user_uid=user_uid, deleted_at=None) return {"publicKey": public_key_standard_b64()}
) except Exception as exc:
if not registrations: logger.error("VAPID key material unavailable: %s", exc)
logger.debug("No active push subscriptions for user %s", user_uid) return {}
return
body = json.dumps(payload) def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
async with stealth.stealth_async_client(timeout=10.0) as client: keys = body.get("keys")
for subscription in registrations: if not isinstance(keys, dict):
endpoint = subscription["endpoint"] return None
try: endpoint = body.get("endpoint")
notification_payload = create_notification_info_with_payload( key_auth = keys.get("auth")
endpoint, key_p256dh = keys.get("p256dh")
subscription["key_auth"], if not (
subscription["key_p256dh"], isinstance(endpoint, str)
body, and isinstance(key_auth, str)
) and isinstance(key_p256dh, str)
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS} and endpoint
response = await client.post( and key_auth
endpoint, headers=headers, content=notification_payload["data"] and key_p256dh
) ):
except (httpx.HTTPError, ValueError) as exc: return None
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc) return {
continue "endpoint": endpoint,
"key_auth": key_auth,
"key_p256dh": key_p256dh,
}
if response.status_code in ACCEPTED_STATUSES: def prepare(self, payload: dict[str, Any]) -> str:
logger.debug("Push delivered to %s via %s", user_uid, endpoint) return json.dumps(payload)
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
_mark_subscription_dead(subscription["id"])
else:
logger.warning(
"Push rejected (%s) for %s via %s",
response.status_code,
user_uid,
endpoint,
)
async def deliver(
async def register( self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str ) -> Delivery:
) -> tuple[dict[str, Any], bool]: endpoint = registration.get("endpoint") or ""
table = get_table("push_registration") if not endpoint:
existing = table.find_one( return Delivery(DEAD, "missing endpoint")
user_uid=user_uid, try:
endpoint=endpoint, notification_payload = create_notification_info_with_payload(
key_auth=key_auth, endpoint,
key_p256dh=key_p256dh, registration["key_auth"],
deleted_at=None, registration["key_p256dh"],
) prepared,
if existing: )
logger.debug("Push subscription already registered for user %s", user_uid) headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
return existing, False response = await client.post(
endpoint, headers=headers, content=notification_payload["data"]
record = { )
"uid": generate_uid(), except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
"user_uid": user_uid, return Delivery(REJECTED, str(exc))
"endpoint": endpoint, if response.status_code in ACCEPTED_STATUSES:
"key_auth": key_auth, return Delivery(ACCEPTED)
"key_p256dh": key_p256dh, if response.status_code in DEAD_SUBSCRIPTION_STATUSES:
"created_at": datetime.now(timezone.utc).isoformat(), return Delivery(DEAD, str(response.status_code))
"deleted_at": None, return Delivery(REJECTED, str(response.status_code))
}
table.insert(record)
logger.info("Registered push subscription for user %s", user_uid)
return record, True

83
devplacepy/push/store.py Normal file
View File

@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from typing import Any
from devplacepy.database import db, get_table
from devplacepy.push.providers import DEFAULT_PROVIDER
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
TABLE = "push_registration"
def table():
return get_table(TABLE)
def provider_of(registration: dict[str, Any]) -> str:
return registration.get("provider") or DEFAULT_PROVIDER
def active_for_user(user_uid: str) -> list[dict[str, Any]]:
return list(table().find(user_uid=user_uid, deleted_at=None))
def register(
user_uid: str, provider: str, fields: dict[str, Any]
) -> tuple[dict[str, Any], bool]:
registrations = table()
existing = registrations.find_one(
user_uid=user_uid, provider=provider, deleted_at=None, **fields
)
if existing:
logger.debug("Push subscription already registered for user %s", user_uid)
return existing, False
record = {
"uid": generate_uid(),
"user_uid": user_uid,
"provider": provider,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
**fields,
}
registrations.insert(record)
logger.info("Registered %s push subscription for user %s", provider, user_uid)
return record, True
def mark_dead(registration_id: int) -> None:
table().update(
{"id": registration_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
["id"],
)
logger.info("Soft-deleted dead push subscription id=%s", registration_id)
def prune(cutoff: str) -> int:
if TABLE not in db.tables:
return 0
rows = list(table().find(deleted_at={"<": cutoff}))
if not rows:
return 0
table().delete(deleted_at={"<": cutoff})
return len(rows)
def counts() -> dict[str, int]:
if TABLE not in db.tables:
return {}
totals: dict[str, int] = {"dead": 0}
for row in db.query(
f"SELECT provider AS provider, deleted_at IS NULL AS live, COUNT(*) AS total "
f"FROM {TABLE} GROUP BY provider, deleted_at IS NULL"
):
provider = row["provider"] or DEFAULT_PROVIDER
if row["live"]:
totals[provider] = totals.get(provider, 0) + int(row["total"])
else:
totals["dead"] += int(row["total"])
return totals

View File

@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push from devplacepy import push
from devplacepy.config import STATIC_DIR from devplacepy.config import STATIC_DIR
from devplacepy.push import providers
from devplacepy.utils import require_user_api from devplacepy.utils import require_user_api
from urllib.parse import urlparse from urllib.parse import urlparse
from devplacepy.services.audit import record as audit from devplacepy.services.audit import record as audit
@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
@router.get("/push.json") @router.get("/push.json")
async def push_public_key() -> JSONResponse: async def push_public_key() -> JSONResponse:
return JSONResponse({"publicKey": push.public_key_standard_b64()}) configs = providers.client_config()
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
return JSONResponse(
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
)
@router.post("/push.json") @router.post("/push.json")
@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
except ValueError: except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400) return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None if not isinstance(body, dict):
if not (
isinstance(keys, dict)
and body.get("endpoint")
and keys.get("p256dh")
and keys.get("auth")
):
return JSONResponse({"error": "Invalid request"}, status_code=400) return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = await push.register( provider = providers.get(body.get("provider"))
user_uid=user["uid"], if provider is None or not providers.is_active(provider):
endpoint=body["endpoint"], return JSONResponse({"error": "Unknown provider"}, status_code=400)
key_auth=keys["auth"],
key_p256dh=keys["p256dh"], 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: if created:
try: try:
@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
target_type="user", target_type="user",
target_uid=user["uid"], target_uid=user["uid"],
target_label=user.get("username"), target_label=user.get("username"),
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created}, 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", summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
links=[audit.target("user", user["uid"], user.get("username"))], links=[audit.target("user", user["uid"], user.get("username"))],
) )

View File

@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.push.service import (
DEFAULT_RETENTION_DAYS,
RETENTION_KEY,
PushService,
)
__all__ = ["DEFAULT_RETENTION_DAYS", "PushService", "RETENTION_KEY"]

View File

@ -0,0 +1,79 @@
# 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

View File

@ -54,6 +54,9 @@ export class PushManager {
} }
const keyData = await Http.getJson("/push.json"); const keyData = await Http.getJson("/push.json");
if (!keyData.publicKey) {
return;
}
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0)); const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({ const subscription = await registration.pushManager.subscribe({