attachment keyword in payload now supports Web Based URLs (#164)

This commit is contained in:
Chris Caron 2024-01-14 13:27:14 -05:00 committed by GitHub
parent 68fd580b84
commit 2b21ab71ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 580 additions and 37 deletions

View File

@ -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:

View File

@ -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, {})

View File

@ -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):
"""

View File

@ -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):

View File

@ -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

View File

@ -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.'),