Initial commit

This commit is contained in:
Chris Caron
2019-10-26 23:53:40 -04:00
commit 4a8921abb8
64 changed files with 27784 additions and 0 deletions

View File

29
apprise_api/api/apps.py Normal file
View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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.apps import AppConfig
class ApiConfig(AppConfig):
name = 'api'

137
apprise_api/api/forms.py Normal file
View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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.
import apprise
from django import forms
from django.utils.translation import ugettext_lazy as _
# Define our potential configuration types
CONFIG_FORMATS = (
(apprise.ConfigFormat.TEXT, _('TEXT')),
(apprise.ConfigFormat.YAML, _('YAML')),
)
NOTIFICATION_TYPES = (
(apprise.NotifyType.INFO, _('Info')),
(apprise.NotifyType.SUCCESS, _('Success')),
(apprise.NotifyType.WARNING, _('Warning')),
(apprise.NotifyType.FAILURE, _('Failure')),
)
class AddByUrlForm(forms.Form):
"""
Form field for adding entries simply by passing in a string
of one or more URLs that have been deliminted by either a
comma and/or a space.
This content can just be directly fed straight into Apprise
"""
urls = forms.CharField(
label=_('URLs'),
widget=forms.TextInput(
attrs={
'placeholder': 'mailto://user:pass@domain.com, '
'slack://tokena/tokenb/tokenc, ...'}),
max_length=1024,
)
class AddByConfigForm(forms.Form):
"""
This is the reading in of a configuration file which contains
potential asset information (if yaml file) and tag details.
"""
format = forms.ChoiceField(
label=_('Format'),
choices=CONFIG_FORMATS,
)
config = forms.CharField(
label=_('Configuration'),
widget=forms.Textarea(),
max_length=4096,
)
class NotifyForm(forms.Form):
"""
This is the reading in of a configuration file which contains
potential asset information (if yaml file) and tag details.
"""
type = forms.ChoiceField(
label=_('Type'),
choices=NOTIFICATION_TYPES,
initial=NOTIFICATION_TYPES[0][0],
required=False,
)
title = forms.CharField(
label=_('Title'),
widget=forms.TextInput(attrs={'placeholder': _('Optional Title')}),
max_length=apprise.NotifyBase.title_maxlen,
required=False,
)
body = forms.CharField(
label=_('Body'),
widget=forms.Textarea(),
max_length=apprise.NotifyBase.body_maxlen,
)
tag = forms.ChoiceField(
label=_('Tags'),
widget=forms.TextInput(
attrs={'placeholder': _('Optional_Tag1, Optional_Tag2, ...')}),
required=False,
)
def clean_type(self):
"""
We just ensure there is a type always set
"""
data = self.cleaned_data['type']
if not data:
# Always set a type
data = apprise.NotifyType.INFO
return data
class NotifyByUrlForm(AddByUrlForm, NotifyForm):
"""
Same as the NotifyForm but additionally processes a string of URLs to
notify directly.
"""
pass
class NotifyByConfigForm(AddByConfigForm, NotifyForm):
"""
Same as the NotifyForm but additionally process a configuration file as
well.
"""
pass

View File

View File

View File

