Skip to content

Commit a562452

Browse files
authored
Add listing of configuration keys in 'simple' mode (#240)
1 parent 91c850a commit a562452

File tree

10 files changed

+198
-3
lines changed

10 files changed

+198
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ The use of environment variables allow you to provide over-rides to default sett
413413
| `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.
414414
| `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.
415415
| `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.
416+
| `APPRISE_ADMIN` | Enables admin mode. This removes the distinction between users and admins and allows listing stored configuration keys (when `STATEFUL_MODE` is set to `simple`). This defaults to `no` and can be set to `yes`.
416417
| `APPRISE_DENY_SERVICES` | A comma separated set of entries identifying what plugins to deny access to. You only need to identify one schema entry associated with a plugin to in turn disable all of it. Hence, if you wanted to disable the `glib` plugin, you do not need to additionally include `qt` as well since it's included as part of the (`dbus`) package; consequently specifying `qt` would in turn disable the `glib` module as well (another way to accomplish the same task). To exclude/disable more the one upstream service, simply specify additional entries separated by a `,` (comma) or ` ` (space). The `APPRISE_DENY_SERVICES` entries are ignored if the `APPRISE_ALLOW_SERVICES` is identified. By default, this is initialized to `windows, dbus, gnome, macosx, syslog` (blocking local actions from being issued inside of the docker container)
417418
| `APPRISE_ALLOW_SERVICES` | A comma separated set of entries identifying what plugins to allow access to. You may only use alpha-numeric characters as is the restriction of Apprise Schemas (schema://) anyway. To exclusively include more the one upstream service, simply specify additional entries separated by a `,` (comma) or ` ` (space). The `APPRISE_DENY_SERVICES` entries are ignored if the `APPRISE_ALLOW_SERVICES` is identified.
418419
| `APPRISE_ATTACH_ALLOW_URLS` | A comma separated set of entries identifying the HTTP Attach URLs the Apprise API shall always accept. Use wildcards such as `*` and `?` to help construct the URL/Hosts you identify. Use a space and/or a comma to identify more then one entry. By default this is set to `*` (Accept all provided URLs).

apprise_api/api/context_processors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ def config_lock(request):
4242
return {'CONFIG_LOCK': settings.APPRISE_CONFIG_LOCK}
4343

4444

45+
def admin_enabled(request):
46+
"""
47+
Returns whether we allow the config list to be displayed
48+
"""
49+
return {'APPRISE_ADMIN': settings.APPRISE_ADMIN}
50+
51+
4552
def apprise_version(request):
4653
"""
4754
Returns the current version of apprise loaded under the hood

apprise_api/api/templates/base.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ <h1>{% trans "Apprise API" %}</h1>
5454
<a class="collection-item" id='cfg-gen' href="{% url 'config' UNIQUE_CONFIG_ID %}"><i class="material-icons">refresh</i>
5555
{% trans "New Configuration" %}</a>
5656
{% endif %}
57+
{% if STATEFUL_MODE == 'simple' and APPRISE_ADMIN %}
58+
<a class="collection-item" id='cfg-list' href="{% url 'config_list' %}"><i class="material-icons">list</i>
59+
{% trans "Configuration List" %}</a>
60+
{% endif %}
5761
</ul>
5862
{% endif %}
5963
<ul class="collection z-depth-1">
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{% extends 'base.html' %}
2+
{% load i18n %}
3+
{% block body %}
4+
<h4>{% trans "Configuration List" %}</h4>
5+
{% if keys %}
6+
<ul class="collection z-depth-1">
7+
{% for key in keys %}
8+
<a class="collection-item" href="{% url 'config' key %}"><i
9+
class="material-icons">settings</i> {{ key }}</a>
10+
{% endfor %}
11+
</ul>
12+
{% else %}
13+
<p>{% blocktrans %}There is no configuration defined.{% endblocktrans %}</p>
14+
{% endif %}
15+
{% endblock %}

apprise_api/api/tests/test_config_cache.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2424
# THE SOFTWARE.
2525
import os
26+
2627
from ..utils import AppriseConfigCache
2728
from ..utils import AppriseStoreMode
2829
from ..utils import SimpleFileExtension
@@ -112,6 +113,86 @@ def test_apprise_config_io_hash_mode(tmpdir):
112113
assert acc_obj.get(key) == (content, ConfigFormat.YAML)
113114

114115

116+
def test_apprise_config_list_simple_mode(tmpdir):
117+
"""
118+
Test Apprise Config Keys List using SIMPLE mode
119+
"""
120+
# Create our object to work with
121+
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
122+
123+
# Add a hidden file to the config directory (which should be ignored)
124+
hidden_file = os.path.join(str(tmpdir), '.hidden')
125+
with open(hidden_file, 'w') as f:
126+
f.write('hidden file')
127+
128+
# Write 5 text configs and 5 yaml configs
129+
content_text = 'mailto://test:pass@gmail.com'
130+
content_yaml = """
131+
version: 1
132+
urls:
133+
- windows://
134+
"""
135+
text_key_tpl = 'test_apprise_config_list_simple_text_{}'
136+
yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}'
137+
text_keys = [text_key_tpl.format(i) for i in range(5)]
138+
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
139+
key = None
140+
for key in text_keys:
141+
assert acc_obj.put(key, content_text, ConfigFormat.TEXT)
142+
for key in yaml_keys:
143+
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML)
144+
145+
# Ensure the 10 configuration files (plus the hidden file) are the only
146+
# contents of the directory
147+
conf_dir, _ = acc_obj.path(key)
148+
contents = os.listdir(conf_dir)
149+
assert len(contents) == 11
150+
151+
keys = acc_obj.keys()
152+
assert len(keys) == 10
153+
assert sorted(keys) == sorted(text_keys + yaml_keys)
154+
155+
156+
def test_apprise_config_list_hash_mode(tmpdir):
157+
"""
158+
Test Apprise Config Keys List using HASH mode
159+
"""
160+
# Create our object to work with
161+
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
162+
163+
# Add a hidden file to the config directory (which should be ignored)
164+
hidden_file = os.path.join(str(tmpdir), '.hidden')
165+
with open(hidden_file, 'w') as f:
166+
f.write('hidden file')
167+
168+
# Write 5 text configs and 5 yaml configs
169+
content_text = 'mailto://test:pass@gmail.com'
170+
content_yaml = """
171+
version: 1
172+
urls:
173+
- windows://
174+
"""
175+
text_key_tpl = 'test_apprise_config_list_simple_text_{}'
176+
yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}'
177+
text_keys = [text_key_tpl.format(i) for i in range(5)]
178+
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
179+
key = None
180+
for key in text_keys:
181+
assert acc_obj.put(key, content_text, ConfigFormat.TEXT)
182+
for key in yaml_keys:
183+
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML)
184+
185+
# Ensure the 10 configuration files (plus the hidden file) are the only
186+
# contents of the directory
187+
conf_dir, _ = acc_obj.path(key)
188+
contents = os.listdir(conf_dir)
189+
assert len(contents) == 1
190+
191+
# does not search on hash mode
192+
keys = acc_obj.keys()
193+
assert len(keys) == 0
194+
195+
115196
def test_apprise_config_io_simple_mode(tmpdir):
116197
"""
117198
Test Apprise Config Disk Put/Get using SIMPLE mode

apprise_api/api/tests/test_manager.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2424
# THE SOFTWARE.
2525
from django.test import SimpleTestCase
26+
from django.test import override_settings
2627

2728

2829
class ManagerPageTests(SimpleTestCase):
@@ -34,9 +35,30 @@ def test_manage_status_code(self):
3435
"""
3536
General testing of management page
3637
"""
37-
# No key was specified
38+
# No permission to get keys
3839
response = self.client.get('/cfg/')
39-
assert response.status_code == 404
40+
assert response.status_code == 403
41+
42+
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='hash'):
43+
response = self.client.get('/cfg/')
44+
assert response.status_code == 403
45+
46+
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='simple'):
47+
response = self.client.get('/cfg/')
48+
assert response.status_code == 403
49+
50+
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='disabled'):
51+
response = self.client.get('/cfg/')
52+
assert response.status_code == 403
53+
54+
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='disabled'):
55+
response = self.client.get('/cfg/')
56+
assert response.status_code == 403
57+
58+
# But only when the setting is enabled
59+
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='simple'):
60+
response = self.client.get('/cfg/')
61+
assert response.status_code == 200
4062

4163
# An invalid key was specified
4264
response = self.client.get('/cfg/**invalid-key**')

apprise_api/api/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
re_path(
3939
r'^cfg/(?P<key>[\w_-]{1,128})/?$',
4040
views.ConfigView.as_view(), name='config'),
41+
re_path(
42+
r'^cfg/?$',
43+
views.ConfigListView.as_view(), name='config_list'),
4144
re_path(
4245
r'^add/(?P<key>[\w_-]{1,128})/?$',
4346
views.AddView.as_view(), name='add'),

apprise_api/api/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,24 @@ def path(self, key):
711711
else: # AppriseStoreMode.SIMPLE
712712
return (self.root, key)
713713

714+
def keys(self):
715+
"""
716+
Returns a list of keys that are currently stored
717+
"""
718+
keys = []
719+
if self.mode != AppriseStoreMode.SIMPLE:
720+
return keys
721+
722+
for filename in sorted(os.listdir(self.root)):
723+
if filename.startswith('.'):
724+
continue
725+
path = os.path.join(self.root, filename)
726+
if os.path.isfile(path):
727+
key_name = os.path.splitext(filename)[0]
728+
keys.append(key_name)
729+
730+
return keys
731+
714732

715733
# Initialize our singleton
716734
ConfigCache = AppriseConfigCache(

apprise_api/api/views.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .payload_mapper import remap_fields
3939
from .utils import parse_attachments
4040
from .utils import ConfigCache
41+
from .utils import AppriseStoreMode
4142
from .utils import apply_global_filters
4243
from .utils import send_webhook
4344
from .utils import healthcheck
@@ -114,7 +115,6 @@ class ResponseCode(object):
114115
no_content = 204
115116
bad_request = 400
116117
no_access = 403
117-
not_found = 404
118118
method_not_allowed = 405
119119
method_not_accepted = 406
120120
expectation_failed = 417
@@ -253,6 +253,43 @@ def get(self, request, key):
253253
})
254254

255255

256+
@method_decorator(never_cache, name='dispatch')
257+
class ConfigListView(View):
258+
"""
259+
A Django view used to list all configuration keys
260+
"""
261+
template_name = 'config_list.html'
262+
263+
def get(self, request):
264+
"""
265+
Handle a GET request
266+
"""
267+
# Detect the format our response should be in
268+
json_response = \
269+
MIME_IS_JSON.match(
270+
request.content_type
271+
if request.content_type
272+
else request.headers.get(
273+
'accept', request.headers.get(
274+
'content-type', ''))) is not None
275+
276+
if not (settings.APPRISE_ADMIN and settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.SIMPLE):
277+
msg = _('The site has been configured to deny this request')
278+
status = ResponseCode.no_access
279+
return HttpResponse(msg, status=status, content_type='text/plain') \
280+
if not json_response else JsonResponse({
281+
'error': msg,
282+
},
283+
encoder=JSONEncoder,
284+
safe=False,
285+
status=status,
286+
)
287+
288+
return render(request, self.template_name, {
289+
'keys': ConfigCache.keys(),
290+
})
291+
292+
256293
@method_decorator(never_cache, name='dispatch')
257294
class AddView(View):
258295
"""

apprise_api/core/settings/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
'api.context_processors.unique_config_id',
9090
'api.context_processors.stateful_mode',
9191
'api.context_processors.config_lock',
92+
'api.context_processors.admin_enabled',
9293
'api.context_processors.apprise_version',
9394
],
9495
},
@@ -284,3 +285,9 @@
284285
# Define the number of attachments that can exist as part of a payload
285286
# Setting this to zero disables the limit
286287
APPRISE_MAX_ATTACHMENTS = int(os.environ.get('APPRISE_MAX_ATTACHMENTS', 6))
288+
289+
# Allow Admin mode:
290+
# - showing a list of configuration keys (when STATEFUL_MODE is set to simple)
291+
APPRISE_ADMIN = \
292+
os.environ.get("APPRISE_ADMIN", 'no')[0].lower() in (
293+
'a', 'y', '1', 't', 'e', '+')

0 commit comments

Comments
 (0)