APPRISE_CONFIG_LOCK switch added for extra security (#57)

This commit is contained in:
Chris Caron
2021-11-06 17:21:41 -04:00
committed by GitHub
parent e65b80cb11
commit 2fcc5f43a9
11 changed files with 686 additions and 307 deletions

View File

@ -196,11 +196,12 @@ 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.
| `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.
| `APPRISE_CONFIG_LOCK` | Locks down your API hosting so that you can no longer delete/update/access stateful information. Your configuration is still referenced when stateful calls are made to `/notify`. The idea of this switch is to allow someone to set their (Apprise) configuration up and then as an added security tactic, they may choose to lock their configuration down (in a read-only state). Those who use the Apprise CLI tool may still do it, however the `--config` (`-c`) switch will not successfully reference this access point anymore. You can however use the `apprise://` plugin without any problem ([see here for more details](https://github.com/caronc/apprise/wiki/Notify_apprise_api)). This defaults to `no` and can however be set to `yes` by simply defining the global variable as such.
| `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.
| `BASE_URL` | Those who are hosting the API behind a proxy that requires a subpath to gain access to this API should specify this path here as well. By default this is not set at all.
| `LOG_LEVEL` | Adjust the log level to the console. Possible values are `CRITICAL`, `ERROR`, `WARNING`, `INFO`, and `DEBUG`.
| `DEBUG` | This defaults to `False` however can be set to `True` if defined with a non-zero value (such as `1`).
| `DEBUG` | This defaults to `no` and can however be set to `yes` by simply defining the global variable as such.
## Development Environment
@ -249,10 +250,10 @@ A scenario where you want to poll the API for your configuration:
```bash
# A simple example of the Apprise CLI
# pulling down previously stored configuration
apprise --body="test message" --config=http://localhost:8000/get/{KEY}
apprise -vvv --body="test message" --config=http://localhost:8000/get/{KEY}
```
You can also leverage the `import` parameter supported in Apprise configuration files.
You can also leverage the `import` parameter supported in Apprise configuration files if `APPRISE_CONFIG_LOCK` isn't set on the server you're accessing:
```nginx
# Linux users can place this in ~/.apprise
@ -266,14 +267,41 @@ Now you'll just automatically source the configuration file without the need of
```bash
# Configuration is automatically loaded from our server.
apprise --body="my notification"
apprise -vvv --body="my notification"
```
If you used tagging, then you can notify the specific service like so:
```bash
# Configuration is automatically loaded from our server.
apprise --tag=devops --body="Tell James GitLab is down again."
apprise -vvv --tag=devops --body="Tell James GitLab is down again."
```
If you're server has the `APPRISE_CONFIG_LOCK` set, you can still leverage [the `apprise://` plugin](https://github.com/caronc/apprise/wiki/Notify_apprise_api) to trigger our pre-saved notifications:
```bash
# Swap {KEY} with your apprise key you configured on your API
apprise -vvv --body="There are donut's in the front hall if anyone wants any" \
apprise://localhost:8000/{KEY}
```
Alternatively we can set this up in a configuration file and even tie our local tags to our upstream ones like so:
```nginx
# Linux users can place this in ~/.apprise
# Windows users can place this info in %APPDATA%/Apprise/apprise
# Swap {KEY} with your apprise key you configured on your API
devteam=apprise://localhost:8000/{KEY}?tags=devteam
# the only catch is you need to map your tags on the local server to the tags
# you want to pass upstream to your Apprise server using this method.
# In the above we tied the local keyword `friends` to the apprise server using the tag `friends`
```
We could trigger our notification to our friends now like:
```bash
# Trigger our service:
apprise -vvv --tag=devteam --body="Guys, don't forget about the audit tomorrow morning."
```
### AppriseConfig() Pull Example

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from .utils import ConfigCache
from django.conf import settings
def stateful_mode(request):
@ -30,3 +31,10 @@ def stateful_mode(request):
Returns our loaded Stateful Mode
"""
return {'STATEFUL_MODE': ConfigCache.mode}
def config_lock(request):
"""
Returns the state of our global configuration lock
"""
return {'CONFIG_LOCK': settings.APPRISE_CONFIG_LOCK}

View File

@ -90,6 +90,7 @@ class AddByConfigForm(forms.Form):
label=_('Configuration'),
widget=forms.Textarea(),
max_length=4096,
required=False,
)
def clean_format(self):

View File

@ -8,13 +8,14 @@
<ul class="tabs config-overview">
<li class="tab col s4"><a class="active" href="#overview"><i class="material-icons">info</i>
{% trans "Overview" %}</a></li>
<li class="tab col s4"><a href="#config"><i class="material-icons">settings</i> {%trans "Configuration" %}</a>
<li class="tab {% if CONFIG_LOCK %}tab-locked {% endif %}col s4"><a href="#config"><i class="material-icons">{% if not CONFIG_LOCK %}settings{% else %}lock{% endif %}</i> {%trans "Configuration" %}</a>
</li>
<li class="tab col s4"><a href="#notify"><i class="material-icons">announcement</i> {%trans "Notifications" %}</a>
</li>
</ul>
</div>
<div id="overview" class="col s12">
{% if not CONFIG_LOCK %}
<div class="section">
<h5>{% trans "Getting Started" %}</h5>
<ol>
@ -27,8 +28,7 @@
{% blocktrans %}
You can always refer to the
<a href="https://github.com/caronc/apprise/wiki#notification-services">Apprise Wiki</a> if you're having
troubles
assembling your URL(s).
troubles assembling your URL(s).
{% endblocktrans %}
</li>
<li>
@ -44,20 +44,40 @@
<div class="divider"></div>
<p><strong>
{% blocktrans %}To get started, the first thing you want to do is define your configuration. Do this by
clicking
on the <i>Configuration tab</i>.
clicking on the <i>Configuration tab</i>.
{% endblocktrans %}
</strong></p>
<div class="divider"></div>
</div>
{% else %}
<div class="section">
<h5>{% trans "Apprise Configuration is Locked" %}</h5>
<p>
{% blocktrans %}At this time, the administrator of this server has locked down all configuration. This means
That pre-created configuration is securely hidden for the purpose of notification transmission only.
New configuration can not be set, and existing configuration can not be modified or viewed.
{% endblocktrans %}</p>
</div>
{% endif %}
<div class="has-config">
<div class="section">
<h5>{% trans "Working With Your Configuration" %}</h5>
<p>
{% blocktrans %}The following command would cause apprise to directly notify all of your services:{% endblocktrans %}
<br />
<pre><code class="bash">apprise --body="Test Message" \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;apprise{% if request.is_secure %}s{% endif %}://{{request.META.HTTP_HOST}}{{BASE_URL}}/{{key}}?tags=all</code></pre>
</p>
{% if not CONFIG_LOCK %}
<p>
{% blocktrans %}The following command would cause apprise to retrieve the configuration loaded and
send a test notification to all of your added services:{% endblocktrans %}
<br />
<pre><code class="bash">apprise --body="Test Message" --tag=all \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;--config={{request.scheme}}://{{request.META.HTTP_HOST}}{{BASE_URL}}/get/{{key}}</code></pre>
</p>
{% endif %}
</div>
<div class="section">
<h5>{% trans "Loaded Configuration" %}</h5>
@ -69,6 +89,7 @@
</div>
</div>
<div id="config" class="col s12">
{% if not CONFIG_LOCK %}
<p>
{% blocktrans %}Define your configuration below:{% endblocktrans %}
<form id="addconfig" action="{% url "add" key %}" method="post">
@ -78,7 +99,13 @@
</button>
</form>
</p>
{% else %}
<h5>{% trans "Your Configuration Is Locked" %}</h5>
<p>{% blocktrans %}Access to your configuration has been disabled by your administrator.{% endblocktrans %}
{% endif %}
</div>
<div id="notify" class="col s12">
<p>
{% blocktrans %}
@ -102,7 +129,8 @@
{% endblock %}
{% block jsfooter %}
async function update() {
{% if STATEFUL_MODE != 'disabled' %}
async function main_init(){
// disable the notification tab until we know for certain
// a notification is possible
@ -117,68 +145,29 @@ async function update() {
document.querySelector('#url-list').textContent = ''
document.querySelector('#url-list-progress').style.display = null;
{% if not CONFIG_LOCK %}
// Ensure no-config sections are visible
document.querySelector('.no-config')
.style.display = null;
// perform our status check
let response = await fetch('{% url "get" key %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
});
if(response.status == 204)
{
// no problem; we simply have no content to retrieve
return '';
}
else if(response.status == 200)
{
// configuration found
// Remove our restrictions on sending notifications
document.querySelector('.config-overview li a[href="#notify"]')
.parentNode.classList.remove('disabled');
// get our results
let result = await response.json();
// Set our configuration so it's visible
document.querySelector('#id_config').value = result.config;
// Set our format
document.querySelector('#id_format').value = result.format;
// dispatch our event to update our select box
if (typeof(Event) === 'function') {
var event = new Event('change');
} else { // for IE11
var event = document.createEvent('Event');
event.initEvent('change', true, true);
}
document.querySelector('#id_format').dispatchEvent(event);
// Ensure has-config sections are visible
document.querySelector('.has-config')
.style.display = null;
// Disable any no-config entries
document.querySelector('.no-config')
.style.display = 'none';
{% endif %}
// perform a tag retrieval; start with 'all'
let tags = ['all'];
let jsonResponse = fetch('{% url "json_urls" key %}?privacy=1', {
let jsonResponse = await fetch('{% url "json_urls" key %}/?privacy=1', {
method: 'GET',
}).then(function(jsonResponse) {
return jsonResponse.json();
})
if(jsonResponse.status != 200) {
// Take an early exit
document.querySelector('#url-list-progress').style.display = 'none';
document.querySelector('#url-list').textContent = '{% trans "An error occurred retrieving the list of loaded Apprise URL(s)" %}'
return;
}
}).then(function (data) {
// Initialize our tags making it easy for an end user to
// choose from. Tags are based off ones found in the saved
// configuration.
const data = await jsonResponse.json();
let external_data = tags.concat(data.tags).reduce(function(result, item) {
result[item] = null;
return result;
@ -226,31 +215,79 @@ async function update() {
document.querySelector('#url-list-progress').style.display = 'none';
document.querySelector('#url-list').textContent = ''
if(urlList.childNodes.length > 0) {
// Ensure has-config sections are visible
document.querySelector('.has-config')
.style.display = null;
// Remove our restrictions on sending notifications
document.querySelector('.config-overview li a[href="#notify"]')
.parentNode.classList.remove('disabled');
{% if not CONFIG_LOCK %}
// Disable any no-config entries
document.querySelector('.no-config')
.style.display = 'none';
{% endif %}
// Save our list to the screen
document.querySelector('#url-list').appendChild(urlList);
{% if not CONFIG_LOCK %}
//
// Load our configuration now into the configuration tab
//
let response = await fetch('{% url "get" key %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
});
if(response.status == 204)
{
// no problem; we simply have no content to retrieve
return '';
}
else if(response.status == 200)
{
// configuration found
// get our results
let result = await response.json();
// Set our configuration so it's visible
document.querySelector('#id_config').value = result.config;
// Set our format
document.querySelector('#id_format').value = result.format;
// dispatch our event to update our select box
if (typeof(Event) === 'function') {
var event = new Event('change');
} else { // for IE11
var event = document.createEvent('Event');
event.initEvent('change', true, true);
}
document.querySelector('#id_format').dispatchEvent(event);
}
{% endif %}
} else {
document.querySelector('#url-list').textContent = '{% trans "There are no Apprise URL(s) loaded." %}'
}
}).catch(function (err) {
// There was an error
document.querySelector('#url-list-progress').style.display = 'none';
document.querySelector('#url-list').textContent = '{% trans "An error occurred retrieving the list of loaded Apprise URL(s)" %}'
});
return response;
}
// if we reach here, we failed
return null;
}
update();
function config_init() {
// over-ride manual submit for a nicer user experience
document.querySelector('#addconfig').onsubmit = function(event) {
event.preventDefault();
const form = this;
const body = new URLSearchParams(new FormData(form));
content = document.querySelector('#id_config')
.value.replace(/^\s+|\s+$/gm,'');
if(content.length) {
// perform our status check
let response = fetch('{% url "add" key %}', {
method: 'POST',
@ -259,7 +296,7 @@ document.querySelector('#addconfig').onsubmit = function(event) {
if(response.status == 200)
{
// update our settings
update();
main_init();
// user notification
Swal.fire(
@ -283,9 +320,39 @@ document.querySelector('#addconfig').onsubmit = function(event) {
);
}
});
} else {
// Perform Delete
// perform our status check
let response = fetch('{% url "del" key %}', {
method: 'POST',
body: body,
}).then(function(response) {
if(response.status == 200 || response.status == 204)
{
// update our settings
main_init();
// user notification
Swal.fire(
'{% trans "Delete" %}',
'{% trans "Successfully removed configuration." %}',
'success'
);
} else {
// user notification
Swal.fire(
'{% trans "Delete" %}',
'{% trans "There was an issue removing the configuration." %}',
'error'
);
}
});
}
return false;
}
}
function notify_init() {
// over-ride manual submit for a nicer user experience
document.querySelector('#donotify').onsubmit = function(event) {
event.preventDefault();
@ -378,12 +445,22 @@ document.querySelector('#donotify').onsubmit = function(event) {
});
return false;
}
}
/* Initialize our page */
main_init();
{% if not CONFIG_LOCK %}
/* Initialze our configuration */
config_init();
{% endif %}
notify_init();
{% endif %}
{% endblock %}
{% block onload %}
{% if STATEFUL_MODE != 'disabled' %}
{{ block.super }}
document.querySelector('label [for="id_tag"]')
{
// create a new div with the class 'chips' assigned to it
const element = document.createElement('div')
@ -395,4 +472,5 @@ document.querySelector('label [for="id_tag"]')
// Hide tag field since we use the pretty Materialize Chip setup instead
document.querySelector('#id_tag').style.display = 'none';
{% endif %}
{% endblock %}

View File

@ -25,6 +25,7 @@
from django.test import SimpleTestCase
from apprise import ConfigFormat
from unittest.mock import patch
from django.test.utils import override_settings
from ..forms import AUTO_DETECT_CONFIG_KEYWORD
import json
@ -38,6 +39,19 @@ class AddTests(SimpleTestCase):
response = self.client.get('/add/**invalid-key**')
assert response.status_code == 404
@override_settings(APPRISE_CONFIG_LOCK=True)
def test_save_config_by_urls_with_lock(self):
"""
Test adding a configuration by URLs with lock set won't work
"""
# our key to use
key = 'test_save_config_by_urls_with_lock'
# We simply do not have permission to do so
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
assert response.status_code == 403
def test_save_config_by_urls(self):
"""
Test adding an configuration by URLs
@ -99,6 +113,22 @@ class AddTests(SimpleTestCase):
)
assert response.status_code == 200
# Test with JSON (and no payload provided)
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({}),
content_type='application/json',
)
assert response.status_code == 400
# Test with XML which simply isn't supported
response = self.client.post(
'/add/{}'.format(key),
data='<urls><url>mailto://user:pass@yahoo.ca</url></urls>',
content_type='application/xml',
)
assert response.status_code == 400
# Invalid JSON
response = self.client.post(
'/add/{}'.format(key),

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch
@ -35,6 +36,18 @@ class DelTests(SimpleTestCase):
response = self.client.get('/del/**invalid-key**')
assert response.status_code == 404
@override_settings(APPRISE_CONFIG_LOCK=True)
def test_del_with_lock(self):
"""
Test deleting a configuration by URLs with lock set won't work
"""
# our key to use
key = 'test_delete_with_lock'
# We simply do not have permission to do so
response = self.client.post('/del/{}'.format(key))
assert response.status_code == 403
def test_del_post(self):
"""
Test DEL POST

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch
@ -110,6 +111,23 @@ class JsonUrlsTests(SimpleTestCase):
assert 'tags' in response.json()['urls'][0]
assert len(response.json()['urls'][0]['tags']) == 0
# We can see that th URLs are not the same when the privacy flag is set
without_privacy = \
self.client.get('/json/urls/{}?privacy=1'.format(key))
with_privacy = self.client.get('/json/urls/{}'.format(key))
assert with_privacy.json()['urls'][0] != \
without_privacy.json()['urls'][0]
with override_settings(APPRISE_CONFIG_LOCK=True):
# When our configuration lock is set, our result set enforces the
# privacy flag even if it was otherwise set:
with_privacy = \
self.client.get('/json/urls/{}?privacy=1'.format(key))
# But now they're the same under this new condition
assert with_privacy.json()['urls'][0] == \
without_privacy.json()['urls'][0]
# Add a YAML file
response = self.client.post(
'/add/{}'.format(key), {

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch
from ..forms import NotifyForm
from ..utils import ConfigCache
@ -36,6 +37,20 @@ class StatefulNotifyTests(SimpleTestCase):
Test stateless notifications
"""
@override_settings(APPRISE_CONFIG_LOCK=True)
def test_stateful_configuration_with_lock(self):
"""
Test the retrieval of configuration when the lock is set
"""
# our key to use
key = 'test_stateful_with_lock'
# It doesn't matter if there is or isn't any configuration; when this
# flag is set. All that overhead is skipped and we're denied access
# right off the bat
response = self.client.post('/get/{}'.format(key))
assert response.status_code == 403
@patch('apprise.Apprise.notify')
def test_stateful_configuration_io(self, mock_notify):
"""

View File

@ -80,6 +80,7 @@ class ResponseCode(object):
okay = 200
no_content = 204
bad_request = 400
no_access = 403
not_found = 404
method_not_allowed = 405
failed_dependency = 424
@ -125,6 +126,22 @@ class AddView(View):
"""
Handle a POST request
"""
# Detect the format our response should be in
json_response = MIME_IS_JSON.match(request.content_type) is not None
if settings.APPRISE_CONFIG_LOCK:
# General Access Control
msg = _('The site has been configured to deny this request.')
status = ResponseCode.no_access
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# our content
content = {}
if MIME_IS_FORM.match(request.content_type):
@ -137,7 +154,7 @@ class AddView(View):
if form.is_valid():
content.update(form.cleaned_data)
elif MIME_IS_JSON.match(request.content_type):
elif json_response:
# Prepare our default response
try:
# load our JSON content
@ -145,14 +162,26 @@ class AddView(View):
except (AttributeError, ValueError):
# could not parse JSON response...
return HttpResponse(
_('Invalid JSON specified.'),
status=ResponseCode.bad_request)
return JsonResponse({
'error': _('Invalid JSON specified.'),
},
encoder=JSONEncoder,
safe=False,
status=ResponseCode.bad_request,
)
if not content:
return HttpResponse(
_('The message format is not supported.'),
status=ResponseCode.bad_request)
# No information was posted
msg = _('The message format is not supported.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Create ourselves an apprise object to work with
a_obj = apprise.Apprise()
@ -161,27 +190,45 @@ class AddView(View):
a_obj.add(content['urls'])
if not len(a_obj):
# No URLs were loaded
return HttpResponse(
_('No valid URLs were found.'),
status=ResponseCode.bad_request,
msg = _('No valid URLs were found.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
if not ConfigCache.put(
key, '\r\n'.join([s.url() for s in a_obj]),
apprise.ConfigFormat.TEXT):
return HttpResponse(
_('The configuration could not be saved.'),
status=ResponseCode.internal_server_error,
msg = _('The configuration could not be saved.')
status = ResponseCode.internal_server_error
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
elif 'config' in content:
fmt = content.get('format', '').lower()
if fmt not in [i[0] for i in CONFIG_FORMATS]:
# Format must be one supported by apprise
return HttpResponse(
_('The format specified is invalid.'),
status=ResponseCode.bad_request,
msg = _('The format specified is invalid.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# prepare our apprise config object
@ -195,9 +242,15 @@ class AddView(View):
# Load our configuration
if not ac_obj.add_config(content['config'], format=fmt):
# The format could not be detected
return HttpResponse(
_('The configuration format could not be detected.'),
status=ResponseCode.bad_request,
msg = _('The configuration format could not be detected.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Add our configuration
@ -206,23 +259,43 @@ class AddView(View):
if not len(a_obj):
# No specified URL(s) were loaded due to
# mis-configuration on the caller's part
return HttpResponse(
_('No valid URL(s) were specified.'),
status=ResponseCode.bad_request,
msg = _('No valid URL(s) were specified.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
if not ConfigCache.put(
key, content['config'], fmt=ac_obj[0].config_format):
# Something went very wrong; return 500
return HttpResponse(
_('An error occured saving configuration.'),
status=ResponseCode.internal_server_error,
msg = _('An error occured saving configuration.')
status = ResponseCode.internal_server_error
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
else:
# No configuration specified; we're done
return HttpResponse(
_('No configuration specified.'),
status=ResponseCode.bad_request,
msg = _('No configuration specified.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# If we reach here; we successfully loaded the configuration so we can
@ -242,19 +315,47 @@ class DelView(View):
"""
Handle a POST request
"""
# Detect the format our response should be in
json_response = MIME_IS_JSON.match(request.content_type) is not None
if settings.APPRISE_CONFIG_LOCK:
# General Access Control
msg = _('The site has been configured to deny this request.')
status = ResponseCode.no_access
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Clear the key
result = ConfigCache.clear(key)
if result is None:
return HttpResponse(
_('There was no configuration to remove.'),
status=ResponseCode.no_content,
msg = _('There was no configuration to remove.')
status = ResponseCode.no_content
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
elif result is False:
# There was a failure at the os level
return HttpResponse(
_('The configuration could not be removed.'),
status=ResponseCode.internal_server_error,
msg = _('The configuration could not be removed.')
status = ResponseCode.internal_server_error
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Removed content
@ -277,6 +378,20 @@ class GetView(View):
# Detect the format our response should be in
json_response = MIME_IS_JSON.match(request.content_type) is not None
if settings.APPRISE_CONFIG_LOCK:
# General Access Control
return HttpResponse(
_('The site has been configured to deny this request.'),
status=ResponseCode.no_access,
) if not json_response else JsonResponse({
'error':
_('The site has been configured to deny this request.')
},
encoder=JSONEncoder,
safe=False,
status=ResponseCode.no_access,
)
config, format = ConfigCache.get(key)
if config is None:
# The returned value of config and format tell a rather cryptic
@ -299,15 +414,15 @@ class GetView(View):
)
# Something went very wrong; return 500
return HttpResponse(
_('An error occured accessing configuration.'),
status=ResponseCode.internal_server_error,
) if not json_response else JsonResponse({
'error': _('There was no configuration found.')
msg = _('An error occured accessing configuration.')
status = ResponseCode.internal_server_error
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=ResponseCode.internal_server_error,
status=status,
)
# Our configuration was retrieved; now our response varies on whether
@ -342,6 +457,9 @@ class NotifyView(View):
"""
Handle a POST request
"""
# Detect the format our response should be in
json_response = MIME_IS_JSON.match(request.content_type) is not None
# our content
content = {}
if MIME_IS_FORM.match(request.content_type):
@ -350,7 +468,7 @@ class NotifyView(View):
if form.is_valid():
content.update(form.cleaned_data)
elif MIME_IS_JSON.match(request.content_type):
elif json_response:
# Prepare our default response
try:
# load our JSON content
@ -358,31 +476,54 @@ class NotifyView(View):
except (AttributeError, ValueError):
# could not parse JSON response...
return HttpResponse(
return JsonResponse(
_('Invalid JSON specified.'),
encoder=JSONEncoder,
safe=False,
status=ResponseCode.bad_request)
if not content:
# We could not handle the Content-Type
return HttpResponse(
_('The message format is not supported.'),
status=ResponseCode.bad_request)
msg = _('The message format is not supported.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Some basic error checking
if not content.get('body') or \
content.get('type', apprise.NotifyType.INFO) \
not in apprise.NOTIFY_TYPES:
return HttpResponse(
_('An invalid payload was specified.'),
status=ResponseCode.bad_request)
msg = _('An invalid payload was specified.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Acquire our body format (if identified)
body_format = content.get('format', apprise.NotifyFormat.TEXT)
if body_format and body_format not in apprise.NOTIFY_FORMATS:
return HttpResponse(
_('An invalid body input format was specified.'),
status=ResponseCode.bad_request)
msg = _('An invalid body input format was specified.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# If we get here, we have enough information to generate a notification
# with.
@ -396,15 +537,27 @@ class NotifyView(View):
# config != None: we simply have no data
if format is not None:
# no content to return
return HttpResponse(
_('There was no configuration found.'),
status=ResponseCode.no_content,
msg = _('There was no configuration found.')
status = ResponseCode.no_content
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Something went very wrong; return 500
return HttpResponse(
_('An error occured accessing configuration.'),
status=ResponseCode.internal_server_error,
msg = _('An error occured accessing configuration.')
status = ResponseCode.internal_server_error
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Prepare ourselves a default Asset
@ -493,11 +646,16 @@ class NotifyView(View):
if not result:
# If at least one notification couldn't be sent; change up
# the response to a 424 error code
return HttpResponse(
response if response is not None else
_('One or more notification could not be sent.'),
content_type=content_type,
status=ResponseCode.failed_dependency)
msg = _('One or more notification could not be sent.')
status = ResponseCode.failed_dependency
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# Return our retrieved content
return HttpResponse(
@ -633,7 +791,8 @@ class JsonUrlView(View):
# Privacy flag
# Support 'yes', '1', 'true', 'enable', 'active', and +
privacy = request.GET.get('privacy', 'no')[0] in (
privacy = settings.APPRISE_CONFIG_LOCK or \
request.GET.get('privacy', 'no')[0] in (
'a', 'y', '1', 't', 'e', '+')
# Optionally filter on tags. Use comma to identify more then one

View File

@ -75,6 +75,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'core.context_processors.base_url',
'api.context_processors.stateful_mode',
'api.context_processors.config_lock',
],
},
},
@ -119,6 +120,26 @@ STATIC_URL = BASE_URL + '/s/'
APPRISE_CONFIG_DIR = os.environ.get(
'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config'))
# When set Apprise API Locks itself down so that future (configuration)
# changes can not be made or accessed. It disables access to:
# - the configuration screen: /cfg/{token}
# - this in turn makes it so the Apprise CLI tool can not use it's
# --config= (-c) options against this server.
# - All notifications (both persistent and non persistent) continue to work
# as they did before. This includes both /notify/{token}/ and /notify/
# - Certain API calls no longer work such as:
# - /del/{token}/
# - /add/{token}/
# - the /json/urls/{token} API location will continue to work but will always
# enforce it's privacy mode.
#
# The idea here is that someone has set up the configuration they way they want
# and do not want this information exposed any more then it needs to be.
# it's a lock down mode if you will.
APPRISE_CONFIG_LOCK = \
os.environ.get("APPRISE_CONFIG_LOCK", 'no')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
# 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', '')

View File

@ -52,7 +52,6 @@ textarea {
}
.tabs .tab a:hover,.tabs .tab a.active {
background-color:transparent;
/* color:#2bbbad;*/
color:#004d40;
font-weight: bold;
background-color: #eee;
@ -63,6 +62,15 @@ textarea {
.tabs .indicator {
background-color:#004d40;
}
.tabs .tab-locked a {
/* Handle locked out tabs */
color:rgba(212, 161, 157, 0.7);
}
.tabs .tab-locked a:hover,.tabs .tab-locked a.active {
/* Handle locked out tabs */
color: #6b0900;
}
.material-icons{
display: inline-flex;
vertical-align: middle;