diff --git a/pushserver/api/errors/validation_error.py b/pushserver/api/errors/validation_error.py index a3fc7ae..ea546e6 100644 --- a/pushserver/api/errors/validation_error.py +++ b/pushserver/api/errors/validation_error.py @@ -1,60 +1,61 @@ from typing import Union from fastapi import Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import ValidationError from pushserver.resources import settings from pushserver.resources.utils import pick_log_function + async def validation_exception_handler(request: Request, exc: Union[RequestValidationError, ValidationError]) -> JSONResponse: host, port = request.scope['client'][0], request.scope['client'][1] account = None error_msg = None code = 400 status_code = status.HTTP_400_BAD_REQUEST for entry in exc.errors(): if 'found' in entry['msg'] or 'configured' in entry['msg']: status_code = status.HTTP_404_NOT_FOUND code = 404 if not error_msg: if '__root__' not in exc.errors()[0]["loc"][2]: error_msg = f'{exc.errors()[0]["msg"]}: {exc.errors()[0]["loc"][2]}' else: error_msg = exc.errors()[0]["msg"] try: request_id = f"{exc.body['event']} - " \ f"{exc.body['app-id']}-" \ f"{exc.body['call-id']}" except (KeyError, TypeError): try: account = request['path_params']['account'] request_id = f"{account}-" \ f"{exc.body['app-id']}-" \ f"{exc.body['device-id']}" except (KeyError, TypeError): request_id = "unknown" pick_log_function(exc, task='log_request', host=host, loggers=settings.params.loggers, request_id=request_id, body=exc.body) pick_log_function(exc, task='log_failure', host=host, loggers=settings.params.loggers, request_id=request_id, body=exc.body, error_msg=error_msg) content = jsonable_encoder({'code': code, 'description': error_msg, 'data': ''}) return JSONResponse( status_code=status_code, content=content) diff --git a/pushserver/applications/apple.py b/pushserver/applications/apple.py index dd8abb0..9f46862 100644 --- a/pushserver/applications/apple.py +++ b/pushserver/applications/apple.py @@ -1,137 +1,137 @@ __all__ = ['AppleHeaders', 'ApplePayload'] class AppleHeaders(object): """ Apple headers structure for a push notification """ def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, sip_to: str, media_type: str, silent: bool, reason: str): """ :param app_id: `str` id provided by the mobile application (bundle id) :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. - _param sip_from: `str` SIP URI for who is calling + :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification :param reason: `str` Cancel reason """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason self.apns_push_type = self.create_push_type() self.apns_expiration = self.create_expiration() self.apns_topic = self.create_topic() self.apns_priority = self.create_priority() def create_push_type(self) -> str: """ logic to define apns_push_type value using request parameters apns_push_type reflect the contents of the notification’s payload, it can be: 'alert', 'background', 'voip', 'complication', 'fileprovider' or 'mdm'. """ return def create_expiration(self) -> int: """ logic to define apns_expiration value using request parameters apns_expiration is the date at which the notification expires, (UNIX epoch expressed in seconds UTC). """ return def create_topic(self) -> str: """ logic to define apns_topic value using request parameters apns_topic is in general is the app’s bundle ID and may have a suffix based on the notification’s type. """ return def create_priority(self) -> int: """ logic to define apns_priority value using request parameters Notification priority, apns_prioriy 10 o send the notification immediately, 5 to send the notification based on power considerations on the user’s device. """ return - @property + @property def headers(self) -> dict: """ Generate apple notification headers :return: a `dict` object with headers. """ headers = { 'apns-push-type': self.apns_push_type, 'apns-expiration': self.apns_expiration, 'apns-priority': self.apns_priority, 'apns-topic': self.apns_topic, 'authorization': f"bearer {self.token}"} if self.apns_push_type == 'background': headers['content-available'] = '1' return headers class ApplePayload(object): """ Apple payload structure for a push notification """ def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, sip_to: str, media_type, silent: bool, reason: str): """ :param app_id: `str` id provided by the mobile application (bundle id) :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification :param reason: `str` Cancel reason """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason @property def payload(self) -> dict: """ logic to define apple payload using request parameters """ payload = {} return payload diff --git a/pushserver/applications/firebase.py b/pushserver/applications/firebase.py index d5b9e66..756a6f7 100644 --- a/pushserver/applications/firebase.py +++ b/pushserver/applications/firebase.py @@ -1,114 +1,117 @@ import json from pushserver.resources import settings from oauth2client.service_account import ServiceAccountCredentials __all__ = ['FirebaseHeaders', 'FirebasePayload'] class FirebaseHeaders(object): def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, sip_to: str, media_type: str, silent: bool, reason: str): """ :param app_id: `str` id provided by the mobile application (bundle id) :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason try: self.auth_key = settings.params.pns_register[(self.app_id, 'firebase')]['auth_key'] except KeyError: self.auth_key = None - + try: self.auth_file = settings.params.pns_register[(self.app_id, 'firebase')]['auth_file'] except KeyError: self.auth_file = None @property def access_token(self) -> str: - #https://github.com/firebase/quickstart-python/blob/909f39e77395cb0682108184ba565150caa68a31/messaging/messaging.py#L25-L33 + # https://github.com/firebase/quickstart-python/blob/909f39e77395cb0682108184ba565150caa68a31/messaging/messaging.py#L25-L33 """ Retrieve a valid access token that can be used to authorize requests. :return: `str` Access token. """ scopes = ['https://www.googleapis.com/auth/firebase.messaging'] try: credentials = ServiceAccountCredentials.from_json_keyfile_name(self.auth_file, scopes) access_token_info = credentials.get_access_token() return access_token_info.access_token except Exception as e: self.error = f"Error: cannot generated Firebase access token: {e}" return '' - + @property def headers(self): """ Generate Firebase headers structure for a push notification :return: a firebase push notification header. """ if self.auth_key: headers = {'Content-Type': 'application/json', - 'Authorization': f"key={self.auth_key}"} + 'Authorization': f"key={self.auth_key}"} else: headers = { 'Authorization': f"Bearer {self.access_token}", 'Content-Type': 'application/json; UTF-8', } return headers class FirebasePayload(object): def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, sip_to: str, media_type: str, silent: bool, reason: str): """ - :param token: `str` destination device token (required for sylk-apple) - :param call_id: `str` unique SIP session id for each call - :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' + :param app_id: `str` id provided by the mobile application (bundle id) + :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' + :param token: `str` destination device token. + :param call_id: `str` unique sip parameter. + :param sip_from: `str` SIP URI for who is calling + :param from_display_name: `str` display name of the caller + :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' - :param sip_from: `str` originator of the sip call. - :param from_display_name: `str` - :param sip_to: `str` destination uri + :param silent: `bool` True for silent notification + :param reason: `str` Cancel reason """ self.app_id = app_id self.token = token self.call_id = call_id # corresponds to session_id in the output self.event = event self.media_type = media_type self.sip_from = sip_from self.from_display_name = from_display_name self.sip_to = sip_to self.silent = silent self.reason = reason @property def payload(self) -> dict: """ Generate a Firebase payload for a push notification :return a Firebase payload: """ payload = {} return payload diff --git a/pushserver/applications/sylk.py b/pushserver/applications/sylk.py index a5eba01..2d97bab 100644 --- a/pushserver/applications/sylk.py +++ b/pushserver/applications/sylk.py @@ -1,159 +1,159 @@ import datetime from pushserver.applications.apple import * from pushserver.applications.firebase import * from pushserver.resources.utils import callid_to_uuid #from firebase_admin import messaging __all__ = ['AppleSylkHeaders', 'AppleSylkPayload', 'FirebaseSylkHeaders', 'FirebaseSylkPayload'] class AppleSylkHeaders(AppleHeaders): """ An Apple headers structure for a push notification """ def create_push_type(self) -> str: """ logic to define apns_push_type value using request parameters apns_push_type reflect the contents of the notification’s payload, it can be: 'alert', 'background', 'voip', 'complication', 'fileprovider' or 'mdm'. """ push_type = 'voip' if self.event in ('incoming_session', 'incoming_conference_request') else 'background' return push_type def create_expiration(self) -> int: """ logic to define apns_expiration value using request parameters apns_expiration is the date at which the notification expires, (UNIX epoch expressed in seconds UTC). """ return '120' def create_topic(self) -> str: """ logic to define apns_topic value using request parameters apns_topic is in general is the app’s bundle ID and may have a suffix based on the notification’s type. """ apns_topic = self.app_id if self.app_id.endswith('.dev') or self.app_id.endswith('.prod'): apns_topic = '.'.join(self.app_id.split('.')[:-1]) if self.event in ('incoming_session', 'incoming_conference_request'): apns_topic = f"{apns_topic}.voip" return apns_topic def create_priority(self) -> int: """ logic to define apns_priority value using request parameters Notification priority, apns_prioriy 10 o send the notification immediately, 5 to send the notification based on power considerations on the user’s device. """ apns_priority = '10' if self.event in ('incoming_session', 'incoming_conference_request') else '5' return apns_priority class FirebaseSylkHeaders(FirebaseHeaders): """ Firebase headers for a push notification """ class AppleSylkPayload(ApplePayload): """ A payload for a Apple Sylk push notification """ - @property + @property def payload(self) -> str: """ Generate an AppleSylk notification payload """ if self.event == 'cancel': payload = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'reason': self.reason } else: payload = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'media-type': self.media_type, 'from_uri': self.sip_from, 'from_display_name': self.from_display_name, 'to_uri': self.sip_to } return payload class FirebaseSylkPayload(FirebasePayload): """ A payload for a Firebase Sylk push notification """ @property def payload(self) -> str: """ Generate a Sylk payload and extra Firebase parameters """ if not self.from_display_name: from_display_name = self.sip_from else: from_display_name = self.from_display_name if self.event == 'cancel': data = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'reason': self.reason } else: data = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'media-type': self.media_type, 'from_uri': self.sip_from, 'from_display_name': from_display_name, 'to_uri': self.sip_to } http_payload = { 'message': { 'token': self.token, 'data': data, 'android': { 'priority': 'high', 'ttl': '60s' } } } #fcm_payload = messaging.Message( # token=self.token, # data=data, # android=messaging.AndroidConfig( # ttl=datetime.timedelta(seconds=60), # priority='high' # ) #) return http_payload diff --git a/pushserver/models/requests.py b/pushserver/models/requests.py index 84dec30..32ea1b2 100644 --- a/pushserver/models/requests.py +++ b/pushserver/models/requests.py @@ -1,221 +1,221 @@ from pydantic import BaseModel, root_validator, validator from pushserver.resources import settings from pushserver.resources.utils import fix_platform_name def gen_validator_items() -> tuple: """ Generate some dicts according to minimum required parameters, and each app required paramaters, usefull for request validation. :return: two `dict` objects with common items and apps items. """ common_items = {'app-id', 'call-id', 'platform', 'from', 'token'} only_sylk_items = {'silent', 'to', 'event'} only_linphone_items = set() apps_items = {'sylk': common_items | only_sylk_items, # union 'linphone': common_items | only_linphone_items} return common_items, apps_items common_items, apps_items = gen_validator_items() def alias_rename(attribute: str) -> str: """ Rename request name attribute, replacing '_' by '_' and removing 'sip_' characters. :param attribute: `str` from request :return: a `str` corresponding to alias. """ if attribute.startswith('sip_'): return attribute.split('_', maxsplit=1)[1] return attribute.replace('_', '-') class AddRequest(BaseModel): app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' token: str # destination device token in hex device_id: str # the device-id that owns the token (used for logging purposes) silent: bool = True user_agent: str = None class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_for_add(cls, values): app_id, platform = values.get('app-id'), values.get('platform') if not app_id: - raise ValueError(f"Field 'app-id' required") + raise ValueError("Field 'app-id' required") if not platform: - raise ValueError(f"Field 'platform' required") + raise ValueError("Field 'platform' required") platform = fix_platform_name(platform) if platform not in ('firebase', 'apple'): raise ValueError(f"The '{platform}' platform is not configured") pns_register = settings.params.pns_register if (app_id, platform) not in pns_register.keys(): raise ValueError(f"{platform.capitalize()} {app_id} app " f"is not configured") return values @validator('platform') def platform_valid_values(cls, v): if v not in ('apple', 'ios', 'android', 'firebase'): raise ValueError("platform must be 'apple', 'android' or 'firebase'") return v class AddResponse(BaseModel): app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' token: str # destination device token in hex device_id: str # the device-id that owns the token (used for logging purposes) silent: bool = True user_agent: str = None class Config: allow_population_by_field_name = True alias_generator = alias_rename class RemoveRequest(BaseModel): app_id: str # id provided by the mobile application (bundle id) device_id: str = None # the device-id that owns the token (used for logging purposes) class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_for_add(cls, values): app_id = values.get('app-id') if not app_id: - raise ValueError(f"Field 'app-id' required") + raise ValueError("Field 'app-id' required") return values class RemoveResponse(BaseModel): app_id: str # id provided by the mobile application (bundle id) device_id: str = None # the device-id that owns the token (used for logging purposes) class Config: allow_population_by_field_name = True alias_generator = alias_rename class PushRequest(BaseModel): event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' call_id: str # (required for apple) unique sip parameter sip_from: str # (required for firebase) SIP URI for who is calling from_display_name: str = None # (required for sylk) display name of the caller to: str # SIP URI for who is called media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' reason: str = None # Cancel reason class Config: alias_generator = alias_rename class WakeUpRequest(BaseModel): # API expects a json object like: app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' token: str # destination device token in hex device_id: str = None # the device-id that owns the token (used for logging purposes) call_id: str # (required for apple) unique sip parameter sip_from: str # (required for firebase) SIP URI for who is calling from_display_name: str = None # (required for sylk) display name of the caller sip_to: str # SIP URI for who is called media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' silent: bool = True # True for silent notification reason: str = None # Cancel reason class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_by_app(cls, values): app_id, platform = values.get('app-id'), values.get('platform') if not app_id: - raise ValueError(f"Field 'app-id' required") + raise ValueError("Field 'app-id' required") if not platform: - raise ValueError(f"Field 'platform' required") + raise ValueError("Field 'platform' required") platform = fix_platform_name(platform) if platform not in ('firebase', 'apple'): raise ValueError(f"'{platform}' platform is not configured") pns_register = settings.params.pns_register if (app_id, platform) not in pns_register.keys(): raise ValueError(f"{platform.capitalize()} {app_id} app " f"is not configured") try: name = pns_register[(app_id, platform)]['name'] check_items = apps_items[name] missing_items = [] for item in check_items: if values.get(item) is None: missing_items.append(item) if missing_items: missing_items_show = [] for item in missing_items: if item in ('sip_to', 'sip_from', 'device_id'): item = item.split('_')[1] else: item = item.replace('-', '_') missing_items_show.append(item) raise ValueError(f"'{' ,'.join(missing_items)}' " f"item(s) missing.") except KeyError: pass event = values.get('event') if event != 'cancel': media_type = values.get('media-type') if not media_type: raise ValueError("Field media-type required") if media_type not in ('audio', 'video', 'chat', 'sms', 'file-transfer'): raise ValueError("media-type must be 'audio', 'video', " "'chat', 'sms', 'file-transfer'") if 'linphone' in name: if event: if event != 'incoming_session': raise ValueError('event not found (must be incoming_sesion)') else: values['event'] = 'incoming_session' return values @validator('platform') def platform_valid_values(cls, v): if v not in ('apple', 'ios', 'android', 'firebase'): raise ValueError("platform must be 'apple', 'android' or 'firebase'") return v @validator('event') def event_valid_values(cls, v): if v not in ('incoming_session', 'incoming_conference_request', 'cancel'): raise ValueError("event must be 'incoming_session', 'incoming_conference_request' or 'cancel'") return v diff --git a/pushserver/pns/apple.py b/pushserver/pns/apple.py index 086b369..c2335a4 100644 --- a/pushserver/pns/apple.py +++ b/pushserver/pns/apple.py @@ -1,403 +1,402 @@ import json import os import socket import ssl import time import hyper from hyper import HTTP20Connection, tls from pushserver.models.requests import WakeUpRequest from pushserver.resources.utils import log_event, ssl_cert from pushserver.pns.base import PNS, PushRequest, PlatformRegister class ApplePNS(PNS): """ An Apple Push Notification service """ def __init__(self, app_id: str, app_name: str, url_push: str, voip: bool, cert_file: str, key_file: str): """ :param app_id: `str`, blunde id provided by application. :param url_push: `str`, URI to push a notification (from applications.ini) :param cert_file `str`: path to APNS certificate (provided by dev app kit) :param key_file `str`: path to APNS key (provided by dev app kit) :param voip: `bool`, Required for apple, `True` for voip push notification type. """ self.app_id = app_id self.app_name = app_name self.url_push = url_push self.voip = voip self.key_file = key_file self.cert_file = cert_file class AppleConn(ApplePNS): """ An Apple connection """ def __init__(self, app_id: str, app_name: str, url_push: str, - voip: bool, cert_file: str, key_file: str, + voip: bool, cert_file: str, key_file: str, apple_pns: PNS, loggers: dict, port: int = 443): """ :param apple_pns `ApplePNS`: Apple Push Notification Service. :param port `int`: 443 or 2197 to allow APNS traffic but block other HTTP traffic. :param loggers: `dict` global logging instances to write messages (params.loggers) :attribute ssl_context `ssl.SSLContext`: generated with a valid apple certificate. :attribute connection `HTTP20Connection`: related to an app and its corresponding certificate. """ self.app_id = app_id self.app_name = app_name self.url_push = url_push self.voip = voip self.key_file = key_file self.cert_file = cert_file self.apple_pns = apple_pns self.port = port self.loggers = loggers - @property def ssl_context(self) -> ssl.SSLContext: """ Define a ssl context using a cert_file to open a connection requires a valid certificate file :return: a ssl.SSLContext object """ cert_file = self.cert_file key_file = self.key_file if self.key_file else self.cert_file ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE ssl_context.load_cert_chain(keyfile=key_file, certfile=cert_file) return ssl_context @property def connection(self) -> HTTP20Connection: """ Open an apple connection requires a ssl context :return: an hyper.http20.connection.HTTP20Connection object """ host = self.url_push port = self.port ssl_context = self.ssl_context connection = HTTP20Connection(host=host, port=port, ssl_context=ssl_context, force_proto=tls.H2C_PROTOCOL) cert_file_name = self.cert_file.split('/')[-1] key_file_name = self.key_file.split('/')[-1] if self.key_file else None if key_file_name: msg = f'{self.app_name.capitalize()} app: Connecting to {host}:{port} ' \ - f'using {cert_file_name} certificate ' \ - f'and {key_file_name} key files' + f'using {cert_file_name} certificate ' \ + f'and {key_file_name} key files' else: msg = f'{self.app_name.capitalize()} app: Connecting to {host}:{port} ' \ - f'using {cert_file_name} certificate' + f'using {cert_file_name} certificate' log_event(loggers=self.loggers, msg=msg, level='deb') return connection class AppleRegister(PlatformRegister): - def __init__(self, app_id: str, app_name:str, voip: bool, + def __init__(self, app_id: str, app_name: str, voip: bool, credentials_path: str, config_dict: dict, loggers: dict): self.app_id = app_id self.app_name = app_name self.voip = voip self.credentials_path = credentials_path self.config_dict = config_dict self.loggers = loggers self.error = '' @property def url_push(self) -> str: try: return self.config_dict['apple_push_url'] except KeyError: self.error = 'apple_push_url not found in applications.ini' return None @property def certificate(self) -> dict: if self.error: return {} else: try: cert_file = f"{self.credentials_path}/" \ f"{self.config_dict['apple_certificate']}" cert_exists = os.path.exists(cert_file) if not cert_exists: self.error = f"{cert_file} - no such file." return {} else: return {'cert_file': cert_file, 'cert_exists': cert_exists} except KeyError: self.error = 'apple_certificate not found in applications.ini' return {} @property def key(self) -> dict: if self.error: return {} try: key_file = f"{self.credentials_path}/" \ f"{self.config_dict['apple_key']}" key_exists = os.path.exists(key_file) if not key_exists: self.error = f"{key_file} - no such file." return {} except KeyError: return {} return {'key_file': key_file, 'key_exists': key_exists} @property def ssl_valid_cert(self) -> bool: if self.error: return else: try: cert_file = self.certificate.get('cert_file') key_file = self.key.get('key_file') if not (cert_file or key_file): self.error = 'An apple certificate/key is needed to open a connection' elif not ssl_cert(cert_file, key_file): self.error = f"{cert_file} - bad ssl certificate." return else: return True except FileNotFoundError as exc: self.error = exc return @property def apple_pns(self) -> ApplePNS: if self.error: return if self.ssl_valid_cert: cert_file = self.certificate.get('cert_file') key_file = self.key.get('key_file') return ApplePNS(app_id=self.app_id, app_name=self.app_name, url_push=self.url_push, voip=self.voip, cert_file=cert_file, key_file=key_file) @property def apple_conn(self): if self.error: return return AppleConn(app_id=self.app_id, app_name=self.app_name, url_push=self.url_push, voip=self.voip, cert_file=self.certificate.get('cert_file'), key_file=self.key.get('key_file'), apple_pns=self.apple_pns, loggers=self.loggers).connection @property def register_entries(self): if self.error: return {} return {'pns': self.apple_pns, 'conn': self.apple_conn} class ApplePushRequest(PushRequest): """ Apple push notification request """ def __init__(self, error: str, app_name: str, app_id: str, request_id: str, headers: str, payload: dict, loggers: dict, log_remote: dict, wp_request: WakeUpRequest, register: dict): """ :param error: `str` :param app_name: `str` 'linphone' or 'payload' :param app_id: `str` bundle id :param headers: `AppleHeaders` Apple push notification headers :param payload: `ApplePayload` Apple push notification payload :param wp_request: `WakeUpRequest` :param loggers: `dict` global logging instances to write messages (params.loggers) """ self.error = error self.app_name = app_name self.app_id = app_id self.platform = 'apple' self.request_id = request_id self.headers = headers self.payload = payload self.token = wp_request.token self.call_id = wp_request.call_id self.media_type = wp_request.media_type self.wp_request = wp_request self.loggers = loggers self.log_remote = log_remote self.apple_pns = register['pns'] self.connection = register['conn'] self.path = f'/3/device/{self.token}' self.results = self.send_notification() def send_notification(self) -> dict: """ Send an apple push requests to a single device. If status of response is like 5xx, an exponential backoff factor is implemented to retry the notification according to media type. :param `hstr` token: destination device. :param `str` method: HTTP request method, must be 'POST'. :param `AppleHeaders` headers: Apple push notification headers. :param `ApplePayload` payload: Apple push notification payload. """ if self.error: self.log_error() return {'code': 500, 'body': {}, 'reason': 'Internal server error'} n_retries, backoff_factor = self.retries_params(self.media_type) log_path = f'http://{self.apple_pns.url_push}{self.path}' status_forcelist = tuple([status for status in range(500, 600)]) counter = 0 status = 500 reason = '' body = {} while counter <= n_retries: if self.connection: try: self.log_request(path=log_path) self.connection.request('POST', self.path, self.payload, self.headers) response = self.connection.get_response() reason_str = response.read().decode('utf8').replace("'", '"') if reason_str: reason_json = json.loads(reason_str) reason = reason_json.get('reason') else: reason = reason_str status = response.status if status not in status_forcelist: break except socket.gaierror: reason = 'socket error' except hyper.http20.exceptions.StreamResetError: reason = 'stream error' except ValueError as err: reason = f'Bad type of object in headers or payload: {err}' break else: reason = 'no connection' counter += 1 timer = backoff_factor * (2 ** (counter - 1)) time.sleep(timer) if counter == n_retries: reason = 'max retries reached' url = f'https:{self.connection.host}:{self.connection.port}{self.path}' if status != 200: details = self.apple_error_info(reason) if details: reason = f'{reason} - {details}' if status == 400 and 'BadDeviceToken' in reason: status = 410 results = {'body': body, 'code': status, 'reason': reason, 'url': url, 'platform': 'apple', 'call_id': self.call_id, 'token': self.token } self.results = results self.log_results() return results def apple_error_info(self, reason): """ Give a human readable message according to 'reason' from apple APN. :returns : a string with message according to reason """ description_codes = {'ConnectionFailed': 'There was an error connecting to APNs.', 'InternalException': 'This exception should not be raised. If it is, please report this as a bug.', 'BadPayloadException': 'Something bad with the payload.', 'BadCollapseId': 'The collapse identifier exceeds the maximum allowed size', 'BadDeviceToken': 'The specified device token was bad. Verify that the request contains a valid token and that the token matches the environment.', 'BadExpirationDate:': 'The apns-expiration value is bad.', 'BadMessageId': 'The apns-id value is bad.', 'BadPriority': 'The apns-priority value is bad.', 'BadTopic': 'The apns-topic was invalid.', 'DeviceTokenNotForTopic': 'The device token does not match the specified topic.', 'DuplicateHeaders': 'One or more headers were repeated.', 'IdleTimeout': 'Idle time out.', 'MissingDeviceToken': 'The device token is not specified in the request :path. Verify that the :path header contains the device token.', 'MissingTopic': 'The apns-topic header of the request was not specified and was required. The apns-topic header is mandatory when the client is connected using a certificate that supports multiple topics.', 'PayloadEmpty': 'The message payload was empty.', 'TopicDisallowed': 'Pushing to this topic is not allowed.', 'BadCertificate': 'The certificate was bad.', 'BadCertificateEnvironment': 'The client certificate was for the wrong environment.', 'ExpiredProviderToken': 'The provider token is stale and a new token should be generated.', 'Forbidden': 'The specified action is not allowed.', 'InvalidProviderToken': 'The provider token is not valid or the token signature could not be verified.', 'MissingProviderToken': 'No provider certificate was used to connect to APNs and Authorization header was missing or no provider token was specified.', 'BadPath': 'The request contained a bad :path value.', 'MethodNotAllowed': 'The specified :method was not POST.', 'Unregistered': 'The device token is inactive for the specified topic.', 'PayloadTooLarge': 'The message payload was too large. The maximum payload size is 4096 bytes.', 'TooManyProviderTokenUpdates': 'The provider token is being updated too often.', 'TooManyRequests': 'Too many requests were made consecutively to the same device token.', 'InternalServerError': 'An internal server error occurred.', 'ServiceUnavailable': 'The service is unavailable.', 'Shutdown': 'The server is shutting down.', 'InvalidPushType': 'The apns-push-type value is invalid.'} try: message = description_codes[reason] return message except KeyError: return None diff --git a/pushserver/resources/notification.py b/pushserver/resources/notification.py index e4cdd3d..d8f6e1f 100644 --- a/pushserver/resources/notification.py +++ b/pushserver/resources/notification.py @@ -1,96 +1,96 @@ import importlib import json from pushserver.models.requests import WakeUpRequest from pushserver.resources import settings def handle_request(wp_request, request_id: str) -> dict: """ Create a PushNotification object, and call methods to send the notification. :param wp_request: `WakeUpRequest', received from /push route. :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str`, request ID generated on request event. :return: a `dict` with push notification results """ push_notification = PushNotification(wp_request=wp_request, request_id=request_id) results = push_notification.send_notification() return results class PushNotification(object): """ Push Notification actions from wake up request """ def __init__(self, wp_request: WakeUpRequest, request_id: str): """ :param wp_request: `WakeUpRequest`, from http request :param request_id: `str`, request ID generated on request event. """ self.wp_request = wp_request self.app_id = self.wp_request.app_id self.platform = self.wp_request.platform self.pns_register = settings.params.pns_register self.request_id = request_id self.loggers = settings.params.loggers self.log_remote = self.pns_register[(self.app_id, self.platform)].get('log_remote') self.config_dict = self.pns_register[(self.app_id, self.platform)] self.app_name = self.pns_register[(self.app_id, self.platform)]['name'] self.args = [self.app_id, self.wp_request.event, self.wp_request.token, self.wp_request.call_id, self.wp_request.sip_from, self.wp_request.from_display_name, self.wp_request.sip_to, self.wp_request.media_type, self.wp_request.silent, self.wp_request.reason] @property def custom_apps(self): apps = [self.pns_register[key]['name'] for key in self.pns_register.keys()] custom_apps = set(app for app in apps if app not in ('sylk', 'linphone')) return custom_apps def send_notification(self) -> dict: """ Send a push notification according to wakeup request params. """ error = '' headers_class = self.pns_register[(self.app_id, self.platform)]['headers_class'] headers = headers_class(*self.args).headers payload_class = self.pns_register[(self.app_id, self.platform)]['payload_class'] payload_dict = payload_class(*self.args).payload - try: + try: payload = json.dumps(payload_dict) except Exception: payload = None if not (headers and payload): error = f'{headers_class.__name__} and {payload_class.__name__} ' \ f'returned bad objects:' \ f'{headers}, {payload}' if not isinstance(headers, dict) or not isinstance(payload, str): error = f'{headers_class.__name__} and {payload_class.__name__} ' \ f'returned bad objects:' \ f'{headers}, {payload}' register = self.pns_register[(self.app_id, self.platform)] platform_module = importlib.import_module(f'pushserver.pns.{self.platform}') push_request_class = getattr(platform_module, f'{self.platform.capitalize()}PushRequest') push_request = push_request_class(error=error, app_name=self.app_name, app_id=self.app_id, request_id=self.request_id, headers=headers, payload=payload, loggers=self.loggers, log_remote=self.log_remote, wp_request=self.wp_request, register=register) return push_request.results