mirror of
https://github.com/caronc/apprise-api.git
synced 2025-01-21 13:28:56 +01:00
Stateful modes 'simple' and 'disabled' introduced (#25)
This commit is contained in:
parent
289fbd8640
commit
466aa8271e
11
README.md
11
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:<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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
Loading…
Reference in New Issue
Block a user