diff --git a/README.md b/README.md
index 021797b..20b6e2c 100644
--- a/README.md
+++ b/README.md
@@ -132,6 +132,45 @@ The following architectures are supported: `amd64`, `arm/v7`, and `arm64`. The f
## API Details
+### Health Checks
+
+You can perform status or health checks on your server configuration by accessing `/status`.
+
+| Path | Method | Description |
+|------------- | ------ | ----------- |
+| `/status` | GET | Simply returns a server status. The server http response code is a `200` if the server is working correcty and a `417` if there was an unexpected issue. You can set the `Accept` header to `application/json` or `text/plain` for different response outputs.
+
+Below is a sample of just a simple text response:
+```bash
+# Request a general text response
+# Output will read `OK` if everything is fine, otherwise it will return
+# one or more of the following separated by a comma:
+# - ATTACH_PERMISSION_ISSUE: Can not write attachments (likely a permission issue)
+# - CONFIG_PERMISSION_ISSUE: Can not write configuration (likely a permission issue)
+curl -X GET http://localhost:8000/status
+```
+
+Below is a sample of a JSON response:
+```bash
+curl -X GET -H "Accept: application/json" http://localhost:8000/status
+```
+The above output may look like this:
+```json
+{
+ "config_lock": false,
+ "status": {
+ "can_write_config": true,
+ "can_write_attach": true,
+ "details": ["OK"]
+ }
+}
+```
+
+- The `config_lock` always cross references if the `APPRISE_CONFIG_LOCK` is enabled or not.
+- The `status.can_write_config` defines if the configuration directory is writable or not. If the environment variable `APPRISE_STATEFUL_MODE` is set to `disabled`, this value will always read `false` and it will not impact the `status.details`
+- The `status.can_write_attach` defines if the attachment directory is writable or not. If the environment variable `APPRISE_ATTACH_SIZE` or `APPRISE_MAX_ATTACHMENTS` is set to `0` (zero) or lower, this value will always read `false` and it will not impact the `status.details`.
+- The `status.details` identifies the overall status. If there is more then 1 issue to report here, they will all show in this list. In a working orderly environment, this will always be set to `OK` and the http response type will be `200`.
+
### Stateless Solution
Some people may wish to only have a sidecar solution that does require use of any persistent storage. The following API endpoint can be used to directly send a notification of your choice to any of the [supported services by Apprise](https://github.com/caronc/apprise/wiki) without any storage based requirements:
diff --git a/apprise_api/api/templates/config.html b/apprise_api/api/templates/config.html
index 1fd7f11..abe8a55 100644
--- a/apprise_api/api/templates/config.html
+++ b/apprise_api/api/templates/config.html
@@ -22,15 +22,15 @@
{% trans "Getting Started" %}
-
- {% blocktrans %}
- Here is where you can store your Apprise configuration associated with the key
{{key}}
.
- {% endblocktrans %}
- For some examples on how to build a development environment around this, click here.
+ {% trans "Verify your Apprise API Status:" %} click here
+
+ -
+ {% trans "Here is where you can store your Apprise configuration associated with the key:" %}
{{key}}
+ {% trans "For some examples on how to build a development environment around this:" %} click here
-
{% blocktrans %}
- In the future you can return to this configuration screen at any time by placing the following into your
- browser:
+ In the future you can return to this configuration screen at any time by placing the following into your browser:
{% endblocktrans %}
{{request.scheme}}://{{request.META.HTTP_HOST}}{{BASE_URL}}/cfg/{{key}}
diff --git a/apprise_api/api/tests/test_attachment.py b/apprise_api/api/tests/test_attachment.py
index af6fd22..73d6fec 100644
--- a/apprise_api/api/tests/test_attachment.py
+++ b/apprise_api/api/tests/test_attachment.py
@@ -132,6 +132,18 @@ class AttachmentTests(SimpleTestCase):
with self.assertRaises(ValueError):
parse_attachments(None, files_request)
+ # Test Attachment Size seto t zer0
+ with override_settings(APPRISE_ATTACH_SIZE=0):
+ files_request = {
+ 'file1': SimpleUploadedFile(
+ "attach.txt",
+ # More then 1 MB in size causing error to trip
+ ("content" * 1024 * 1024).encode('utf-8'),
+ content_type="text/plain")
+ }
+ with self.assertRaises(ValueError):
+ parse_attachments(None, files_request)
+
# Bad data provided in filename field
files_request = {
'file1': SimpleUploadedFile(
diff --git a/apprise_api/api/tests/test_healthecheck.py b/apprise_api/api/tests/test_healthecheck.py
new file mode 100644
index 0000000..2d65eba
--- /dev/null
+++ b/apprise_api/api/tests/test_healthecheck.py
@@ -0,0 +1,223 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2024 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# 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 mock
+from django.test import SimpleTestCase
+from json import loads
+from django.test.utils import override_settings
+from ..utils import healthcheck
+
+
+class HealthCheckTests(SimpleTestCase):
+
+ def test_post_not_supported(self):
+ """
+ Test POST requests
+ """
+ response = self.client.post('/status')
+ # 405 as posting is not allowed
+ assert response.status_code == 405
+
+ def test_healthcheck_simple(self):
+ """
+ Test retrieving basic successful health-checks
+ """
+
+ # First Status Check
+ response = self.client.get('/status')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b'OK')
+ assert response['Content-Type'].startswith('text/plain')
+
+ # Second Status Check (Lazy Mode kicks in)
+ response = self.client.get('/status')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b'OK')
+ assert response['Content-Type'].startswith('text/plain')
+
+ # JSON Response
+ response = self.client.get(
+ '/status', content_type='application/json',
+ **{'HTTP_CONTENT_TYPE': 'application/json'})
+ self.assertEqual(response.status_code, 200)
+ content = loads(response.content)
+ assert content == {
+ 'config_lock': False,
+ 'status': {
+ 'can_write_config': True,
+ 'can_write_attach': True,
+ 'details': ['OK']
+ }
+ }
+ assert response['Content-Type'].startswith('application/json')
+
+ with override_settings(APPRISE_CONFIG_LOCK=True):
+ # Status Check (Form based)
+ response = self.client.get('/status')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b'OK')
+ assert response['Content-Type'].startswith('text/plain')
+
+ # JSON Response
+ response = self.client.get(
+ '/status', content_type='application/json',
+ **{'HTTP_CONTENT_TYPE': 'application/json'})
+ self.assertEqual(response.status_code, 200)
+ content = loads(response.content)
+ assert content == {
+ 'config_lock': True,
+ 'status': {
+ 'can_write_config': False,
+ 'can_write_attach': True,
+ 'details': ['OK']
+ }
+ }
+
+ with override_settings(APPRISE_STATEFUL_MODE='disabled'):
+ # Status Check (Form based)
+ response = self.client.get('/status')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b'OK')
+ assert response['Content-Type'].startswith('text/plain')
+
+ # JSON Response
+ response = self.client.get(
+ '/status', content_type='application/json',
+ **{'HTTP_CONTENT_TYPE': 'application/json'})
+ self.assertEqual(response.status_code, 200)
+ content = loads(response.content)
+ assert content == {
+ 'config_lock': False,
+ 'status': {
+ 'can_write_config': False,
+ 'can_write_attach': True,
+ 'details': ['OK']
+ }
+ }
+
+ with override_settings(APPRISE_ATTACH_SIZE=0):
+ # Status Check (Form based)
+ response = self.client.get('/status')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b'OK')
+ assert response['Content-Type'].startswith('text/plain')
+
+ # JSON Response
+ response = self.client.get(
+ '/status', content_type='application/json',
+ **{'HTTP_CONTENT_TYPE': 'application/json'})
+ self.assertEqual(response.status_code, 200)
+ content = loads(response.content)
+ assert content == {
+ 'config_lock': False,
+ 'status': {
+ 'can_write_config': True,
+ 'can_write_attach': False,
+ 'details': ['OK']
+ }
+ }
+
+ with override_settings(APPRISE_MAX_ATTACHMENTS=0):
+ # Status Check (Form based)
+ response = self.client.get('/status')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, b'OK')
+ assert response['Content-Type'].startswith('text/plain')
+
+ # JSON Response
+ response = self.client.get(
+ '/status', content_type='application/json',
+ **{'HTTP_CONTENT_TYPE': 'application/json'})
+ self.assertEqual(response.status_code, 200)
+ content = loads(response.content)
+ assert content == {
+ 'config_lock': False,
+ 'status': {
+ 'can_write_config': True,
+ 'can_write_attach': False,
+ 'details': ['OK']
+ }
+ }
+
+ def test_healthcheck_library(self):
+ """
+ Test underlining healthcheck library
+ """
+
+ result = healthcheck(lazy=True)
+ assert result == {
+ 'can_write_config': True,
+ 'can_write_attach': True,
+ 'details': ['OK']
+ }
+
+ # A Double lazy check
+ result = healthcheck(lazy=True)
+ assert result == {
+ 'can_write_config': True,
+ 'can_write_attach': True,
+ 'details': ['OK']
+ }
+
+ # Force a lazy check where we can't acquire the modify time
+ with mock.patch('os.path.getmtime') as mock_getmtime:
+ mock_getmtime.side_effect = FileNotFoundError()
+ result = healthcheck(lazy=True)
+ # We still succeed; we just don't leverage our lazy check
+ # which prevents addition (unnessisary) writes
+ assert result == {
+ 'can_write_config': True,
+ 'can_write_attach': True,
+ 'details': ['OK'],
+ }
+
+ # Force a non-lazy check
+ with mock.patch('os.makedirs') as mock_makedirs:
+ mock_makedirs.side_effect = OSError()
+ result = healthcheck(lazy=False)
+ assert result == {
+ 'can_write_config': False,
+ 'can_write_attach': False,
+ 'details': [
+ 'CONFIG_PERMISSION_ISSUE',
+ 'ATTACH_PERMISSION_ISSUE',
+ ]}
+
+ mock_makedirs.side_effect = (None, OSError())
+ result = healthcheck(lazy=False)
+ assert result == {
+ 'can_write_config': True,
+ 'can_write_attach': False,
+ 'details': [
+ 'ATTACH_PERMISSION_ISSUE',
+ ]}
+
+ mock_makedirs.side_effect = (OSError(), None)
+ result = healthcheck(lazy=False)
+ assert result == {
+ 'can_write_config': False,
+ 'can_write_attach': True,
+ 'details': [
+ 'CONFIG_PERMISSION_ISSUE',
+ ]}
diff --git a/apprise_api/api/tests/test_payload_mapper.py b/apprise_api/api/tests/test_payload_mapper.py
index da7dd4a..9845e7f 100644
--- a/apprise_api/api/tests/test_payload_mapper.py
+++ b/apprise_api/api/tests/test_payload_mapper.py
@@ -197,7 +197,6 @@ class NotifyPayloadMapper(SimpleTestCase):
'body': 'the message',
}
-
#
# mapping of fields don't align - test 6
#
diff --git a/apprise_api/api/tests/test_stateless_notify.py b/apprise_api/api/tests/test_stateless_notify.py
index 3eccb71..18804fd 100644
--- a/apprise_api/api/tests/test_stateless_notify.py
+++ b/apprise_api/api/tests/test_stateless_notify.py
@@ -129,7 +129,7 @@ class StatelessNotifyTests(SimpleTestCase):
# We sent the notification successfully (use our rule mapping)
# FORM
response = self.client.post(
- f'/notify/?:payload=body&:fmt=format&:extra=urls',
+ '/notify/?:payload=body&:fmt=format&:extra=urls',
form_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
diff --git a/apprise_api/api/tests/test_webhook.py b/apprise_api/api/tests/test_webhook.py
index a46859a..51ce44a 100644
--- a/apprise_api/api/tests/test_webhook.py
+++ b/apprise_api/api/tests/test_webhook.py
@@ -85,7 +85,21 @@ class WebhookTests(SimpleTestCase):
mock_post.reset_mock()
with override_settings(APPRISE_WEBHOOK_URL='invalid'):
- # Invalid wbhook defined
+ # Invalid webhook defined
+ send_webhook({})
+ assert mock_post.call_count == 0
+
+ mock_post.reset_mock()
+
+ with override_settings(APPRISE_WEBHOOK_URL=None):
+ # Invalid webhook defined
+ send_webhook({})
+ assert mock_post.call_count == 0
+
+ mock_post.reset_mock()
+
+ with override_settings(APPRISE_WEBHOOK_URL='http://$#@'):
+ # Invalid hostname defined
send_webhook({})
assert mock_post.call_count == 0
@@ -93,7 +107,7 @@ class WebhookTests(SimpleTestCase):
with override_settings(
APPRISE_WEBHOOK_URL='invalid://hostname'):
- # Invalid wbhook defined
+ # Invalid webhook defined
send_webhook({})
assert mock_post.call_count == 0
diff --git a/apprise_api/api/urls.py b/apprise_api/api/urls.py
index aae908e..0cc8a08 100644
--- a/apprise_api/api/urls.py
+++ b/apprise_api/api/urls.py
@@ -29,6 +29,9 @@ urlpatterns = [
re_path(
r'^$',
views.WelcomeView.as_view(), name='welcome'),
+ re_path(
+ r'^status/?$',
+ views.HealthCheckView.as_view(), name='health'),
re_path(
r'^details/?$',
views.DetailsView.as_view(), name='details'),
diff --git a/apprise_api/api/utils.py b/apprise_api/api/utils.py
index 32f86b4..b0e28d8 100644
--- a/apprise_api/api/utils.py
+++ b/apprise_api/api/utils.py
@@ -229,6 +229,9 @@ def parse_attachments(attachment_payload, files_request):
attachment_payload = (attachment_payload, )
count += 1
+ if settings.APPRISE_ATTACH_SIZE <= 0:
+ raise ValueError("The attachment size is restricted to 0MB")
+
if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \
(settings.APPRISE_MAX_ATTACHMENTS > 0 and
count > settings.APPRISE_MAX_ATTACHMENTS):
@@ -707,8 +710,11 @@ def send_webhook(payload):
'Content-Type': 'application/json',
}
- if not apprise.utils.VALID_URL_RE.match(
- settings.APPRISE_WEBHOOK_URL):
+ try:
+ if not apprise.utils.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group('schema'):
+ raise AttributeError()
+
+ except (AttributeError, TypeError):
logger.warning(
'The Apprise Webhook Result URL is not a valid web based URI')
return
@@ -750,3 +756,88 @@ def send_webhook(payload):
logger.debug('Socket Exception: %s' % str(e))
return
+
+
+def healthcheck(lazy=True):
+ """
+ Runs a status check on the data and returns the statistics
+ """
+
+ # Some status variables we can flip
+ response = {
+ 'can_write_config': False,
+ 'can_write_attach': False,
+ 'details': [],
+ }
+
+ if not (settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.DISABLED or settings.APPRISE_CONFIG_LOCK):
+ # Update our Configuration Check Block
+ path = os.path.join(ConfigCache.root, '.tmp_healthcheck')
+ if lazy:
+ try:
+ modify_date = datetime.fromtimestamp(os.path.getmtime(path))
+ delta = (datetime.now() - modify_date).total_seconds()
+ if delta <= 7200.00: # 2hrs
+ response['can_write_config'] = True
+
+ except FileNotFoundError:
+ # No worries... continue with below testing
+ pass
+
+ if not response['can_write_config']:
+ try:
+ os.makedirs(path, exist_ok=True)
+
+ # Write a small file
+ with tempfile.TemporaryFile(mode='w+b', dir=path) as fp:
+ # Test writing 1 block
+ fp.write(b'.')
+ # Read it back
+ fp.seek(0)
+ fp.read(1) == b'.'
+ # Toggle our status
+ response['can_write_config'] = True
+
+ except OSError:
+ # We can take an early exit
+ response['details'].append('CONFIG_PERMISSION_ISSUE')
+
+ if settings.APPRISE_MAX_ATTACHMENTS > 0 and settings.APPRISE_ATTACH_SIZE > 0:
+ # Test our ability to access write attachments
+
+ # Update our Configuration Check Block
+ path = os.path.join(settings.APPRISE_ATTACH_DIR, '.tmp_healthcheck')
+ if lazy:
+ try:
+ modify_date = datetime.fromtimestamp(os.path.getmtime(path))
+ delta = (datetime.now() - modify_date).total_seconds()
+ if delta <= 7200.00: # 2hrs
+ response['can_write_attach'] = True
+
+ except FileNotFoundError:
+ # No worries... continue with below testing
+ pass
+
+ if not response['can_write_attach']:
+ # No lazy mode set or content require a refresh
+ try:
+ os.makedirs(path, exist_ok=True)
+
+ # Write a small file
+ with tempfile.TemporaryFile(mode='w+b', dir=path) as fp:
+ # Test writing 1 block
+ fp.write(b'.')
+ # Read it back
+ fp.seek(0)
+ fp.read(1) == b'.'
+ # Toggle our status
+ response['can_write_attach'] = True
+
+ except OSError:
+ # We can take an early exit
+ response['details'].append('ATTACH_PERMISSION_ISSUE')
+
+ if not response['details']:
+ response['details'].append('OK')
+
+ return response
diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py
index 3e569c2..4d42865 100644
--- a/apprise_api/api/views.py
+++ b/apprise_api/api/views.py
@@ -40,6 +40,7 @@ from .utils import parse_attachments
from .utils import ConfigCache
from .utils import apply_global_filters
from .utils import send_webhook
+from .utils import healthcheck
from .forms import AddByUrlForm
from .forms import AddByConfigForm
from .forms import NotifyForm
@@ -116,6 +117,7 @@ class ResponseCode(object):
not_found = 404
method_not_allowed = 405
method_not_accepted = 406
+ expectation_failed = 417
failed_dependency = 424
fields_too_large = 431
internal_server_error = 500
@@ -136,6 +138,44 @@ class WelcomeView(View):
})
+@method_decorator((gzip_page, never_cache), name='dispatch')
+class HealthCheckView(View):
+ """
+ A Django view used to return a simple status/healthcheck
+ """
+
+ def get(self, request):
+ """
+ Handle a GET request
+ """
+ # Detect the format our incoming payload
+ json_payload = \
+ MIME_IS_JSON.match(
+ request.content_type
+ if request.content_type
+ else request.headers.get(
+ 'content-type', '')) is not None
+
+ # Detect the format our response should be in
+ json_response = True if json_payload \
+ and ACCEPT_ALL.match(request.headers.get('accept', '')) else \
+ MIME_IS_JSON.match(request.headers.get('accept', '')) is not None
+
+ # Run our healthcheck
+ response = healthcheck()
+
+ # Prepare our response
+ status = ResponseCode.okay if 'OK' in response['details'] else ResponseCode.expectation_failed
+ if not json_response:
+ response = ','.join(response['details'])
+
+ return HttpResponse(response, status=status, content_type='text/plain') \
+ if not json_response else JsonResponse({
+ 'config_lock': settings.APPRISE_CONFIG_LOCK,
+ 'status': response,
+ }, encoder=JSONEncoder, safe=False, status=status)
+
+
@method_decorator((gzip_page, never_cache), name='dispatch')
class DetailsView(View):
"""
@@ -664,7 +704,7 @@ class NotifyView(View):
MIME_IS_JSON.match(request.headers.get('accept', '')) is not None
# rules
- rules = {k[1:]: v for k,v in request.GET.items() if k[0] == ':'}
+ rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ':'}
# our content
content = {}
@@ -1187,7 +1227,7 @@ class StatelessNotifyView(View):
MIME_IS_JSON.match(request.headers.get('accept', '')) is not None
# rules
- rules = {k[1:]: v for k,v in request.GET.items() if k[0] == ':'}
+ rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ':'}
# our content
content = {}