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:
@@ -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)
|
||||
Reference in New Issue
Block a user