mirror of
https://github.com/caronc/apprise-api.git
synced 2024-12-04 22:10:42 +01:00
attachment keyword in payload now supports Web Based URLs (#164)
This commit is contained in:
parent
68fd580b84
commit
2b21ab71ef
31
README.md
31
README.md
@ -147,16 +147,47 @@ curl -X POST \
|
|||||||
-F attach2=@/my/path/to/Apprise.doc \
|
-F attach2=@/my/path/to/Apprise.doc \
|
||||||
http://localhost:8000/notify
|
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' \
|
curl -X POST -d 'urls=mailto://user:pass@gmail.com&body=test message' \
|
||||||
-F @/path/to/your/attachment \
|
-F @/path/to/your/attachment \
|
||||||
http://localhost:8000/notify
|
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
|
# Send your notifications directly using JSON
|
||||||
curl -X POST -d '{"urls": "mailto://user:pass@gmail.com", "body":"test message"}' \
|
curl -X POST -d '{"urls": "mailto://user:pass@gmail.com", "body":"test message"}' \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
http://localhost:8000/notify
|
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
|
### 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:
|
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:
|
||||||
|
@ -22,12 +22,21 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# 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
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
import requests
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
from unittest import mock
|
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 django.test.utils import override_settings
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from shutil import rmtree
|
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):
|
class AttachmentTests(SimpleTestCase):
|
||||||
@ -54,10 +63,324 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
with mock.patch('os.makedirs', side_effect=OSError):
|
with mock.patch('os.makedirs', side_effect=OSError):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
Attachment('file')
|
Attachment('file')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
HTTPAttachment('web')
|
||||||
|
|
||||||
with mock.patch('tempfile.mkstemp', side_effect=FileNotFoundError):
|
with mock.patch('tempfile.mkstemp', side_effect=FileNotFoundError):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
Attachment('file')
|
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')
|
a = Attachment('file')
|
||||||
assert a.filename
|
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, {})
|
||||||
|
@ -828,6 +828,25 @@ class NotifyTests(SimpleTestCase):
|
|||||||
assert response.status_code == 424
|
assert response.status_code == 424
|
||||||
assert mock_notify.call_count == 2
|
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')
|
@mock.patch('apprise.Apprise.notify')
|
||||||
def test_notify_by_loaded_urls_with_json(self, mock_notify):
|
def test_notify_by_loaded_urls_with_json(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
|
@ -231,6 +231,25 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert response.status_code == 424
|
assert response.status_code == 424
|
||||||
assert mock_notify.call_count == 2
|
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)
|
@override_settings(APPRISE_RECURSION_MAX=1)
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch('apprise.Apprise.notify')
|
||||||
def test_stateless_notify_recursion(self, mock_notify):
|
def test_stateless_notify_recursion(self, mock_notify):
|
||||||
|
@ -60,6 +60,17 @@ class AppriseStoreMode(object):
|
|||||||
DISABLED = 'disabled'
|
DISABLED = 'disabled'
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentPayload(object):
|
||||||
|
"""
|
||||||
|
Defines the supported Attachment Payload Types
|
||||||
|
"""
|
||||||
|
# BASE64
|
||||||
|
BASE64 = 'base64'
|
||||||
|
|
||||||
|
# URL request
|
||||||
|
URL = 'url'
|
||||||
|
|
||||||
|
|
||||||
STORE_MODES = (
|
STORE_MODES = (
|
||||||
AppriseStoreMode.HASH,
|
AppriseStoreMode.HASH,
|
||||||
AppriseStoreMode.SIMPLE,
|
AppriseStoreMode.SIMPLE,
|
||||||
@ -79,7 +90,7 @@ class Attachment(A_MGR['file']):
|
|||||||
Attachments
|
Attachments
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, filename, path=None, delete=True):
|
def __init__(self, filename, path=None, delete=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize our attachment
|
Initialize our attachment
|
||||||
"""
|
"""
|
||||||
@ -108,7 +119,7 @@ class Attachment(A_MGR['file']):
|
|||||||
self._path = path
|
self._path = path
|
||||||
|
|
||||||
# Prepare our item
|
# 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
|
# Update our file size based on the settings value
|
||||||
self.max_file_size = settings.APPRISE_ATTACH_SIZE
|
self.max_file_size = settings.APPRISE_ATTACH_SIZE
|
||||||
@ -136,6 +147,67 @@ class Attachment(A_MGR['file']):
|
|||||||
pass
|
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):
|
def parse_attachments(attachment_payload, files_request):
|
||||||
"""
|
"""
|
||||||
Takes the payload provided in a `/notify` call and extracts the
|
Takes the payload provided in a `/notify` call and extracts the
|
||||||
@ -148,11 +220,16 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
|
|
||||||
# Attachment Count
|
# Attachment Count
|
||||||
count = sum([
|
count = sum([
|
||||||
0 if not isinstance(attachment_payload, (tuple, list))
|
0 if not isinstance(attachment_payload, (set, tuple, list))
|
||||||
else len(attachment_payload),
|
else len(attachment_payload),
|
||||||
0 if not isinstance(files_request, dict) else len(files_request),
|
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 \
|
if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \
|
||||||
(settings.APPRISE_MAX_ATTACHMENTS > 0 and
|
(settings.APPRISE_MAX_ATTACHMENTS > 0 and
|
||||||
count > settings.APPRISE_MAX_ATTACHMENTS):
|
count > settings.APPRISE_MAX_ATTACHMENTS):
|
||||||
@ -161,10 +238,9 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
settings.APPRISE_MAX_ATTACHMENTS
|
settings.APPRISE_MAX_ATTACHMENTS
|
||||||
if settings.APPRISE_MAX_ATTACHMENTS > 0 else 0)
|
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):
|
for no, entry in enumerate(attachment_payload, start=1):
|
||||||
|
if isinstance(entry, (str, bytes)):
|
||||||
if isinstance(entry, str):
|
|
||||||
filename = "attachment.%.3d" % no
|
filename = "attachment.%.3d" % no
|
||||||
|
|
||||||
elif isinstance(entry, dict):
|
elif isinstance(entry, dict):
|
||||||
@ -180,7 +256,8 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
elif not filename:
|
elif not filename:
|
||||||
filename = "attachment.%.3d" % no
|
filename = "attachment.%.3d" % no
|
||||||
|
|
||||||
except TypeError:
|
except AttributeError:
|
||||||
|
# not a string that was provided
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"An invalid filename was provided for attachment %d" %
|
"An invalid filename was provided for attachment %d" %
|
||||||
no)
|
no)
|
||||||
@ -194,30 +271,70 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
#
|
#
|
||||||
# Prepare our Attachment
|
# 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:
|
attachment = HTTPAttachment(
|
||||||
with open(attachment.path, 'wb') as f:
|
filename, **A_MGR['http'].parse_url(entry))
|
||||||
# Write our content to disk
|
if not attachment:
|
||||||
f.write(base64.b64decode(entry["base64"]))
|
# We failed to retrieve the attachment
|
||||||
|
raise ValueError(
|
||||||
|
"Failed to retrieve attachment %d: %s" % (no, entry))
|
||||||
|
|
||||||
except binascii.Error:
|
else: # web, base64 or raw
|
||||||
# The file ws not base64 encoded
|
attachment = Attachment(filename)
|
||||||
raise ValueError(
|
try:
|
||||||
"Invalid filecontent was provided for attachment %s" %
|
with open(attachment.path, 'wb') as f:
|
||||||
filename)
|
# Write our content to disk
|
||||||
|
if isinstance(entry, dict) and \
|
||||||
|
AttachmentPayload.BASE64 in entry:
|
||||||
|
# BASE64
|
||||||
|
f.write(
|
||||||
|
base64.b64decode(
|
||||||
|
entry[AttachmentPayload.BASE64]))
|
||||||
|
|
||||||
except OSError:
|
elif isinstance(entry, dict) and \
|
||||||
raise ValueError(
|
AttachmentPayload.URL in entry:
|
||||||
"Could not write attachment %s to disk" % filename)
|
|
||||||
|
|
||||||
#
|
attachment = HTTPAttachment(
|
||||||
# Some Validation
|
filename, **A_MGR['http']
|
||||||
#
|
.parse_url(entry[AttachmentPayload.URL]))
|
||||||
if settings.APPRISE_MAX_ATTACHMENT_SIZE > 0 and \
|
if not attachment:
|
||||||
attachment.size > settings.APPRISE_MAX_ATTACHMENT_SIZE:
|
# We failed to retrieve the attachment
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"attachment %s's filesize is to large" % filename)
|
"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
|
# Add our attachment
|
||||||
attachments.append(attachment)
|
attachments.append(attachment)
|
||||||
@ -245,8 +362,7 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
|
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"An invalid filename was provided for attachment %d" %
|
"An invalid filename was provided for attachment %d" % no)
|
||||||
no)
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Prepare our Attachment
|
# Prepare our Attachment
|
||||||
|
@ -610,6 +610,10 @@ class NotifyView(View):
|
|||||||
|
|
||||||
# Handle Attachments
|
# Handle Attachments
|
||||||
attach = None
|
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:
|
if 'attachment' in content or request.FILES:
|
||||||
try:
|
try:
|
||||||
attach = parse_attachments(
|
attach = parse_attachments(
|
||||||
@ -974,8 +978,9 @@ class NotifyView(View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'NOTIFY - %s - Proccessed%s KEY: %s', request.META['REMOTE_ADDR'],
|
'NOTIFY - %s - Delivered Notification(s) - %sKEY: %s',
|
||||||
'' if not tag else f' (Tags: {tag}),', key)
|
request.META['REMOTE_ADDR'],
|
||||||
|
'' if not tag else f'Tags: {tag}, ', key)
|
||||||
|
|
||||||
# Return our retrieved content
|
# Return our retrieved content
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@ -1011,14 +1016,22 @@ class StatelessNotifyView(View):
|
|||||||
|
|
||||||
except (AttributeError, ValueError):
|
except (AttributeError, ValueError):
|
||||||
# could not parse JSON response...
|
# could not parse JSON response...
|
||||||
|
logger.warning(
|
||||||
|
'NOTIFY - %s - Invalid JSON Payload provided',
|
||||||
|
request.META['REMOTE_ADDR'])
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_('Invalid JSON specified.'),
|
_('Invalid JSON specified.'),
|
||||||
status=ResponseCode.bad_request)
|
status=ResponseCode.bad_request)
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
# We could not handle the Content-Type
|
# We could not handle the Content-Type
|
||||||
|
logger.warning(
|
||||||
|
'NOTIFY - %s - Invalid FORM Payload provided',
|
||||||
|
request.META['REMOTE_ADDR'])
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_('The message format is not supported.'),
|
_('Bad FORM Payload provided.'),
|
||||||
status=ResponseCode.bad_request)
|
status=ResponseCode.bad_request)
|
||||||
|
|
||||||
if not content.get('urls') and settings.APPRISE_STATELESS_URLS:
|
if not content.get('urls') and settings.APPRISE_STATELESS_URLS:
|
||||||
@ -1031,15 +1044,22 @@ class StatelessNotifyView(View):
|
|||||||
content.get('type', apprise.NotifyType.INFO) \
|
content.get('type', apprise.NotifyType.INFO) \
|
||||||
not in apprise.NOTIFY_TYPES:
|
not in apprise.NOTIFY_TYPES:
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
'NOTIFY - %s - Payload lacks minimum requirements',
|
||||||
|
request.META['REMOTE_ADDR'])
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_('An invalid payload was specified.'),
|
_('Payload lacks minimum requirements.'),
|
||||||
status=ResponseCode.bad_request)
|
status=ResponseCode.bad_request)
|
||||||
|
|
||||||
# Acquire our body format (if identified)
|
# Acquire our body format (if identified)
|
||||||
body_format = content.get('format', apprise.NotifyFormat.TEXT)
|
body_format = content.get('format', apprise.NotifyFormat.TEXT)
|
||||||
if body_format and body_format not in apprise.NOTIFY_FORMATS:
|
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(
|
return HttpResponse(
|
||||||
_('An invalid (body) format was specified.'),
|
_('An invalid body input format was specified.'),
|
||||||
status=ResponseCode.bad_request)
|
status=ResponseCode.bad_request)
|
||||||
|
|
||||||
# Prepare our keyword arguments (to be passed into an AppriseAsset
|
# Prepare our keyword arguments (to be passed into an AppriseAsset
|
||||||
@ -1053,15 +1073,19 @@ class StatelessNotifyView(View):
|
|||||||
kwargs['body_format'] = body_format
|
kwargs['body_format'] = body_format
|
||||||
|
|
||||||
# Acquire our recursion count (if defined)
|
# Acquire our recursion count (if defined)
|
||||||
|
recursion = request.headers.get('X-Apprise-Recursion-Count', 0)
|
||||||
try:
|
try:
|
||||||
recursion = \
|
recursion = int(recursion)
|
||||||
int(request.headers.get('X-Apprise-Recursion-Count', 0))
|
|
||||||
|
|
||||||
if recursion < 0:
|
if recursion < 0:
|
||||||
# We do not accept negative numbers
|
# We do not accept negative numbers
|
||||||
raise TypeError("Invalid Recursion Value")
|
raise TypeError("Invalid Recursion Value")
|
||||||
|
|
||||||
if recursion > settings.APPRISE_RECURSION_MAX:
|
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(
|
return HttpResponse(
|
||||||
_('The recursion limit has been reached.'),
|
_('The recursion limit has been reached.'),
|
||||||
status=ResponseCode.method_not_accepted)
|
status=ResponseCode.method_not_accepted)
|
||||||
@ -1070,6 +1094,9 @@ class StatelessNotifyView(View):
|
|||||||
kwargs['_recursion'] = recursion
|
kwargs['_recursion'] = recursion
|
||||||
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
'NOTIFY - %s - Invalid recursion value (%s) provided',
|
||||||
|
request.META['REMOTE_ADDR'], str(recursion))
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_('An invalid recursion value was specified.'),
|
_('An invalid recursion value was specified.'),
|
||||||
status=ResponseCode.bad_request)
|
status=ResponseCode.bad_request)
|
||||||
@ -1100,6 +1127,10 @@ class StatelessNotifyView(View):
|
|||||||
|
|
||||||
# Handle Attachments
|
# Handle Attachments
|
||||||
attach = None
|
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:
|
if 'attachment' in content or request.FILES:
|
||||||
try:
|
try:
|
||||||
attach = parse_attachments(
|
attach = parse_attachments(
|
||||||
@ -1178,6 +1209,10 @@ class StatelessNotifyView(View):
|
|||||||
_('One or more notification could not be sent.'),
|
_('One or more notification could not be sent.'),
|
||||||
status=ResponseCode.failed_dependency)
|
status=ResponseCode.failed_dependency)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'NOTIFY - %s - Delivered Stateless Notification(s)',
|
||||||
|
request.META['REMOTE_ADDR'])
|
||||||
|
|
||||||
# Return our retrieved content
|
# Return our retrieved content
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_('Notification(s) sent.'),
|
_('Notification(s) sent.'),
|
||||||
|
Loading…
Reference in New Issue
Block a user