@@ -0,0 +1,61 @@
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<!--Let browser know website is optimized for mobile-->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{% static 'css/materialize.min.css' %}"/>
<link rel="stylesheet" href="{% static 'iconfont/material-icons.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}"/>
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
<script src="{% static 'js/materialize.min.js' %}"></script>
<title>{% block title %}{% trans "Apprise API" %}{% endblock %}</title>
</head>
<body>
<div class="content">
<!-- Title -->
<div class="nav row teal lighten-5 z-depth-2">
<div class="col s12">
<a href="{% url 'welcome' %}">
<img class="left" src="{% static "logo.png" %}" alt="{% trans "Apprise Logo" %}" />
</a>
<h1>{% trans "Apprise API" %}</h1>
</div>
</div>
<!-- Page Layout here -->
<div class="row">
<div class="col s3">
<ul class="collection z-depth-1">
<a class="collection-item" href="{% url 'config' 'apprise' %}"><i class="tiny material-icons">settings</i> {% trans "Configuration Manager" %}</a>
</ul>
<ul class="collection z-depth-1">
<a class="collection-item" target="_blank" href="https://github.com/caronc/apprise/wiki#notification-services">📣 {% trans "Notification Services" %}</a>
<a class="collection-item" target="_blank" href="https://github.com/caronc/apprise/wiki/config"><i class="tiny material-icons">local_library</i> {% trans "Configuration Help" %}</a>
<a class="collection-item" target="_blank" href="https://github.com/caronc/apprise/wiki/Troubleshooting"><i class="tiny material-icons">build</i> {% trans "Troubleshooting" %}</a>
<a class="collection-item" target="_blank" href="https://github.com/caronc/apprise/wiki/CLI_Usage"><i class="tiny material-icons">lightbulb_outline</i> {% trans "Using the CLI" %}</a>
</ul>
<ul class="collection z-depth-1">
<a class="collection-item" target="_blank" href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MHANV39UZNQ5E"><i class="tiny material-icons">favorite</i> {% trans "Support Apprise" %}</a>
<a class="collection-item" target="_blank" href="https://github.com/sponsors/caronc"><i class="tiny material-icons">favorite</i> {% trans "Sponsor Developer" %}</a>
</ul>
{% block menu %}{% endblock %}
</div>
<div class="col s9">
{% block body %}{% endblock %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
M.AutoInit();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,76 @@
{% extends 'base.html' %}
{% load i18n %}
{% block body %}
<h3>{% trans "Management for:" %} <em>{{ key }}</em></h3>
<div class="row">
<div class="col s12">
<ul class="tabs">
<li class="tab col s4"><a class="active" href="#overview">{% trans "Overview" %}</a></li>
<li class="tab col s4"><a href="#config">{%trans "Configuration" %}</a></li>
<li class="tab col s4"><a href="#notify">{%trans "Notifications" %}</a></li>
</ul>
</div>
<div id="overview" class="col s12">
<p>
{% blocktrans %}
Here is where you can store your configuration so that it can be accessed by Apprise. 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).
You have chosen to associate your configuration with the key <code>{{key}}</code>. If anything was previously associated with this key, it will be replaced if you continue.
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
In the future you can return to this configuration screen at any time by placing the following into your browser:{% endblocktrans %}
<br/><code>{{request.scheme}}://{{request.META.HTTP_HOST}}{{request.path}}</code>
</p>
<div class="section">
{% blocktrans %}For example, the following command would cause apprise to retrieve the configuration loaded and send a test notification to all of your added services:{% endblocktrans %}
<br/><code>apprise --body="Test Message" --tag=all --config={{request.scheme}}://{{request.META.HTTP_HOST}}{% url "get" key %}</code>
</div>
</div>
<div id="config" class="col s12">
<div class="section">
<h5>{% trans "Option 1: Add By URL" %}</h5>
<p>
{% blocktrans %}
Use a comma and/or space to separate one Apprise URL from another.
{% endblocktrans %}
<form action="#" method="post">
{{ form_url }}
<button class="btn waves-effect waves-light" type="submit" name="action">{% trans "Submit" %}
<i class="material-icons right">send</i>
</button>
</form>
</p>
</div>
<div class="section">
<h5>{% trans "Option 2: Add By Config" %}</h5>
<p>
{% blocktrans %}
This option grants you a bit more flexability because you can additionally associate tags with your URLs. Those using YAML configuration can also alter the Apprise Asset object as well for a more customized look and feel.
{% endblocktrans %}
<form action="#" method="post">
{{ form_cfg }}
<button class="btn waves-effect waves-light" type="submit" name="action">{% trans "Submit" %}
<i class="material-icons right">send</i>
</button>
</form>
</p>
</div>
</div>
<div id="notify" class="col s12">
<p>
{% blocktrans %}
You can send a notification using the loaded configuration:
{% endblocktrans %}
<form action="#" method="post">
{{ form_notify }}
<button class="btn waves-effect waves-light" type="submit" name="action">{% trans "Submit" %}
<i class="material-icons right">send</i>
</button>
</form>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,198 @@
{% extends 'base.html' %}
{% load i18n %}
{% block body %}
<h4>{% trans "The Apprise API" %}</h4>
<p>
{% blocktrans %}
<a target="_blank" href="https://github.com/caronc/apprise">Apprise</a> allows you to send a notification to almost
all of the most popular notification services available to us today such as: <em>Telegram</em>, <em>Discord</em>,
<em>Slack</em>, <em>Amazon SNS</em>, <em>Gotify</em>, etc.
This API provides a simple gateway to directly access it via an HTTP interface.
<ul>
<li><i class="tiny material-icons">chevron_right</i>This project was designed to be incredibly light weight.</li>
<li><i class="tiny material-icons">chevron_right</i>Configuration can be persistently stored for retrieval.</li>
</ul>
{% endblocktrans %}
</p>
<div class="section">
<h4>{% trans "Endpoints" %}</h4>
<p>{% blocktrans %}All endpoints that expect posted data can be received in either JSON or in it's standard encoding.
You must pass along the <code>Content-Type</code> as <code>application/json</code> in order for it to be interpreted
properly.{% endblocktrans %}</p>
<table class="highlighted">
<thead>
<tr>
<th>{% trans "URL" %}</th>
<th>{% trans "Description" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/add/<em>{% trans "KEY" %}</em></code></td>
<td>
{% blocktrans %}Used to add a new Apprise configuration or a set of URLs and associates them with the
specified <em>KEY</em>. See the <a target="_blank"
href="https://github.com/caronc/apprise/wiki#notification-services">Apprise Wiki</a> if you need help
constructing your URLs.{% endblocktrans %}
<div class="section">
<table>
<thead>
<tr>
<th>{% trans "Parameter" %}</th>
<th>{% trans "Description" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>urls</td>
<td>{% blocktrans %}Used to define one or more Apprise URL(s). Use a comma and/or space to separate
one URL from the next.{% endblocktrans %}</td>
</tr>
<tr>
<td>config</td>
<td>{% blocktrans %}Provide the contents of either a YAML or TEXT based Apprise
configuration.{% endblocktrans %}</td>
</tr>
<tr>
<td>format</td>
<td>{% blocktrans %}This field is only required if you've specified the config option. It's purpose is
to tell the server which of the supported (Apprise) configuration types you are passing. Valid
options are:{% endblocktrans %}
<ol>
<li><code>{% trans "yaml" %}</code></li>
<li><code>{% trans "text" %}</code></li>
</ol>
<ul>
<li>{% blocktrans %}You must specify either the <code>urls</code> parameter or the
<code>config</code>.{% endblocktrans %}</li>
<li>{% blocktrans %}The <code>urls</code> takes priority over the <code>config</code> if both were
specified.{% endblocktrans %}</li>
<li>{% blocktrans %}The <code>format</code> parameter is only required if the <code>config</code>
parameter was also specified.{% endblocktrans %}</li>
</ul>
</td>
</tr>
</tbody>
</table>
<ul class="collapsible">
<li>
<div class="collapsible-header"><i class="material-icons">code</i>curl example</div>
<div class="collapsible-body">
<code>#
{% blocktrans %}Load a single URL and assign it to the <em>KEY</em> of abc123{% endblocktrans %}<br/>
curl -X POST -d '{"urls":"mailto://user:pass@gmail.com"}' \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;-H "Content-Type: application/json" \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;http://localhost:8000/add/<em>abc123</em></code>
<code><br/>
<br/>#{% blocktrans %}Load a simple TEXT config entry <em>KEY</em> of abc123{% endblocktrans %}<br/>
curl -X POST -d '{"format":"text","config":"devops=mailto://user:pass@gmail.com"}' \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;-H "Content-Type: application/json" \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;http://localhost:8000/add/abc123/
</code>
</div>
</li>
<li>
<div class="collapsible-header"><i class="material-icons">code</i>python example</div>
<div class="collapsible-body"><code>Coming Soon</code></div>
<li>
<li>
<div class="collapsible-header"><i class="material-icons">code</i>php example</div>
<div class="collapsible-body"><code>Coming Soon</code></div>
</li>
</ul>
</div>
</td>
</tr>
<tr>
<td><code>/del/<em>{% trans "KEY" %}</em></code></td>
<td>{% blocktrans %}There are no arguments required. If the <em>KEY</em> exists and has data associated with it,
it will be removed.{% endblocktrans %}</td>
</tr>
<tr>
<td><code>/get/<em>{% trans "KEY" %}</em></code></td>
<td>{% blocktrans %}This feature can be used by Apprise itself. It provides a means of remotely fetching it's
configuration.{% endblocktrans %}
<p><strong>{% trans "As an example:" %}</strong><br /><code>apprise --body="test message" --config={{ request.scheme }}://{{request.META.HTTP_HOST}}{{ request.path }}<em>{% trans "KEY" %}</em></p>
<ul class="collapsible">
<li>
<div class="collapsible-header"><i class="material-icons">code</i>curl example</div>
<div class="collapsible-body"><code>
# {% blocktrans %}Load a single URL and assign it to the <em>KEY</em> of abc123{% endblocktrans %}</br>
curl -X POST -H "Content-Type: application/json" \<br/>
&nbsp;&nbsp;&nbsp;&nbsp;http://localhost:8000/get/<em>abc123</em></code>
</div>
</li>
<li>
<div class="collapsible-header"><i class="material-icons">code</i>python example</div>
<div class="collapsible-body"><code>Coming Soon</code></div>
<li>
<li>
<div class="collapsible-header"><i class="material-icons">code</i>php example</div>
<div class="collapsible-body"><code>Coming Soon</code></div>
</li>
</ul>
</td>
</tr>
<tr>
<td><code>/notify/<em>{% trans "KEY" %}</em></code></td>
<td>{% blocktrans %}Notifies the URLs associated with the specified <em>KEY</em>.{% endblocktrans %}
<div class="section">
<table>
<thead>
<tr>
<th>{% trans "Parameter" %}</th>
<th>{% trans "Description" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>body</td>
<td>{% blocktrans %}Defines the message body. This field is required!{% endblocktrans %}</td>
</tr>
<tr>
<td>title</td>
<td>{% blocktrans %}The title to include in the notification. This is an optional field.{% endblocktrans %}
</td>
</tr>
<tr>
<td>type</td>
<td>{% blocktrans %}This optional field defines the notification type. The possible options
are:{% endblocktrans %}
<ol>
<li><code>{% trans "info" %}</code> - <i>{% blocktrans %}this is the default option if a type isn't
specified.{% endblocktrans %}</i></li>
<li><code>{% trans "success" %}</code></li>
<li><code>{% trans "warning" %}</code></li>
<li><code>{% trans "failure" %}</code></li>
</ol>
</td>
<tr>
<td>tags</td>
<td>{% blocktrans %}Apply tagging logic to the further filter your URLs. This is an optional
field.{% endblocktrans %}</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
<div class="section">
<h5>{% trans "Endpoint Notes" %}</h5>
<p>
The <em>KEY</em> you plan to associate your configuration with:
<ol>
<li>Can not have spaces and/or special characters in it. Both a dash (<code>-</code>) and underscore
(<code>_</code>) are the only exceptions to this rule.</li>
<li>Must start with at least 2 alpha/numeric characters.</li>
<li>Can not exceed 64 characters in total length.</li>
</ol>
</p>
</div>
</div>
{% endblock %}

View File

View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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
from apprise import ConfigFormat
from unittest.mock import patch
import json
class AddTests(SimpleTestCase):
def test_add_invalid_key_status_code(self):
"""
Test GET requests to invalid key
"""
response = self.client.get('/add/**invalid-key**')
assert response.status_code == 404
def test_save_config_by_urls(self):
"""
Test adding an configuration by URLs
"""
# our key to use
key = 'test_save_config_by_urls'
# GET returns 405 (not allowed)
response = self.client.get('/add/{}'.format(key))
assert response.status_code == 405
# no data
response = self.client.post('/add/{}'.format(key))
assert response.status_code == 400
# No entries specified
response = self.client.post(
'/add/{}'.format(key), {'urls': ''})
assert response.status_code == 400
# Added successfully
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
assert response.status_code == 200
# URL is actually not a valid one (invalid Slack tokens specified
# below)
response = self.client.post(
'/add/{}'.format(key), {'urls': 'slack://TokenA/TokenB/TokenC'})
assert response.status_code == 400
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
content_type='application/json',
)
assert response.status_code == 200
# Invalid JSON
response = self.client.post(
'/add/{}'.format(key),
data='{',
content_type='application/json',
)
assert response.status_code == 400
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
def test_save_config_by_config(self):
"""
Test adding an configuration by a config file
"""
# our key to use
key = 'test_save_config_by_config'
# Empty Text Configuration
config = """
""" # noqa W293
response = self.client.post(
'/add/{}'.format(key), {
'format': ConfigFormat.TEXT, 'config': config})
assert response.status_code == 400
# Valid Text Configuration
config = """
browser,media=notica://VToken
home=mailto://user:pass@hotmail.com
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': ConfigFormat.TEXT, 'config': config})
assert response.status_code == 200
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}),
content_type='application/json',
)
assert response.status_code == 200
# Test invalid config format
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'format': 'INVALID', 'config': config}),
content_type='application/json',
)
assert response.status_code == 400
with patch('tempfile._TemporaryFileWrapper') as mock_ntf:
mock_ntf.side_effect = OSError()
# we won't be able to write our retrieved configuration
# to disk for processing; we'll get a 500 error
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps(
{'format': ConfigFormat.TEXT, 'config': config}),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps(
{'format': ConfigFormat.TEXT, 'config': config}),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
def test_save_with_bad_input(self):
"""
Test adding with bad input in general
"""
# our key to use
key = 'test_save_with_bad_input'
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'garbage': 'input'}),
content_type='application/json',
)
assert response.status_code == 400

View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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.
import os
from ..utils import AppriseConfigCache
from apprise import ConfigFormat
from unittest.mock import patch
import errno
def test_apprise_config_io(tmpdir):
"""
Test Apprise Config Disk Put/Get
"""
content = 'mailto://test:pass@gmail.com'
key = 'test_apprise_config_io'
# Create our object to work with
acc_obj = AppriseConfigCache(str(tmpdir))
# Verify that the content doesn't already exist
assert acc_obj.get(key) == (None, '')
# Write our content assigned to our key
assert acc_obj.put(key, content, ConfigFormat.TEXT)
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
# Get path details
conf_dir, _ = acc_obj.path(key)
# List content of directory
contents = os.listdir(conf_dir)
# There should be just 1 new file in this directory
assert len(contents) == 1
assert contents[0].endswith('.{}'.format(ConfigFormat.TEXT))
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
# Test the handling of underlining disk/read exceptions
with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError()
# We'll fail to read our key now
assert acc_obj.get(key) == (None, None)
# Tidy up our content
assert acc_obj.clear(key) is True
# But the second time is okay as it no longer exists
assert acc_obj.clear(key) is None
with patch('os.remove') as mock_remove:
mock_remove.side_effect = OSError(errno.EPERM)
# OSError
assert acc_obj.clear(key) is False
# Now test with YAML file
content = """
version: 1
urls:
- windows://
"""
# Write our content assigned to our key
# This should gracefully clear the TEXT entry that was
# previously in the spot
assert acc_obj.put(key, content, ConfigFormat.YAML)
# List content of directory
contents = os.listdir(conf_dir)
# There should STILL be just 1 new file in this directory
assert len(contents) == 1
assert contents[0].endswith('.{}'.format(ConfigFormat.YAML))
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.YAML)

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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 GetTests(SimpleTestCase):
def test_get_invalid_key_status_code(self):
"""
Test GET requests to invalid key
"""
response = self.client.get('/get/**invalid-key**')
assert response.status_code == 404
def test_get_config(self):
"""
Test retrieving configuration
"""
# our key to use
key = 'test_get_config'
# GET returns 405 (not allowed)
response = self.client.get('/get/{}'.format(key))
assert response.status_code == 405
# No content saved to the location yet
response = self.client.post('/get/{}'.format(key))
assert response.status_code == 204
# Add some content
response = self.client.post(
'/add/{}'.format(key),
{'urls': 'mailto://user:pass@yahoo.ca'})
assert response.status_code == 200
# Now we should be able to see our content
response = self.client.post('/get/{}'.format(key))
assert response.status_code == 200

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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 ManagerPageTests(SimpleTestCase):
"""
Manager Webpage testing
"""
def test_manage_status_code(self):
"""
General testing of management page
"""
# No key was specified
response = self.client.get('/cfg/')
assert response.status_code == 404
# An invalid key was specified
response = self.client.get('/cfg/**invalid-key**')
assert response.status_code == 404
# An invalid key was specified
response = self.client.get('/cfg/valid-key')
assert response.status_code == 200

View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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
from unittest.mock import patch
from ..forms import NotifyForm
import json
import apprise
class NotifyTests(SimpleTestCase):
"""
Test notifications
"""
@patch('apprise.Apprise.notify')
def test_notify_by_loaded_urls(self, mock_notify):
"""
Test adding a simple notification and notifying it
"""
# Set our return value
mock_notify.return_value = True
# our key to use
key = 'test_notify_by_loaded_urls'
# Add some content
response = self.client.post(
'/add/{}'.format(key),
{'urls': 'mailto://user:pass@yahoo.ca'})
assert response.status_code == 200
# Preare our form data
form_data = {
'body': 'test notifiction',
}
# At a minimum, just a body is required
form = NotifyForm(data=form_data)
assert form.is_valid()
# we always set a type if one wasn't done so already
assert form.cleaned_data['type'] == apprise.NotifyType.INFO
# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
@patch('apprise.Apprise.notify')
def test_notify_by_loaded_urls_with_json(self, mock_notify):
"""
Test adding a simple notification and notifying it using JSON
"""
# Set our return value
mock_notify.return_value = True
# our key to use
key = 'test_notify_by_loaded_urls_with_json'
# Add some content
response = self.client.post(
'/add/{}'.format(key),
{'urls': 'mailto://user:pass@yahoo.ca'})
assert response.status_code == 200
# Preare our JSON data
json_data = {
'body': 'test notifiction',
'type': apprise.NotifyType.WARNING,
}
# Send our notification as a JSON object
response = self.client.post(
'/notify/{}'.format(key),
data=json.dumps(json_data),
content_type='application/json',
)
# Still supported
assert response.status_code == 200
assert mock_notify.call_count == 1
# Reset our count
mock_notify.reset_mock()
# Test referencing a key that doesn't exist
response = self.client.post(
'/notify/non-existant-key',
data=json.dumps(json_data),
content_type='application/json',
)
# Nothing notified
assert response.status_code == 204
assert mock_notify.call_count == 0
# Test sending a garbage JSON object
response = self.client.post(
'/notify/{}'.format(key),
data="{",
content_type='application/json',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test sending with an invalid content type
response = self.client.post(
'/notify/{}'.format(key),
data="{}",
content_type='application/xml',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test sending without any content at all
response = self.client.post(
'/notify/{}'.format(key),
data="{}",
content_type='application/json',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test sending without a body
json_data = {
'type': apprise.NotifyType.WARNING,
}
response = self.client.post(
'/notify/{}'.format(key),
data=json.dumps(json_data),
content_type='application/json',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test inability to prepare writing config to disk
json_data = {
'body': 'test message'
}
with patch('tempfile._TemporaryFileWrapper') as mock_ntf:
mock_ntf.side_effect = OSError()
# we won't be able to write our retrieved configuration
# to disk for processing; we'll get a 500 error
response = self.client.post(
'/notify/{}'.format(key),
data=json.dumps(json_data),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
assert mock_notify.call_count == 0
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
'/notify/{}'.format(key),
data=json.dumps(json_data),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
assert mock_notify.call_count == 0

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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 WelcomePageTests(SimpleTestCase):
def test_welcome_page_status_code(self):
response = self.client.get('/')
assert response.status_code == 200

47
apprise_api/api/urls.py Normal file
View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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.urls import re_path
from . import views
urlpatterns = [
re_path(
r'^$',
views.WelcomeView.as_view(), name='welcome'),
re_path(
r'^cfg/(?P<key>[\w_-]{1,64})/?',
views.ConfigView.as_view(), name='config'),
re_path(
r'^add/(?P<key>[\w_-]{1,64})/?',
views.AddView.as_view(), name='add'),
re_path(
r'^del/(?P<key>[\w_-]{1,64})/?',
views.DelView.as_view(), name='del'),
re_path(
r'^get/(?P<key>[\w_-]{1,64})/?',
views.GetView.as_view(), name='get'),
re_path(
r'^notify/(?P<key>[\w_-]{1,64})/?',
views.NotifyView.as_view(), name='notify'),
]

196
apprise_api/api/utils.py Normal file
View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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.
import os
import tempfile
import shutil
import gzip
import apprise
import hashlib
import errno
from django.conf import settings
class AppriseConfigCache(object):
"""
Designed to make it easy to store/read contact back from disk in a cache
type structure that is fast.
"""
def __init__(self, cache_root, salt="apprise"):
"""
Works relative to the cache_root
"""
self.root = cache_root
self.salt = salt.encode()
def put(self, key, content, fmt):
"""
Based on the key specified, content is written to disk (compressed)
key: is an alphanumeric string needed to write and read back this
file being written.
content: the content to be written to disk
fmt: the content config format (of type apprise.ConfigFormat)
"""
# There isn't a lot of error handling done here as it is presumed most
# of the checking has been done higher up.
# First two characters are reserved for cache level directory writing.
path, filename = self.path(key)
os.makedirs(path, exist_ok=True)
# Write our file to a temporary file
_, tmp_path = tempfile.mkstemp(suffix='.tmp', dir=path)
try:
with gzip.open(tmp_path, 'wb') as f:
# Write our content to disk
f.write(content.encode())
except OSError:
# Handle failure
os.remove(tmp_path)
return False
# If we reach here we successfully wrote the content. We now safely
# move our configuration into place. The following writes our content
# to disk as /xx/key.fmt
shutil.move(tmp_path, os.path.join(
path, '{}.{}'.format(filename, fmt)))
# perform tidy of any other lingering files of other type in case
# configuration changed from TEXT -> YAML or YAML -> TEXT
if self.clear(key, set(apprise.CONFIG_FORMATS) - {fmt}) is False:
# We couldn't remove an existing entry; clear what we just created
self.clear(key, {fmt})
# fail
return False
return True
def get(self, key):
"""
Based on the key specified, content is written to disk (compressed)
key: is an alphanumeric string needed to write and read back this
file being written.
The function returns a tuple of (content, fmt) where the content
is the uncompressed content found in the file and fmt is the
content representation (of type apprise.ConfigFormat).
If no data was found, then (None, None) is returned.
"""
# There isn't a lot of error handling done here as it is presumed most
# of the checking has been done higher up.
# First two characters are reserved for cache level directory writing.
path, filename = self.path(key)
# prepare our format to return
fmt = None
# Test the only possible hashed files we expect to find
text_file = os.path.join(
path, '{}.{}'.format(filename, apprise.ConfigFormat.TEXT))
yaml_file = os.path.join(
path, '{}.{}'.format(filename, apprise.ConfigFormat.YAML))
if os.path.isfile(text_file):
fmt = apprise.ConfigFormat.TEXT
path = text_file
elif os.path.isfile(yaml_file):
fmt = apprise.ConfigFormat.YAML
path = yaml_file
else:
# Not found; we set the fmt to something other than none as
# an indication for the upstream handling to know that we didn't
# fail on error
return (None, '')
# Initialize our content
content = None
try:
with gzip.open(path, 'rb') as f:
# Write our content to disk
content = f.read().decode()
except OSError:
# all none return means to let upstream know we had a hard failure
return (None, None)
# return our read content
return (content, fmt)
def clear(self, key, formats=None):
"""
Removes any content associated with the specified key should it
exist.
None is returned if there was nothing to clear
True is returned if content was cleared
False is returned if an internal error prevented data from being
cleared
"""
# Default our response None
response = None
if formats is None:
formats = apprise.CONFIG_FORMATS
path, filename = self.path(key)
for fmt in formats:
# Eliminate any existing content if present
try:
# Handle failure
os.remove(os.path.join(path, '{}.{}'.format(filename, fmt)))
# If we reach here, an element was removed
response = True
except OSError as e:
if e.errno != errno.ENOENT:
# We were unable to remove the file
response = False
return response
def path(self, key):
"""
returns the path and filename content should be written to based on the
specified key
"""
encoded_key = hashlib.sha224(self.salt + key.encode()).hexdigest()
path = os.path.join(self.root, encoded_key[0:2])
return (path, encoded_key[2:])
# Initialize our singleton
ConfigCache = AppriseConfigCache(
settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY)

419
apprise_api/api/views.py Normal file
View File

@@ -0,0 +1,419 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 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.shortcuts import render
from django.http import HttpResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
from django.views.decorators.gzip import gzip_page
from django.utils.translation import ugettext_lazy as _
from .utils import ConfigCache
from .forms import AddByUrlForm
from .forms import AddByConfigForm
from .forms import NotifyForm
from tempfile import NamedTemporaryFile
import apprise
import json
import re
# Content-Type Parsing
FORM_CTYPE_RE = re.compile('^(.*form-(data|urlencoded))$', re.I)
JSON_CTYPE_RE = re.compile('^.*json$', re.I)
class ResponseCode(object):
"""
These codes are based on those provided by the requests object
"""
okay = 200
no_content = 204
bad_request = 400
not_found = 404
method_not_allowed = 405
internal_server_error = 500
class WelcomeView(View):
"""
A simple welcome/index page
"""
template_name = 'welcome.html'
def get(self, request):
return render(request, self.template_name, {})
@method_decorator(never_cache, name='dispatch')
class ConfigView(View):
"""
A Django view used to manage configuration
"""
template_name = 'config.html'
def get(self, request, key):
"""
Handle a GET request
"""
return render(request, self.template_name, {
'key': key,
'form_url': AddByUrlForm(),
'form_cfg': AddByConfigForm(),
'form_notify': NotifyForm(),
})
@method_decorator(never_cache, name='dispatch')
class AddView(View):
"""
A Django view used to store Apprise configuration
"""
def post(self, request, key):
"""
Handle a POST request
"""
# Our default response type
content_type = 'text/plain'
# our content
content = {}
if FORM_CTYPE_RE.match(request.content_type):
content = {}
form = AddByConfigForm(request.POST)
if form.is_valid():
content.update(form.clean())
form = AddByUrlForm(request.POST)
if form.is_valid():
content.update(form.clean())
elif JSON_CTYPE_RE.match(request.content_type):
# Prepare our default response
try:
# load our JSON content
content = json.loads(request.body)
except (AttributeError, ValueError):
# could not parse JSON response...
return HttpResponse(
_('Invalid JSON specified.'),
content_type=content_type,
status=ResponseCode.bad_request)
if not content:
return HttpResponse(
_('The message format is not supported.'),
content_type=content_type,
status=ResponseCode.bad_request)
# Create ourselves an apprise object to work with
a_obj = apprise.Apprise()
if 'urls' in content:
# Load our content
a_obj.add(content['urls'])
if not len(a_obj):
# No URLs were loaded
return HttpResponse(
_('No valid URLs were found.'),
content_type=content_type,
status=ResponseCode.bad_request)
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.'),
content_type=content_type,
status=ResponseCode.internal_server_error,
)
elif 'config' in content:
fmt = content.get('format', '').lower()
if fmt not in apprise.CONFIG_FORMATS:
# Format must be one supported by apprise
return HttpResponse(
_('The format specified is invalid.'),
content_type=content_type,
status=ResponseCode.bad_request)
# prepare our apprise config object
ac_obj = apprise.AppriseConfig()
try:
# Write our file to a temporary file
with NamedTemporaryFile() as f:
# Write our content to disk
f.write(content['config'].encode())
f.flush()
if not ac_obj.add(
'file://{}?format={}'.format(f.name, fmt)):
# Bad Configuration
return HttpResponse(
_('The configuration specified is invalid.'),
content_type=content_type,
status=ResponseCode.bad_request)
# Add our configuration
a_obj.add(ac_obj)
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.'),
content_type=content_type,
status=ResponseCode.bad_request)
except OSError:
# We could not write the temporary file to disk
return HttpResponse(
_('The configuration could not be loaded.'),
content_type=content_type,
status=ResponseCode.internal_server_error)
if not ConfigCache.put(key, content['config'], fmt=fmt):
# Something went very wrong; return 500
return HttpResponse(
_('An error occured saving configuration.'),
content_type=content_type,
status=ResponseCode.internal_server_error,
)
else:
# No configuration specified; we're done
return HttpResponse(
_('No configuration specified.'),
content_type=content_type, status=ResponseCode.bad_request)
# 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.
return HttpResponse(
_('Successfully saved configuration.'),
content_type=content_type, status=ResponseCode.okay)
@method_decorator(never_cache, name='dispatch')
class DelView(View):
"""
A Django view for removing content associated with a key
"""
def post(self, request, key):
"""
Handle a POST request
"""
# Our default response type
content_type = 'text/plain'
# Clear the key
result = ConfigCache.clear(key)
if result is None:
return HttpResponse(
_('There was no configuration to remove.'),
content_type=content_type,
status=ResponseCode.no_content,
)
elif result is False:
# There was a failure at the os level
return HttpResponse(
_('The configuration could not be removed.'),
content_type=content_type,
status=ResponseCode.internal_server_error,
)
# Removed content
return HttpResponse(
_('Successfully removed configuration.'),
content_type=content_type,
status=ResponseCode.okay,
)
@method_decorator((gzip_page, never_cache), name='dispatch')
class GetView(View):
"""
A Django view used to retrieve previously stored Apprise configuration
"""
def post(self, request, key):
"""
Handle a POST request
"""
# Our default response type
content_type = 'text/plain'
config, format = ConfigCache.get(key)
if config is None:
# The returned value of config and format tell a rather cryptic
# story; this portion could probably be updated in the future.
# but for now it reads like this:
# config == None and format == None: We had an internal error
# config == None and format != None: we simply have no data
# config != None: we simply have no data
if format is not None:
# no content to return
return HttpResponse(
_('There was no configuration found.'),
content_type=content_type,
status=ResponseCode.no_content,
)
# Something went very wrong; return 500
return HttpResponse(
_('An error occured accessing configuration.'),
content_type=content_type,
status=ResponseCode.internal_server_error,
)
# Our configuration was retrieved; now our response varies on whether
# we are a YAML configuration or a TEXT based one. This allows us to
# be compatible with those using the AppriseConfig() library or the
# reference to it through the --config (-c) option in the CLI
if format == apprise.ConfigFormat.YAML:
# update our return content type from the default text
content_type = 'text/yaml'
# Return our retrieved content
return HttpResponse(
config, content_type=content_type, status=ResponseCode.okay)
@method_decorator((gzip_page, never_cache), name='dispatch')
class NotifyView(View):
"""
A Django view for sending a notification
"""
def post(self, request, key):
"""
Handle a POST request
"""
# Our default response type
content_type = 'text/plain'
# our content
content = {}
if FORM_CTYPE_RE.match(request.content_type):
content = {}
form = NotifyForm(request.POST)
if form.is_valid():
content.update(form.clean())
elif JSON_CTYPE_RE.match(request.content_type):
# Prepare our default response
try:
# load our JSON content
content = json.loads(request.body)
except (AttributeError, ValueError):
# could not parse JSON response...
return HttpResponse(
_('Invalid JSON specified.'),
content_type=content_type,
status=ResponseCode.bad_request)
if not content:
# We could not handle the Content-Type
return HttpResponse(
_('The message format is not supported.'),
content_type=content_type,
status=ResponseCode.bad_request)
# 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.'),
content_type=content_type,
status=ResponseCode.bad_request)
# If we get here, we have enough information to generate a notification
# with.
config, format = ConfigCache.get(key)
if config is None:
# The returned value of config and format tell a rather cryptic
# story; this portion could probably be updated in the future.
# but for now it reads like this:
# config == None and format == None: We had an internal error
# config == None and format != None: we simply have no data
# config != None: we simply have no data
if format is not None:
# no content to return
return HttpResponse(
_('There was no configuration found.'),
content_type=content_type,
status=ResponseCode.no_content,
)
# Something went very wrong; return 500
return HttpResponse(
_('An error occured accessing configuration.'),
content_type=content_type,
status=ResponseCode.internal_server_error,
)
# Prepare our apprise object
a_obj = apprise.Apprise()
# Create an apprise config object
ac_obj = apprise.AppriseConfig()
try:
# Write our file to a temporary file containing our configuration
# so that we can read it back. In the future a change will be to
# Apprise so that we can just directly write the configuration as
# is to the AppriseConfig() object... but for now...
with NamedTemporaryFile() as f:
# Write our content to disk
f.write(config.encode())
f.flush()
# Read our configuration back in to our configuration
ac_obj.add('file://{}?format={}'.format(f.name, format))
# Add our configuration
a_obj.add(ac_obj)
# Perform our notification at this point
a_obj.notify(
content.get('body'),
title=content.get('title', ''),
notify_type=content.get('type', apprise.NotifyType.INFO),
tag=content.get('tag'),
)
except OSError:
# We could not write the temporary file to disk
return HttpResponse(
_('The configuration could not be loaded.'),
content_type=content_type,
status=ResponseCode.internal_server_error)
# Return our retrieved content
return HttpResponse(
_('Notification(s) sent.'),
content_type=content_type, status=ResponseCode.okay)