Added a Apprise URL Details Endpoint (/details) (#105)

This commit is contained in:
Chris Caron 2023-02-26 10:33:14 -05:00 committed by GitHub
parent ae99d35671
commit 30d87c8284
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 375 additions and 111 deletions

View File

@ -128,6 +128,7 @@ You can pre-save all of your Apprise configuration and/or set of Apprise URLs an
| `/get/{KEY}` | POST | Returns the Apprise Configuration from the persistent store. This can be directly used with the *Apprise CLI* and/or the *AppriseConfig()* object ([see here for details](https://github.com/caronc/apprise/wiki/config)).
| `/notify/{KEY}` | POST | Sends notification(s) to all of the end points you've previously configured associated with a *{KEY}*.<br/>*Payload Parameters*<br/>📌 **body**: Your message body. This is the *only* required field.<br/>📌 **title**: Optionally define a title to go along with the *body*.<br/>📌 **type**: Defines the message type you want to send as. The valid options are `info`, `success`, `warning`, and `failure`. If no *type* is specified then `info` is the default value used.<br/>📌 **tag**: Optionally notify only those tagged accordingly.<br/>📌 **format**: Optionally identify the text format of the data you're feeding Apprise. The valid options are `text`, `markdown`, `html`. The default value if nothing is specified is `text`.
| `/json/urls/{KEY}` | GET | Returns a JSON response object that contains all of the URLS and Tags associated with the key specified.
| `/details` | GET | Set the `Accept` Header to `application/json` and retrieve a JSON response object that contains all of the supported Apprise URLs. See [here for more details](https://github.com/caronc/apprise/wiki/Development_Apprise_Details#apprise-details)
As an example, the `/json/urls/{KEY}` response might return something like this:

View File

@ -48,6 +48,8 @@
</ul>
{% endif %}
<ul class="collection z-depth-1">
<a class="collection-item" href="{% url 'details' %}"><i class="tiny material-icons">settings</i>
{% trans "Apprise Details" %}</a>
<a class="collection-item" target="_blank"
href="https://github.com/caronc/apprise/wiki#notification-services">📣
{% trans "Notification Services" %}</a>

View File

@ -0,0 +1,128 @@
{% extends 'base.html' %}
{% load i18n %}
{% block body %}
<h4>{% trans 'Apprise Details' %}</h4>
<p>
{% url 'details' as href %}
{% blocktrans %}The following services are supported by this <a target="_blank" href="https://github.com/caronc/apprise">Apprise</a> instance.{% endblocktrans %}
</p>
<p>
{% if show_all %}
{% blocktrans %}To see a simplified listing that only identifies the Apprise services enabled click <a href="{{href}}">here</a>.{% endblocktrans %}
{% else %}
{% blocktrans %}To see a listing that identifies all of Apprise services available to this version (enabled or not) click <a href="{{href}}?all=yes">here</a>.{% endblocktrans %}
{% endif %}
<ul>
<li>
<i class="tiny material-icons">chevron_right</i><strong>{% blocktrans %}Apprise Version:{% endblocktrans %}</strong> {{ details.version }}
</li>
</ul>
<ul class="collapsible">
{% for entry in details.schemas %}
<li>
<div class="collapsible-header">
{{ forloop.counter|stringformat:"03d"}} <i class="tiny material-icons">chevron_right</i>
{% if show_all %}{% if entry.enabled %}<i class="url-enabled material-icons">check_circle</i>{%else%}<i class="url-disabled material-icons">remove_circle</i>{%endif%}{% endif%}
<div style="width:40rem;" lass="service_name">{{ entry.service_name }}</div>
<ul style="width:90%; min-width: 60%;" class="right detail-buttons">
<li class="right">
<a href="{{ entry.setup_url }}" target="_blank" class="service_name"><i class="material-icons">help</i></a>
<a href="{{ entry.service_url }}" target="_blank" class="service_name"><i class="material-icons">explore</i></a>
</li>
</ul>
</div>
<div class="collapsible-body">
<h5>{{ entry.service_name }}</h5>
{% if show_all and not entry.enabled %}
<i class="url-disabled material-icons">report</i>{% blocktrans %}<strong>Note:</strong> This service is unavailable due to the service being disabled by the administrator or the required libraries needed to drive it is not installed or functioning correctly.{% endblocktrans %}
{% endif %}
<hr/>
<ul class="detail-buttons">
<li><strong>{% blocktrans %}Category{% endblocktrans %}:</strong> {{entry.category}}</li>
{% if entry.protocols %}
<li><strong>{% blocktrans %}Insecure Schema(s){% endblocktrans %}:</strong> {{ entry.protocols|join:", " }}</li>
{% endif %}
{% if entry.secure_protocols %}
<li><strong>{% blocktrans %}Secure Schema(s){% endblocktrans %}:</strong> {{ entry.secure_protocols|join:", " }}</li>
{% endif %}
<li><pre><code class="bash">
# {% blocktrans %}Apprise URL Formatting{% endblocktrans %}</br>
{% for url in entry.details.templates %}
{{url}}<br/>
{% endfor %}
</code></pre>
</li>
<li>{% blocktrans %}For more details and additional Apprise configuration options available to this service:{% endblocktrans %}
<a href="{{ entry.setup_url }}" target="_blank" class="service_name">Click Here</a>
</ul>
</div>
</li>
{% endfor %}
</ul>
</p>
<div class="section">
<h4>{% trans 'API Endpoints' %}</h4>
<p>
{% blocktrans %}Developers who wish to receive this result set in a JSON parseable string for their application can perform the following to achive this:{% endblocktrans %}
</p>
<ul class="collapsible">
<li>
<div class="collapsible-header">
<i class="material-icons">code</i>curl example
</div>
<div class="collapsible-body">
<pre><code class="bash">
#{% blocktrans %}Retrieve JSON Formatted Apprise Details{% endblocktrans %}<br />
curl -H "Accept: application/json" \<br />
&nbsp;&nbsp;&nbsp;&nbsp;"{{ request.scheme }}://{{ request.META.HTTP_HOST }}{{ BASE_URL }}/details/{% if show_all %}?all=yes{% endif %}"
</code></pre>
</div>
</li>
<li>
<div class="collapsible-header">
<i class="material-icons">code</i>python example
</div>
<div class="collapsible-body">
<pre><code class="python">
import json<br />
from urllib.request import Request<br />
<br /># The URL<br />
req = Request(<br />
&nbsp;&nbsp;&nbsp;&nbsp;"{{ request.scheme }}://{{ request.META.HTTP_HOST }}{{ BASE_URL }}/details/{% if show_all %}?all=yes{% endif %}",<br />
&nbsp;&nbsp;&nbsp;&nbsp;json.dumps(payload).encode('utf-8'),<br />
&nbsp;&nbsp;&nbsp;&nbsp;{"Accept": "application/json"},<br />
&nbsp;&nbsp;&nbsp;&nbsp;method='GET',<br />
)
</code></pre>
</div>
</li>
<li>
<div class="collapsible-header">
<i class="material-icons">code</i>php example
</div>
<div class="collapsible-body">
<pre><code class="php">
&lt;?php<br />
<br />
// The URL<br />
$url = '{{ request.scheme }}://{{ request.META.HTTP_HOST }}{{ BASE_URL }}/details/{% if show_all %}?all=yes{% endif %}';<br />
<br />
//Initiate cURL.<br />
$ch = curl_init($url);<br />
<br />
//Set the content type to application/json<br />
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Accept: application/json'));<br />
<br />
//Execute the request<br />
$result = curl_exec($ch);
</code></pre>
</div>
</li>
</ul>
<p>
{% blocktrans %}More details on the JSON format can be found <a href="https://github.com/caronc/apprise/wiki/Development_Apprise_Details#details" target="_blank">here</a>.{% endblocktrans %}
</div>
{% endblock %}

View File

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
class DetailTests(SimpleTestCase):
def test_post_not_supported(self):
"""
Test POST requests
"""
response = self.client.post('/details')
# 405 as posting is not allowed
assert response.status_code == 405
def test_details_simple(self):
"""
Test retrieving details
"""
# Nothing to return
response = self.client.get('/details')
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('text/html')
# JSON Response
response = self.client.get(
'/details', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
# JSON Response
response = self.client.get(
'/details', content_type='application/json',
**{'HTTP_ACCEPT': 'application/json'})
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
response = self.client.get('/details?all=yes')
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('text/html')
# JSON Response
response = self.client.get(
'/details?all=yes', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
# JSON Response
response = self.client.get(
'/details?all=yes', content_type='application/json',
**{'HTTP_ACCEPT': 'application/json'})
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')

View File

@ -29,6 +29,9 @@ urlpatterns = [
re_path(
r'^$',
views.WelcomeView.as_view(), name='welcome'),
re_path(
r'^details/?',
views.DetailsView.as_view(), name='details'),
re_path(
r'^cfg/(?P<key>[\w_-]{1,64})/?',
views.ConfigView.as_view(), name='config'),

View File

@ -22,6 +22,7 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import re
import os
import tempfile
import shutil
@ -318,3 +319,62 @@ class AppriseConfigCache(object):
ConfigCache = AppriseConfigCache(
settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY,
mode=settings.APPRISE_STATEFUL_MODE)
def apply_global_filters():
#
# Apply Any Global Filters (if identified)
#
if settings.APPRISE_ALLOW_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_ALLOW_SERVICES)
if alphanum_re.match(x)]
for plugin in set(apprise.common.NOTIFY_SCHEMA_MAP.values()):
if entries:
# Get a list of the current schema's associated with
# a given plugin
schemas = set(apprise.plugins.details(plugin)
['tokens']['schema']['values'])
# Check what was defined and see if there is a hit
for entry in entries:
if entry in schemas:
# We had a hit; we're done
break
if entry in schemas:
entries.remove(entry)
# We can keep this plugin enabled and move along to the
# next one...
continue
# if we reach here, we have to block our plugin
plugin.enabled = False
for entry in entries:
# Generate some noise for those who have bad configurations
logger.warning(
'APPRISE_ALLOW_SERVICES plugin %s:// was not found - '
'ignoring.', entry)
elif settings.APPRISE_DENY_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_DENY_SERVICES)
if alphanum_re.match(x)]
for name in entries:
try:
# Force plugin to be disabled
apprise.common.NOTIFY_SCHEMA_MAP[name].enabled = False
except KeyError:
logger.warning(
'APPRISE_DENY_SERVICES plugin %s:// was not found -'
' ignoring.', name)

View File

@ -35,6 +35,7 @@ from django.utils.translation import gettext_lazy as _
from django.core.serializers.json import DjangoJSONEncoder
from .utils import ConfigCache
from .utils import apply_global_filters
from .forms import AddByUrlForm
from .forms import AddByConfigForm
from .forms import NotifyForm
@ -76,6 +77,10 @@ class JSONEncoder(DjangoJSONEncoder):
def default(self, obj):
if isinstance(obj, set):
return list(obj)
elif isinstance(obj, apprise.AppriseLocale.LazyTranslation):
return str(obj)
return super().default(obj)
@ -104,6 +109,63 @@ class WelcomeView(View):
return render(request, self.template_name, {})
@method_decorator((gzip_page, never_cache), name='dispatch')
class DetailsView(View):
"""
A Django view used to list all supported endpoints
"""
template_name = 'details.html'
def get(self, request):
"""
Handle a GET request
"""
# Detect the format our response should be in
json_response = \
MIME_IS_JSON.match(
request.content_type
if request.content_type
else request.headers.get(
'accept', request.headers.get(
'content-type', ''))) is not None
# Show All flag
# Support 'yes', '1', 'true', 'enable', 'active', and +
show_all = request.GET.get('all', 'no')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
# Our status
status = ResponseCode.okay
#
# Apply Any Global Filters (if identified)
#
apply_global_filters()
# Create an Apprise Object
a_obj = apprise.Apprise()
# Load our details
details = a_obj.details(show_disabled=show_all)
# Sort our result set
details['schemas'] = sorted(
details['schemas'], key=lambda i: str(i['service_name']))
# Return our content
return render(request, self.template_name, {
'show_all': show_all,
'details': details,
}, status=status) if not json_response else \
JsonResponse(
details,
encoder=JSONEncoder,
safe=False,
status=status)
@method_decorator(never_cache, name='dispatch')
class ConfigView(View):
"""
@ -134,7 +196,13 @@ 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
json_response = \
MIME_IS_JSON.match(
request.content_type
if request.content_type
else request.headers.get(
'accept', request.headers.get(
'content-type', ''))) is not None
if settings.APPRISE_CONFIG_LOCK:
# General Access Control
@ -323,7 +391,13 @@ 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
json_response = \
MIME_IS_JSON.match(
request.content_type
if request.content_type
else request.headers.get(
'accept', request.headers.get(
'content-type', ''))) is not None
if settings.APPRISE_CONFIG_LOCK:
# General Access Control
@ -383,7 +457,13 @@ class GetView(View):
"""
# 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
if request.content_type
else request.headers.get(
'accept', request.headers.get(
'content-type', ''))) is not None
if settings.APPRISE_CONFIG_LOCK:
# General Access Control
@ -465,7 +545,13 @@ 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
json_response = \
MIME_IS_JSON.match(
request.content_type
if request.content_type
else request.headers.get(
'accept', request.headers.get(
'content-type', ''))) is not None
# our content
content = {}
@ -599,59 +685,7 @@ class NotifyView(View):
#
# Apply Any Global Filters (if identified)
#
if settings.APPRISE_ALLOW_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_ALLOW_SERVICES)
if alphanum_re.match(x)]
for plugin in set(apprise.common.NOTIFY_SCHEMA_MAP.values()):
if entries:
# Get a list of the current schema's associated with
# a given plugin
schemas = set(apprise.plugins.details(plugin)
['tokens']['schema']['values'])
# Check what was defined and see if there is a hit
for entry in entries:
if entry in schemas:
# We had a hit; we're done
break
if entry in schemas:
entries.remove(entry)
# We can keep this plugin enabled and move along to the
# next one...
continue
# if we reach here, we have to block our plugin
plugin.enabled = False
for entry in entries:
# Generate some noise for those who have bad configurations
logger.warning(
'APPRISE_ALLOW_SERVICES plugin %s:// was not found - '
'ignoring.', entry)
elif settings.APPRISE_DENY_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_DENY_SERVICES)
if alphanum_re.match(x)]
for name in entries:
try:
# Force plugin to be disabled
apprise.common.NOTIFY_SCHEMA_MAP[name].enabled = False
except KeyError:
logger.warning(
'APPRISE_DENY_SERVICES plugin %s:// was not found -'
' ignoring.', name)
apply_global_filters()
# Prepare our keyword arguments (to be passed into an AppriseAsset
# object)
@ -892,59 +926,7 @@ class StatelessNotifyView(View):
#
# Apply Any Global Filters (if identified)
#
if settings.APPRISE_ALLOW_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_ALLOW_SERVICES)
if alphanum_re.match(x)]
for plugin in set(apprise.common.NOTIFY_SCHEMA_MAP.values()):
if entries:
# Get a list of the current schema's associated with
# a given plugin
schemas = set(apprise.plugins.details(plugin)
['tokens']['schema']['values'])
# Check what was defined and see if there is a hit
for entry in entries:
if entry in schemas:
# We had a hit; we're done
break
if entry in schemas:
entries.remove(entry)
# We can keep this plugin enabled and move along to the
# next one...
continue
# if we reach here, we have to block our plugin
plugin.enabled = False
for entry in entries:
# Generate some noise for those who have bad configurations
logger.warning(
'APPRISE_ALLOW_SERVICES plugin %s:// was not found - '
'ignoring.', entry)
elif settings.APPRISE_DENY_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_DENY_SERVICES)
if alphanum_re.match(x)]
for name in entries:
try:
# Force plugin to be disabled
apprise.common.NOTIFY_SCHEMA_MAP[name].enabled = False
except KeyError:
logger.warning(
'APPRISE_DENY_SERVICES plugin %s:// was not found -'
' ignoring.', name)
apply_global_filters()
# Prepare our apprise object
a_obj = apprise.Apprise(asset=asset)
@ -1012,7 +994,7 @@ class JsonUrlView(View):
# Privacy flag
# Support 'yes', '1', 'true', 'enable', 'active', and +
privacy = settings.APPRISE_CONFIG_LOCK or \
request.GET.get('privacy', 'no')[0] in (
request.GET.get('privacy', 'no')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
# Optionally filter on tags. Use comma to identify more then one

View File

@ -24,6 +24,9 @@
# THE SOFTWARE.
import os
# Disable Timezones
USE_TZ = False
# Base Directory (relative to settings)
BASE_DIR = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

View File

@ -148,3 +148,10 @@ ul.logs li.log_WARNING {
ul.logs li.log_ERROR {
color: #8B0000;
}
.url-enabled {
color:#004d40;
}
.url-disabled {
color: #8B0000;
}