diff --git a/README.md b/README.md
index 7bc54b2..c84c6bf 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ docker-compose up
```
## Apprise URLs
-📣 In order to trigger a notification, you first need to define one or more [Apprise URLs](https://github.com/caronc/apprise/wiki) to support the services you want to send to. Apprise supports well over 50 notification services today and is always expanding to add support for more! Visit https://github.com/caronc/apprise/wiki to see the ever-growing list of the services supported today.
+📣 In order to trigger a notification, you first need to define one or more [Apprise URLs](https://github.com/caronc/apprise/wiki) to support the services you want to send to. Apprise supports over 60+ notification services today and is always expanding to add support for more! Visit https://github.com/caronc/apprise/wiki to see the ever-growing list of the services supported today.
## API Details
@@ -67,6 +67,10 @@ Some people may wish to only have a sidecar solution that does require use of an
Here is a *stateless* example of how one might send a notification (using `/notify/`):
```bash
# Send your notifications directly
+curl -X POST -d 'urls=mailto://user:pass@gmail.com&body=test message' \
+ 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
@@ -146,7 +150,8 @@ The use of environment variables allow you to provide over-rides to default sett
|--------------------- | ----------- |
| `APPRISE_CONFIG_DIR` | Defines an (optional) persistent store location of all configuration files saved. By default:
- Configuration is written to the `apprise_api/var/config` directory when just using the _Django_ `manage runserver` script. However for the path for the container is `/config`.
| `APPRISE_STATELESS_URLS` | For a non-persistent solution, you can take advantage of this global variable. Use this to define a default set of Apprise URLs to notify when using API calls to `/notify`. If no `{KEY}` is defined when calling `/notify` then the URLs defined here are used instead. By default, nothing is defined for this variable.
-| `SECRET_KEY` | A Django variable acting as a *salt* for most things that require security. This API uses it for the hash sequences when writing the configuration files to disk.
+| `APPRISE_STATEFUL_MODE` | This can be set to the following possible modes:
📌 **hash**: This is also the default. It stores the server configuration in a hash formatted that can be easily indexed and compressed.
📌 **simple**: Configuration is written straight to disk using the `{KEY}.cfg` (if `TEXT` based) and `{KEY}.yml` (if `YAML` based).
📌 **disabled**: Straight up deny any read/write queries to the servers stateful store. Effectively turn off the Apprise Stateful feature completely.
+| `SECRET_KEY` | A Django variable acting as a *salt* for most things that require security. This API uses it for the hash sequences when writing the configuration files to disk (`hash` mode only).
| `ALLOWED_HOSTS` | A list of strings representing the host/domain names that this API can serve. This is a security measure to prevent HTTP Host header attacks, which are possible even under many seemingly-safe web server configurations. By default this is set to `*` allowing any host. Use space to delimit more than one host.
| `DEBUG` | This defaults to `False` however can be set to `True` if defined with a non-zero value (such as `1`).
@@ -195,7 +200,7 @@ apprise --body="test message" --config=http://localhost:8000/get/{KEY}
### AppriseConfig() Pull Example
-Using the [Apprise Python library](https://github.com/caronc/apprise), you can easily pull your saved configuration off of the API to use for future notifications.
+Using the [Apprise Python library](https://github.com/caronc/apprise), you can easily access and load your saved configuration off of this API in order to use for future notifications.
```python
import apprise
diff --git a/apprise_api/api/tests/test_config_cache.py b/apprise_api/api/tests/test_config_cache.py
index 9271c2d..28ddf2b 100644
--- a/apprise_api/api/tests/test_config_cache.py
+++ b/apprise_api/api/tests/test_config_cache.py
@@ -24,20 +24,23 @@
# THE SOFTWARE.
import os
from ..utils import AppriseConfigCache
+from ..utils import AppriseStoreMode
+from ..utils import SimpleFileExtension
from apprise import ConfigFormat
from unittest.mock import patch
+from unittest.mock import mock_open
import errno
-def test_apprise_config_io(tmpdir):
+def test_apprise_config_io_hash_mode(tmpdir):
"""
- Test Apprise Config Disk Put/Get
+ Test Apprise Config Disk Put/Get using HASH mode
"""
content = 'mailto://test:pass@gmail.com'
- key = 'test_apprise_config_io'
+ key = 'test_apprise_config_io_hash'
# Create our object to work with
- acc_obj = AppriseConfigCache(str(tmpdir))
+ acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
# Verify that the content doesn't already exist
assert acc_obj.get(key) == (None, '')
@@ -46,8 +49,8 @@ def test_apprise_config_io(tmpdir):
assert acc_obj.put(key, content, ConfigFormat.TEXT)
# Test the handling of underlining disk/write exceptions
- with patch('gzip.open') as mock_open:
- mock_open.side_effect = OSError()
+ with patch('gzip.open') as mock_gzopen:
+ mock_gzopen.side_effect = OSError()
# We'll fail to write our key now
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
@@ -65,8 +68,8 @@ def test_apprise_config_io(tmpdir):
assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
# Test the handling of underlining disk/read exceptions
- with patch('gzip.open') as mock_open:
- mock_open.side_effect = OSError()
+ with patch('gzip.open') as mock_gzopen:
+ mock_gzopen.side_effect = OSError()
# We'll fail to read our key now
assert acc_obj.get(key) == (None, None)
@@ -81,6 +84,10 @@ def test_apprise_config_io(tmpdir):
# OSError
assert acc_obj.clear(key) is False
+ # If we try to put the same file, we'll fail since
+ # one exists there already
+ assert not acc_obj.put(key, content, ConfigFormat.TEXT)
+
# Now test with YAML file
content = """
version: 1
@@ -103,3 +110,119 @@ def test_apprise_config_io(tmpdir):
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.YAML)
+
+
+def test_apprise_config_io_simple_mode(tmpdir):
+ """
+ Test Apprise Config Disk Put/Get using SIMPLE mode
+ """
+ content = 'mailto://test:pass@gmail.com'
+ key = 'test_apprise_config_io_simple'
+
+ # Create our object to work with
+ acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
+
+ # Verify that the content doesn't already exist
+ assert acc_obj.get(key) == (None, '')
+
+ # Write our content assigned to our key
+ assert acc_obj.put(key, content, ConfigFormat.TEXT)
+
+ m = mock_open()
+ m.side_effect = OSError()
+ with patch('builtins.open', m):
+ # We'll fail to write our key now
+ assert not acc_obj.put(key, content, ConfigFormat.TEXT)
+
+ # Get path details
+ conf_dir, _ = acc_obj.path(key)
+
+ # List content of directory
+ contents = os.listdir(conf_dir)
+
+ # There should be just 1 new file in this directory
+ assert len(contents) == 1
+ assert contents[0].endswith('.{}'.format(SimpleFileExtension.TEXT))
+
+ # Verify that the content is retrievable
+ assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
+
+ # Test the handling of underlining disk/read exceptions
+ with patch('builtins.open', m) as mock__open:
+ mock__open.side_effect = OSError()
+ # We'll fail to read our key now
+ assert acc_obj.get(key) == (None, None)
+
+ # Tidy up our content
+ assert acc_obj.clear(key) is True
+
+ # But the second time is okay as it no longer exists
+ assert acc_obj.clear(key) is None
+
+ with patch('os.remove') as mock_remove:
+ mock_remove.side_effect = OSError(errno.EPERM)
+ # OSError
+ assert acc_obj.clear(key) is False
+
+ # If we try to put the same file, we'll fail since
+ # one exists there already
+ assert not acc_obj.put(key, content, ConfigFormat.TEXT)
+
+ # Now test with YAML file
+ content = """
+ version: 1
+
+ urls:
+ - windows://
+ """
+
+ # Write our content assigned to our key
+ # This should gracefully clear the TEXT entry that was
+ # previously in the spot
+ assert acc_obj.put(key, content, ConfigFormat.YAML)
+
+ # List content of directory
+ contents = os.listdir(conf_dir)
+
+ # There should STILL be just 1 new file in this directory
+ assert len(contents) == 1
+ assert contents[0].endswith('.{}'.format(SimpleFileExtension.YAML))
+
+ # Verify that the content is retrievable
+ assert acc_obj.get(key) == (content, ConfigFormat.YAML)
+
+
+def test_apprise_config_io_disabled_mode(tmpdir):
+ """
+ Test Apprise Config Disk Put/Get using DISABLED mode
+ """
+ content = 'mailto://test:pass@gmail.com'
+ key = 'test_apprise_config_io_disabled'
+
+ # Create our object to work with using an invalid mode
+ acc_obj = AppriseConfigCache(str(tmpdir), mode="invalid")
+
+ # We always fall back to disabled if we can't interpret the mode
+ assert acc_obj.mode is AppriseStoreMode.DISABLED
+
+ # Create our object to work with
+ acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.DISABLED)
+
+ # Verify that the content doesn't already exist
+ assert acc_obj.get(key) == (None, '')
+
+ # Write our content assigned to our key
+ # This isn't allowed
+ assert acc_obj.put(key, content, ConfigFormat.TEXT) is False
+
+ # Get path details
+ conf_dir, _ = acc_obj.path(key)
+
+ # List content of directory
+ contents = os.listdir(conf_dir)
+
+ # There should never be an entry
+ assert len(contents) == 0
+
+ # Content never exists
+ assert acc_obj.clear(key) is None
diff --git a/apprise_api/api/utils.py b/apprise_api/api/utils.py
index 12130ec..3a78f10 100644
--- a/apprise_api/api/utils.py
+++ b/apprise_api/api/utils.py
@@ -32,6 +32,56 @@ import errno
from django.conf import settings
+# import the logging library
+import logging
+
+# Get an instance of a logger
+logger = logging.getLogger(__name__)
+
+
+class AppriseStoreMode(object):
+ """
+ Defines the store modes of configuration
+ """
+ # This is the default option. Content is cached and written by
+ # it's key
+ HASH = 'hash'
+
+ # Content is written straight to disk using it's key
+ # there is nothing further done
+ SIMPLE = 'simple'
+
+ # When set to disabled; stateful functionality is disabled
+ DISABLED = 'disabled'
+
+
+STORE_MODES = (
+ AppriseStoreMode.HASH,
+ AppriseStoreMode.SIMPLE,
+ AppriseStoreMode.DISABLED,
+)
+
+
+class SimpleFileExtension(object):
+ """
+ Defines the simple file exension lookups
+ """
+ # Simple Configuration file
+ TEXT = 'cfg'
+
+ # YAML Configuration file
+ YAML = 'yml'
+
+
+SIMPLE_FILE_EXTENSION_MAPPING = {
+ apprise.ConfigFormat.TEXT: SimpleFileExtension.TEXT,
+ apprise.ConfigFormat.YAML: SimpleFileExtension.YAML,
+ SimpleFileExtension.TEXT: SimpleFileExtension.TEXT,
+ SimpleFileExtension.YAML: SimpleFileExtension.YAML,
+}
+
+SIMPLE_FILE_EXTENSIONS = (SimpleFileExtension.TEXT, SimpleFileExtension.YAML)
+
class AppriseConfigCache(object):
"""
@@ -39,12 +89,18 @@ class AppriseConfigCache(object):
type structure that is fast.
"""
- def __init__(self, cache_root, salt="apprise"):
+ def __init__(self, cache_root, salt="apprise", mode=AppriseStoreMode.HASH):
"""
Works relative to the cache_root
"""
self.root = cache_root
self.salt = salt.encode()
+ self.mode = mode.strip().lower()
+ if self.mode not in STORE_MODES:
+ self.mode = AppriseStoreMode.DISABLED
+ logger.error(
+ 'APPRISE_STATEFUL_MODE {} is not supported; '
+ 'reverted to {}.'.format(mode, self.mode))
def put(self, key, content, fmt):
"""
@@ -58,6 +114,9 @@ class AppriseConfigCache(object):
"""
# There isn't a lot of error handling done here as it is presumed most
# of the checking has been done higher up.
+ if self.mode == AppriseStoreMode.DISABLED:
+ # Do nothing
+ return False
# First two characters are reserved for cache level directory writing.
path, filename = self.path(key)
@@ -65,26 +124,49 @@ class AppriseConfigCache(object):
# Write our file to a temporary file
_, tmp_path = tempfile.mkstemp(suffix='.tmp', dir=path)
- try:
- with gzip.open(tmp_path, 'wb') as f:
- # Write our content to disk
- f.write(content.encode())
+ if self.mode == AppriseStoreMode.HASH:
+ try:
+ with gzip.open(tmp_path, 'wb') as f:
+ # Write our content to disk
+ f.write(content.encode())
- except OSError:
- # Handle failure
- os.remove(tmp_path)
- return False
+ except OSError:
+ # Handle failure
+ os.remove(tmp_path)
+ return False
+
+ else: # AppriseStoreMode.SIMPLE
+ # Update our file extenion based on our fmt
+ fmt = SIMPLE_FILE_EXTENSION_MAPPING[fmt]
+ try:
+ with open(tmp_path, 'wb') as f:
+ # Write our content to disk
+ f.write(content.encode())
+
+ except OSError:
+ # Handle failure
+ os.remove(tmp_path)
+ return False
# If we reach here we successfully wrote the content. We now safely
# move our configuration into place. The following writes our content
- # to disk as /xx/key.fmt
+ # to disk
shutil.move(tmp_path, os.path.join(
path, '{}.{}'.format(filename, fmt)))
# perform tidy of any other lingering files of other type in case
# configuration changed from TEXT -> YAML or YAML -> TEXT
- if self.clear(key, set(apprise.CONFIG_FORMATS) - {fmt}) is False:
- # We couldn't remove an existing entry; clear what we just created
+ if self.mode == AppriseStoreMode.HASH:
+ if self.clear(key, set(apprise.CONFIG_FORMATS) - {fmt}) is False:
+ # We couldn't remove an existing entry; clear what we just
+ # created
+ self.clear(key, {fmt})
+ # fail
+ return False
+
+ elif self.clear(key, set(SIMPLE_FILE_EXTENSIONS) - {fmt}) is False:
+ # We couldn't remove an existing entry; clear what we just
+ # created
self.clear(key, {fmt})
# fail
return False
@@ -105,6 +187,10 @@ class AppriseConfigCache(object):
If no data was found, then (None, None) is returned.
"""
+ if self.mode == AppriseStoreMode.DISABLED:
+ # Do nothing
+ return (None, '')
+
# There isn't a lot of error handling done here as it is presumed most
# of the checking has been done higher up.
@@ -115,10 +201,17 @@ class AppriseConfigCache(object):
fmt = None
# Test the only possible hashed files we expect to find
- text_file = os.path.join(
- path, '{}.{}'.format(filename, apprise.ConfigFormat.TEXT))
- yaml_file = os.path.join(
- path, '{}.{}'.format(filename, apprise.ConfigFormat.YAML))
+ if self.mode == AppriseStoreMode.HASH:
+ text_file = os.path.join(
+ path, '{}.{}'.format(filename, apprise.ConfigFormat.TEXT))
+ yaml_file = os.path.join(
+ path, '{}.{}'.format(filename, apprise.ConfigFormat.YAML))
+
+ else: # AppriseStoreMode.SIMPLE
+ text_file = os.path.join(
+ path, '{}.{}'.format(filename, SimpleFileExtension.TEXT))
+ yaml_file = os.path.join(
+ path, '{}.{}'.format(filename, SimpleFileExtension.YAML))
if os.path.isfile(text_file):
fmt = apprise.ConfigFormat.TEXT
@@ -136,14 +229,27 @@ class AppriseConfigCache(object):
# Initialize our content
content = None
- try:
- with gzip.open(path, 'rb') as f:
- # Write our content to disk
- content = f.read().decode()
+ if self.mode == AppriseStoreMode.HASH:
+ try:
+ with gzip.open(path, 'rb') as f:
+ # Write our content to disk
+ content = f.read().decode()
- except OSError:
- # all none return means to let upstream know we had a hard failure
- return (None, None)
+ except OSError:
+ # all none return means to let upstream know we had a hard
+ # failure
+ return (None, None)
+
+ else: # AppriseStoreMode.SIMPLE
+ try:
+ with open(path, 'rb') as f:
+ # Write our content to disk
+ content = f.read().decode()
+
+ except OSError:
+ # all none return means to let upstream know we had a hard
+ # failure
+ return (None, None)
# return our read content
return (content, fmt)
@@ -161,6 +267,10 @@ class AppriseConfigCache(object):
# Default our response None
response = None
+ if self.mode == AppriseStoreMode.DISABLED:
+ # Do nothing
+ return response
+
if formats is None:
formats = apprise.CONFIG_FORMATS
@@ -169,7 +279,10 @@ class AppriseConfigCache(object):
# Eliminate any existing content if present
try:
# Handle failure
- os.remove(os.path.join(path, '{}.{}'.format(filename, fmt)))
+ os.remove(os.path.join(path, '{}.{}'.format(
+ filename,
+ fmt if self.mode == AppriseStoreMode.HASH
+ else SIMPLE_FILE_EXTENSION_MAPPING[fmt])))
# If we reach here, an element was removed
response = True
@@ -186,11 +299,16 @@ class AppriseConfigCache(object):
returns the path and filename content should be written to based on the
specified key
"""
- encoded_key = hashlib.sha224(self.salt + key.encode()).hexdigest()
- path = os.path.join(self.root, encoded_key[0:2])
- return (path, encoded_key[2:])
+ if self.mode == AppriseStoreMode.HASH:
+ encoded_key = hashlib.sha224(self.salt + key.encode()).hexdigest()
+ path = os.path.join(self.root, encoded_key[0:2])
+ return (path, encoded_key[2:])
+
+ else: # AppriseStoreMode.SIMPLE
+ return (self.root, key)
# Initialize our singleton
ConfigCache = AppriseConfigCache(
- settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY)
+ settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY,
+ mode=settings.APPRISE_STATEFUL_MODE)
diff --git a/apprise_api/core/settings/__init__.py b/apprise_api/core/settings/__init__.py
index 4cc032e..b0e7973 100644
--- a/apprise_api/core/settings/__init__.py
+++ b/apprise_api/core/settings/__init__.py
@@ -87,3 +87,9 @@ APPRISE_CONFIG_DIR = os.environ.get(
# Stateless posts to /notify/ will resort to this set of URLs if none
# were otherwise posted with the URL request.
APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '')
+
+# Defines the stateful mode; possible values are:
+# - hash (default): content is hashed and zipped
+# - simple: content is just written straight to disk 'as-is'
+# - disabled: disable all stateful functionality
+APPRISE_STATEFUL_MODE = os.environ.get('APPRISE_STATEFUL_MODE', 'hash')