From 2b21ab71efb737b9b01f64f0939dd7ca0cb71412 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 14 Jan 2024 13:27:14 -0500 Subject: [PATCH] attachment keyword in payload now supports Web Based URLs (#164) --- README.md | 31 ++ apprise_api/api/tests/test_attachment.py | 325 +++++++++++++++++- apprise_api/api/tests/test_notify.py | 19 + .../api/tests/test_stateless_notify.py | 19 + apprise_api/api/utils.py | 174 ++++++++-- apprise_api/api/views.py | 49 ++- 6 files changed, 580 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index a73bf93..3475803 100644 --- a/README.md +++ b/README.md @@ -147,16 +147,47 @@ curl -X POST \ -F attach2=@/my/path/to/Apprise.doc \ http://localhost:8000/notify +# This example shows how you can place the body among other parameters +# in the GET parameter and not the payload as another option. curl -X POST -d 'urls=mailto://user:pass@gmail.com&body=test message' \ -F @/path/to/your/attachment \ http://localhost:8000/notify +# The body is not required if an attachment is provided: +curl -X POST -d 'urls=mailto://user:pass@gmail.com' \ + -F @/path/to/your/attachment \ + http://localhost:8000/notify + # Send your notifications directly using JSON curl -X POST -d '{"urls": "mailto://user:pass@gmail.com", "body":"test message"}' \ -H "Content-Type: application/json" \ http://localhost:8000/notify ``` +You can also send notifications that are URLs. Apprise will download the item so that it can send it along to all end points that should be notified about it. +```bash +# Use the 'attachment' parameter and send along a web request +curl -X POST \ + -F 'urls=mailto://user:pass@gmail.com' \ + -F attachment=https://i.redd.it/my2t4d2fx0u31.jpg \ + http://localhost:8000/notify + +# To send more then one URL, the following would work: +curl -X POST \ + -F 'urls=mailto://user:pass@gmail.com' \ + -F attachment=https://i.redd.it/my2t4d2fx0u31.jpg \ + -F attachment=https://path/to/another/remote/file.pdf \ + http://localhost:8000/notify + +# Finally feel free to mix and match local files with external ones: +curl -X POST \ + -F 'urls=mailto://user:pass@gmail.com' \ + -F attachment=https://i.redd.it/my2t4d2fx0u31.jpg \ + -F attachment=https://path/to/another/remote/file.pdf \ + -F @/path/to/your/local/file/attachment \ + http://localhost:8000/notify +``` + ### Persistent Storage Solution You can pre-save all of your Apprise configuration and/or set of Apprise URLs and associate them with a `{KEY}` of your choosing. Once set, the configuration persists for retrieval by the `apprise` [CLI tool](https://github.com/caronc/apprise/wiki/CLI_Usage) or any other custom integration you've set up. The built in website with comes with a user interface that you can use to leverage these API calls as well. Those who wish to build their own application around this can use the following API end points: diff --git a/apprise_api/api/tests/test_attachment.py b/apprise_api/api/tests/test_attachment.py index 6eca047..6906f84 100644 --- a/apprise_api/api/tests/test_attachment.py +++ b/apprise_api/api/tests/test_attachment.py @@ -22,12 +22,21 @@ # 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 requests from django.test import SimpleTestCase from unittest import mock -from ..utils import Attachment +from unittest.mock import mock_open +from ..utils import Attachment, HTTPAttachment +from ..utils import parse_attachments from django.test.utils import override_settings from tempfile import TemporaryDirectory from shutil import rmtree +import base64 +from unittest.mock import patch +from django.core.files.uploadedfile import SimpleUploadedFile +from os.path import dirname, join, getsize + +SAMPLE_FILE = join(dirname(dirname(dirname(__file__))), 'static', 'logo.png') class AttachmentTests(SimpleTestCase): @@ -54,10 +63,324 @@ class AttachmentTests(SimpleTestCase): with mock.patch('os.makedirs', side_effect=OSError): with self.assertRaises(ValueError): Attachment('file') + with self.assertRaises(ValueError): + HTTPAttachment('web') with mock.patch('tempfile.mkstemp', side_effect=FileNotFoundError): with self.assertRaises(ValueError): Attachment('file') + with self.assertRaises(ValueError): + HTTPAttachment('web') + + with mock.patch('os.remove', side_effect=FileNotFoundError): + a = Attachment('file') + # Force __del__ call to throw an exception which we gracefully + # handle + del a + + a = HTTPAttachment('web') + a._path = 'abcd' + assert a.filename == 'web' + # Force __del__ call to throw an exception which we gracefully + # handle + del a a = Attachment('file') assert a.filename + + def test_form_file_attachment_parsing(self): + """ + Test the parsing of file attachments + """ + # Get ourselves a file to work with + + files_request = { + 'file1': SimpleUploadedFile( + "attach.txt", b"content here", content_type="text/plain") + } + result = parse_attachments(None, files_request) + assert isinstance(result, list) + assert len(result) == 1 + + # Test case where no filename was specified + files_request = { + 'file1': SimpleUploadedFile( + " ", b"content here", content_type="text/plain") + } + result = parse_attachments(None, files_request) + assert isinstance(result, list) + assert len(result) == 1 + + # Test our case where we throw an error trying to open/read/write our + # attachment to disk + m = mock_open() + m.side_effect = OSError() + with patch('builtins.open', m): + with self.assertRaises(ValueError): + parse_attachments(None, files_request) + + # Test a case where our attachment exceeds the maximum size we allow + # for + with override_settings(APPRISE_MAX_ATTACHMENT_SIZE=1): + files_request = { + 'file1': SimpleUploadedFile( + "attach.txt", + b"some content more then 1 byte in length to pass along.", + content_type="text/plain") + } + with self.assertRaises(ValueError): + parse_attachments(None, files_request) + + # Bad data provided in filename field + files_request = { + 'file1': SimpleUploadedFile( + None, b"content here", content_type="text/plain") + } + with self.assertRaises(ValueError): + parse_attachments(None, files_request) + + @patch('requests.get') + def test_direct_attachment_parsing(self, mock_get): + """ + Test the parsing of file attachments + """ + # Test the processing of file attachments + result = parse_attachments([], {}) + assert isinstance(result, list) + assert len(result) == 0 + + # Response object + response = mock.Mock() + response.status_code = requests.codes.ok + response.raise_for_status.return_value = True + response.headers = { + 'Content-Length': getsize(SAMPLE_FILE), + } + ref = { + 'io': None, + } + + def iter_content(chunk_size=1024, *args, **kwargs): + if not ref['io']: + ref['io'] = open(SAMPLE_FILE) + block = ref['io'].read(chunk_size) + if not block: + # Close for re-use + ref['io'].close() + ref['io'] = None + yield block + response.iter_content = iter_content + + def test(*args, **kwargs): + return response + response.__enter__ = test + response.__exit__ = test + mock_get.return_value = response + + # Support base64 encoding + attachment_payload = { + 'base64': base64.b64encode(b'data to be encoded').decode('utf-8') + } + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 1 + + # Support multi entries + attachment_payload = [ + { + 'base64': base64.b64encode( + b'data to be encoded 1').decode('utf-8'), + }, { + 'base64': base64.b64encode( + b'data to be encoded 2').decode('utf-8'), + }, { + 'base64': base64.b64encode( + b'data to be encoded 3').decode('utf-8'), + } + ] + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 3 + + # Support multi entries + attachment_payload = [ + { + 'url': 'http://localhost/my.attachment.3', + }, { + 'url': 'http://localhost/my.attachment.2', + }, { + 'url': 'http://localhost/my.attachment.1', + } + ] + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 3 + + # Garbage handling (integer, float, object, etc is invalid) + attachment_payload = 5 + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 0 + attachment_payload = 5.5 + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 0 + attachment_payload = object() + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 0 + + # filename provided, but its empty (and/or contains whitespace) + attachment_payload = { + 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), + 'filename': ' ' + } + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 1 + + # filename too long + attachment_payload = { + 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), + 'filename': 'a' * 1000, + } + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # filename invalid + attachment_payload = { + 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), + 'filename': 1, + } + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + attachment_payload = { + 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), + 'filename': None, + } + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + attachment_payload = { + 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), + 'filename': object(), + } + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # List Entry with bad data + attachment_payload = [ + None, + ] + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # We expect at least a 'base64' or something in our dict + attachment_payload = [ + {}, + ] + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # We can't parse entries that are not base64 but specified as + # though they are + attachment_payload = { + 'base64': 'not-base-64', + } + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # Support string; these become web requests + attachment_payload = \ + "https://avatars.githubusercontent.com/u/850374?v=4" + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 1 + + # Local files are not allowed + attachment_payload = "file:///etc/hosts" + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + attachment_payload = "/etc/hosts" + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + attachment_payload = "simply invalid" + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # Test our case where we throw an error trying to write our attachment + # to disk + m = mock_open() + m.side_effect = OSError() + with patch('builtins.open', m): + with self.assertRaises(ValueError): + attachment_payload = b"some data to work with." + parse_attachments(attachment_payload, {}) + + # Test a case where our attachment exceeds the maximum size we allow + # for + with override_settings(APPRISE_MAX_ATTACHMENT_SIZE=1): + attachment_payload = \ + b"some content more then 1 byte in length to pass along." + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # Support byte data + attachment_payload = b"some content to pass along as an attachment." + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 1 + + attachment_payload = [ + # Request several images + "https://localhost/myotherfile.png", + "https://localhost/myfile.png" + ] + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 2 + + attachment_payload = [{ + # Request several images + 'url': "https://localhost/myotherfile.png", + }, { + 'url': "https://localhost/myfile.png" + }] + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 2 + + # Test pure binary payload (raw) + attachment_payload = [ + b"some content to pass along as an attachment.", + b"some more content to pass along as an attachment.", + ] + result = parse_attachments(attachment_payload, {}) + assert isinstance(result, list) + assert len(result) == 2 + + def test_direct_attachment_parsing_nw(self): + """ + Test the parsing of file attachments with network availability + We test web requests that do not work or in accessible to access + this part of the test cases + """ + attachment_payload = [ + # While we have a network in place, we're intentionally requesting + # URLs that do not exist (hopefully they don't anyway) as we want + # this test to fail. + "https://localhost/garbage/abcd1.png", + "https://localhost/garbage/abcd2.png", + ] + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) + + # Support url encoding + attachment_payload = [{ + 'url': "https://localhost/garbage/abcd1.png", + }, { + 'url': "https://localhost/garbage/abcd2.png", + }] + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, {}) diff --git a/apprise_api/api/tests/test_notify.py b/apprise_api/api/tests/test_notify.py index 76d4e8a..6df89c7 100644 --- a/apprise_api/api/tests/test_notify.py +++ b/apprise_api/api/tests/test_notify.py @@ -828,6 +828,25 @@ class NotifyTests(SimpleTestCase): assert response.status_code == 424 assert mock_notify.call_count == 2 + # One more test but we test our URL fetching + mock_notify.side_effect = True + + # Reset our mock object + mock_notify.reset_mock() + + # Preare our form data + form_data = { + 'body': 'test notifiction', + 'attachment': 'https://localhost/invalid/path/to/image.png', + } + + # Send our notification + response = self.client.post( + '/notify/{}'.format(key), form_data) + # We fail because we couldn't retrieve our attachment + assert response.status_code == 400 + assert mock_notify.call_count == 0 + @mock.patch('apprise.Apprise.notify') def test_notify_by_loaded_urls_with_json(self, mock_notify): """ diff --git a/apprise_api/api/tests/test_stateless_notify.py b/apprise_api/api/tests/test_stateless_notify.py index ff60715..7c70190 100644 --- a/apprise_api/api/tests/test_stateless_notify.py +++ b/apprise_api/api/tests/test_stateless_notify.py @@ -231,6 +231,25 @@ class StatelessNotifyTests(SimpleTestCase): assert response.status_code == 424 assert mock_notify.call_count == 2 + # Reset our mock object + mock_notify.reset_mock() + + # Preare our form data + form_data = { + 'body': 'test notifiction', + 'urls': ', '.join([ + 'mailto://user:pass@hotmail.com', + 'mailto://user:pass@gmail.com', + ]), + 'attachment': 'https://localhost/invalid/path/to/image.png', + } + + # Send our notification + response = self.client.post('/notify', form_data) + # We fail because we couldn't retrieve our attachment + assert response.status_code == 400 + assert mock_notify.call_count == 0 + @override_settings(APPRISE_RECURSION_MAX=1) @mock.patch('apprise.Apprise.notify') def test_stateless_notify_recursion(self, mock_notify): diff --git a/apprise_api/api/utils.py b/apprise_api/api/utils.py index 05b989a..cd15fba 100644 --- a/apprise_api/api/utils.py +++ b/apprise_api/api/utils.py @@ -60,6 +60,17 @@ class AppriseStoreMode(object): DISABLED = 'disabled' +class AttachmentPayload(object): + """ + Defines the supported Attachment Payload Types + """ + # BASE64 + BASE64 = 'base64' + + # URL request + URL = 'url' + + STORE_MODES = ( AppriseStoreMode.HASH, AppriseStoreMode.SIMPLE, @@ -79,7 +90,7 @@ class Attachment(A_MGR['file']): Attachments """ - def __init__(self, filename, path=None, delete=True): + def __init__(self, filename, path=None, delete=True, **kwargs): """ Initialize our attachment """ @@ -108,7 +119,7 @@ class Attachment(A_MGR['file']): self._path = path # Prepare our item - super().__init__(path=self._path, name=filename) + super().__init__(path=self._path, name=filename, **kwargs) # Update our file size based on the settings value self.max_file_size = settings.APPRISE_ATTACH_SIZE @@ -136,6 +147,67 @@ class Attachment(A_MGR['file']): pass +class HTTPAttachment(A_MGR['http']): + """ + A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise + Web Attachments + """ + + def __init__(self, filename, delete=True, **kwargs): + """ + Initialize our attachment + """ + self._filename = filename + self.delete = delete + self._path = None + try: + os.makedirs(settings.APPRISE_ATTACH_DIR, exist_ok=True) + + except OSError: + # Permission error + raise ValueError('Could not create directory {}'.format( + settings.APPRISE_ATTACH_DIR)) + + try: + d, self._path = tempfile.mkstemp(dir=settings.APPRISE_ATTACH_DIR) + # Close our file descriptor + os.close(d) + + except FileNotFoundError: + raise ValueError( + 'Could not prepare {} attachment in {}'.format( + filename, settings.APPRISE_ATTACH_DIR)) + + + # Prepare our item + super().__init__(name=filename, **kwargs) + + # Update our file size based on the settings value + self.max_file_size = settings.APPRISE_ATTACH_SIZE + + @property + def filename(self): + return self._filename + + @property + def size(self): + """ + Return filesize + """ + return 0 if not self else os.stat(self._path).st_size + + def __del__(self): + """ + De-Construtor is used to tidy up files during garbage collection + """ + if self.delete and self._path: + try: + os.remove(self._path) + except FileNotFoundError: + # no problem + pass + + def parse_attachments(attachment_payload, files_request): """ Takes the payload provided in a `/notify` call and extracts the @@ -148,11 +220,16 @@ def parse_attachments(attachment_payload, files_request): # Attachment Count count = sum([ - 0 if not isinstance(attachment_payload, (tuple, list)) + 0 if not isinstance(attachment_payload, (set, tuple, list)) else len(attachment_payload), 0 if not isinstance(files_request, dict) else len(files_request), ]) + if isinstance(attachment_payload, (dict, str, bytes)): + # Convert and adjust counter + attachment_payload = (attachment_payload, ) + count += 1 + if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \ (settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS): @@ -161,10 +238,9 @@ def parse_attachments(attachment_payload, files_request): settings.APPRISE_MAX_ATTACHMENTS if settings.APPRISE_MAX_ATTACHMENTS > 0 else 0) - if isinstance(attachment_payload, (tuple, list)): + if isinstance(attachment_payload, (tuple, list, set)): for no, entry in enumerate(attachment_payload, start=1): - - if isinstance(entry, str): + if isinstance(entry, (str, bytes)): filename = "attachment.%.3d" % no elif isinstance(entry, dict): @@ -180,7 +256,8 @@ def parse_attachments(attachment_payload, files_request): elif not filename: filename = "attachment.%.3d" % no - except TypeError: + except AttributeError: + # not a string that was provided raise ValueError( "An invalid filename was provided for attachment %d" % no) @@ -194,30 +271,70 @@ def parse_attachments(attachment_payload, files_request): # # Prepare our Attachment # - attachment = Attachment(filename) + if isinstance(entry, str): + if not re.match(r'^https?://.+', entry[:10], re.I): + # We failed to retrieve the product + raise ValueError( + "Failed to load attachment " + "%d (not web request): %s" % (no, entry)) - try: - with open(attachment.path, 'wb') as f: - # Write our content to disk - f.write(base64.b64decode(entry["base64"])) + attachment = HTTPAttachment( + filename, **A_MGR['http'].parse_url(entry)) + if not attachment: + # We failed to retrieve the attachment + raise ValueError( + "Failed to retrieve attachment %d: %s" % (no, entry)) - except binascii.Error: - # The file ws not base64 encoded - raise ValueError( - "Invalid filecontent was provided for attachment %s" % - filename) + else: # web, base64 or raw + attachment = Attachment(filename) + try: + with open(attachment.path, 'wb') as f: + # Write our content to disk + if isinstance(entry, dict) and \ + AttachmentPayload.BASE64 in entry: + # BASE64 + f.write( + base64.b64decode( + entry[AttachmentPayload.BASE64])) - except OSError: - raise ValueError( - "Could not write attachment %s to disk" % filename) + elif isinstance(entry, dict) and \ + AttachmentPayload.URL in entry: - # - # Some Validation - # - if settings.APPRISE_MAX_ATTACHMENT_SIZE > 0 and \ - attachment.size > settings.APPRISE_MAX_ATTACHMENT_SIZE: - raise ValueError( - "attachment %s's filesize is to large" % filename) + attachment = HTTPAttachment( + filename, **A_MGR['http'] + .parse_url(entry[AttachmentPayload.URL])) + if not attachment: + # We failed to retrieve the attachment + raise ValueError( + "Failed to retrieve attachment " + "%d: %s" % (no, entry)) + + elif isinstance(entry, bytes): + # RAW + f.write(entry) + + else: + raise ValueError( + "Invalid filetype was provided for " + "attachment %s" % filename) + + except binascii.Error: + # The file ws not base64 encoded + raise ValueError( + "Invalid filecontent was provided for attachment %s" % + filename) + + except OSError: + raise ValueError( + "Could not write attachment %s to disk" % filename) + + # + # Some Validation + # + if settings.APPRISE_MAX_ATTACHMENT_SIZE > 0 and \ + attachment.size > settings.APPRISE_MAX_ATTACHMENT_SIZE: + raise ValueError( + "attachment %s's filesize is to large" % filename) # Add our attachment attachments.append(attachment) @@ -245,8 +362,7 @@ def parse_attachments(attachment_payload, files_request): except (AttributeError, TypeError): raise ValueError( - "An invalid filename was provided for attachment %d" % - no) + "An invalid filename was provided for attachment %d" % no) # # Prepare our Attachment diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py index ef5e203..4a798c7 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -610,6 +610,10 @@ class NotifyView(View): # Handle Attachments attach = None + if not content.get('attachment') and 'attachment' in request.POST: + # Acquire attachments to work with them + content['attachment'] = request.POST.getlist('attachment') + if 'attachment' in content or request.FILES: try: attach = parse_attachments( @@ -974,8 +978,9 @@ class NotifyView(View): ) logger.info( - 'NOTIFY - %s - Proccessed%s KEY: %s', request.META['REMOTE_ADDR'], - '' if not tag else f' (Tags: {tag}),', key) + 'NOTIFY - %s - Delivered Notification(s) - %sKEY: %s', + request.META['REMOTE_ADDR'], + '' if not tag else f'Tags: {tag}, ', key) # Return our retrieved content return HttpResponse( @@ -1011,14 +1016,22 @@ class StatelessNotifyView(View): except (AttributeError, ValueError): # could not parse JSON response... + logger.warning( + 'NOTIFY - %s - Invalid JSON Payload provided', + request.META['REMOTE_ADDR']) + return HttpResponse( _('Invalid JSON specified.'), status=ResponseCode.bad_request) if not content: # We could not handle the Content-Type + logger.warning( + 'NOTIFY - %s - Invalid FORM Payload provided', + request.META['REMOTE_ADDR']) + return HttpResponse( - _('The message format is not supported.'), + _('Bad FORM Payload provided.'), status=ResponseCode.bad_request) if not content.get('urls') and settings.APPRISE_STATELESS_URLS: @@ -1031,15 +1044,22 @@ class StatelessNotifyView(View): content.get('type', apprise.NotifyType.INFO) \ not in apprise.NOTIFY_TYPES: + logger.warning( + 'NOTIFY - %s - Payload lacks minimum requirements', + request.META['REMOTE_ADDR']) + return HttpResponse( - _('An invalid payload was specified.'), + _('Payload lacks minimum requirements.'), status=ResponseCode.bad_request) # Acquire our body format (if identified) body_format = content.get('format', apprise.NotifyFormat.TEXT) if body_format and body_format not in apprise.NOTIFY_FORMATS: + logger.warning( + 'NOTIFY - %s - Format parameter contains an unsupported ' + 'value (%s)', request.META['REMOTE_ADDR'], str(body_format)) return HttpResponse( - _('An invalid (body) format was specified.'), + _('An invalid body input format was specified.'), status=ResponseCode.bad_request) # Prepare our keyword arguments (to be passed into an AppriseAsset @@ -1053,15 +1073,19 @@ class StatelessNotifyView(View): kwargs['body_format'] = body_format # Acquire our recursion count (if defined) + recursion = request.headers.get('X-Apprise-Recursion-Count', 0) try: - recursion = \ - int(request.headers.get('X-Apprise-Recursion-Count', 0)) + recursion = int(recursion) if recursion < 0: # We do not accept negative numbers raise TypeError("Invalid Recursion Value") if recursion > settings.APPRISE_RECURSION_MAX: + logger.warning( + 'NOTIFY - %s - Recursion limit reached (%d > %d)', + request.META['REMOTE_ADDR'], recursion, + settings.APPRISE_RECURSION_MAX) return HttpResponse( _('The recursion limit has been reached.'), status=ResponseCode.method_not_accepted) @@ -1070,6 +1094,9 @@ class StatelessNotifyView(View): kwargs['_recursion'] = recursion except (TypeError, ValueError): + logger.warning( + 'NOTIFY - %s - Invalid recursion value (%s) provided', + request.META['REMOTE_ADDR'], str(recursion)) return HttpResponse( _('An invalid recursion value was specified.'), status=ResponseCode.bad_request) @@ -1100,6 +1127,10 @@ class StatelessNotifyView(View): # Handle Attachments attach = None + if not content.get('attachment') and 'attachment' in request.POST: + # Acquire attachments to work with them + content['attachment'] = request.POST.getlist('attachment') + if 'attachment' in content or request.FILES: try: attach = parse_attachments( @@ -1178,6 +1209,10 @@ class StatelessNotifyView(View): _('One or more notification could not be sent.'), status=ResponseCode.failed_dependency) + logger.info( + 'NOTIFY - %s - Delivered Stateless Notification(s)', + request.META['REMOTE_ADDR']) + # Return our retrieved content return HttpResponse( _('Notification(s) sent.'),