diff options
Diffstat (limited to 'libs/apprise/plugins/fcm')
-rw-r--r-- | libs/apprise/plugins/fcm/__init__.py | 628 | ||||
-rw-r--r-- | libs/apprise/plugins/fcm/color.py | 121 | ||||
-rw-r--r-- | libs/apprise/plugins/fcm/common.py | 46 | ||||
-rw-r--r-- | libs/apprise/plugins/fcm/oauth.py | 319 | ||||
-rw-r--r-- | libs/apprise/plugins/fcm/priority.py | 251 |
5 files changed, 1365 insertions, 0 deletions
diff --git a/libs/apprise/plugins/fcm/__init__.py b/libs/apprise/plugins/fcm/__init__.py new file mode 100644 index 000000000..9dc0679f1 --- /dev/null +++ b/libs/apprise/plugins/fcm/__init__.py @@ -0,0 +1,628 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron <[email protected]> +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# For this plugin to work correct, the FCM server must be set up to allow +# for remote connections. + +# Firebase Cloud Messaging +# Visit your console page: https://console.firebase.google.com +# 1. Create a project if you haven't already. If you did the +# {project} ID will be listed as name-XXXXX. +# 2. Click on your project from here to open it up. +# 3. Access your Web API Key by clicking on: +# - The (gear-next-to-project-name) > Project Settings > Cloud Messaging + +# Visit the following site to get you're Project information: +# - https://console.cloud.google.com/project/_/settings/general/ +# +# Docs: https://firebase.google.com/docs/cloud-messaging/send-message + +# Legacy Docs: +# https://firebase.google.com/docs/cloud-messaging/http-server-ref\ +# #send-downstream +# +# If you Generate a new private key, it will provide a .json file +# You will need this in order to send an apprise messag +import requests +from json import dumps +from ..base import NotifyBase +from ...common import NotifyType +from ...utils import validate_regex +from ...utils import parse_list +from ...utils import parse_bool +from ...utils import dict_full_update +from ...common import NotifyImageSize +from ...apprise_attachment import AppriseAttachment +from ...locale import gettext_lazy as _ +from .common import (FCMMode, FCM_MODES) +from .priority import (FCM_PRIORITIES, FCMPriorityManager) +from .color import FCMColorManager + +# Default our global support flag +NOTIFY_FCM_SUPPORT_ENABLED = False + +try: + from .oauth import GoogleOAuth + + # We're good to go + NOTIFY_FCM_SUPPORT_ENABLED = True + +except ImportError: + # cryptography is the dependency of the .oauth library + + # Create a dummy object for init() call to work + class GoogleOAuth: + pass + + +# Our lookup map +FCM_HTTP_ERROR_MAP = { + 400: 'A bad request was made to the server.', + 401: 'The provided API Key was not valid.', + 404: 'The token could not be registered.', +} + + +class NotifyFCM(NotifyBase): + """ + A wrapper for Google's Firebase Cloud Messaging Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_FCM_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'cryptography' + } + + # The default descriptive name associated with the Notification + service_name = 'Firebase Cloud Messaging' + + # The services URL + service_url = 'https://firebase.google.com' + + # The default protocol + secure_protocol = 'fcm' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_fcm' + + # Project Notification + # https://firebase.google.com/docs/cloud-messaging/send-message + notify_oauth2_url = \ + "https://fcm.googleapis.com/v1/projects/{project}/messages:send" + + notify_legacy_url = "https://fcm.googleapis.com/fcm/send" + + # There is no reason we should exceed 5KB when reading in a JSON file. + # If it is more than this, then it is not accepted. + max_fcm_keyfile_size = 5000 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # The maximum length of the body + body_maxlen = 1024 + + # Define object templates + templates = ( + # OAuth2 + '{schema}://{project}/{targets}?keyfile={keyfile}', + # Legacy Mode + '{schema}://{apikey}/{targets}', + ) + + # Define our template + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + }, + 'keyfile': { + 'name': _('OAuth2 KeyFile'), + 'type': 'string', + 'private': True, + }, + 'project': { + 'name': _('Project ID'), + 'type': 'string', + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_topic': { + 'name': _('Target Topic'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'mode': { + 'name': _('Mode'), + 'type': 'choice:string', + 'values': FCM_MODES, + 'default': FCMMode.Legacy, + }, + 'priority': { + 'name': _('Mode'), + 'type': 'choice:string', + 'values': FCM_PRIORITIES, + }, + 'image_url': { + 'name': _('Custom Image URL'), + 'type': 'string', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + # Color can either be yes, no, or a #rrggbb ( + # rrggbb without hashtag is accepted to) + 'color': { + 'name': _('Notification Color'), + 'type': 'string', + 'default': 'yes', + }, + }) + + # Define our data entry + template_kwargs = { + 'data_kwargs': { + 'name': _('Data Entries'), + 'prefix': '+', + }, + } + + def __init__(self, project, apikey, targets=None, mode=None, keyfile=None, + data_kwargs=None, image_url=None, include_image=False, + color=None, priority=None, **kwargs): + """ + Initialize Firebase Cloud Messaging + + """ + super().__init__(**kwargs) + + if mode is None: + # Detect our mode + self.mode = FCMMode.OAuth2 if keyfile else FCMMode.Legacy + + else: + # Setup our mode + self.mode = NotifyFCM.template_tokens['mode']['default'] \ + if not isinstance(mode, str) else mode.lower() + if self.mode and self.mode not in FCM_MODES: + msg = 'The FCM mode specified ({}) is invalid.'.format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Used for Legacy Mode; this is the Web API Key retrieved from the + # User Panel + self.apikey = None + + # Path to our Keyfile + self.keyfile = None + + # Our Project ID is required to verify against the keyfile + # specified + self.project = None + + # Initialize our Google OAuth module we can work with + self.oauth = GoogleOAuth( + user_agent=self.app_id, timeout=self.request_timeout, + verify_certificate=self.verify_certificate) + + if self.mode == FCMMode.OAuth2: + # The project ID associated with the account + self.project = validate_regex(project) + if not self.project: + msg = 'An invalid FCM Project ID ' \ + '({}) was specified.'.format(project) + self.logger.warning(msg) + raise TypeError(msg) + + if not keyfile: + msg = 'No FCM JSON KeyFile was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Our keyfile object is just an AppriseAttachment object + self.keyfile = AppriseAttachment(asset=self.asset) + # Add our definition to our template + self.keyfile.add(keyfile) + # Enforce maximum file size + self.keyfile[0].max_file_size = self.max_fcm_keyfile_size + + else: # Legacy Mode + + # The apikey associated with the account + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid FCM API key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Device IDs to notify + self.targets = parse_list(targets) + + # Our data Keyword/Arguments to include in our outbound payload + self.data_kwargs = {} + if isinstance(data_kwargs, dict): + self.data_kwargs.update(data_kwargs) + + # Include the image as part of the payload + self.include_image = include_image + + # A Custom Image URL + # FCM allows you to provide a remote https?:// URL to an image_url + # located on the internet that it will download and include in the + # payload. + # + # self.image_url() is reserved as an internal function name; so we + # jsut store it into a different variable for now + self.image_src = image_url + + # Initialize our priority + self.priority = FCMPriorityManager(self.mode, priority) + + # Initialize our color + self.color = FCMColorManager(color, asset=self.asset) + return + + @property + def access_token(self): + """ + Generates a access_token based on the keyfile provided + """ + keyfile = self.keyfile[0] + if not keyfile: + # We could not access the keyfile + self.logger.error( + 'Could not access FCM keyfile {}.'.format( + keyfile.url(privacy=True))) + return None + + if not self.oauth.load(keyfile.path): + self.logger.error( + 'FCM keyfile {} could not be loaded.'.format( + keyfile.url(privacy=True))) + return None + + # Verify our project id against the one provided in our keyfile + if self.project != self.oauth.project_id: + self.logger.error( + 'FCM keyfile {} identifies itself for a different project' + .format(keyfile.url(privacy=True))) + return None + + # Return our generated key; the below returns None if a token could + # not be acquired + return self.oauth.access_token + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform FCM Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning('There are no FCM devices or topics to notify') + return False + + if self.mode == FCMMode.OAuth2: + access_token = self.access_token + if not access_token: + # Error message is generated in access_tokengen() so no reason + # to additionally write anything here + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + "Authorization": "Bearer {}".format(access_token), + } + + # Prepare our notify URL + notify_url = self.notify_oauth2_url + + else: # FCMMode.Legacy + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + "Authorization": "key={}".format(self.apikey), + } + + # Prepare our notify URL + notify_url = self.notify_legacy_url + + # Acquire image url + image = self.image_url(notify_type) \ + if not self.image_src else self.image_src + + has_error = False + # Create a copy of the targets list + targets = list(self.targets) + while len(targets): + recipient = targets.pop(0) + + if self.mode == FCMMode.OAuth2: + payload = { + 'message': { + 'token': None, + 'notification': { + 'title': title, + 'body': body, + } + } + } + + if self.color: + # Acquire our color + payload['message']['android'] = { + 'notification': {'color': self.color.get(notify_type)}} + + if self.include_image and image: + payload['message']['notification']['image'] = image + + if self.data_kwargs: + payload['message']['data'] = self.data_kwargs + + if recipient[0] == '#': + payload['message']['topic'] = recipient[1:] + self.logger.debug( + "FCM recipient %s parsed as a topic", + recipient[1:]) + + else: + payload['message']['token'] = recipient + self.logger.debug( + "FCM recipient %s parsed as a device token", + recipient) + + else: # FCMMode.Legacy + payload = { + 'notification': { + 'notification': { + 'title': title, + 'body': body, + } + } + } + + if self.color: + # Acquire our color + payload['notification']['notification']['color'] = \ + self.color.get(notify_type) + + if self.include_image and image: + payload['notification']['notification']['image'] = image + + if self.data_kwargs: + payload['data'] = self.data_kwargs + + if recipient[0] == '#': + payload['to'] = '/topics/{}'.format(recipient) + self.logger.debug( + "FCM recipient %s parsed as a topic", + recipient[1:]) + + else: + payload['to'] = recipient + self.logger.debug( + "FCM recipient %s parsed as a device token", + recipient) + + # A more advanced dict.update() that recursively includes + # sub-dictionaries as well + dict_full_update(payload, self.priority.payload()) + + self.logger.debug( + 'FCM %s POST URL: %s (cert_verify=%r)', + self.mode, notify_url, self.verify_certificate, + ) + self.logger.debug('FCM %s Payload: %s', self.mode, str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url.format(project=self.project), + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, FCM_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send {} FCM notification: ' + '{}{}error={}.'.format( + self.mode, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n%s', r.content) + + has_error = True + + else: + self.logger.info('Sent %s FCM notification.', self.mode) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending FCM ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s', str(e)) + + has_error = True + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'mode': self.mode, + 'image': 'yes' if self.include_image else 'no', + 'color': str(self.color), + } + + if self.priority: + # Store our priority if one was defined + params['priority'] = str(self.priority) + + if self.keyfile: + # Include our keyfile if specified + params['keyfile'] = NotifyFCM.quote( + self.keyfile[0].url(privacy=privacy), safe='') + + if self.image_src: + # Include our image path as part of our URL payload + params['image_url'] = self.image_src + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Add our data keyword/args into our URL response + params.update( + {'+{}'.format(k): v for k, v in self.data_kwargs.items()}) + + reference = NotifyFCM.quote(self.project) \ + if self.mode == FCMMode.OAuth2 \ + else self.pprint(self.apikey, privacy, safe='') + + return '{schema}://{reference}/{targets}?{params}'.format( + schema=self.secure_protocol, + reference=reference, + targets='/'.join( + [NotifyFCM.quote(x) for x in self.targets]), + params=NotifyFCM.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The apikey/project is stored in the hostname + results['apikey'] = NotifyFCM.unquote(results['host']) + results['project'] = results['apikey'] + + # Get our Device IDs + results['targets'] = NotifyFCM.split_path(results['fullpath']) + + # Get our mode + results['mode'] = results['qsd'].get('mode') + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyFCM.parse_list(results['qsd']['to']) + + # Our Project ID + if 'project' in results['qsd'] and results['qsd']['project']: + results['project'] = \ + NotifyFCM.unquote(results['qsd']['project']) + + # Our Web API Key + if 'apikey' in results['qsd'] and results['qsd']['apikey']: + results['apikey'] = \ + NotifyFCM.unquote(results['qsd']['apikey']) + + # Our Keyfile (JSON) + if 'keyfile' in results['qsd'] and results['qsd']['keyfile']: + results['keyfile'] = \ + NotifyFCM.unquote(results['qsd']['keyfile']) + + # Our Priority + if 'priority' in results['qsd'] and results['qsd']['priority']: + results['priority'] = \ + NotifyFCM.unquote(results['qsd']['priority']) + + # Our Color + if 'color' in results['qsd'] and results['qsd']['color']: + results['color'] = \ + NotifyFCM.unquote(results['qsd']['color']) + + # Boolean to include an image or not + results['include_image'] = parse_bool(results['qsd'].get( + 'image', NotifyFCM.template_args['image']['default'])) + + # Extract image_url if it was specified + if 'image_url' in results['qsd']: + results['image_url'] = \ + NotifyFCM.unquote(results['qsd']['image_url']) + if 'image' not in results['qsd']: + # Toggle default behaviour if a custom image was provided + # but ONLY if the `image` boolean was not set + results['include_image'] = True + + # Store our data keyword/args if specified + results['data_kwargs'] = results['qsd+'] + + return results diff --git a/libs/apprise/plugins/fcm/color.py b/libs/apprise/plugins/fcm/color.py new file mode 100644 index 000000000..20149eedd --- /dev/null +++ b/libs/apprise/plugins/fcm/color.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron <[email protected]> +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# New priorities are defined here: +# - https://firebase.google.com/docs/reference/fcm/rest/v1/\ +# projects.messages#NotificationPriority + +# Legacy color payload example here: +# https://firebase.google.com/docs/reference/fcm/rest/v1/\ +# projects.messages#androidnotification +import re +from ...utils import parse_bool +from ...common import NotifyType +from ...asset import AppriseAsset + + +class FCMColorManager: + """ + A Simple object to accept either a boolean value + - True: Use colors provided by Apprise + - False: Do not use colors at all + - rrggbb: where you provide the rgb values (hence #333333) + - rgb: is also accepted as rgb values (hence #333) + + For RGB colors, the hashtag is optional + """ + + __color_rgb = re.compile( + r'#?((?P<r1>[0-9A-F]{2})(?P<g1>[0-9A-F]{2})(?P<b1>[0-9A-F]{2})' + r'|(?P<r2>[0-9A-F])(?P<g2>[0-9A-F])(?P<b2>[0-9A-F]))', re.IGNORECASE) + + def __init__(self, color, asset=None): + """ + Parses the color object accordingly + """ + + # Initialize an asset object if one isn't otherwise defined + self.asset = asset \ + if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Prepare our color + self.color = color + if isinstance(color, str): + self.color = self.__color_rgb.match(color) + if self.color: + # Store our RGB value as #rrggbb + self.color = '{red}{green}{blue}'.format( + red=self.color.group('r1'), + green=self.color.group('g1'), + blue=self.color.group('b1')).lower() \ + if self.color.group('r1') else \ + '{red1}{red2}{green1}{green2}{blue1}{blue2}'.format( + red1=self.color.group('r2'), + red2=self.color.group('r2'), + green1=self.color.group('g2'), + green2=self.color.group('g2'), + blue1=self.color.group('b2'), + blue2=self.color.group('b2')).lower() + + if self.color is None: + # Color not determined, so base it on boolean parser + self.color = parse_bool(color) + + def get(self, notify_type=NotifyType.INFO): + """ + Returns color or true/false value based on configuration + """ + + if isinstance(self.color, bool) and self.color: + # We want to use the asset value + return self.asset.color(notify_type=notify_type) + + elif self.color: + # return our color as is + return '#' + self.color + + # No color to return + return None + + def __str__(self): + """ + our color representation + """ + if isinstance(self.color, bool): + return 'yes' if self.color else 'no' + + # otherwise return our color + return self.color + + def __bool__(self): + """ + Allows this object to be wrapped in an 'if statement'. + True is returned if a color was loaded + """ + return True if self.color is True or \ + isinstance(self.color, str) else False diff --git a/libs/apprise/plugins/fcm/common.py b/libs/apprise/plugins/fcm/common.py new file mode 100644 index 000000000..9f139226a --- /dev/null +++ b/libs/apprise/plugins/fcm/common.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron <[email protected]> +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +class FCMMode: + """ + Define the Firebase Cloud Messaging Modes + """ + # The legacy way of sending a message + Legacy = "legacy" + + # The new API + OAuth2 = "oauth2" + + +# FCM Modes +FCM_MODES = ( + # Legacy API + FCMMode.Legacy, + # HTTP v1 URL + FCMMode.OAuth2, +) diff --git a/libs/apprise/plugins/fcm/oauth.py b/libs/apprise/plugins/fcm/oauth.py new file mode 100644 index 000000000..fbde3ccf7 --- /dev/null +++ b/libs/apprise/plugins/fcm/oauth.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron <[email protected]> +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# To generate a private key file for your service account: +# +# 1. In the Firebase console, open Settings > Service Accounts. +# 2. Click Generate New Private Key, then confirm by clicking Generate Key. +# 3. Securely store the JSON file containing the key. + +import requests +import base64 +import json +import calendar +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import asymmetric +from cryptography.exceptions import UnsupportedAlgorithm +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from json.decoder import JSONDecodeError +from urllib.parse import urlencode as _urlencode + +from ...logger import logger + + +class GoogleOAuth: + """ + A OAuth simplified implimentation to Google's Firebase Cloud Messaging + + """ + scopes = [ + 'https://www.googleapis.com/auth/firebase.messaging', + ] + + # 1 hour in seconds (the lifetime of our token) + access_token_lifetime_sec = timedelta(seconds=3600) + + # The default URI to use if one is not found + default_token_uri = 'https://oauth2.googleapis.com/token' + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + + def __init__(self, user_agent=None, timeout=(5, 4), + verify_certificate=True): + """ + Initialize our OAuth object + """ + + # Wether or not to verify ssl + self.verify_certificate = verify_certificate + + # Our (connect, read) timeout + self.request_timeout = timeout + + # assign our user-agent if defined + self.user_agent = user_agent + + # initialize our other object variables + self.__reset() + + def __reset(self): + """ + Reset object internal variables + """ + + # Google Keyfile Encoding + self.encoding = 'utf-8' + + # Our retrieved JSON content (unmangled) + self.content = None + + # Our generated key information we cache once loaded + self.private_key = None + + # Our keys we build using the provided content + self.__refresh_token = None + self.__access_token = None + self.__access_token_expiry = datetime.now(timezone.utc) + + def load(self, path): + """ + Generate our SSL details + """ + + # Reset our objects + self.content = None + self.private_key = None + self.__access_token = None + self.__access_token_expiry = datetime.now(timezone.utc) + + try: + with open(path, mode="r", encoding=self.encoding) as fp: + self.content = json.loads(fp.read()) + + except (OSError, IOError): + logger.debug('FCM keyfile {} could not be accessed'.format(path)) + return False + + except JSONDecodeError as e: + logger.debug( + 'FCM keyfile {} generated a JSONDecodeError: {}'.format( + path, e)) + return False + + if not isinstance(self.content, dict): + logger.debug( + 'FCM keyfile {} is incorrectly structured'.format(path)) + self.__reset() + return False + + # Verify we've got the correct tokens in our content to work with + is_valid = next((False for k in ( + 'client_email', 'private_key_id', 'private_key', + 'type', 'project_id') if not self.content.get(k)), True) + + if not is_valid: + logger.debug( + 'FCM keyfile {} is missing required information'.format(path)) + self.__reset() + return False + + # Verify our service_account type + if self.content.get('type') != 'service_account': + logger.debug( + 'FCM keyfile {} is not of type service_account'.format(path)) + self.__reset() + return False + + # Prepare our private key which is in PKCS8 PEM format + try: + self.private_key = serialization.load_pem_private_key( + self.content.get('private_key').encode(self.encoding), + password=None, backend=backends.default_backend()) + + except (TypeError, ValueError): + # ValueError: If the PEM data could not be decrypted or if its + # structure could not be decoded successfully. + # TypeError: If a password was given and the private key was + # not encrypted. Or if the key was encrypted but + # no password was supplied. + logger.error('FCM provided private key is invalid.') + self.__reset() + return False + + except UnsupportedAlgorithm: + # If the serialized key is of a type that is not supported by + # the backend. + logger.error('FCM provided private key is not supported') + self.__reset() + return False + + # We've done enough validation to move on + return True + + @property + def access_token(self): + """ + Returns our access token (if it hasn't expired yet) + - if we do not have one we'll fetch one. + - if it expired, we'll renew it + - if a key simply can't be acquired, then we return None + """ + + if not self.private_key or not self.content: + # invalid content (or not loaded) + logger.error( + 'No FCM JSON keyfile content loaded to generate a access ' + 'token with.') + return None + + if self.__access_token_expiry > datetime.now(timezone.utc): + # Return our no-expired key + return self.__access_token + + # If we reach here we need to prepare our payload + token_uri = self.content.get('token_uri', self.default_token_uri) + service_email = self.content.get('client_email') + key_identifier = self.content.get('private_key_id') + + # Generate our Assertion + now = datetime.now(timezone.utc) + expiry = now + self.access_token_lifetime_sec + + payload = { + # The number of seconds since the UNIX epoch. + "iat": calendar.timegm(now.utctimetuple()), + "exp": calendar.timegm(expiry.utctimetuple()), + # The issuer must be the service account email. + "iss": service_email, + # The audience must be the auth token endpoint's URI + "aud": token_uri, + # Our token scopes + "scope": " ".join(self.scopes), + } + + # JWT Details + header = { + 'typ': 'JWT', + 'alg': 'RS256' if isinstance( + self.private_key, asymmetric.rsa.RSAPrivateKey) else 'ES256', + + # Key Identifier + 'kid': key_identifier, + } + + # Encodes base64 strings removing any padding characters. + segments = [ + base64.urlsafe_b64encode( + json.dumps(header).encode(self.encoding)).rstrip(b"="), + base64.urlsafe_b64encode( + json.dumps(payload).encode(self.encoding)).rstrip(b"="), + ] + + signing_input = b".".join(segments) + signature = self.private_key.sign( + signing_input, + asymmetric.padding.PKCS1v15(), + hashes.SHA256(), + ) + + # Finally append our segment + segments.append(base64.urlsafe_b64encode(signature).rstrip(b"=")) + assertion = b".".join(segments) + + http_payload = _urlencode({ + 'assertion': assertion, + 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', + }) + + http_headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + if self.user_agent: + http_headers['User-Agent'] = self.user_agent + + logger.info('Refreshing FCM Access Token') + try: + r = requests.post( + token_uri, + data=http_payload, + headers=http_headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + logger.warning( + 'Failed to update FCM Access Token error={}.' + .format(r.status_code)) + + logger.debug( + 'Response Details:\r\n%s', r.content) + return None + + except requests.RequestException as e: + logger.warning( + 'A Connection error occurred refreshing FCM ' + 'Access Token.' + ) + logger.debug('Socket Exception: %s', str(e)) + return None + + # If we get here, we made our request successfully, now we need + # to parse out the data + response = json.loads(r.content) + self.__access_token = response['access_token'] + self.__refresh_token = response.get( + 'refresh_token', self.__refresh_token) + + if 'expires_in' in response: + delta = timedelta(seconds=int(response['expires_in'])) + self.__access_token_expiry = \ + delta + datetime.now(timezone.utc) - self.clock_skew + + else: + # Allow some grace before we expire + self.__access_token_expiry = expiry - self.clock_skew + + logger.debug( + 'Access Token successfully acquired: %s', self.__access_token) + + # Return our token + return self.__access_token + + @property + def project_id(self): + """ + Returns the project id found in the file + """ + return None if not self.content \ + else self.content.get('project_id') diff --git a/libs/apprise/plugins/fcm/priority.py b/libs/apprise/plugins/fcm/priority.py new file mode 100644 index 000000000..8564d3460 --- /dev/null +++ b/libs/apprise/plugins/fcm/priority.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron <[email protected]> +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# New priorities are defined here: +# - https://firebase.google.com/docs/reference/fcm/rest/v1/\ +# projects.messages#NotificationPriority + +# Legacy priorities are defined here: +# - https://firebase.google.com/docs/cloud-messaging/http-server-ref +from .common import (FCMMode, FCM_MODES) +from ...logger import logger + + +class NotificationPriority: + """ + Defines the Notification Priorities as described on: + https://firebase.google.com/docs/reference/fcm/rest/v1/\ + projects.messages#androidmessagepriority + + NORMAL: + Default priority for data messages. Normal priority messages won't + open network connections on a sleeping device, and their delivery + may be delayed to conserve the battery. For less time-sensitive + messages, such as notifications of new email or other data to sync, + choose normal delivery priority. + + HIGH: + Default priority for notification messages. FCM attempts to + deliver high priority messages immediately, allowing the FCM + service to wake a sleeping device when possible and open a network + connection to your app server. Apps with instant messaging, chat, + or voice call alerts, for example, generally need to open a + network connection and make sure FCM delivers the message to the + device without delay. Set high priority if the message is + time-critical and requires the user's immediate interaction, but + beware that setting your messages to high priority contributes + more to battery drain compared with normal priority messages. + """ + + NORMAL = 'NORMAL' + HIGH = 'HIGH' + + +class FCMPriority: + """ + Defines our accepted priorites + """ + MIN = "min" + + LOW = "low" + + NORMAL = "normal" + + HIGH = "high" + + MAX = "max" + + +FCM_PRIORITIES = ( + FCMPriority.MIN, + FCMPriority.LOW, + FCMPriority.NORMAL, + FCMPriority.HIGH, + FCMPriority.MAX, +) + + +class FCMPriorityManager: + """ + A Simple object to make it easier to work with FCM set priorities + """ + + priority_map = { + FCMPriority.MIN: { + FCMMode.OAuth2: { + 'message': { + 'android': { + 'priority': NotificationPriority.NORMAL + }, + 'apns': { + 'headers': { + 'apns-priority': "5" + } + }, + 'webpush': { + 'headers': { + 'Urgency': 'very-low' + } + }, + } + }, + FCMMode.Legacy: { + 'priority': 'normal', + } + }, + FCMPriority.LOW: { + FCMMode.OAuth2: { + 'message': { + 'android': { + 'priority': NotificationPriority.NORMAL + }, + 'apns': { + 'headers': { + 'apns-priority': "5" + } + }, + 'webpush': { + 'headers': { + 'Urgency': 'low' + } + } + } + }, + FCMMode.Legacy: { + 'priority': 'normal', + } + }, + FCMPriority.NORMAL: { + FCMMode.OAuth2: { + 'message': { + 'android': { + 'priority': NotificationPriority.NORMAL + }, + 'apns': { + 'headers': { + 'apns-priority': "5" + } + }, + 'webpush': { + 'headers': { + 'Urgency': 'normal' + } + } + } + }, + FCMMode.Legacy: { + 'priority': 'normal', + } + }, + FCMPriority.HIGH: { + FCMMode.OAuth2: { + 'message': { + 'android': { + 'priority': NotificationPriority.HIGH + }, + 'apns': { + 'headers': { + 'apns-priority': "10" + } + }, + 'webpush': { + 'headers': { + 'Urgency': 'high' + } + } + } + }, + FCMMode.Legacy: { + 'priority': 'high', + } + }, + FCMPriority.MAX: { + FCMMode.OAuth2: { + 'message': { + 'android': { + 'priority': NotificationPriority.HIGH + }, + 'apns': { + 'headers': { + 'apns-priority': "10" + } + }, + 'webpush': { + 'headers': { + 'Urgency': 'high' + } + } + } + }, + FCMMode.Legacy: { + 'priority': 'high', + } + } + } + + def __init__(self, mode, priority=None): + """ + Takes a FCMMode and Priority + """ + + self.mode = mode + if self.mode not in FCM_MODES: + msg = 'The FCM mode specified ({}) is invalid.'.format(mode) + logger.warning(msg) + raise TypeError(msg) + + self.priority = None + if priority: + self.priority = \ + next((p for p in FCM_PRIORITIES + if p.startswith(priority[:2].lower())), None) + if not self.priority: + msg = 'An invalid FCM Priority ' \ + '({}) was specified.'.format(priority) + logger.warning(msg) + raise TypeError(msg) + + def payload(self): + """ + Returns our payload depending on our mode + """ + return self.priority_map[self.priority][self.mode] \ + if self.priority else {} + + def __str__(self): + """ + our priority representation + """ + return self.priority if self.priority else '' + + def __bool__(self): + """ + Allows this object to be wrapped in an 'if statement'. + True is returned if a priority was loaded + """ + return True if self.priority else False |