diff --git a/CHANGES.md b/CHANGES.md index 40bd551..ff65e14 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ * Change episode overview, move pulldown from 'Set/Failed' to 'Override/Failed' * Change add rarfile_py3 3.1 (a4202ca) * Change backport rarfile_py2; Fixes for multivolume RAR3 with encrypted headers -* Update Apprise 0.8.0 (6aa52c3) to 0.8.4 (1ce7cbb) +* Update Apprise 0.8.0 (6aa52c3) to 0.8.5 (55a2edc) * Update attr 19.2.0.dev0 (daf2bc8) to 20.1.0.dev0 (4bd6827) * Update Beautiful Soup 4.8.1 (r540) to 4.8.2 (r559) * Update Certifi 2019.06.16 (84dc766) to 2019.11.28 (21abb9b) @@ -40,6 +40,7 @@ * Fix config/Media Process/Unpack test unrar under py3 * Change remove deprecated `buildNameCache` * Update Apprise 0.8.0 (6aa52c3) to 0.8.3 (4aee9de) +* Update Apprise 0.8.3 (4aee9de) to 0.8.4 (1ce7cbb) * Update attr 19.2.0.dev0 (daf2bc8) to 20.1.0.dev0 (9b5e988) * Update Beautiful Soup 4.8.1 (r540) to 4.8.2 (r554) * Update Beautiful Soup 4.8.2 (r544) to 4.8.2 (r556) @@ -48,7 +49,6 @@ * Update urllib3 release 1.25.6 (4a6c288) to 1.25.7 (37ba61a) -### 0.21.21 (2019-03-11 21:15:00 UTC) ### 0.21.22 (2020-03-20 20:00:00 UTC) * Fix Bulk Change/Edit for py3 diff --git a/lib/apprise/__init__.py b/lib/apprise/__init__.py index cf080be..63da23f 100644 --- a/lib/apprise/__init__.py +++ b/lib/apprise/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2020 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -24,7 +24,7 @@ # THE SOFTWARE. __title__ = 'apprise' -__version__ = '0.8.4' +__version__ = '0.8.5' __author__ = 'Chris Caron' __license__ = 'MIT' __copywrite__ = 'Copyright (C) 2020 Chris Caron ' diff --git a/lib/apprise/i18n/apprise.pot b/lib/apprise/i18n/apprise.pot index ffd9b70..ea3fdfa 100644 --- a/lib/apprise/i18n/apprise.pot +++ b/lib/apprise/i18n/apprise.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: apprise 0.8.4\n" +"Project-Id-Version: apprise 0.8.5\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" -"POT-Creation-Date: 2020-02-01 12:59-0500\n" +"POT-Creation-Date: 2020-03-30 16:00-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -98,6 +98,9 @@ msgstr "" msgid "Device ID" msgstr "" +msgid "Device Name" +msgstr "" + msgid "Display Footer" msgstr "" diff --git a/lib/apprise/plugins/NotifyEmail.py b/lib/apprise/plugins/NotifyEmail.py index 222e32e..de686c8 100644 --- a/lib/apprise/plugins/NotifyEmail.py +++ b/lib/apprise/plugins/NotifyEmail.py @@ -269,6 +269,14 @@ class NotifyEmail(NotifyBase): # Define object templates templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', + '{schema}://{user}@{host}', + '{schema}://{user}@{host}:{port}', + '{schema}://{user}@{host}/{targets}', + '{schema}://{user}@{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}', '{schema}://{user}:{password}@{host}:{port}', '{schema}://{user}:{password}@{host}/{targets}', @@ -280,13 +288,11 @@ class NotifyEmail(NotifyBase): 'user': { 'name': _('User Name'), 'type': 'string', - 'required': True, }, 'password': { 'name': _('Password'), 'type': 'string', 'private': True, - 'required': True, }, 'host': { 'name': _('Domain'), @@ -388,7 +394,7 @@ class NotifyEmail(NotifyBase): self.from_name = from_name self.from_addr = from_addr - if not self.from_addr: + if self.user and not self.from_addr: # detect our email address self.from_addr = '{}@{}'.format( re.split(r'[\s@]+', self.user)[0], @@ -446,6 +452,10 @@ class NotifyEmail(NotifyBase): # Apply any defaults based on certain known configurations self.NotifyEmailDefaults() + # if there is still no smtp_host then we fall back to the hostname + if not self.smtp_host: + self.smtp_host = self.host + return def NotifyEmailDefaults(self): @@ -454,10 +464,11 @@ class NotifyEmail(NotifyBase): it was provided. """ - if self.smtp_host: + if self.smtp_host or not self.user: # SMTP Server was explicitly specified, therefore it is assumed # the caller knows what he's doing and is intentionally - # over-riding any smarts to be applied + # over-riding any smarts to be applied. We also can not apply + # any default if there was no user specified. return # detect our email address using our user/host combo @@ -683,7 +694,7 @@ class NotifyEmail(NotifyBase): args['bcc'] = ','.join(self.bcc) # pull email suffix from username (if present) - user = self.user.split('@')[0] + user = None if not self.user else self.user.split('@')[0] # Determine Authentication auth = '' @@ -693,7 +704,7 @@ class NotifyEmail(NotifyBase): password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) - else: + elif user: # user url auth = '{user}@'.format( user=NotifyEmail.quote(user, safe=''), diff --git a/lib/apprise/plugins/NotifySlack.py b/lib/apprise/plugins/NotifySlack.py index b17ecd8..d4e4f61 100644 --- a/lib/apprise/plugins/NotifySlack.py +++ b/lib/apprise/plugins/NotifySlack.py @@ -176,7 +176,7 @@ class NotifySlack(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[A-Z0-9]{9}$', 'i'), + 'regex': (r'^[A-Z0-9]+$', 'i'), }, # Token required as part of the Webhook request # /........./BBBBBBBBB/........................ @@ -185,7 +185,7 @@ class NotifySlack(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[A-Z0-9]{9}$', 'i'), + 'regex': (r'^[A-Z0-9]+$', 'i'), }, # Token required as part of the Webhook request # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC @@ -194,7 +194,7 @@ class NotifySlack(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[A-Za-z0-9]{24}$', 'i'), + 'regex': (r'^[A-Za-z0-9]+$', 'i'), }, 'target_encoded_id': { 'name': _('Target Encoded ID'), diff --git a/lib/apprise/plugins/NotifyXMPP/SleekXmppAdapter.py b/lib/apprise/plugins/NotifyXMPP/SleekXmppAdapter.py new file mode 100644 index 0000000..a28e9ce --- /dev/null +++ b/lib/apprise/plugins/NotifyXMPP/SleekXmppAdapter.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- + +import ssl +from os.path import isfile +import logging + + +# Default our global support flag +SLEEKXMPP_SUPPORT_AVAILABLE = False + +try: + # Import sleekxmpp if available + import sleekxmpp + + SLEEKXMPP_SUPPORT_AVAILABLE = True + +except ImportError: + # No problem; we just simply can't support this plugin because we're + # either using Linux, or simply do not have sleekxmpp installed. + pass + + +class SleekXmppAdapter(object): + """ + Wrapper to sleekxmpp + + """ + + # Reference to XMPP client. + xmpp = None + + # Whether everything succeeded + success = False + + # The default protocol + protocol = 'xmpp' + + # The default secure protocol + secure_protocol = 'xmpps' + + # The default XMPP port + default_unsecure_port = 5222 + + # The default XMPP secure port + default_secure_port = 5223 + + # Taken from https://golang.org/src/crypto/x509/root_linux.go + CA_CERTIFICATE_FILE_LOCATIONS = [ + # Debian/Ubuntu/Gentoo etc. + "/etc/ssl/certs/ca-certificates.crt", + # Fedora/RHEL 6 + "/etc/pki/tls/certs/ca-bundle.crt", + # OpenSUSE + "/etc/ssl/ca-bundle.pem", + # OpenELEC + "/etc/pki/tls/cacert.pem", + # CentOS/RHEL 7 + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + ] + + # This entry is a bit hacky, but it allows us to unit-test this library + # in an environment that simply doesn't have the sleekxmpp package + # available to us. + # + # If anyone is seeing this had knows a better way of testing this + # outside of what is defined in test/test_xmpp_plugin.py, please + # let me know! :) + _enabled = SLEEKXMPP_SUPPORT_AVAILABLE + + def __init__(self, host=None, port=None, secure=False, + verify_certificate=True, xep=None, jid=None, password=None, + body=None, targets=None, before_message=None, logger=None): + """ + Initialize our SleekXmppAdapter object + """ + + self.host = host + self.port = port + self.secure = secure + self.verify_certificate = verify_certificate + + self.xep = xep + self.jid = jid + self.password = password + + self.body = body + self.targets = targets + self.before_message = before_message + + self.logger = logger or logging.getLogger(__name__) + + # Use the Apprise log handlers for configuring the sleekxmpp logger. + apprise_logger = logging.getLogger('apprise') + sleek_logger = logging.getLogger('sleekxmpp') + for handler in apprise_logger.handlers: + sleek_logger.addHandler(handler) + sleek_logger.setLevel(apprise_logger.level) + + if not self.load(): + raise ValueError("Invalid XMPP Configuration") + + def load(self): + + # Prepare our object + self.xmpp = sleekxmpp.ClientXMPP(self.jid, self.password) + + # Register our session + self.xmpp.add_event_handler("session_start", self.session_start) + + for xep in self.xep: + # Load xep entries + try: + self.xmpp.register_plugin('xep_{0:04d}'.format(xep)) + + except sleekxmpp.plugins.base.PluginNotFound: + self.logger.warning( + 'Could not register plugin {}'.format( + 'xep_{0:04d}'.format(xep))) + return False + + if self.secure: + # Don't even try to use the outdated ssl.PROTOCOL_SSLx + self.xmpp.ssl_version = ssl.PROTOCOL_TLSv1 + + # If the python version supports it, use highest TLS version + # automatically + if hasattr(ssl, "PROTOCOL_TLS"): + # Use the best version of TLS available to us + self.xmpp.ssl_version = ssl.PROTOCOL_TLS + + self.xmpp.ca_certs = None + if self.verify_certificate: + # Set the ca_certs variable for certificate verification + self.xmpp.ca_certs = next( + (cert for cert in self.CA_CERTIFICATE_FILE_LOCATIONS + if isfile(cert)), None) + + if self.xmpp.ca_certs is None: + self.logger.warning( + 'XMPP Secure comunication can not be verified; ' + 'no local CA certificate file') + return False + + # We're good + return True + + def process(self): + """ + Thread that handles the server/client i/o + + """ + + # Establish connection to XMPP server. + # To speed up sending messages, don't use the "reattempt" feature, + # it will add a nasty delay even before connecting to XMPP server. + if not self.xmpp.connect((self.host, self.port), + use_ssl=self.secure, reattempt=False): + + default_port = self.default_secure_port \ + if self.secure else self.default_unsecure_port + + default_schema = self.secure_protocol \ + if self.secure else self.protocol + + # Log connection issue + self.logger.warning( + 'Failed to authenticate {jid} with: {schema}://{host}{port}' + .format( + jid=self.jid, + schema=default_schema, + host=self.host, + port='' if not self.port or self.port == default_port + else ':{}'.format(self.port), + )) + return False + + # Process XMPP communication. + self.xmpp.process(block=True) + + return self.success + + def session_start(self, *args, **kwargs): + """ + Session Manager + """ + + targets = list(self.targets) + if not targets: + # We always default to notifying ourselves + targets.append(self.jid) + + while len(targets) > 0: + + # Get next target (via JID) + target = targets.pop(0) + + # Invoke "before_message" event hook. + self.before_message() + + # The message we wish to send, and the JID that will receive it. + self.xmpp.send_message(mto=target, mbody=self.body, mtype='chat') + + # Using wait=True ensures that the send queue will be + # emptied before ending the session. + self.xmpp.disconnect(wait=True) + + # Toggle our success flag + self.success = True diff --git a/lib/apprise/plugins/NotifyXMPP/__init__.py b/lib/apprise/plugins/NotifyXMPP/__init__.py new file mode 100644 index 0000000..a1cd007 --- /dev/null +++ b/lib/apprise/plugins/NotifyXMPP/__init__.py @@ -0,0 +1,351 @@ +# -*- 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 re + +from ..NotifyBase import NotifyBase +from ...URLBase import PrivacyMode +from ...common import NotifyType +from ...utils import parse_list +from ...AppriseLocale import gettext_lazy as _ +from .SleekXmppAdapter import SleekXmppAdapter + +# xep string parser +XEP_PARSE_RE = re.compile('^[^1-9]*(?P[1-9][0-9]{0,3})$') + + +class NotifyXMPP(NotifyBase): + """ + A wrapper for XMPP Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'XMPP' + + # The default protocol + protocol = 'xmpp' + + # The default secure protocol + secure_protocol = 'xmpps' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_xmpp' + + # Lower throttle rate for XMPP + request_rate_per_sec = 0.5 + + # The default XMPP port + default_unsecure_port = 5222 + + # The default XMPP secure port + default_secure_port = 5223 + + # XMPP does not support a title + title_maxlen = 0 + + # This entry is a bit hacky, but it allows us to unit-test this library + # in an environment that simply doesn't have the sleekxmpp package + # available to us. + # + # If anyone is seeing this had knows a better way of testing this + # outside of what is defined in test/test_xmpp_plugin.py, please + # let me know! :) + _enabled = SleekXmppAdapter._enabled + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{password}@{host}', + '{schema}://{password}@{host}:{port}', + '{schema}://{user}:{password}@{host}', + '{schema}://{user}:{password}@{host}:{port}', + '{schema}://{host}/{targets}', + '{schema}://{password}@{host}/{targets}', + '{schema}://{password}@{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + ) + + # Define our tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_jid': { + 'name': _('Target JID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'xep': { + 'name': _('XEP'), + 'type': 'list:string', + 'prefix': 'xep-', + 'regex': (r'^[1-9][0-9]{0,3}$', 'i'), + }, + 'jid': { + 'name': _('Source JID'), + 'type': 'string', + }, + }) + + def __init__(self, targets=None, jid=None, xep=None, **kwargs): + """ + Initialize XMPP Object + """ + super(NotifyXMPP, self).__init__(**kwargs) + + # JID Details: + # - JID's normally have an @ symbol in them, but it is not required + # - Each allowable portion of a JID MUST NOT be more than 1023 bytes + # in length. + # - JID's can identify resource paths at the end separated by slashes + # hence the following is valid: user@example.com/resource/path + + # Since JID's can clash with URLs offered by aprise (specifically the + # resource paths we need to allow users an alternative character to + # represent the slashes. The grammer is defined here: + # https://xmpp.org/extensions/xep-0029.html as follows: + # + # ::= ["@"]["/"] + # ::= []* + # ::= ["."]* + # ::= []* + # ::= |[[||"-"]*|] + # ::= [a-z] | [A-Z] + # ::= [0-9] + # ::= #x21 | [#x23-#x25] | [#x28-#x2E] | + # [#x30-#x39] | #x3B | #x3D | #x3F | + # [#x41-#x7E] | [#x80-#xD7FF] | + # [#xE000-#xFFFD] | [#x10000-#x10FFFF] + # ::= [#x20-#xD7FF] | [#xE000-#xFFFD] | + # [#x10000-#x10FFFF] + + # The best way to do this is to choose characters that aren't allowed + # in this case we will use comma and/or space. + + # Assemble our jid using the information available to us: + self.jid = jid + + if not (self.user or self.password): + # you must provide a jid/pass for this to work; if no password + # is specified then the user field acts as the password instead + # so we know that if there is no user specified, our url was + # really busted up. + msg = 'You must specify a XMPP password' + self.logger.warning(msg) + raise TypeError(msg) + + # See https://xmpp.org/extensions/ for details on xep values + if xep is None: + # Default xep setting + self.xep = [ + # xep_0030: Service Discovery + 30, + # xep_0199: XMPP Ping + 199, + ] + + else: + # Prepare the list + _xep = parse_list(xep) + self.xep = [] + + for xep in _xep: + result = XEP_PARSE_RE.match(xep) + if result is not None: + self.xep.append(int(result.group('xep'))) + self.logger.debug('Loaded XMPP {}'.format(xep)) + + else: + self.logger.warning( + "Could not load XMPP {}".format(xep)) + + # By default we send ourselves a message + if targets: + self.targets = parse_list(targets) + + else: + self.targets = list() + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform XMPP Notification + """ + + if not self._enabled: + self.logger.warning( + 'XMPP Notifications are not supported by this system ' + '- install sleekxmpp.') + return False + + # Detect our JID if it isn't otherwise specified + jid = self.jid + password = self.password + if not jid: + if self.user and self.password: + # xmpp://user:password@hostname + jid = '{}@{}'.format(self.user, self.host) + + else: + # xmpp://password@hostname + jid = self.host + password = self.password if self.password else self.user + + # Compute port number + if not self.port: + port = self.default_secure_port \ + if self.secure else self.default_unsecure_port + + else: + port = self.port + + try: + # Communicate with XMPP. + xmpp_adapter = SleekXmppAdapter( + host=self.host, port=port, secure=self.secure, + verify_certificate=self.verify_certificate, xep=self.xep, + jid=jid, password=password, body=body, targets=self.targets, + before_message=self.throttle, logger=self.logger) + + except ValueError: + # We failed + return False + + # Initialize XMPP machinery and begin processing the XML stream. + outcome = xmpp_adapter.process() + + return outcome + + def url(self, privacy=False, *args, **kwargs): + """ + 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.jid: + args['jid'] = self.jid + + if self.xep: + # xep are integers, so we need to just iterate over a list and + # switch them to a string + args['xep'] = ','.join([str(xep) for xep in self.xep]) + + # Target JID(s) can clash with our existing paths, so we just use comma + # and/or space as a delimiters - %20 = space + jids = '%20'.join([NotifyXMPP.quote(x, safe='') for x in self.targets]) + + default_port = self.default_secure_port \ + if self.secure else self.default_unsecure_port + + default_schema = self.secure_protocol if self.secure else self.protocol + + if self.user and self.password: + auth = '{user}:{password}'.format( + user=NotifyXMPP.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe='')) + + else: + auth = self.pprint( + self.password if self.password else self.user, privacy, + mode=PrivacyMode.Secret, safe='') + + return '{schema}://{auth}@{hostname}{port}/{jids}?{args}'.format( + auth=auth, + schema=default_schema, + hostname=NotifyXMPP.quote(self.host, safe=''), + port='' if not self.port or self.port == default_port + else ':{}'.format(self.port), + jids=jids, + args=NotifyXMPP.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 + + # Get our targets; we ignore path slashes since they identify + # our resources + results['targets'] = NotifyXMPP.parse_list(results['fullpath']) + + # Over-ride the xep plugins + if 'xep' in results['qsd'] and len(results['qsd']['xep']): + results['xep'] = \ + NotifyXMPP.parse_list(results['qsd']['xep']) + + # Over-ride the default (and detected) jid + if 'jid' in results['qsd'] and len(results['qsd']['jid']): + results['jid'] = NotifyXMPP.unquote(results['qsd']['jid']) + + # Over-ride the default (and detected) jid + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyXMPP.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/__init__.py b/lib/apprise/plugins/__init__.py index 21ff47f..fd41cb7 100644 --- a/lib/apprise/plugins/__init__.py +++ b/lib/apprise/plugins/__init__.py @@ -34,6 +34,7 @@ from os.path import abspath # Used for testing from . import NotifyEmail as NotifyEmailBase from .NotifyGrowl import gntp +from .NotifyXMPP import SleekXmppAdapter # NotifyBase object is passed in as a module not class from . import NotifyBase @@ -63,6 +64,9 @@ __all__ = [ # gntp (used for NotifyGrowl Testing) 'gntp', + + # sleekxmpp access points (used for NotifyXMPP Testing) + 'SleekXmppAdapter', ] # we mirror our base purely for the ability to reset everything; this