From 24fd7d5baaba32d5dc4c9b08178dd0f3f0c85715 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 7 Sep 2019 18:57:02 -0400 Subject: [PATCH] SimplePush Support (#146) --- README.md | 1 + apprise/plugins/NotifySimplePush.py | 317 +++++++++++++++++++++++++++ dev-requirements.txt | 1 + packaging/redhat/python-apprise.spec | 6 +- setup.py | 6 +- test/test_rest_plugins.py | 58 +++++ test/test_simplepush_plugin.py | 86 ++++++++ 7 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 apprise/plugins/NotifySimplePush.py create mode 100644 test/test_simplepush_plugin.py diff --git a/README.md b/README.md index df10750c..b8e0bb38 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The table below identifies the services this tool supports and some example serv | [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID
rocket://user:password@hostname/#Channel
rocket://webhook@hostname
rockets://webhook@hostname/@User/#Channel | [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [SendGrid](https://github.com/caronc/apprise/wiki/Notify_sendgrid) | sendgrid:// | (TCP) 443 | sendgrid://APIToken:FromEmail/
sendgrid://APIToken:FromEmail/ToEmail
sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ +| [SimplePush](https://github.com/caronc/apprise/wiki/Notify_simplepush) | spush:// | (TCP) 443 | spush://apikey
spush://salt:password@apikey
spush://apikey?event=Apprise | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet diff --git a/apprise/plugins/NotifySimplePush.py b/apprise/plugins/NotifySimplePush.py new file mode 100644 index 00000000..9ae2825b --- /dev/null +++ b/apprise/plugins/NotifySimplePush.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from os import urandom +from json import loads +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ + +# Default our global support flag +CRYPTOGRAPHY_AVAILABLE = False + +try: + from cryptography.hazmat.primitives import padding + from cryptography.hazmat.primitives.ciphers import Cipher + from cryptography.hazmat.primitives.ciphers import algorithms + from cryptography.hazmat.primitives.ciphers import modes + from cryptography.hazmat.backends import default_backend + from base64 import urlsafe_b64encode + import hashlib + + CRYPTOGRAPHY_AVAILABLE = True + +except ImportError: + # no problem; this just means the added encryption functionality isn't + # available. You can still send a SimplePush message + pass + + +class NotifySimplePush(NotifyBase): + """ + A wrapper for SimplePush Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SimplePush' + + # The services URL + service_url = 'https://simplepush.io/' + + # The default secure protocol + secure_protocol = 'spush' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_simplepush' + + # SimplePush uses the http protocol with SimplePush requests + notify_url = 'https://api.simplepush.io/send' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 10000 + + # Defines the maximum allowable characters in the title + title_maxlen = 1024 + + # Define object templates + templates = ( + '{schema}://{apikey}', + '{schema}://{salt}:{password}@{apikey}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + + # Used for encrypted logins + 'password': { + 'name': _('Encrypted Password'), + 'type': 'string', + 'private': True, + }, + 'salt': { + 'name': _('Encrypted Salt'), + 'type': 'string', + 'private': True, + 'map_to': 'user', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'event': { + 'name': _('Event'), + 'type': 'string', + }, + }) + + def __init__(self, apikey, event=None, **kwargs): + """ + Initialize SimplePush Object + """ + super(NotifySimplePush, self).__init__(**kwargs) + + # Store the API key + self.apikey = apikey + + # Event Name + self.event = event + + # Encrypt Message (providing support is available) + if self.password and self.user and not CRYPTOGRAPHY_AVAILABLE: + # Provide the end user at least some notification that they're + # not getting what they asked for + self.logger.warning( + 'SimplePush extended encryption is not supported by this ' + 'system.') + + # Used/cached in _encrypt() function + self._iv = None + self._iv_hex = None + self._key = None + + def _encrypt(self, content): + """ + Encrypts message for use with SimplePush + """ + + if self._iv is None: + # initialization vector and cache it + self._iv = urandom(algorithms.AES.block_size // 8) + + # convert vector into hex string (used in payload) + self._iv_hex = ''.join(["{:02x}".format(ord(self._iv[idx:idx + 1])) + for idx in range(len(self._iv))]).upper() + + # encrypted key and cache it + self._key = bytes(bytearray.fromhex( + hashlib.sha1('{}{}'.format(self.password, self.user) + .encode('utf-8')).hexdigest()[0:32])) + + padder = padding.PKCS7(algorithms.AES.block_size).padder() + content = padder.update(content.encode()) + padder.finalize() + + encryptor = Cipher( + algorithms.AES(self._key), + modes.CBC(self._iv), + default_backend()).encryptor() + + return urlsafe_b64encode( + encryptor.update(content) + encryptor.finalize()) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform SimplePush Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-type': "application/x-www-form-urlencoded", + } + + # Prepare our payload + payload = { + 'key': self.apikey, + } + event = self.event + + if self.password and self.user and CRYPTOGRAPHY_AVAILABLE: + body = self._encrypt(body) + title = self._encrypt(title) + payload.update({ + 'encrypted': 'true', + 'iv': self._iv_hex, + }) + + # prepare SimplePush Object + payload.update({ + 'msg': body, + 'title': title, + }) + + if event: + payload['event'] = event + + self.logger.debug('SimplePush POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('SimplePush Payload: %s' % str(payload)) + + # We need to rely on the status string returned in the SimplePush + # response + status_str = None + status = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + + # Get our SimplePush response (if it's possible) + try: + json_response = loads(r.content) + status_str = json_response.get('message') + status = json_response.get('status') + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + pass + + if r.status_code != requests.codes.ok or status != 'OK': + # We had a problem + status_str = status_str if status_str else\ + NotifyBase.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send SimplePush notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent SimplePush notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending SimplePush notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + } + + if self.event: + args['event'] = self.event + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{salt}:{password}@'.format( + salt=NotifySimplePush.quote(self.user, safe=''), + password=NotifySimplePush.quote(self.password, safe=''), + ) + + return '{schema}://{auth}{apikey}/?{args}'.format( + schema=self.secure_protocol, + auth=auth, + apikey=NotifySimplePush.quote(self.apikey, safe=''), + args=NotifySimplePush.urlencode(args), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + # Set the API Key + results['apikey'] = NotifySimplePush.unquote(results['host']) + + # Event + if 'event' in results['qsd'] and len(results['qsd']['event']): + # Extract the account sid from an argument + results['event'] = \ + NotifySimplePush.unquote(results['qsd']['event']) + + return results diff --git a/dev-requirements.txt b/dev-requirements.txt index b41395ac..b5a8ac37 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -5,3 +5,4 @@ pytest pytest-cov tox babel +cryptography diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 124cb76c..93c4eccf 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -50,9 +50,9 @@ it easy to access: Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Notify MyAndroid, Prowl, Pushalot, -PushBullet, Pushjet, Pushover, Rocket.Chat, SendGrid, Slack, Super Toasty, -Stride, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, XMPP, -Webex Teams} +PushBullet, Pushjet, Pushover, Rocket.Chat, SendGrid, SimplePush, Slack, +Super Toasty, Stride, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, +XMPP, Webex Teams} Name: python-%{pypi_name} Version: 0.7.9 diff --git a/setup.py b/setup.py index f79c1712..1934fc5a 100755 --- a/setup.py +++ b/setup.py @@ -72,9 +72,9 @@ setup( keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend ' 'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join ' 'KODI Mailgun Matrix Mattermost MessageBird MSG91 Nexmo Prowl ' - 'PushBullet Pushjet Pushed Pushover Rocket.Chat Ryver SendGrid Slack ' - 'Stride Techulus Push Telegram Twilio Twist Twitter XBMC Microsoft ' - 'MSTeams Windows Webex CLI API', + 'PushBullet Pushjet Pushed Pushover Rocket.Chat Ryver SendGrid ' + 'SimplePush Slack Stride Techulus Push Telegram Twilio Twist Twitter ' + 'XBMC Microsoft MSTeams Windows Webex CLI API', author='Chris Caron', author_email='lead2gold@gmail.com', packages=find_packages(), diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index e009a223..df68704e 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -2029,6 +2029,64 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifySimplePush + ################################## + ('spush://', { + # No api key + 'instance': None, + }), + ('spush://{}'.format('A' * 14), { + # API Key specified however expected server response + # didn't have 'OK' in JSON response + 'instance': plugins.NotifySimplePush, + # Expected notify() response + 'notify_response': False, + }), + ('spush://{}'.format('X' * 14), { + # API Key valid and expected response was valid + 'instance': plugins.NotifySimplePush, + # Set our response to OK + 'requests_response_text': { + 'status': 'OK', + }, + }), + ('spush://{}?event=Not%20So%20Good'.format('X' * 14), { + # API Key valid and expected response was valid + 'instance': plugins.NotifySimplePush, + # Set our response to something that is not okay + 'requests_response_text': { + 'status': 'NOT-OK', + }, + # Expected notify() response + 'notify_response': False, + }), + ('spush://salt:pass@{}'.format('X' * 14, 'A' * 16), { + # Now we'll test encrypted messages with new salt + 'instance': plugins.NotifySimplePush, + # Set our response to OK + 'requests_response_text': { + 'status': 'OK', + }, + }), + ('spush://{}'.format('Y' * 14), { + 'instance': plugins.NotifySimplePush, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + # Set a failing message too + 'requests_response_text': { + 'status': 'BadRequest', + 'message': 'Title or message too long', + }, + }), + ('spush://{}'.format('Z' * 14), { + 'instance': plugins.NotifySimplePush, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifySlack ################################## diff --git a/test/test_simplepush_plugin.py b/test/test_simplepush_plugin.py new file mode 100644 index 00000000..3a6a0764 --- /dev/null +++ b/test/test_simplepush_plugin.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import sys +import apprise + +try: + # Python v3.4+ + from importlib import reload +except ImportError: + try: + # Python v3.0-v3.3 + from imp import reload + except ImportError: + # Python v2.7 + pass + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + + +def test_simplepush_plugin(tmpdir): + """ + API: NotifySimplePush Plugin() + + """ + suite = tmpdir.mkdir("simplepush") + suite.join("__init__.py").write('') + module_name = 'cryptography' + suite.join("{}.py".format(module_name)).write('raise ImportError()') + + # Update our path to point to our new test suite + sys.path.insert(0, str(suite)) + + for name in list(sys.modules.keys()): + if name.startswith('{}.'.format(module_name)): + del sys.modules[name] + del sys.modules[module_name] + + # The following libraries need to be reloaded to prevent + # TypeError: super(type, obj): obj must be an instance or subtype of type + # This is better explained in this StackOverflow post: + # https://stackoverflow.com/questions/31363311/\ + # any-way-to-manually-fix-operation-of-\ + # super-after-ipython-reload-avoiding-ty + # + reload(sys.modules['apprise.plugins.NotifySimplePush']) + reload(sys.modules['apprise.plugins']) + reload(sys.modules['apprise.Apprise']) + reload(sys.modules['apprise']) + + # imported and therefore the extra encryption offered by SimplePush is + # not available... + obj = apprise.Apprise.instantiate('spush://salt:pass@valid_api_key') + assert obj is not None + + # Tidy-up / restore things to how they were + os.unlink(str(suite.join("{}.py".format(module_name)))) + reload(sys.modules['apprise.plugins.NotifySimplePush']) + reload(sys.modules['apprise.plugins']) + reload(sys.modules['apprise.Apprise']) + reload(sys.modules['apprise'])