Stateful modes 'simple' and 'disabled' introduced (#25)

This commit is contained in:
Chris Caron 2020-09-02 21:33:33 -04:00 committed by GitHub
parent 289fbd8640
commit 466aa8271e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 291 additions and 39 deletions

View File

@ -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:<br/> - 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:<br/>📌 **hash**: This is also the default. It stores the server configuration in a hash formatted that can be easily indexed and compressed.<br/>📌 **simple**: Configuration is written straight to disk using the `{KEY}.cfg` (if `TEXT` based) and `{KEY}.yml` (if `YAML` based).<br/>📌 **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

View File

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

View File

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

View File

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