# retoor 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)