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_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_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_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). | `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. | `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. | `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`. | `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 ## Development Environment
@ -249,10 +250,10 @@ A scenario where you want to poll the API for your configuration:
```bash ```bash
# A simple example of the Apprise CLI # A simple example of the Apprise CLI
# pulling down previously stored configuration # 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 ```nginx
# Linux users can place this in ~/.apprise # 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 ```bash
# Configuration is automatically loaded from our server. # 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: If you used tagging, then you can notify the specific service like so:
```bash ```bash
# Configuration is automatically loaded from our server. # 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 ### AppriseConfig() Pull Example

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from .utils import ConfigCache from .utils import ConfigCache
from django.conf import settings
def stateful_mode(request): def stateful_mode(request):
@ -30,3 +31,10 @@ def stateful_mode(request):
Returns our loaded Stateful Mode Returns our loaded Stateful Mode
""" """
return {'STATEFUL_MODE': ConfigCache.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'), label=_('Configuration'),
widget=forms.Textarea(), widget=forms.Textarea(),
max_length=4096, max_length=4096,
required=False,
) )
def clean_format(self): def clean_format(self):

View File

@ -8,13 +8,14 @@
<ul class="tabs config-overview"> <ul class="tabs config-overview">
<li class="tab col s4"><a class="active" href="#overview"><i class="material-icons">info</i> <li class="tab col s4"><a class="active" href="#overview"><i class="material-icons">info</i>
{% trans "Overview" %}</a></li> {% 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>
<li class="tab col s4"><a href="#notify"><i class="material-icons">announcement</i> {%trans "Notifications" %}</a> <li class="tab col s4"><a href="#notify"><i class="material-icons">announcement</i> {%trans "Notifications" %}</a>
</li> </li>
</ul> </ul>
</div> </div>
<div id="overview" class="col s12"> <div id="overview" class="col s12">
{% if not CONFIG_LOCK %}
<div class="section"> <div class="section">
<h5>{% trans "Getting Started" %}</h5> <h5>{% trans "Getting Started" %}</h5>
<ol> <ol>
@ -27,8 +28,7 @@
{% blocktrans %} {% blocktrans %}
You can always refer to the You can always refer to the
<a href="https://github.com/caronc/apprise/wiki#notification-services">Apprise Wiki</a> if you're having <a href="https://github.com/caronc/apprise/wiki#notification-services">Apprise Wiki</a> if you're having
troubles troubles assembling your URL(s).
assembling your URL(s).
{% endblocktrans %} {% endblocktrans %}
</li> </li>
<li> <li>
@ -44,20 +44,40 @@
<div class="divider"></div> <div class="divider"></div>
<p><strong> <p><strong>
{% blocktrans %}To get started, the first thing you want to do is define your configuration. Do this by {% blocktrans %}To get started, the first thing you want to do is define your configuration. Do this by
clicking clicking on the <i>Configuration tab</i>.
on the <i>Configuration tab</i>.
{% endblocktrans %} {% endblocktrans %}
</strong></p> </strong></p>
<div class="divider"></div> <div class="divider"></div>
</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="has-config">
<div class="section"> <div class="section">
<h5>{% trans "Working With Your Configuration" %}</h5> <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 {% blocktrans %}The following command would cause apprise to retrieve the configuration loaded and
send a test notification to all of your added services:{% endblocktrans %} send a test notification to all of your added services:{% endblocktrans %}
<br /> <br />
<pre><code class="bash">apprise --body="Test Message" --tag=all \<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> &nbsp;&nbsp;&nbsp;&nbsp;--config={{request.scheme}}://{{request.META.HTTP_HOST}}{{BASE_URL}}/get/{{key}}</code></pre>
</p>
{% endif %}
</div> </div>
<div class="section"> <div class="section">
<h5>{% trans "Loaded Configuration" %}</h5> <h5>{% trans "Loaded Configuration" %}</h5>
@ -69,6 +89,7 @@
</div> </div>
</div> </div>
<div id="config" class="col s12"> <div id="config" class="col s12">
{% if not CONFIG_LOCK %}
<p> <p>
{% blocktrans %}Define your configuration below:{% endblocktrans %} {% blocktrans %}Define your configuration below:{% endblocktrans %}
<form id="addconfig" action="{% url "add" key %}" method="post"> <form id="addconfig" action="{% url "add" key %}" method="post">
@ -78,7 +99,13 @@
</button> </button>
</form> </form>
</p> </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>
<div id="notify" class="col s12"> <div id="notify" class="col s12">
<p> <p>
{% blocktrans %} {% blocktrans %}
@ -102,7 +129,8 @@
{% endblock %} {% endblock %}
{% block jsfooter %} {% block jsfooter %}
async function update() { {% if STATEFUL_MODE != 'disabled' %}
async function main_init(){
// disable the notification tab until we know for certain // disable the notification tab until we know for certain
// a notification is possible // a notification is possible
@ -117,273 +145,322 @@ async function update() {
document.querySelector('#url-list').textContent = '' document.querySelector('#url-list').textContent = ''
document.querySelector('#url-list-progress').style.display = null; document.querySelector('#url-list-progress').style.display = null;
{% if not CONFIG_LOCK %}
// Ensure no-config sections are visible // Ensure no-config sections are visible
document.querySelector('.no-config') document.querySelector('.no-config')
.style.display = null; .style.display = null;
{% endif %}
// perform our status check // perform a tag retrieval; start with 'all'
let response = await fetch('{% url "get" key %}', { let tags = ['all'];
method: 'POST',
headers: { let jsonResponse = await fetch('{% url "json_urls" key %}/?privacy=1', {
'Content-Type': 'application/json;charset=utf-8' method: 'GET',
})
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;
}
// 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;
}, {})
M.Chips.init(document.querySelectorAll('.chips'), {
placeholder: 'Optional Tag',
secondaryPlaceholder: 'Another Tag',
autocompleteOptions: {
data: external_data,
minLength: 0
}, },
onChipAdd: function(e, chip) {
var $this = this;
$this.chipsData.forEach(function(e, index) {
if(!(e.tag in external_data))
$this.deleteChip(index);
})
}
}); });
if(response.status == 204) // Now build our our loaded list of configuration for our welcome page
{ let urlList = document.createElement('ul');
// no problem; we simply have no content to retrieve
return '';
}
else if(response.status == 200)
{
// configuration found
// Remove our restrictions on sending notifications // Create a list item for each url retrieved
document.querySelector('.config-overview li a[href="#notify"]') data.urls.forEach(function (entry) {
.parentNode.classList.remove('disabled'); let code = document.createElement('code');
let li = document.createElement('li');
code.textContent = entry.url;
li.setAttribute('class', 'card-panel');
li.appendChild(code);
// get our results // Get our tags associate with the URL
let result = await response.json(); entry.tags.forEach(function (tag) {
let chip = document.createElement('div');
chip.setAttribute('class', 'chip');
chip.textContent = `🏷️ ${tag}`;
li.appendChild(chip);
});
// Set our configuration so it's visible urlList.appendChild(li);
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 // Store our new list
if (typeof(Event) === 'function') { document.querySelector('#url-list-progress').style.display = 'none';
var event = new Event('change'); document.querySelector('#url-list').textContent = ''
} else { // for IE11 if(urlList.childNodes.length > 0) {
var event = document.createEvent('Event');
event.initEvent('change', true, true);
}
document.querySelector('#id_format').dispatchEvent(event);
// Ensure has-config sections are visible // Ensure has-config sections are visible
document.querySelector('.has-config') document.querySelector('.has-config')
.style.display = null; .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 // Disable any no-config entries
document.querySelector('.no-config') document.querySelector('.no-config')
.style.display = 'none'; .style.display = 'none';
{% endif %}
// perform a tag retrieval; start with 'all' // Save our list to the screen
let tags = ['all']; document.querySelector('#url-list').appendChild(urlList);
let jsonResponse = fetch('{% url "json_urls" key %}?privacy=1', { {% if not CONFIG_LOCK %}
method: 'GET', //
}).then(function(jsonResponse) { // Load our configuration now into the configuration tab
return jsonResponse.json(); //
let response = await fetch('{% url "get" key %}', {
}).then(function (data) { method: 'POST',
// Initialize our tags making it easy for an end user to headers: {
// choose from. Tags are based off ones found in the saved 'Content-Type': 'application/json;charset=utf-8'
// configuration. },
let external_data = tags.concat(data.tags).reduce(function(result, item) {
result[item] = null;
return result;
}, {})
M.Chips.init(document.querySelectorAll('.chips'), {
placeholder: 'Optional Tag',
secondaryPlaceholder: 'Another Tag',
autocompleteOptions: {
data: external_data,
minLength: 0
},
onChipAdd: function(e, chip) {
var $this = this;
$this.chipsData.forEach(function(e, index) {
if(!(e.tag in external_data))
$this.deleteChip(index);
})
}
});
// Now build our our loaded list of configuration for our welcome page
let urlList = document.createElement('ul');
// Create a list item for each url retrieved
data.urls.forEach(function (entry) {
let code = document.createElement('code');
let li = document.createElement('li');
code.textContent = entry.url;
li.setAttribute('class', 'card-panel');
li.appendChild(code);
// Get our tags associate with the URL
entry.tags.forEach(function (tag) {
let chip = document.createElement('div');
chip.setAttribute('class', 'chip');
chip.textContent = `🏷️ ${tag}`;
li.appendChild(chip);
});
urlList.appendChild(li);
});
// Store our new list
document.querySelector('#url-list-progress').style.display = 'none';
document.querySelector('#url-list').textContent = ''
if(urlList.childNodes.length > 0) {
document.querySelector('#url-list').appendChild(urlList);
} 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(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." %}'
} }
// if we reach here, we failed
return null; 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));
// over-ride manual submit for a nicer user experience content = document.querySelector('#id_config')
document.querySelector('#addconfig').onsubmit = function(event) { .value.replace(/^\s+|\s+$/gm,'');
event.preventDefault(); if(content.length) {
const form = this; // perform our status check
const body = new URLSearchParams(new FormData(form)); let response = fetch('{% url "add" key %}', {
method: 'POST',
body: body,
}).then(function(response) {
if(response.status == 200)
{
// update our settings
main_init();
// perform our status check // user notification
let response = fetch('{% url "add" key %}', { Swal.fire(
method: 'POST', '{% trans "Save" %}',
body: body, '{% trans "Successfully saved the specified URL(s)." %}',
}).then(function(response) { 'success'
if(response.status == 200) );
{ } else if(response.status == 500) {
// update our settings // Disk issue
update(); Swal.fire(
'{% trans "Save" %}',
'{% trans "There was an issue writing the configuration to disk. Check your file permissions and try again." %}',
'error'
);
} else {
// user notification
Swal.fire(
'{% trans "Save" %}',
'{% trans "Failed to save the specified URL(s). Check your syntax and try again." %}',
'error'
);
}
});
} 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 // user notification
Swal.fire( Swal.fire(
'{% trans "Save" %}', '{% trans "Delete" %}',
'{% trans "Successfully saved the specified URL(s)." %}', '{% trans "Successfully removed configuration." %}',
'success' 'success'
); );
} else if(response.status == 500) { } else {
// Disk issue // user notification
Swal.fire( Swal.fire(
'{% trans "Save" %}', '{% trans "Delete" %}',
'{% trans "There was an issue writing the configuration to disk. Check your file permissions and try again." %}', '{% trans "There was an issue removing the configuration." %}',
'error' 'error'
); );
} else { }
// user notification });
Swal.fire(
'{% trans "Save" %}',
'{% trans "Failed to save the specified URL(s). Check your syntax and try again." %}',
'error'
);
}
});
return false;
}
// over-ride manual submit for a nicer user experience
document.querySelector('#donotify').onsubmit = function(event) {
event.preventDefault();
const chipElement = document.querySelector('.chips');
chipElement.querySelector('.chips');
const chipInput = document.querySelector('.chips > input');
if(chipInput.value) {
// This code just handles text typed in the tag section that was
// not submitted. This forces any lingering un-committed text
// into a tag just prior to it's submission
const ev = new KeyboardEvent('keydown', {
altKey:false,
bubbles: true,
cancelBubble: false,
cancelable: true,
charCode: 0,
code: "Enter",
composed: true,
ctrlKey: false,
currentTarget: null,
defaultPrevented: true,
detail: 0,
eventPhase: 0,
isComposing: false,
isTrusted: true,
key: "Enter",
keyCode: 13,
location: 0,
metaKey: false,
repeat: false,
returnValue: false,
shiftKey: false,
type: "keydown",
which: 13
});
chipInput.dispatchEvent(ev);
}
// store tags (as comma separated string) from materialize chip type into form
document.querySelector('#id_tag').value = M.Chips.getInstance(chipElement).chipsData.reduce(
function(s, a){
s.push(a.tag)
return s;
}, []).join(",")
const form = this;
const body = new URLSearchParams(new FormData(form));
// perform our notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "Sending notification(s)..." %}',
);
Swal.showLoading()
let response = fetch('{% url "notify" key %}', {
method: 'POST',
body: body,
headers: {
'Accept': 'text/html',
'X-Apprise-Log-Level': 'info'
} }
}).then(function(response) { return false;
response.text().then(function (html) { }
if(response.status == 200)
{
// user notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "Successfully sent the notification(s)." %}' + html,
'success'
);
} else if(response.status == 424) {
// user notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "One or more of the notification(s) were not sent." %}' + html,
'warning'
);
} else {
// user notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "Failed to send the notification(s)." %}' + html,
'error'
);
}
});
});
return false;
} }
function notify_init() {
// over-ride manual submit for a nicer user experience
document.querySelector('#donotify').onsubmit = function(event) {
event.preventDefault();
const chipElement = document.querySelector('.chips');
chipElement.querySelector('.chips');
const chipInput = document.querySelector('.chips > input');
if(chipInput.value) {
// This code just handles text typed in the tag section that was
// not submitted. This forces any lingering un-committed text
// into a tag just prior to it's submission
const ev = new KeyboardEvent('keydown', {
altKey:false,
bubbles: true,
cancelBubble: false,
cancelable: true,
charCode: 0,
code: "Enter",
composed: true,
ctrlKey: false,
currentTarget: null,
defaultPrevented: true,
detail: 0,
eventPhase: 0,
isComposing: false,
isTrusted: true,
key: "Enter",
keyCode: 13,
location: 0,
metaKey: false,
repeat: false,
returnValue: false,
shiftKey: false,
type: "keydown",
which: 13
});
chipInput.dispatchEvent(ev);
}
// store tags (as comma separated string) from materialize chip type into form
document.querySelector('#id_tag').value = M.Chips.getInstance(chipElement).chipsData.reduce(
function(s, a){
s.push(a.tag)
return s;
}, []).join(",")
const form = this;
const body = new URLSearchParams(new FormData(form));
// perform our notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "Sending notification(s)..." %}',
);
Swal.showLoading()
let response = fetch('{% url "notify" key %}', {
method: 'POST',
body: body,
headers: {
'Accept': 'text/html',
'X-Apprise-Log-Level': 'info'
}
}).then(function(response) {
response.text().then(function (html) {
if(response.status == 200)
{
// user notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "Successfully sent the notification(s)." %}' + html,
'success'
);
} else if(response.status == 424) {
// user notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "One or more of the notification(s) were not sent." %}' + html,
'warning'
);
} else {
// user notification
Swal.fire(
'{% trans "Notification" %}',
'{% trans "Failed to send the notification(s)." %}' + html,
'error'
);
}
});
});
return false;
}
}
/* Initialize our page */
main_init();
{% if not CONFIG_LOCK %}
/* Initialze our configuration */
config_init();
{% endif %}
notify_init();
{% endif %}
{% endblock %} {% endblock %}
{% block onload %} {% block onload %}
{% if STATEFUL_MODE != 'disabled' %}
{{ block.super }} {{ block.super }}
document.querySelector('label [for="id_tag"]') document.querySelector('label [for="id_tag"]')
{ {
// create a new div with the class 'chips' assigned to it // create a new div with the class 'chips' assigned to it
const element = document.createElement('div') 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 // Hide tag field since we use the pretty Materialize Chip setup instead
document.querySelector('#id_tag').style.display = 'none'; document.querySelector('#id_tag').style.display = 'none';
{% endif %}
{% endblock %} {% endblock %}

View File

@ -25,6 +25,7 @@
from django.test import SimpleTestCase from django.test import SimpleTestCase
from apprise import ConfigFormat from apprise import ConfigFormat
from unittest.mock import patch from unittest.mock import patch
from django.test.utils import override_settings
from ..forms import AUTO_DETECT_CONFIG_KEYWORD from ..forms import AUTO_DETECT_CONFIG_KEYWORD
import json import json
@ -38,6 +39,19 @@ class AddTests(SimpleTestCase):
response = self.client.get('/add/**invalid-key**') response = self.client.get('/add/**invalid-key**')
assert response.status_code == 404 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): def test_save_config_by_urls(self):
""" """
Test adding an configuration by URLs Test adding an configuration by URLs
@ -99,6 +113,22 @@ class AddTests(SimpleTestCase):
) )
assert response.status_code == 200 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 # Invalid JSON
response = self.client.post( response = self.client.post(
'/add/{}'.format(key), '/add/{}'.format(key),

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch from unittest.mock import patch
@ -35,6 +36,18 @@ class DelTests(SimpleTestCase):
response = self.client.get('/del/**invalid-key**') response = self.client.get('/del/**invalid-key**')
assert response.status_code == 404 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): def test_del_post(self):
""" """
Test DEL POST Test DEL POST

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch from unittest.mock import patch
@ -110,6 +111,23 @@ class JsonUrlsTests(SimpleTestCase):
assert 'tags' in response.json()['urls'][0] assert 'tags' in response.json()['urls'][0]
assert len(response.json()['urls'][0]['tags']) == 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 # Add a YAML file
response = self.client.post( response = self.client.post(
'/add/{}'.format(key), { '/add/{}'.format(key), {

View File

@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch from unittest.mock import patch
from ..forms import NotifyForm from ..forms import NotifyForm
from ..utils import ConfigCache from ..utils import ConfigCache
@ -36,6 +37,20 @@ class StatefulNotifyTests(SimpleTestCase):
Test stateless notifications 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') @patch('apprise.Apprise.notify')
def test_stateful_configuration_io(self, mock_notify): def test_stateful_configuration_io(self, mock_notify):
""" """

View File

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

View File

@ -75,6 +75,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'core.context_processors.base_url', 'core.context_processors.base_url',
'api.context_processors.stateful_mode', '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.environ.get(
'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config')) '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 # Stateless posts to /notify/ will resort to this set of URLs if none
# were otherwise posted with the URL request. # were otherwise posted with the URL request.
APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '') APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '')

View File

@ -52,7 +52,6 @@ textarea {
} }
.tabs .tab a:hover,.tabs .tab a.active { .tabs .tab a:hover,.tabs .tab a.active {
background-color:transparent; background-color:transparent;
/* color:#2bbbad;*/
color:#004d40; color:#004d40;
font-weight: bold; font-weight: bold;
background-color: #eee; background-color: #eee;
@ -63,6 +62,15 @@ textarea {
.tabs .indicator { .tabs .indicator {
background-color:#004d40; 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{ .material-icons{
display: inline-flex; display: inline-flex;
vertical-align: middle; vertical-align: middle;