1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com> 4# All rights reserved. 5# 6# This code is licensed under the MIT License. 7# 8# Permission is hereby granted, free of charge, to any person obtaining a copy 9# of this software and associated documentation files(the "Software"), to deal 10# in the Software without restriction, including without limitation the rights 11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell 12# copies of the Software, and to permit persons to whom the Software is 13# furnished to do so, subject to the following conditions : 14# 15# The above copyright notice and this permission notice shall be included in 16# all copies or substantial portions of the Software. 17# 18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE 21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24# THE SOFTWARE. 25 26import re 27import six 28import requests 29import hmac 30from json import dumps 31from time import time 32from hashlib import sha1 33from itertools import chain 34try: 35 from urlparse import urlparse 36 37except ImportError: 38 from urllib.parse import urlparse 39 40from .NotifyBase import NotifyBase 41from ..URLBase import PrivacyMode 42from ..utils import parse_bool 43from ..utils import validate_regex 44from ..common import NotifyType 45from ..common import NotifyImageSize 46from ..AppriseLocale import gettext_lazy as _ 47 48# Default to sending to all devices if nothing is specified 49DEFAULT_TAG = '@all' 50 51# The tags value is an structure containing an array of strings defining the 52# list of tagged devices that the notification need to be send to, and a 53# boolean operator (‘and’ / ‘or’) that defines the criteria to match devices 54# against those tags. 55IS_TAG = re.compile(r'^[@](?P<name>[A-Z0-9]{1,63})$', re.I) 56 57# Device tokens are only referenced when developing. 58# It's not likely you'll send a message directly to a device, but if you do; 59# this plugin supports it. 60IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I) 61 62# Used to break apart list of potential tags by their delimiter into a useable 63# list. 64TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') 65 66 67class NotifyBoxcar(NotifyBase): 68 """ 69 A wrapper for Boxcar Notifications 70 """ 71 72 # The default descriptive name associated with the Notification 73 service_name = 'Boxcar' 74 75 # The services URL 76 service_url = 'https://boxcar.io/' 77 78 # All boxcar notifications are secure 79 secure_protocol = 'boxcar' 80 81 # A URL that takes you to the setup/help of the specific protocol 82 setup_url = 'https://github.com/caronc/apprise/wiki/Notify_boxcar' 83 84 # Boxcar URL 85 notify_url = 'https://boxcar-api.io/api/push/' 86 87 # Allows the user to specify the NotifyImageSize object 88 image_size = NotifyImageSize.XY_72 89 90 # The maximum allowable characters allowed in the body per message 91 body_maxlen = 10000 92 93 # Define object templates 94 templates = ( 95 '{schema}://{access_key}/{secret_key}/', 96 '{schema}://{access_key}/{secret_key}/{targets}', 97 ) 98 99 # Define our template tokens 100 template_tokens = dict(NotifyBase.template_tokens, **{ 101 'access_key': { 102 'name': _('Access Key'), 103 'type': 'string', 104 'private': True, 105 'required': True, 106 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), 107 'map_to': 'access', 108 }, 109 'secret_key': { 110 'name': _('Secret Key'), 111 'type': 'string', 112 'private': True, 113 'required': True, 114 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), 115 'map_to': 'secret', 116 }, 117 'target_tag': { 118 'name': _('Target Tag ID'), 119 'type': 'string', 120 'prefix': '@', 121 'regex': (r'^[A-Z0-9]{1,63}$', 'i'), 122 'map_to': 'targets', 123 }, 124 'target_device': { 125 'name': _('Target Device ID'), 126 'type': 'string', 127 'regex': (r'^[A-Z0-9]{64}$', 'i'), 128 'map_to': 'targets', 129 }, 130 'targets': { 131 'name': _('Targets'), 132 'type': 'list:string', 133 }, 134 }) 135 136 # Define our template arguments 137 template_args = dict(NotifyBase.template_args, **{ 138 'image': { 139 'name': _('Include Image'), 140 'type': 'bool', 141 'default': True, 142 'map_to': 'include_image', 143 }, 144 'to': { 145 'alias_of': 'targets', 146 }, 147 }) 148 149 def __init__(self, access, secret, targets=None, include_image=True, 150 **kwargs): 151 """ 152 Initialize Boxcar Object 153 """ 154 super(NotifyBoxcar, self).__init__(**kwargs) 155 156 # Initialize tag list 157 self.tags = list() 158 159 # Initialize device_token list 160 self.device_tokens = list() 161 162 # Access Key (associated with project) 163 self.access = validate_regex( 164 access, *self.template_tokens['access_key']['regex']) 165 if not self.access: 166 msg = 'An invalid Boxcar Access Key ' \ 167 '({}) was specified.'.format(access) 168 self.logger.warning(msg) 169 raise TypeError(msg) 170 171 # Secret Key (associated with project) 172 self.secret = validate_regex( 173 secret, *self.template_tokens['secret_key']['regex']) 174 if not self.secret: 175 msg = 'An invalid Boxcar Secret Key ' \ 176 '({}) was specified.'.format(secret) 177 self.logger.warning(msg) 178 raise TypeError(msg) 179 180 if not targets: 181 self.tags.append(DEFAULT_TAG) 182 targets = [] 183 184 elif isinstance(targets, six.string_types): 185 targets = [x for x in filter(bool, TAGS_LIST_DELIM.split( 186 targets, 187 ))] 188 189 # Validate targets and drop bad ones: 190 for target in targets: 191 if IS_TAG.match(target): 192 # store valid tag/alias 193 self.tags.append(IS_TAG.match(target).group('name')) 194 195 elif IS_DEVICETOKEN.match(target): 196 # store valid device 197 self.device_tokens.append(target) 198 199 else: 200 self.logger.warning( 201 'Dropped invalid tag/alias/device_token ' 202 '({}) specified.'.format(target), 203 ) 204 205 # Track whether or not we want to send an image with our notification 206 # or not. 207 self.include_image = include_image 208 209 return 210 211 def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): 212 """ 213 Perform Boxcar Notification 214 """ 215 headers = { 216 'User-Agent': self.app_id, 217 'Content-Type': 'application/json' 218 } 219 220 # prepare Boxcar Object 221 payload = { 222 'aps': { 223 'badge': 'auto', 224 'alert': '', 225 }, 226 'expires': str(int(time() + 30)), 227 } 228 229 if title: 230 payload['aps']['@title'] = title 231 232 if body: 233 payload['aps']['alert'] = body 234 235 if self.tags: 236 payload['tags'] = {'or': self.tags} 237 238 if self.device_tokens: 239 payload['device_tokens'] = self.device_tokens 240 241 # Source picture should be <= 450 DP wide, ~2:1 aspect. 242 image_url = None if not self.include_image \ 243 else self.image_url(notify_type) 244 245 if image_url: 246 # Set our image 247 payload['@img'] = image_url 248 249 # Acquire our hostname 250 host = urlparse(self.notify_url).hostname 251 252 # Calculate signature. 253 str_to_sign = "%s\n%s\n%s\n%s" % ( 254 "POST", host, "/api/push", dumps(payload)) 255 256 h = hmac.new( 257 bytearray(self.secret, 'utf-8'), 258 bytearray(str_to_sign, 'utf-8'), 259 sha1, 260 ) 261 262 params = NotifyBoxcar.urlencode({ 263 "publishkey": self.access, 264 "signature": h.hexdigest(), 265 }) 266 267 notify_url = '%s?%s' % (self.notify_url, params) 268 self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % ( 269 notify_url, self.verify_certificate, 270 )) 271 self.logger.debug('Boxcar Payload: %s' % str(payload)) 272 273 # Always call throttle before any remote server i/o is made 274 self.throttle() 275 276 try: 277 r = requests.post( 278 notify_url, 279 data=dumps(payload), 280 headers=headers, 281 verify=self.verify_certificate, 282 timeout=self.request_timeout, 283 ) 284 285 # Boxcar returns 201 (Created) when successful 286 if r.status_code != requests.codes.created: 287 # We had a problem 288 status_str = \ 289 NotifyBoxcar.http_response_code_lookup(r.status_code) 290 291 self.logger.warning( 292 'Failed to send Boxcar notification: ' 293 '{}{}error={}.'.format( 294 status_str, 295 ', ' if status_str else '', 296 r.status_code)) 297 298 self.logger.debug('Response Details:\r\n{}'.format(r.content)) 299 300 # Return; we're done 301 return False 302 303 else: 304 self.logger.info('Sent Boxcar notification.') 305 306 except requests.RequestException as e: 307 self.logger.warning( 308 'A Connection error occurred sending Boxcar ' 309 'notification to %s.' % (host)) 310 311 self.logger.debug('Socket Exception: %s' % str(e)) 312 313 # Return; we're done 314 return False 315 316 return True 317 318 def url(self, privacy=False, *args, **kwargs): 319 """ 320 Returns the URL built dynamically based on specified arguments. 321 """ 322 323 # Define any URL parameters 324 params = { 325 'image': 'yes' if self.include_image else 'no', 326 } 327 328 # Extend our parameters 329 params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) 330 331 return '{schema}://{access}/{secret}/{targets}?{params}'.format( 332 schema=self.secure_protocol, 333 access=self.pprint(self.access, privacy, safe=''), 334 secret=self.pprint( 335 self.secret, privacy, mode=PrivacyMode.Secret, safe=''), 336 targets='/'.join([ 337 NotifyBoxcar.quote(x, safe='') for x in chain( 338 self.tags, self.device_tokens) if x != DEFAULT_TAG]), 339 params=NotifyBoxcar.urlencode(params), 340 ) 341 342 @staticmethod 343 def parse_url(url): 344 """ 345 Parses the URL and returns it broken apart into a dictionary. 346 347 """ 348 results = NotifyBase.parse_url(url, verify_host=False) 349 if not results: 350 # We're done early 351 return None 352 353 # The first token is stored in the hostname 354 results['access'] = NotifyBoxcar.unquote(results['host']) 355 356 # Get our entries; split_path() looks after unquoting content for us 357 # by default 358 entries = NotifyBoxcar.split_path(results['fullpath']) 359 360 try: 361 # Now fetch the remaining tokens 362 results['secret'] = entries.pop(0) 363 364 except IndexError: 365 # secret wasn't specified 366 results['secret'] = None 367 368 # Our recipients make up the remaining entries of our array 369 results['targets'] = entries 370 371 # The 'to' makes it easier to use yaml configuration 372 if 'to' in results['qsd'] and len(results['qsd']['to']): 373 results['targets'] += \ 374 NotifyBoxcar.parse_list(results['qsd'].get('to')) 375 376 # Include images with our message 377 results['include_image'] = \ 378 parse_bool(results['qsd'].get('image', True)) 379 380 return results 381