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')