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