{% blocktrans %}Apprise can not write new configuration information to the directory:{% endblocktrans %} {{CONFIG_DIR}}.
95 |
{% blocktrans %}Note: If this is the expected behavior, you should pre-set the environment variable APPRISE_CONFIG_LOCK=yes and reload your Apprise instance.{% endblocktrans %}
{% blocktrans %}Apprise can not circulate attachments (if provided) along to supported endpoints due to not having write access to the directory:{% endblocktrans %} {{ATTACH_DIR}}.
102 |
{% blocktrans %}Note: If this is the expected behavior, you should pre-set the environment variable APPRISE_ATTACH_SIZE=0 and reload your Apprise instance.{% endblocktrans %}
103 |
104 |
105 |
106 |
{% blocktrans %}Under most circumstances, the issue(s) identified here are usually related to permission issues. Make sure you set the correct PUID and GUID to reflect the permissions you wish Apprise to utilize when it is reading and writing its files. In addition to this, you may need to make sure the permissions are set correctly on the directories you mapped them too.{% endblocktrans %}
107 |
{% blocktrans %}The issue(s) identified here can also be associated with SELinux too. You may wish to rule out SELinux by first temporarily disabling it using the command setenforce 0. You can always re-enstate it with setenforce 1{% endblocktrans %}.
7 | {% url 'details' as href %}
8 | {% blocktrans %}The following services are supported by this Apprise instance.{% endblocktrans %}
9 |
10 |
11 | {% if show_all %}
12 | {% blocktrans %}To see a simplified listing that only identifies the Apprise services enabled click here.{% endblocktrans %}
13 | {% else %}
14 | {% blocktrans %}To see a listing that identifies all of Apprise services available to this version (enabled or not) click here.{% endblocktrans %}
15 | {% endif %}
16 |
37 | {% if show_all and not entry.enabled %}
38 | report{% blocktrans %}Note: 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 %}
39 | {% endif %}
40 |
41 |
42 |
{% blocktrans %}For more details and additional Apprise configuration options available to this service:{% endblocktrans %}
58 | Click Here
59 |
60 |
61 |
62 | {% endfor %}
63 |
64 |
65 |
66 |
67 |
{% trans 'API Endpoints' %}
68 |
69 | {% 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 %}
70 |
126 | {% blocktrans %}More details on the JSON format can be found here.{% endblocktrans %}
127 |
128 | {% endblock %}
129 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/api/tests/__init__.py
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 |
26 | import io
27 | from django.test import SimpleTestCase
28 | from django.core import management
29 |
30 |
31 | class CommandTests(SimpleTestCase):
32 |
33 | def test_command_style(self):
34 | out = io.StringIO()
35 | management.call_command('storeprune', days=40, stdout=out)
36 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_config_cache.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import os
26 |
27 | from ..utils import AppriseConfigCache
28 | from ..utils import AppriseStoreMode
29 | from ..utils import SimpleFileExtension
30 | from apprise import ConfigFormat
31 | from unittest.mock import patch
32 | from unittest.mock import mock_open
33 | import errno
34 |
35 |
36 | def test_apprise_config_io_hash_mode(tmpdir):
37 | """
38 | Test Apprise Config Disk Put/Get using HASH mode
39 | """
40 | content = 'mailto://test:pass@gmail.com'
41 | key = 'test_apprise_config_io_hash'
42 |
43 | # Create our object to work with
44 | acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
45 |
46 | # Verify that the content doesn't already exist
47 | assert acc_obj.get(key) == (None, '')
48 |
49 | # Write our content assigned to our key
50 | assert acc_obj.put(key, content, ConfigFormat.TEXT)
51 |
52 | # Test the handling of underlining disk/write exceptions
53 | with patch('gzip.open') as mock_gzopen:
54 | mock_gzopen.side_effect = OSError()
55 | # We'll fail to write our key now
56 | assert not acc_obj.put(key, content, ConfigFormat.TEXT)
57 |
58 | # Get path details
59 | conf_dir, _ = acc_obj.path(key)
60 |
61 | # List content of directory
62 | contents = os.listdir(conf_dir)
63 |
64 | # There should be just 1 new file in this directory
65 | assert len(contents) == 1
66 | assert contents[0].endswith('.{}'.format(ConfigFormat.TEXT))
67 |
68 | # Verify that the content is retrievable
69 | assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
70 |
71 | # Test the handling of underlining disk/read exceptions
72 | with patch('gzip.open') as mock_gzopen:
73 | mock_gzopen.side_effect = OSError()
74 | # We'll fail to read our key now
75 | assert acc_obj.get(key) == (None, None)
76 |
77 | # Tidy up our content
78 | assert acc_obj.clear(key) is True
79 |
80 | # But the second time is okay as it no longer exists
81 | assert acc_obj.clear(key) is None
82 |
83 | with patch('os.remove') as mock_remove:
84 | mock_remove.side_effect = OSError(errno.EPERM)
85 | # OSError
86 | assert acc_obj.clear(key) is False
87 |
88 | # If we try to put the same file, we'll fail since
89 | # one exists there already
90 | assert not acc_obj.put(key, content, ConfigFormat.TEXT)
91 |
92 | # Now test with YAML file
93 | content = """
94 | version: 1
95 |
96 | urls:
97 | - windows://
98 | """
99 |
100 | # Write our content assigned to our key
101 | # This should gracefully clear the TEXT entry that was
102 | # previously in the spot
103 | assert acc_obj.put(key, content, ConfigFormat.YAML)
104 |
105 | # List content of directory
106 | contents = os.listdir(conf_dir)
107 |
108 | # There should STILL be just 1 new file in this directory
109 | assert len(contents) == 1
110 | assert contents[0].endswith('.{}'.format(ConfigFormat.YAML))
111 |
112 | # Verify that the content is retrievable
113 | assert acc_obj.get(key) == (content, ConfigFormat.YAML)
114 |
115 |
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 |
196 | def test_apprise_config_io_simple_mode(tmpdir):
197 | """
198 | Test Apprise Config Disk Put/Get using SIMPLE mode
199 | """
200 | content = 'mailto://test:pass@gmail.com'
201 | key = 'test_apprise_config_io_simple'
202 |
203 | # Create our object to work with
204 | acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
205 |
206 | # Verify that the content doesn't already exist
207 | assert acc_obj.get(key) == (None, '')
208 |
209 | # Write our content assigned to our key
210 | assert acc_obj.put(key, content, ConfigFormat.TEXT)
211 |
212 | m = mock_open()
213 | m.side_effect = OSError()
214 | with patch('builtins.open', m):
215 | # We'll fail to write our key now
216 | assert not acc_obj.put(key, content, ConfigFormat.TEXT)
217 |
218 | # Get path details
219 | conf_dir, _ = acc_obj.path(key)
220 |
221 | # List content of directory
222 | contents = os.listdir(conf_dir)
223 |
224 | # There should be just 1 new file in this directory
225 | assert len(contents) == 1
226 | assert contents[0].endswith('.{}'.format(SimpleFileExtension.TEXT))
227 |
228 | # Verify that the content is retrievable
229 | assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
230 |
231 | # Test the handling of underlining disk/read exceptions
232 | with patch('builtins.open', m) as mock__open:
233 | mock__open.side_effect = OSError()
234 | # We'll fail to read our key now
235 | assert acc_obj.get(key) == (None, None)
236 |
237 | # Tidy up our content
238 | assert acc_obj.clear(key) is True
239 |
240 | # But the second time is okay as it no longer exists
241 | assert acc_obj.clear(key) is None
242 |
243 | with patch('os.remove') as mock_remove:
244 | mock_remove.side_effect = OSError(errno.EPERM)
245 | # OSError
246 | assert acc_obj.clear(key) is False
247 |
248 | # If we try to put the same file, we'll fail since
249 | # one exists there already
250 | assert not acc_obj.put(key, content, ConfigFormat.TEXT)
251 |
252 | # Now test with YAML file
253 | content = """
254 | version: 1
255 |
256 | urls:
257 | - windows://
258 | """
259 |
260 | # Write our content assigned to our key
261 | # This should gracefully clear the TEXT entry that was
262 | # previously in the spot
263 | assert acc_obj.put(key, content, ConfigFormat.YAML)
264 |
265 | # List content of directory
266 | contents = os.listdir(conf_dir)
267 |
268 | # There should STILL be just 1 new file in this directory
269 | assert len(contents) == 1
270 | assert contents[0].endswith('.{}'.format(SimpleFileExtension.YAML))
271 |
272 | # Verify that the content is retrievable
273 | assert acc_obj.get(key) == (content, ConfigFormat.YAML)
274 |
275 |
276 | def test_apprise_config_io_disabled_mode(tmpdir):
277 | """
278 | Test Apprise Config Disk Put/Get using DISABLED mode
279 | """
280 | content = 'mailto://test:pass@gmail.com'
281 | key = 'test_apprise_config_io_disabled'
282 |
283 | # Create our object to work with using an invalid mode
284 | acc_obj = AppriseConfigCache(str(tmpdir), mode="invalid")
285 |
286 | # We always fall back to disabled if we can't interpret the mode
287 | assert acc_obj.mode is AppriseStoreMode.DISABLED
288 |
289 | # Create our object to work with
290 | acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.DISABLED)
291 |
292 | # Verify that the content doesn't already exist
293 | assert acc_obj.get(key) == (None, '')
294 |
295 | # Write our content assigned to our key
296 | # This isn't allowed
297 | assert acc_obj.put(key, content, ConfigFormat.TEXT) is False
298 |
299 | # Get path details
300 | conf_dir, _ = acc_obj.path(key)
301 |
302 | # List content of directory
303 | contents = os.listdir(conf_dir)
304 |
305 | # There should never be an entry
306 | assert len(contents) == 0
307 |
308 | # Content never exists
309 | assert acc_obj.clear(key) is None
310 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_del.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 | from django.test.utils import override_settings
27 | from unittest.mock import patch
28 | import hashlib
29 |
30 |
31 | class DelTests(SimpleTestCase):
32 |
33 | def test_del_get_invalid_key_status_code(self):
34 | """
35 | Test GET requests to invalid key
36 | """
37 | response = self.client.get('/del/**invalid-key**')
38 | assert response.status_code == 404
39 |
40 | def test_key_lengths(self):
41 | """
42 | Test our key lengths
43 | """
44 |
45 | # our key to use
46 | h = hashlib.sha512()
47 | h.update(b'string')
48 | key = h.hexdigest()
49 |
50 | # Our limit
51 | assert len(key) == 128
52 |
53 | # Add our URL
54 | response = self.client.post(
55 | '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
56 | assert response.status_code == 200
57 |
58 | # remove a key that is too long
59 | response = self.client.post('/del/{}'.format(key + 'x'))
60 | assert response.status_code == 404
61 |
62 | # remove the key
63 | response = self.client.post('/del/{}'.format(key))
64 | assert response.status_code == 200
65 |
66 | # Test again; key is gone
67 | response = self.client.post('/del/{}'.format(key))
68 | assert response.status_code == 204
69 |
70 | @override_settings(APPRISE_CONFIG_LOCK=True)
71 | def test_del_with_lock(self):
72 | """
73 | Test deleting a configuration by URLs with lock set won't work
74 | """
75 | # our key to use
76 | key = 'test_delete_with_lock'
77 |
78 | # We simply do not have permission to do so
79 | response = self.client.post('/del/{}'.format(key))
80 | assert response.status_code == 403
81 |
82 | def test_del_post(self):
83 | """
84 | Test DEL POST
85 | """
86 | # our key to use
87 | key = 'test_delete'
88 |
89 | # Invalid Key
90 | response = self.client.post('/del/**invalid-key**')
91 | assert response.status_code == 404
92 |
93 | # A key that just simply isn't present
94 | response = self.client.post('/del/{}'.format(key))
95 | assert response.status_code == 204
96 |
97 | # Add our key
98 | response = self.client.post(
99 | '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
100 | assert response.status_code == 200
101 |
102 | # Test removing key when the OS just can't do it:
103 | with patch('os.remove', side_effect=OSError):
104 | # We can now remove the key
105 | response = self.client.post('/del/{}'.format(key))
106 | assert response.status_code == 500
107 |
108 | # We can now remove the key
109 | response = self.client.post('/del/{}'.format(key))
110 | assert response.status_code == 200
111 |
112 | # Key has already been removed
113 | response = self.client.post('/del/{}'.format(key))
114 | assert response.status_code == 204
115 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_details.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 |
27 |
28 | class DetailTests(SimpleTestCase):
29 |
30 | def test_post_not_supported(self):
31 | """
32 | Test POST requests
33 | """
34 | response = self.client.post('/details')
35 | # 405 as posting is not allowed
36 | assert response.status_code == 405
37 |
38 | def test_details_simple(self):
39 | """
40 | Test retrieving details
41 | """
42 |
43 | # Nothing to return
44 | response = self.client.get('/details')
45 | self.assertEqual(response.status_code, 200)
46 | assert response['Content-Type'].startswith('text/html')
47 |
48 | # JSON Response
49 | response = self.client.get(
50 | '/details', content_type='application/json',
51 | **{'HTTP_CONTENT_TYPE': 'application/json'})
52 | self.assertEqual(response.status_code, 200)
53 | assert response['Content-Type'].startswith('application/json')
54 |
55 | # JSON Response
56 | response = self.client.get(
57 | '/details', content_type='application/json',
58 | **{'HTTP_ACCEPT': 'application/json'})
59 | self.assertEqual(response.status_code, 200)
60 | assert response['Content-Type'].startswith('application/json')
61 |
62 | response = self.client.get('/details?all=yes')
63 | self.assertEqual(response.status_code, 200)
64 | assert response['Content-Type'].startswith('text/html')
65 |
66 | # JSON Response
67 | response = self.client.get(
68 | '/details?all=yes', content_type='application/json',
69 | **{'HTTP_CONTENT_TYPE': 'application/json'})
70 | self.assertEqual(response.status_code, 200)
71 | assert response['Content-Type'].startswith('application/json')
72 |
73 | # JSON Response
74 | response = self.client.get(
75 | '/details?all=yes', content_type='application/json',
76 | **{'HTTP_ACCEPT': 'application/json'})
77 | self.assertEqual(response.status_code, 200)
78 | assert response['Content-Type'].startswith('application/json')
79 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_get.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 | from unittest.mock import patch
27 |
28 |
29 | class GetTests(SimpleTestCase):
30 |
31 | def test_get_invalid_key_status_code(self):
32 | """
33 | Test GET requests to invalid key
34 | """
35 | response = self.client.get('/get/**invalid-key**')
36 | assert response.status_code == 404
37 |
38 | def test_get_config(self):
39 | """
40 | Test retrieving configuration
41 | """
42 |
43 | # our key to use
44 | key = 'test_get_config_'
45 |
46 | # GET returns 405 (not allowed)
47 | response = self.client.get('/get/{}'.format(key))
48 | assert response.status_code == 405
49 |
50 | # No content saved to the location yet
51 | response = self.client.post('/get/{}'.format(key))
52 | self.assertEqual(response.status_code, 204)
53 |
54 | # Add some content
55 | response = self.client.post(
56 | '/add/{}'.format(key),
57 | {'urls': 'mailto://user:pass@yahoo.ca'})
58 | assert response.status_code == 200
59 |
60 | # Handle case when we try to retrieve our content but we have no idea
61 | # what the format is in. Essentialy there had to have been disk
62 | # corruption here or someone meddling with the backend.
63 | with patch('gzip.open', side_effect=OSError):
64 | response = self.client.post('/get/{}'.format(key))
65 | assert response.status_code == 500
66 |
67 | # Now we should be able to see our content
68 | response = self.client.post('/get/{}'.format(key))
69 | assert response.status_code == 200
70 |
71 | # Add a YAML file
72 | response = self.client.post(
73 | '/add/{}'.format(key), {
74 | 'format': 'yaml',
75 | 'config': """
76 | urls:
77 | - dbus://"""})
78 | assert response.status_code == 200
79 |
80 | # Now retrieve our YAML configuration
81 | response = self.client.post('/get/{}'.format(key))
82 | assert response.status_code == 200
83 |
84 | # Verify that the correct Content-Type is set in the header of the
85 | # response
86 | assert 'Content-Type' in response
87 | assert response['Content-Type'].startswith('text/yaml')
88 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_healthecheck.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2024 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import mock
26 | from django.test import SimpleTestCase
27 | from json import loads
28 | from django.test.utils import override_settings
29 | from ..utils import healthcheck
30 |
31 |
32 | class HealthCheckTests(SimpleTestCase):
33 |
34 | def test_post_not_supported(self):
35 | """
36 | Test POST requests
37 | """
38 | response = self.client.post('/status')
39 | # 405 as posting is not allowed
40 | assert response.status_code == 405
41 |
42 | def test_healthcheck_simple(self):
43 | """
44 | Test retrieving basic successful health-checks
45 | """
46 |
47 | # First Status Check
48 | response = self.client.get('/status')
49 | self.assertEqual(response.status_code, 200)
50 | self.assertEqual(response.content, b'OK')
51 | assert response['Content-Type'].startswith('text/plain')
52 |
53 | # Second Status Check (Lazy Mode kicks in)
54 | response = self.client.get('/status')
55 | self.assertEqual(response.status_code, 200)
56 | self.assertEqual(response.content, b'OK')
57 | assert response['Content-Type'].startswith('text/plain')
58 |
59 | # JSON Response
60 | response = self.client.get(
61 | '/status', content_type='application/json',
62 | **{'HTTP_CONTENT_TYPE': 'application/json'})
63 | self.assertEqual(response.status_code, 200)
64 | content = loads(response.content)
65 | assert content == {
66 | 'config_lock': False,
67 | 'attach_lock': False,
68 | 'status': {
69 | 'persistent_storage': True,
70 | 'can_write_config': True,
71 | 'can_write_attach': True,
72 | 'details': ['OK']
73 | }
74 | }
75 | assert response['Content-Type'].startswith('application/json')
76 |
77 | with override_settings(APPRISE_CONFIG_LOCK=True):
78 | # Status Check (Form based)
79 | response = self.client.get('/status')
80 | self.assertEqual(response.status_code, 200)
81 | self.assertEqual(response.content, b'OK')
82 | assert response['Content-Type'].startswith('text/plain')
83 |
84 | # JSON Response
85 | response = self.client.get(
86 | '/status', content_type='application/json',
87 | **{'HTTP_CONTENT_TYPE': 'application/json'})
88 | self.assertEqual(response.status_code, 200)
89 | content = loads(response.content)
90 | assert content == {
91 | 'config_lock': True,
92 | 'attach_lock': False,
93 | 'status': {
94 | 'persistent_storage': True,
95 | 'can_write_config': False,
96 | 'can_write_attach': True,
97 | 'details': ['OK']
98 | }
99 | }
100 |
101 | with override_settings(APPRISE_STATEFUL_MODE='disabled'):
102 | # Status Check (Form based)
103 | response = self.client.get('/status')
104 | self.assertEqual(response.status_code, 200)
105 | self.assertEqual(response.content, b'OK')
106 | assert response['Content-Type'].startswith('text/plain')
107 |
108 | # JSON Response
109 | response = self.client.get(
110 | '/status', content_type='application/json',
111 | **{'HTTP_CONTENT_TYPE': 'application/json'})
112 | self.assertEqual(response.status_code, 200)
113 | content = loads(response.content)
114 | assert content == {
115 | 'config_lock': False,
116 | 'attach_lock': False,
117 | 'status': {
118 | 'persistent_storage': True,
119 | 'can_write_config': False,
120 | 'can_write_attach': True,
121 | 'details': ['OK']
122 | }
123 | }
124 |
125 | with override_settings(APPRISE_ATTACH_SIZE=0):
126 | # Status Check (Form based)
127 | response = self.client.get('/status')
128 | self.assertEqual(response.status_code, 200)
129 | self.assertEqual(response.content, b'OK')
130 | assert response['Content-Type'].startswith('text/plain')
131 |
132 | # JSON Response
133 | response = self.client.get(
134 | '/status', content_type='application/json',
135 | **{'HTTP_CONTENT_TYPE': 'application/json'})
136 | self.assertEqual(response.status_code, 200)
137 | content = loads(response.content)
138 | assert content == {
139 | 'config_lock': False,
140 | 'attach_lock': True,
141 | 'status': {
142 | 'persistent_storage': True,
143 | 'can_write_config': True,
144 | 'can_write_attach': False,
145 | 'details': ['OK']
146 | }
147 | }
148 |
149 | with override_settings(APPRISE_MAX_ATTACHMENTS=0):
150 | # Status Check (Form based)
151 | response = self.client.get('/status')
152 | self.assertEqual(response.status_code, 200)
153 | self.assertEqual(response.content, b'OK')
154 | assert response['Content-Type'].startswith('text/plain')
155 |
156 | # JSON Response
157 | response = self.client.get(
158 | '/status', content_type='application/json',
159 | **{'HTTP_CONTENT_TYPE': 'application/json'})
160 | self.assertEqual(response.status_code, 200)
161 | content = loads(response.content)
162 | assert content == {
163 | 'config_lock': False,
164 | 'attach_lock': False,
165 | 'status': {
166 | 'persistent_storage': True,
167 | 'can_write_config': True,
168 | 'can_write_attach': True,
169 | 'details': ['OK']
170 | }
171 | }
172 |
173 | def test_healthcheck_library(self):
174 | """
175 | Test underlining healthcheck library
176 | """
177 |
178 | result = healthcheck(lazy=True)
179 | assert result == {
180 | 'persistent_storage': True,
181 | 'can_write_config': True,
182 | 'can_write_attach': True,
183 | 'details': ['OK']
184 | }
185 |
186 | # A Double lazy check
187 | result = healthcheck(lazy=True)
188 | assert result == {
189 | 'persistent_storage': True,
190 | 'can_write_config': True,
191 | 'can_write_attach': True,
192 | 'details': ['OK']
193 | }
194 |
195 | # Force a lazy check where we can't acquire the modify time
196 | with mock.patch('os.path.getmtime') as mock_getmtime:
197 | mock_getmtime.side_effect = FileNotFoundError()
198 | result = healthcheck(lazy=True)
199 | # We still succeed; we just don't leverage our lazy check
200 | # which prevents addition (unnessisary) writes
201 | assert result == {
202 | 'persistent_storage': True,
203 | 'can_write_config': True,
204 | 'can_write_attach': True,
205 | 'details': ['OK'],
206 | }
207 |
208 | # Force a lazy check where we can't acquire the modify time
209 | with mock.patch('os.path.getmtime') as mock_getmtime:
210 | mock_getmtime.side_effect = OSError()
211 | result = healthcheck(lazy=True)
212 | # We still succeed; we just don't leverage our lazy check
213 | # which prevents addition (unnessisary) writes
214 | assert result == {
215 | 'persistent_storage': True,
216 | 'can_write_config': False,
217 | 'can_write_attach': False,
218 | 'details': [
219 | 'CONFIG_PERMISSION_ISSUE',
220 | 'ATTACH_PERMISSION_ISSUE',
221 | ]}
222 |
223 | # Force a non-lazy check
224 | with mock.patch('os.makedirs') as mock_makedirs:
225 | mock_makedirs.side_effect = OSError()
226 | result = healthcheck(lazy=False)
227 | assert result == {
228 | 'persistent_storage': False,
229 | 'can_write_config': False,
230 | 'can_write_attach': False,
231 | 'details': [
232 | 'CONFIG_PERMISSION_ISSUE',
233 | 'ATTACH_PERMISSION_ISSUE',
234 | 'STORE_PERMISSION_ISSUE',
235 | ]}
236 |
237 | with mock.patch('os.path.getmtime') as mock_getmtime:
238 | with mock.patch('os.fdopen', side_effect=OSError()):
239 | mock_getmtime.side_effect = OSError()
240 | mock_makedirs.side_effect = None
241 | result = healthcheck(lazy=False)
242 | assert result == {
243 | 'persistent_storage': True,
244 | 'can_write_config': False,
245 | 'can_write_attach': False,
246 | 'details': [
247 | 'CONFIG_PERMISSION_ISSUE',
248 | 'ATTACH_PERMISSION_ISSUE',
249 | ]}
250 |
251 | with mock.patch('apprise.PersistentStore.flush', return_value=False):
252 | result = healthcheck(lazy=False)
253 | assert result == {
254 | 'persistent_storage': False,
255 | 'can_write_config': True,
256 | 'can_write_attach': True,
257 | 'details': [
258 | 'STORE_PERMISSION_ISSUE',
259 | ]}
260 |
261 | # Test a case where we simply do not define a persistent store path
262 | # health checks will always disable persistent storage
263 | with override_settings(APPRISE_STORAGE_DIR=""):
264 | with mock.patch('apprise.PersistentStore.flush', return_value=False):
265 | result = healthcheck(lazy=False)
266 | assert result == {
267 | 'persistent_storage': False,
268 | 'can_write_config': True,
269 | 'can_write_attach': True,
270 | 'details': ['OK']}
271 |
272 | mock_makedirs.side_effect = (OSError(), OSError(), None, None, None, None)
273 | result = healthcheck(lazy=False)
274 | assert result == {
275 | 'persistent_storage': True,
276 | 'can_write_config': False,
277 | 'can_write_attach': False,
278 | 'details': [
279 | 'CONFIG_PERMISSION_ISSUE',
280 | 'ATTACH_PERMISSION_ISSUE',
281 | ]}
282 |
283 | mock_makedirs.side_effect = (OSError(), None, None, None, None)
284 | result = healthcheck(lazy=False)
285 | assert result == {
286 | 'persistent_storage': True,
287 | 'can_write_config': False,
288 | 'can_write_attach': True,
289 | 'details': [
290 | 'CONFIG_PERMISSION_ISSUE',
291 | ]}
292 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_json_urls.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 | from django.test.utils import override_settings
27 | from unittest.mock import patch
28 |
29 |
30 | class JsonUrlsTests(SimpleTestCase):
31 |
32 | def test_get_invalid_key_status_code(self):
33 | """
34 | Test GET requests to invalid key
35 | """
36 | response = self.client.get('/get/**invalid-key**')
37 | assert response.status_code == 404
38 |
39 | def test_post_not_supported(self):
40 | """
41 | Test POST requests with key
42 | """
43 | response = self.client.post('/json/urls/test')
44 | # 405 as posting is not allowed
45 | assert response.status_code == 405
46 |
47 | def test_json_urls_config(self):
48 | """
49 | Test retrieving configuration
50 | """
51 |
52 | # our key to use
53 | key = 'test_json_urls_config'
54 |
55 | # Nothing to return
56 | response = self.client.get('/json/urls/{}'.format(key))
57 | self.assertEqual(response.status_code, 204)
58 |
59 | # Add some content
60 | response = self.client.post(
61 | '/add/{}'.format(key),
62 | {'urls': 'mailto://user:pass@yahoo.ca'})
63 | assert response.status_code == 200
64 |
65 | # Handle case when we try to retrieve our content but we have no idea
66 | # what the format is in. Essentialy there had to have been disk
67 | # corruption here or someone meddling with the backend.
68 | with patch('gzip.open', side_effect=OSError):
69 | response = self.client.get('/json/urls/{}'.format(key))
70 | assert response.status_code == 500
71 | assert response['Content-Type'].startswith('application/json')
72 | assert 'tags' in response.json()
73 | assert 'urls' in response.json()
74 |
75 | # has error directive
76 | assert 'error' in response.json()
77 |
78 | # entries exist by are empty
79 | assert len(response.json()['tags']) == 0
80 | assert len(response.json()['urls']) == 0
81 |
82 | # Now we should be able to see our content
83 | response = self.client.get('/json/urls/{}'.format(key))
84 | assert response.status_code == 200
85 | assert response['Content-Type'].startswith('application/json')
86 | assert 'tags' in response.json()
87 | assert 'urls' in response.json()
88 |
89 | # No errors occurred, therefore no error entry
90 | assert 'error' not in response.json()
91 |
92 | # No tags (but can be assumed "all") is always present
93 | assert len(response.json()['tags']) == 0
94 |
95 | # Same request as above but we set the privacy flag
96 | response = self.client.get('/json/urls/{}?privacy=1'.format(key))
97 | assert response.status_code == 200
98 | assert response['Content-Type'].startswith('application/json')
99 | assert 'tags' in response.json()
100 | assert 'urls' in response.json()
101 |
102 | # No errors occurred, therefore no error entry
103 | assert 'error' not in response.json()
104 |
105 | # No tags (but can be assumed "all") is always present
106 | assert len(response.json()['tags']) == 0
107 |
108 | # One URL loaded
109 | assert len(response.json()['urls']) == 1
110 | assert 'url' in response.json()['urls'][0]
111 | assert 'tags' in response.json()['urls'][0]
112 | assert len(response.json()['urls'][0]['tags']) == 0
113 |
114 | # We can see that th URLs are not the same when the privacy flag is set
115 | without_privacy = \
116 | self.client.get('/json/urls/{}?privacy=1'.format(key))
117 | with_privacy = self.client.get('/json/urls/{}'.format(key))
118 | assert with_privacy.json()['urls'][0] != \
119 | without_privacy.json()['urls'][0]
120 |
121 | with override_settings(APPRISE_CONFIG_LOCK=True):
122 | # When our configuration lock is set, our result set enforces the
123 | # privacy flag even if it was otherwise set:
124 | with_privacy = \
125 | self.client.get('/json/urls/{}?privacy=1'.format(key))
126 |
127 | # But now they're the same under this new condition
128 | assert with_privacy.json()['urls'][0] == \
129 | without_privacy.json()['urls'][0]
130 |
131 | # Add a YAML file
132 | response = self.client.post(
133 | '/add/{}'.format(key), {
134 | 'format': 'yaml',
135 | 'config': """
136 | urls:
137 | - dbus://:
138 | - tag: tag1, tag2"""})
139 | assert response.status_code == 200
140 |
141 | # Now retrieve our JSON resonse
142 | response = self.client.get('/json/urls/{}'.format(key))
143 | assert response.status_code == 200
144 |
145 | # No errors occured, therefore no error entry
146 | assert 'error' not in response.json()
147 |
148 | # No tags (but can be assumed "all") is always present
149 | assert len(response.json()['tags']) == 2
150 |
151 | # One URL loaded
152 | assert len(response.json()['urls']) == 1
153 | assert 'url' in response.json()['urls'][0]
154 | assert 'tags' in response.json()['urls'][0]
155 | assert len(response.json()['urls'][0]['tags']) == 2
156 |
157 | # Verify that the correct Content-Type is set in the header of the
158 | # response
159 | assert 'Content-Type' in response
160 | assert response['Content-Type'].startswith('application/json')
161 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_manager.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 | from django.test import override_settings
27 |
28 |
29 | class ManagerPageTests(SimpleTestCase):
30 | """
31 | Manager Webpage testing
32 | """
33 |
34 | def test_manage_status_code(self):
35 | """
36 | General testing of management page
37 | """
38 | # No permission to get keys
39 | response = self.client.get('/cfg/')
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
62 |
63 | # An invalid key was specified
64 | response = self.client.get('/cfg/**invalid-key**')
65 | assert response.status_code == 404
66 |
67 | # An invalid key was specified
68 | response = self.client.get('/cfg/valid-key')
69 | assert response.status_code == 200
70 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_payload_mapper.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2024 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 | from ..payload_mapper import remap_fields
27 |
28 |
29 | class NotifyPayloadMapper(SimpleTestCase):
30 | """
31 | Test Payload Mapper
32 | """
33 |
34 | def test_remap_fields(self):
35 | """
36 | Test payload re-mapper
37 | """
38 |
39 | #
40 | # No rules defined
41 | #
42 | rules = {}
43 | payload = {
44 | 'format': 'markdown',
45 | 'title': 'title',
46 | 'body': '# body',
47 | }
48 | payload_orig = payload.copy()
49 |
50 | # Map our fields
51 | remap_fields(rules, payload)
52 |
53 | # no change is made
54 | assert payload == payload_orig
55 |
56 | #
57 | # rules defined - test 1
58 | #
59 | rules = {
60 | # map 'as' to 'format'
61 | 'as': 'format',
62 | # map 'subject' to 'title'
63 | 'subject': 'title',
64 | # map 'content' to 'body'
65 | 'content': 'body',
66 | # 'missing' is an invalid entry so this will be skipped
67 | 'unknown': 'missing',
68 |
69 | # Empty field
70 | 'attachment': '',
71 |
72 | # Garbage is an field that can be removed since it doesn't
73 | # conflict with the form
74 | 'garbage': '',
75 |
76 | # Tag
77 | 'tag': 'test',
78 | }
79 | payload = {
80 | 'as': 'markdown',
81 | 'subject': 'title',
82 | 'content': '# body',
83 | 'tag': '',
84 | 'unknown': 'hmm',
85 | 'attachment': '',
86 | 'garbage': '',
87 | }
88 |
89 | # Map our fields
90 | remap_fields(rules, payload)
91 |
92 | # Our field mappings have taken place
93 | assert payload == {
94 | 'tag': 'test',
95 | 'unknown': 'missing',
96 | 'format': 'markdown',
97 | 'title': 'title',
98 | 'body': '# body',
99 | }
100 |
101 | #
102 | # rules defined - test 2
103 | #
104 | rules = {
105 | #
106 | # map 'content' to 'body'
107 | 'content': 'body',
108 | # a double mapping to body will trigger an error
109 | 'message': 'body',
110 | # Swapping fields
111 | 'body': 'another set of data',
112 | }
113 | payload = {
114 | 'as': 'markdown',
115 | 'subject': 'title',
116 | 'content': '# content body',
117 | 'message': '# message body',
118 | 'body': 'another set of data',
119 | }
120 |
121 | # Map our fields
122 | remap_fields(rules, payload)
123 |
124 | # Our information gets swapped
125 | assert payload == {
126 | 'as': 'markdown',
127 | 'subject': 'title',
128 | 'body': 'another set of data',
129 | }
130 |
131 | #
132 | # swapping fields - test 3
133 | #
134 | rules = {
135 | #
136 | # map 'content' to 'body'
137 | 'title': 'body',
138 | }
139 | payload = {
140 | 'format': 'markdown',
141 | 'title': 'body',
142 | 'body': '# title',
143 | }
144 |
145 | # Map our fields
146 | remap_fields(rules, payload)
147 |
148 | # Our information gets swapped
149 | assert payload == {
150 | 'format': 'markdown',
151 | 'title': '# title',
152 | 'body': 'body',
153 | }
154 |
155 | #
156 | # swapping fields - test 4
157 | #
158 | rules = {
159 | #
160 | # map 'content' to 'body'
161 | 'title': 'body',
162 | }
163 | payload = {
164 | 'format': 'markdown',
165 | 'title': 'body',
166 | }
167 |
168 | # Map our fields
169 | remap_fields(rules, payload)
170 |
171 | # Our information gets swapped
172 | assert payload == {
173 | 'format': 'markdown',
174 | 'body': 'body',
175 | }
176 |
177 | #
178 | # swapping fields - test 5
179 | #
180 | rules = {
181 | #
182 | # map 'content' to 'body'
183 | 'content': 'body',
184 | }
185 | payload = {
186 | 'format': 'markdown',
187 | 'content': 'the message',
188 | 'body': 'to-be-replaced',
189 | }
190 |
191 | # Map our fields
192 | remap_fields(rules, payload)
193 |
194 | # Our information gets swapped
195 | assert payload == {
196 | 'format': 'markdown',
197 | 'body': 'the message',
198 | }
199 |
200 | #
201 | # mapping of fields don't align - test 6
202 | #
203 | rules = {
204 | 'payload': 'body',
205 | 'fmt': 'format',
206 | 'extra': 'tag',
207 | }
208 | payload = {
209 | 'format': 'markdown',
210 | 'type': 'info',
211 | 'title': '',
212 | 'body': '## test notifiction',
213 | 'attachment': None,
214 | 'tag': 'general',
215 | 'tags': '',
216 | }
217 |
218 | # Make a copy of our original payload
219 | payload_orig = payload.copy()
220 |
221 | # Map our fields
222 | remap_fields(rules, payload)
223 |
224 | # There are no rules applied since nothing aligned
225 | assert payload == payload_orig
226 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2024 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import os
26 | import mock
27 | import tempfile
28 | from django.test import SimpleTestCase
29 | from .. import utils
30 |
31 |
32 | class UtilsTests(SimpleTestCase):
33 |
34 | def test_touchdir(self):
35 | """
36 | Test touchdir()
37 | """
38 |
39 | with tempfile.TemporaryDirectory() as tmpdir:
40 | with mock.patch('os.makedirs', side_effect=OSError()):
41 | assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
42 |
43 | with mock.patch('os.makedirs', side_effect=FileExistsError()):
44 | # Dir doesn't exist
45 | assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
46 |
47 | assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
48 |
49 | # Date is updated
50 | assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
51 |
52 | with mock.patch('os.utime', side_effect=OSError()):
53 | # Fails to update file
54 | assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
55 |
56 | def test_touch(self):
57 | """
58 | Test touch()
59 | """
60 |
61 | with tempfile.TemporaryDirectory() as tmpdir:
62 | with mock.patch('os.fdopen', side_effect=OSError()):
63 | assert utils.touch(os.path.join(tmpdir, 'tmp-file')) is False
64 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_webhook.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 | from unittest import mock
27 | from json import loads
28 | import requests
29 | from ..utils import send_webhook
30 | from django.test.utils import override_settings
31 |
32 |
33 | class WebhookTests(SimpleTestCase):
34 |
35 | @mock.patch('requests.post')
36 | def test_webhook_testing(self, mock_post):
37 | """
38 | Test webhook handling
39 | """
40 |
41 | # Response object
42 | response = mock.Mock()
43 | response.status_code = requests.codes.ok
44 | mock_post.return_value = response
45 |
46 | with override_settings(
47 | APPRISE_WEBHOOK_URL='https://'
48 | 'user:pass@localhost/webhook'):
49 | send_webhook({})
50 | assert mock_post.call_count == 1
51 |
52 | details = mock_post.call_args_list[0]
53 | assert details[0][0] == 'https://localhost/webhook'
54 | assert loads(details[1]['data']) == {}
55 | assert 'User-Agent' in details[1]['headers']
56 | assert 'Content-Type' in details[1]['headers']
57 | assert details[1]['headers']['User-Agent'] == 'Apprise-API'
58 | assert details[1]['headers']['Content-Type'] == 'application/json'
59 | assert details[1]['auth'] == ('user', 'pass')
60 | assert details[1]['verify'] is True
61 | assert details[1]['params'] == {}
62 | assert details[1]['timeout'] == (4.0, 4.0)
63 |
64 | mock_post.reset_mock()
65 |
66 | with override_settings(
67 | APPRISE_WEBHOOK_URL='http://'
68 | 'user@localhost/webhook/here'
69 | '?verify=False&key=value&cto=2.0&rto=1.0'):
70 | send_webhook({})
71 | assert mock_post.call_count == 1
72 |
73 | details = mock_post.call_args_list[0]
74 | assert details[0][0] == 'http://localhost/webhook/here'
75 | assert loads(details[1]['data']) == {}
76 | assert 'User-Agent' in details[1]['headers']
77 | assert 'Content-Type' in details[1]['headers']
78 | assert details[1]['headers']['User-Agent'] == 'Apprise-API'
79 | assert details[1]['headers']['Content-Type'] == 'application/json'
80 | assert details[1]['auth'] == ('user', None)
81 | assert details[1]['verify'] is False
82 | assert details[1]['params'] == {'key': 'value'}
83 | assert details[1]['timeout'] == (2.0, 1.0)
84 |
85 | mock_post.reset_mock()
86 |
87 | with override_settings(APPRISE_WEBHOOK_URL='invalid'):
88 | # Invalid webhook defined
89 | send_webhook({})
90 | assert mock_post.call_count == 0
91 |
92 | mock_post.reset_mock()
93 |
94 | with override_settings(APPRISE_WEBHOOK_URL=None):
95 | # Invalid webhook defined
96 | send_webhook({})
97 | assert mock_post.call_count == 0
98 |
99 | mock_post.reset_mock()
100 |
101 | with override_settings(APPRISE_WEBHOOK_URL='http://$#@'):
102 | # Invalid hostname defined
103 | send_webhook({})
104 | assert mock_post.call_count == 0
105 |
106 | mock_post.reset_mock()
107 |
108 | with override_settings(
109 | APPRISE_WEBHOOK_URL='invalid://hostname'):
110 | # Invalid webhook defined
111 | send_webhook({})
112 | assert mock_post.call_count == 0
113 |
114 | mock_post.reset_mock()
115 |
116 | # A valid URL with a bad server response:
117 | response.status_code = requests.codes.internal_server_error
118 | mock_post.return_value = response
119 | with override_settings(
120 | APPRISE_WEBHOOK_URL='http://localhost'):
121 |
122 | send_webhook({})
123 | assert mock_post.call_count == 1
124 |
125 | mock_post.reset_mock()
126 |
127 | # A valid URL with a bad server response:
128 | mock_post.return_value = None
129 | mock_post.side_effect = requests.RequestException("error")
130 | with override_settings(
131 | APPRISE_WEBHOOK_URL='http://localhost'):
132 |
133 | send_webhook({})
134 | assert mock_post.call_count == 1
135 |
--------------------------------------------------------------------------------
/apprise_api/api/tests/test_welcome.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.test import SimpleTestCase
26 |
27 |
28 | class WelcomePageTests(SimpleTestCase):
29 |
30 | def test_welcome_page_status_code(self):
31 | response = self.client.get('/')
32 | assert response.status_code == 200
33 |
--------------------------------------------------------------------------------
/apprise_api/api/urlfilter.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import re
26 | from apprise.utils.parse import parse_url
27 |
28 |
29 | class AppriseURLFilter:
30 | """
31 | A URL filtering class that uses pre-parsed and pre-compiled allow/deny lists.
32 |
33 | Deny rules are always processed before allow rules. If a URL matches any deny rule,
34 | it is immediately rejected. If no deny rule matches, then the URL is allowed only
35 | if it matches an allow rule; otherwise, it is rejected.
36 |
37 | Each entry in the allow/deny lists can be provided as:
38 | - A full URL (with http:// or https://)
39 | - A URL without a scheme (e.g. "localhost/resources")
40 | - A plain hostname or IP
41 |
42 | Wildcards:
43 | - '*' will match any sequence of characters.
44 | - '?' will match a single alphanumeric/dash/underscore character.
45 |
46 | A trailing '*' is implied if not already present so that rules operate as a prefix match.
47 | """
48 |
49 | def __init__(self, allow_list: str, deny_list: str):
50 | # Pre-compile our rules.
51 | # Each rule is stored as a tuple (compiled_regex, is_url_based)
52 | # where `is_url_based` indicates if the token included "http://" or "https://"
53 | self.allow_rules = self._parse_list(allow_list)
54 | self.deny_rules = self._parse_list(deny_list)
55 |
56 | def _parse_list(self, list_str: str):
57 | """
58 | Split the list (tokens separated by whitespace or commas) and compile each token.
59 | Tokens are classified as follows:
60 | - URL‐based tokens: if they start with “http://” or “https://” (explicit)
61 | or if they contain a “/” (implicit; no scheme given).
62 | - Host‐based tokens: those that do not contain a “/”.
63 | Returns a list of tuples (compiled_regex, is_url_based).
64 | """
65 | tokens = re.split(r'[\s,]+', list_str.strip().lower())
66 | rules = []
67 | for token in tokens:
68 | if not token:
69 | continue
70 |
71 | if token.startswith("http://") or token.startswith("https://"):
72 | # Explicit URL token.
73 | compiled = self._compile_url_token(token)
74 | is_url_based = True
75 |
76 | elif "/" in token:
77 | # Implicit URL token: prepend a scheme pattern.
78 | compiled = self._compile_implicit_token(token)
79 | is_url_based = True
80 |
81 | else:
82 | # Host-based token.
83 | compiled = self._compile_host_token(token)
84 | is_url_based = False
85 |
86 | rules.append((compiled, is_url_based))
87 | return rules
88 |
89 | def _compile_url_token(self, token: str):
90 | """
91 | Compiles a URL‐based token (explicit token that starts with a scheme) into a regex.
92 | An implied trailing wildcard is added to the path:
93 | - If no path is given (or just “/”) then “(/.*)?” is appended.
94 | - If a nonempty path is given that does not end with “/” or “*”, then “($|/.*)” is appended.
95 | - If the path ends with “/”, then the trailing slash is removed and “(/.*)?” is appended,
96 | so that “/resources” and “/resources/” are treated equivalently.
97 | Also, if no port is specified in the host part, the regex ensures that no port is present.
98 | """
99 | # Determine the scheme.
100 | scheme_regex = ""
101 | if token.startswith("http://"):
102 | scheme_regex = r'http'
103 | # drop http://
104 | token = token[7:]
105 |
106 | elif token.startswith("https://"):
107 | scheme_regex = r'https'
108 | # drop https://
109 | token = token[8:]
110 |
111 | else: # https?
112 | # Used for implicit tokens; our _compile_implicit_token ensures this.
113 | scheme_regex = r'https?'
114 | # strip https?://
115 | token = token[9:]
116 |
117 | # Split token into host (and optional port) and path.
118 | if "/" in token:
119 | netloc, path = token.split("/", 1)
120 | path = "/" + path
121 | else:
122 | netloc = token
123 | path = ""
124 |
125 | # Process netloc and port.
126 | if ":" in netloc:
127 | host, port = netloc.split(":", 1)
128 | port_specified = True
129 |
130 | else:
131 | host = netloc
132 | port_specified = False
133 |
134 | regex = "^" + scheme_regex + "://"
135 | regex += self._wildcard_to_regex(host, is_host=True)
136 | if port_specified:
137 | regex += ":" + re.escape(port)
138 |
139 | else:
140 | # Ensure no port is present.
141 | regex += r"(?!:)"
142 |
143 | # Process the path.
144 | if path in ("", "/"):
145 | regex += r"(/.*)?"
146 |
147 | else:
148 | if path.endswith("*"):
149 | # Remove the trailing "*" and append .*
150 | regex += self._wildcard_to_regex(path[:-1]) + "([^/]+/?)"
151 |
152 | elif path.endswith("/"):
153 | # Remove the trailing "/" and allow an optional slash with extra path.
154 | norm = self._wildcard_to_regex(path.rstrip("/"))
155 | regex += norm + r"(/.*)?"
156 |
157 | else:
158 | # For a nonempty path that does not end with "/" or "*",
159 | # match either an exact match or a prefix (with a following slash).
160 | norm = self._wildcard_to_regex(path)
161 | regex += norm + r"($|/.*)"
162 |
163 | regex += "$"
164 | return re.compile(regex, re.IGNORECASE)
165 |
166 | def _compile_implicit_token(self, token: str):
167 | """
168 | For an implicit token (one that does not start with a scheme but contains a “/”),
169 | prepend “https?://” so that it matches both http and https, then compile it.
170 | """
171 | new_token = "https?://" + token
172 | return self._compile_url_token(new_token)
173 |
174 | def _compile_host_token(self, token: str):
175 | """
176 | Compiles a host‐based token (one with no “/”) into a regex.
177 | Note: When matching host‐based tokens, we require that the URL’s scheme is exactly “http”.
178 | """
179 | regex = "^" + self._wildcard_to_regex(token) + "$"
180 | return re.compile(regex, re.IGNORECASE)
181 |
182 | def _wildcard_to_regex(self, pattern: str, is_host: bool = True) -> str:
183 | """
184 | Converts a pattern containing wildcards into a regex.
185 | - '*' becomes '.*' if host or [^/]+/? if path
186 | - '?' becomes '[A-Za-z0-9_-]'
187 | - Other characters are escaped.
188 | Special handling: if the pattern starts with "https?://", that prefix is preserved
189 | (so it can match either http:// or https://).
190 | """
191 | regex = ""
192 | for char in pattern:
193 | if char == '*':
194 | regex += r"[^/]+/?" if not is_host else r'.*'
195 |
196 | elif char == '?':
197 | regex += r'[^/]' if not is_host else r"[A-Za-z0-9_-]"
198 |
199 | else:
200 | regex += re.escape(char)
201 |
202 | return regex
203 |
204 | def is_allowed(self, url: str) -> bool:
205 | """
206 | Checks a given URL against the deny list first, then the allow list.
207 | For URL-based rules (explicit or implicit), the full URL is tested.
208 | For host-based rules, the URL’s netloc (which includes the port) is tested.
209 | """
210 | parsed = parse_url(url, strict_port=True, simple=True)
211 | if not parsed:
212 | return False
213 |
214 | # includes port if present
215 | netloc = '%s:%d' % (parsed['host'], parsed.get('port')) if parsed.get('port') else parsed['host']
216 |
217 | # Check deny rules first.
218 | for pattern, is_url_based in self.deny_rules:
219 | if is_url_based:
220 | if pattern.match(url):
221 | return False
222 |
223 | elif pattern.match(netloc):
224 | return False
225 |
226 | # Then check allow rules.
227 | for pattern, is_url_based in self.allow_rules:
228 | if is_url_based:
229 | if pattern.match(url):
230 | return True
231 | elif pattern.match(netloc):
232 | return True
233 |
234 | return False
235 |
--------------------------------------------------------------------------------
/apprise_api/api/urls.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.urls import re_path
26 | from . import views
27 |
28 | urlpatterns = [
29 | re_path(
30 | r'^$',
31 | views.WelcomeView.as_view(), name='welcome'),
32 | re_path(
33 | r'^status/?$',
34 | views.HealthCheckView.as_view(), name='health'),
35 | re_path(
36 | r'^details/?$',
37 | views.DetailsView.as_view(), name='details'),
38 | re_path(
39 | r'^cfg/(?P[\w_-]{1,128})/?$',
40 | views.ConfigView.as_view(), name='config'),
41 | re_path(
42 | r'^cfg/?$',
43 | views.ConfigListView.as_view(), name='config_list'),
44 | re_path(
45 | r'^add/(?P[\w_-]{1,128})/?$',
46 | views.AddView.as_view(), name='add'),
47 | re_path(
48 | r'^del/(?P[\w_-]{1,128})/?$',
49 | views.DelView.as_view(), name='del'),
50 | re_path(
51 | r'^get/(?P[\w_-]{1,128})/?$',
52 | views.GetView.as_view(), name='get'),
53 | re_path(
54 | r'^notify/(?P[\w_-]{1,128})/?$',
55 | views.NotifyView.as_view(), name='notify'),
56 | re_path(
57 | r'^notify/?$',
58 | views.StatelessNotifyView.as_view(), name='s_notify'),
59 | re_path(
60 | r'^json/urls/(?P[\w_-]{1,128})/?$',
61 | views.JsonUrlView.as_view(), name='json_urls'),
62 | ]
63 |
--------------------------------------------------------------------------------
/apprise_api/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/core/__init__.py
--------------------------------------------------------------------------------
/apprise_api/core/context_processors.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2020 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.conf import settings
26 |
27 |
28 | def base_url(request):
29 | """
30 | Returns our defined BASE_URL object
31 | """
32 | return {
33 | 'BASE_URL': settings.BASE_URL,
34 | 'CONFIG_DIR': settings.APPRISE_CONFIG_DIR,
35 | 'ATTACH_DIR': settings.APPRISE_ATTACH_DIR,
36 | }
37 |
--------------------------------------------------------------------------------
/apprise_api/core/middleware/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/core/middleware/__init__.py
--------------------------------------------------------------------------------
/apprise_api/core/middleware/config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | #
26 | import re
27 | from django.conf import settings
28 | import datetime
29 |
30 |
31 | class DetectConfigMiddleware:
32 | """
33 | Using the `key=` variable, allow one pre-configure the default
34 | configuration to use.
35 |
36 | """
37 |
38 | _is_cfg_path = re.compile(r'/cfg/(?P[\w_-]{1,128})')
39 |
40 | def __init__(self, get_response):
41 | """
42 | Prepare our initialization
43 | """
44 | self.get_response = get_response
45 |
46 | def __call__(self, request):
47 | """
48 | Define our middleware hook
49 | """
50 |
51 | result = self._is_cfg_path.match(request.path)
52 | if not result:
53 | # Our current config
54 | config = \
55 | request.COOKIES.get('key', settings.APPRISE_DEFAULT_CONFIG_ID)
56 |
57 | # Extract our key (fall back to our default if not set)
58 | config = request.GET.get("key", config).strip()
59 |
60 | else:
61 | config = result.group('key')
62 |
63 | if not config:
64 | # Fallback to default config
65 | config = settings.APPRISE_DEFAULT_CONFIG_ID
66 |
67 | # Set our theme to a cookie
68 | request.default_config_id = config
69 |
70 | # Get our response object
71 | response = self.get_response(request)
72 |
73 | # Set our cookie
74 | max_age = 365 * 24 * 60 * 60 # 1 year
75 | expires = datetime.datetime.utcnow() + \
76 | datetime.timedelta(seconds=max_age)
77 |
78 | # Set our cookie
79 | response.set_cookie('key', config, expires=expires)
80 |
81 | # return our response
82 | return response
83 |
--------------------------------------------------------------------------------
/apprise_api/core/middleware/theme.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | #
26 | from django.conf import settings
27 | from core.themes import SiteTheme, SITE_THEMES
28 | import datetime
29 |
30 |
31 | class AutoThemeMiddleware:
32 | """
33 | Using the `theme=` variable, allow one to fix the language to either
34 | 'dark' or 'light'
35 |
36 | """
37 |
38 | def __init__(self, get_response):
39 | """
40 | Prepare our initialization
41 | """
42 | self.get_response = get_response
43 |
44 | def __call__(self, request):
45 | """
46 | Define our middleware hook
47 | """
48 |
49 | # Our current theme
50 | current_theme = \
51 | request.COOKIES.get('t', request.COOKIES.get(
52 | 'theme', settings.APPRISE_DEFAULT_THEME))
53 |
54 | # Extract our theme (fall back to our default if not set)
55 | theme = request.GET.get("theme", current_theme).strip().lower()
56 | theme = next((entry for entry in SITE_THEMES
57 | if entry.startswith(theme)), None) \
58 | if theme else None
59 |
60 | if theme not in SITE_THEMES:
61 | # Fallback to default theme
62 | theme = SiteTheme.LIGHT
63 |
64 | # Set our theme to a cookie
65 | request.theme = theme
66 |
67 | # Set our next theme
68 | request.next_theme = SiteTheme.LIGHT \
69 | if theme == SiteTheme.DARK \
70 | else SiteTheme.DARK
71 |
72 | # Get our response object
73 | response = self.get_response(request)
74 |
75 | # Set our cookie
76 | max_age = 365 * 24 * 60 * 60 # 1 year
77 | expires = datetime.datetime.utcnow() + \
78 | datetime.timedelta(seconds=max_age)
79 |
80 | # Set our cookie
81 | response.set_cookie('theme', theme, expires=expires)
82 |
83 | # return our response
84 | return response
85 |
--------------------------------------------------------------------------------
/apprise_api/core/settings/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import os
26 | from core.themes import SiteTheme
27 |
28 | # Disable Timezones
29 | USE_TZ = False
30 |
31 | # Base Directory (relative to settings)
32 | BASE_DIR = os.path.dirname(
33 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
34 |
35 | # SECURITY WARNING: keep the secret key used in production secret!
36 | SECRET_KEY = os.environ.get(
37 | 'SECRET_KEY', '+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=')
38 |
39 | # SECURITY WARNING: don't run with debug turned on in production!
40 | # If you want to run this app in DEBUG mode, run the following:
41 | #
42 | # ./manage.py runserver --settings=core.settings.debug
43 | #
44 | # Or alternatively run:
45 | #
46 | # export DJANGO_SETTINGS_MODULE=core.settings.debug
47 | # ./manage.py runserver
48 | #
49 | # Support 'yes', '1', 'true', 'enable', 'active', and +
50 | DEBUG = os.environ.get("DEBUG", 'No')[0].lower() in (
51 | 'a', 'y', '1', 't', 'e', '+')
52 |
53 | # allow all hosts by default otherwise read from the
54 | # ALLOWED_HOSTS environment variable
55 | ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(' ')
56 |
57 | # Application definition
58 | INSTALLED_APPS = [
59 | 'django.contrib.contenttypes',
60 | 'django.contrib.staticfiles',
61 |
62 | # Apprise API
63 | 'api',
64 |
65 | # Prometheus
66 | 'django_prometheus',
67 | ]
68 |
69 | MIDDLEWARE = [
70 | 'django_prometheus.middleware.PrometheusBeforeMiddleware',
71 | 'django.middleware.common.CommonMiddleware',
72 | 'core.middleware.theme.AutoThemeMiddleware',
73 | 'core.middleware.config.DetectConfigMiddleware',
74 | 'django_prometheus.middleware.PrometheusAfterMiddleware',
75 | ]
76 |
77 | ROOT_URLCONF = 'core.urls'
78 |
79 | TEMPLATES = [
80 | {
81 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
82 | 'DIRS': [],
83 | 'APP_DIRS': True,
84 | 'OPTIONS': {
85 | 'context_processors': [
86 | 'django.template.context_processors.request',
87 | 'core.context_processors.base_url',
88 | 'api.context_processors.default_config_id',
89 | 'api.context_processors.unique_config_id',
90 | 'api.context_processors.stateful_mode',
91 | 'api.context_processors.config_lock',
92 | 'api.context_processors.admin_enabled',
93 | 'api.context_processors.apprise_version',
94 | ],
95 | },
96 | },
97 | ]
98 |
99 | LOGGING = {
100 | 'version': 1,
101 | 'disable_existing_loggers': True,
102 | 'formatters': {
103 | 'standard': {
104 | 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
105 | },
106 | },
107 | 'handlers': {
108 | 'console': {
109 | 'class': 'logging.StreamHandler',
110 | 'formatter': 'standard'
111 | },
112 | },
113 | 'loggers': {
114 | 'django': {
115 | 'handlers': ['console'],
116 | 'level': os.environ.get(
117 | 'LOG_LEVEL', 'debug' if DEBUG else 'info').upper(),
118 | },
119 | 'apprise': {
120 | 'handlers': ['console'],
121 | 'level': os.environ.get(
122 | 'LOG_LEVEL', 'debug' if DEBUG else 'info').upper(),
123 | },
124 | }
125 | }
126 |
127 |
128 | WSGI_APPLICATION = 'core.wsgi.application'
129 |
130 | # Define our base URL
131 | # The default value is to be a single slash
132 | BASE_URL = os.environ.get('BASE_URL', '')
133 |
134 | # Define our default configuration ID to use
135 | APPRISE_DEFAULT_CONFIG_ID = \
136 | os.environ.get('APPRISE_DEFAULT_CONFIG_ID', 'apprise')
137 |
138 | # Define our Prometheus Namespace
139 | PROMETHEUS_METRIC_NAMESPACE = "apprise"
140 |
141 | # Static files relative path (CSS, JavaScript, Images)
142 | STATIC_URL = BASE_URL + '/s/'
143 |
144 | # Default theme can be either 'light' or 'dark'
145 | APPRISE_DEFAULT_THEME = \
146 | os.environ.get('APPRISE_DEFAULT_THEME', SiteTheme.LIGHT)
147 |
148 | # Webhook that is posted to upon executed results
149 | # Set it to something like https://myserver.com/path/
150 | # Requets are done as a POST
151 | APPRISE_WEBHOOK_URL = os.environ.get('APPRISE_WEBHOOK_URL', '')
152 |
153 | # The location to store Apprise configuration files
154 | APPRISE_CONFIG_DIR = os.environ.get(
155 | 'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config'))
156 |
157 | # The location to store Apprise Persistent Storage files
158 | APPRISE_STORAGE_DIR = os.environ.get(
159 | 'APPRISE_STORAGE_DIR', os.path.join(APPRISE_CONFIG_DIR, 'store'))
160 |
161 | # Default number of days to prune persistent storage
162 | APPRISE_STORAGE_PRUNE_DAYS = \
163 | int(os.environ.get('APPRISE_STORAGE_PRUNE_DAYS', 30))
164 |
165 | # The default URL ID Length
166 | APPRISE_STORAGE_UID_LENGTH = \
167 | int(os.environ.get('APPRISE_STORAGE_UID_LENGTH', 8))
168 |
169 | # The default storage mode; options are:
170 | # - memory : Disables persistent storage (this is also automatically set
171 | # if the APPRISE_STORAGE_DIR is empty reguardless of what is
172 | # defined below.
173 | # - auto : Writes to storage after each notifications execution (default)
174 | # - flush : Writes to storage constantly (as much as possible). This
175 | # produces more i/o but can allow multiple calls to the same
176 | # notification to be in sync more
177 | APPRISE_STORAGE_MODE = os.environ.get('APPRISE_STORAGE_MODE', 'auto').lower()
178 |
179 | # The location to place file attachments
180 | APPRISE_ATTACH_DIR = os.environ.get(
181 | 'APPRISE_ATTACH_DIR', os.path.join(BASE_DIR, 'var', 'attach'))
182 |
183 | # The maximum file attachment size allowed by the API (defined in MB)
184 | APPRISE_ATTACH_SIZE = int(os.environ.get('APPRISE_ATTACH_SIZE', 200)) * 1048576
185 |
186 | # A provided list that identify all of the URLs/Hosts/IPs that Apprise can
187 | # retrieve remote attachments from.
188 | #
189 | # Processing Order:
190 | # - The DENY list is always processed before the ALLOW list.
191 | # * If a match is found, processing stops, and the URL attachment is ignored
192 | # - The ALLOW list is ONLY processed if there was no match found on the DENY list
193 | # - If there is no match found on the ALLOW List, then the Attachment URL is ignored
194 | # * Not matching anything on the ALLOW list is effectively treated as a DENY
195 | #
196 | # Lists are both processed from top down (stopping on first match)
197 |
198 | # Use the following rules when constructing your ALLOW/DENY entries:
199 | # - Entries are separated with either a comma (,) and/or a space
200 | # - Entries can start with http:// or https:// (enforcing URL security HTTPS as part of check)
201 | # - IPs or hostnames provided is the preferred approach if it doesn't matter if the entry is
202 | # https:// or http://
203 | # - * wildcards are allowed. * means nothing or anything else that follows.
204 | # - ? placeholder wildcards are allowed (identifying the explicit placeholder
205 | # of an alpha/numeric/dash/underscore character)
206 | #
207 | # Notes of interest:
208 | # - If the list is empty, then attachments can not be retrieved via URL at all.
209 | # - If the URL to be attached is not found or matched against an entry in this list then
210 | # the URL based attachment is ignored and is not retrieved at all.
211 | # - Set the list to * (a single astrix) to match all URLs and accepting all provided
212 | # matches
213 | APPRISE_ATTACH_DENY_URLS = \
214 | os.environ.get('APPRISE_ATTACH_REJECT_URL', '127.0.* localhost*').lower()
215 |
216 | # The Allow list which is processed after the Deny list above
217 | APPRISE_ATTACH_ALLOW_URLS = \
218 | os.environ.get('APPRISE_ATTACH_ALLOW_URL', '*').lower()
219 |
220 |
221 | # The maximum size in bytes that a request body may be before raising an error
222 | # (defined in MB)
223 | DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get(
224 | 'APPRISE_UPLOAD_MAX_MEMORY_SIZE', 3))) * 1048576
225 |
226 | # When set Apprise API Locks itself down so that future (configuration)
227 | # changes can not be made or accessed. It disables access to:
228 | # - the configuration screen: /cfg/{token}
229 | # - this in turn makes it so the Apprise CLI tool can not use it's
230 | # --config= (-c) options against this server.
231 | # - All notifications (both persistent and non persistent) continue to work
232 | # as they did before. This includes both /notify/{token}/ and /notify/
233 | # - Certain API calls no longer work such as:
234 | # - /del/{token}/
235 | # - /add/{token}/
236 | # - the /json/urls/{token} API location will continue to work but will always
237 | # enforce it's privacy mode.
238 | #
239 | # The idea here is that someone has set up the configuration they way they want
240 | # and do not want this information exposed any more then it needs to be.
241 | # it's a lock down mode if you will.
242 | APPRISE_CONFIG_LOCK = \
243 | os.environ.get("APPRISE_CONFIG_LOCK", 'no')[0].lower() in (
244 | 'a', 'y', '1', 't', 'e', '+')
245 |
246 | # Stateless posts to /notify/ will resort to this set of URLs if none
247 | # were otherwise posted with the URL request.
248 | APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '')
249 |
250 | # Allow stateless URLS to generate and/or work with persistent storage
251 | # By default this is set to no
252 | APPRISE_STATELESS_STORAGE = \
253 | os.environ.get("APPRISE_STATELESS_STORAGE", 'no')[0].lower() in (
254 | 'a', 'y', '1', 't', 'e', '+')
255 |
256 | # Defines the stateful mode; possible values are:
257 | # - hash (default): content is hashed and zipped
258 | # - simple: content is just written straight to disk 'as-is'
259 | # - disabled: disable all stateful functionality
260 | APPRISE_STATEFUL_MODE = os.environ.get('APPRISE_STATEFUL_MODE', 'hash')
261 |
262 | # Our Apprise Deny List
263 | # - By default we disable all non-remote calling services
264 | # - You do not need to identify every schema supported by the service you
265 | # wish to disable (only one). For example, if you were to specify
266 | # xml, that would include the xmls entry as well (or vs versa)
267 | APPRISE_DENY_SERVICES = os.environ.get('APPRISE_DENY_SERVICES', ','.join((
268 | 'windows', 'dbus', 'gnome', 'macosx', 'syslog')))
269 |
270 | # Our Apprise Exclusive Allow List
271 | # - anything not identified here is denied/disabled)
272 | # - this list trumps the APPRISE_DENY_SERVICES identified above
273 | APPRISE_ALLOW_SERVICES = os.environ.get('APPRISE_ALLOW_SERVICES', '')
274 |
275 | # Define the number of recursive calls your system will allow users to make
276 | # The idea here is to prevent people from defining apprise:// URL's triggering
277 | # a call to the same server again, and again and again. By default we allow
278 | # 1 level of recursion
279 | APPRISE_RECURSION_MAX = int(os.environ.get('APPRISE_RECURSION_MAX', 1))
280 |
281 | # Provided optional plugin paths to scan for custom schema definitions
282 | APPRISE_PLUGIN_PATHS = os.environ.get(
283 | 'APPRISE_PLUGIN_PATHS', os.path.join(BASE_DIR, 'var', 'plugin')).split(',')
284 |
285 | # Define the number of attachments that can exist as part of a payload
286 | # Setting this to zero disables the limit
287 | 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', '+')
294 |
--------------------------------------------------------------------------------
/apprise_api/core/settings/debug/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 |
26 | # To create a valid debug settings.py we need to intentionally pollute our
27 | # file with all of the content found in the master configuration.
28 | import os
29 | from .. import * # noqa F403
30 |
31 | # Debug is always on when running in debug mode
32 | DEBUG = True
33 |
34 | # Allowed hosts is not required in debug mode
35 | ALLOWED_HOSTS = []
36 |
37 | # Over-ride the default URLConf for debugging
38 | ROOT_URLCONF = 'core.settings.debug.urls'
39 |
40 | # Our static paths directory for serving
41 | STATICFILES_DIRS = (
42 | os.path.join(BASE_DIR, 'static'), # noqa F405
43 | )
44 |
--------------------------------------------------------------------------------
/apprise_api/core/settings/debug/urls.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.conf import settings
26 | from django.conf.urls.static import static
27 | from ...urls import * # noqa F403
28 |
29 | # Extend our patterns
30 | urlpatterns += static(settings.STATIC_URL) # noqa F405
31 |
--------------------------------------------------------------------------------
/apprise_api/core/settings/pytest/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 |
26 | # To create a valid debug settings.py we need to intentionally pollute our
27 | # file with all of the content found in the master configuration.
28 | from tempfile import TemporaryDirectory
29 | from .. import * # noqa F403
30 |
31 | # Debug is always on when running in debug mode
32 | DEBUG = True
33 |
34 | # Allowed hosts is not required in debug mode
35 | ALLOWED_HOSTS = []
36 |
37 | # A temporary directory to work in for unit testing
38 | APPRISE_CONFIG_DIR = TemporaryDirectory().name
39 |
40 | # Setup our runner
41 | TEST_RUNNER = 'core.settings.pytest.runner.PytestTestRunner'
42 |
--------------------------------------------------------------------------------
/apprise_api/core/settings/pytest/runner.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 |
26 | # To create a valid debug settings.py we need to intentionally pollute our
27 | # file with all of the content found in the master configuration.
28 |
29 |
30 | class PytestTestRunner(object):
31 | """Runs pytest to discover and run tests."""
32 |
33 | def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs):
34 | self.verbosity = verbosity
35 | self.failfast = failfast
36 | self.keepdb = keepdb
37 |
38 | def run_tests(self, test_labels):
39 | """Run pytest and return the exitcode.
40 |
41 | It translates some of Django's test command option to pytest's.
42 | """
43 | import pytest
44 |
45 | argv = []
46 | if self.verbosity == 0:
47 | argv.append('--quiet')
48 | if self.verbosity == 2:
49 | argv.append('--verbose')
50 | if self.verbosity == 3:
51 | argv.append('-vv')
52 | if self.failfast:
53 | argv.append('--exitfirst')
54 | if self.keepdb:
55 | argv.append('--reuse-db')
56 |
57 | argv.extend(test_labels)
58 | return pytest.main(argv)
59 |
--------------------------------------------------------------------------------
/apprise_api/core/themes.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 |
26 |
27 | class SiteTheme:
28 | """
29 | Defines our site themes
30 | """
31 | LIGHT = 'light'
32 | DARK = 'dark'
33 |
34 |
35 | SITE_THEMES = (
36 | SiteTheme.LIGHT,
37 | SiteTheme.DARK,
38 | )
39 |
--------------------------------------------------------------------------------
/apprise_api/core/urls.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | from django.urls import path
26 | from django.conf.urls import include
27 |
28 | from api import urls as api_urls
29 |
30 | urlpatterns = [
31 | path('', include(api_urls)),
32 | path('', include('django_prometheus.urls')),
33 | ]
34 |
--------------------------------------------------------------------------------
/apprise_api/core/wsgi.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2019 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import os
26 | from django.core.wsgi import get_wsgi_application
27 |
28 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
29 | application = get_wsgi_application()
30 |
--------------------------------------------------------------------------------
/apprise_api/etc/nginx.conf:
--------------------------------------------------------------------------------
1 | daemon off;
2 | worker_processes auto;
3 | pid /run/apprise/nginx.pid;
4 | include /etc/nginx/modules-enabled/*.conf;
5 |
6 | events {
7 | worker_connections 768;
8 | }
9 |
10 | http {
11 | ##
12 | # Basic Settings
13 | ##
14 | sendfile on;
15 | tcp_nopush on;
16 | types_hash_max_size 2048;
17 | include /etc/nginx/mime.types;
18 | default_type application/octet-stream;
19 | # Do not display Nginx Version
20 | server_tokens off;
21 |
22 | ##
23 | # Upload Restriction
24 | ##
25 | client_max_body_size 500M;
26 |
27 | ##
28 | # Logging Settings
29 | ##
30 | access_log /dev/stdout;
31 | error_log /dev/stdout info;
32 |
33 | ##
34 | # Gzip Settings
35 | ##
36 | gzip on;
37 |
38 | ##
39 | # Host Configuration
40 | ##
41 | client_body_buffer_size 256k;
42 | client_body_in_file_only off;
43 |
44 | server {
45 | listen 8000; # IPv4 Support
46 | listen [::]:8000; # IPv6 Support
47 |
48 | # Allow users to map to this file and provide their own custom
49 | # overrides such as
50 | include /etc/nginx/server-override.conf;
51 |
52 | # Main Website
53 | location / {
54 | include /etc/nginx/uwsgi_params;
55 | proxy_set_header Host $http_host;
56 | proxy_set_header X-Real-IP $remote_addr;
57 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
58 | proxy_pass http://localhost:8080;
59 | # Give ample time for notifications to fire
60 | proxy_read_timeout 120s;
61 | include /etc/nginx/location-override.conf;
62 | }
63 |
64 | # Static Content
65 | location /s/ {
66 | root /usr/share/nginx/html;
67 | index index.html;
68 | include /etc/nginx/location-override.conf;
69 | }
70 |
71 | # 404 error handling
72 | error_page 404 /404.html;
73 |
74 | # redirect server error pages to the static page /50x.html
75 | error_page 500 502 503 504 /50x.html;
76 | location = /50x.html {
77 | root /usr/share/nginx/html;
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/apprise_api/etc/supervisord.conf:
--------------------------------------------------------------------------------
1 | [supervisord]
2 | nodaemon=true
3 | pidfile=/run/apprise/supervisord.pid
4 | logfile=/dev/null
5 | logfile_maxbytes=0
6 | user=www-data
7 | group=www-data
8 |
9 | [program:nginx]
10 | command=/usr/sbin/nginx -c /opt/apprise/webapp/etc/nginx.conf -p /opt/apprise
11 | directory=/opt/apprise
12 | stdout_logfile=/dev/stdout
13 | stdout_logfile_maxbytes=0
14 | stderr_logfile=/dev/stderr
15 | stderr_logfile_maxbytes=0
16 |
17 | [program:gunicorn]
18 | command=gunicorn -c /opt/apprise/webapp/gunicorn.conf.py -b :8080 --worker-tmp-dir /dev/shm core.wsgi
19 | directory=/opt/apprise/webapp
20 | stdout_logfile=/dev/stdout
21 | stdout_logfile_maxbytes=0
22 | stderr_logfile=/dev/stderr
23 | stderr_logfile_maxbytes=0
24 |
--------------------------------------------------------------------------------
/apprise_api/gunicorn.conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Copyright (C) 2023 Chris Caron
4 | # All rights reserved.
5 | #
6 | # This code is licensed under the MIT License.
7 | #
8 | # Permission is hereby granted, free of charge, to any person obtaining a copy
9 | # of this software and associated documentation files(the "Software"), to deal
10 | # in the Software without restriction, including without limitation the rights
11 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12 | # copies of the Software, and to permit persons to whom the Software is
13 | # furnished to do so, subject to the following conditions :
14 | #
15 | # The above copyright notice and this permission notice shall be included in
16 | # all copies or substantial portions of the Software.
17 | #
18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | # THE SOFTWARE.
25 | import os
26 | import multiprocessing
27 |
28 | # This file is launched with the call:
29 | # gunicorn --config core.wsgi:application
30 |
31 | raw_env = [
32 | 'LANG={}'.format(os.environ.get('LANG', 'en_US.UTF-8')),
33 | 'DJANGO_SETTINGS_MODULE=core.settings',
34 | ]
35 |
36 | # This is the path as prepared in the docker compose
37 | pythonpath = '/opt/apprise/webapp'
38 |
39 | # bind to port 8000
40 | bind = [
41 | '0.0.0.0:8000', # IPv4 Support
42 | '[::]:8000', # IPv6 Support
43 | ]
44 |
45 | # Workers are relative to the number of CPU's provided by hosting server
46 | workers = int(os.environ.get(
47 | 'APPRISE_WORKER_COUNT', multiprocessing.cpu_count() * 2 + 1))
48 |
49 | # Increase worker timeout value to give upstream services time to
50 | # respond.
51 | timeout = int(os.environ.get('APPRISE_WORKER_TIMEOUT', 300))
52 |
53 | # Our worker type to use; over-ride the default `sync`
54 | worker_class = 'gevent'
55 |
56 | # Get workers memory consumption under control by leveraging gunicorn
57 | # worker recycling timeout
58 | max_requests = 1000
59 | max_requests_jitter = 50
60 |
61 | # Logging
62 | # '-' means log to stdout.
63 | errorlog = '-'
64 | accesslog = '-'
65 | loglevel = 'warn'
66 |
--------------------------------------------------------------------------------
/apprise_api/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | #
4 | # Copyright (C) 2019 Chris Caron
5 | # All rights reserved.
6 | #
7 | # This code is licensed under the MIT License.
8 | #
9 | # Permission is hereby granted, free of charge, to any person obtaining a copy
10 | # of this software and associated documentation files(the "Software"), to deal
11 | # in the Software without restriction, including without limitation the rights
12 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
13 | # copies of the Software, and to permit persons to whom the Software is
14 | # furnished to do so, subject to the following conditions :
15 | #
16 | # The above copyright notice and this permission notice shall be included in
17 | # all copies or substantial portions of the Software.
18 | #
19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 | # THE SOFTWARE.
26 | import os
27 | import sys
28 |
29 |
30 | def main():
31 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug')
32 | try:
33 | from django.core.management import execute_from_command_line
34 | except ImportError as exc:
35 | raise ImportError(
36 | "Couldn't import Django. Are you sure it's installed and "
37 | "available on your PYTHONPATH environment variable? Did you "
38 | "forget to activate a virtual environment?"
39 | ) from exc
40 | execute_from_command_line(sys.argv)
41 |
42 |
43 | if __name__ == '__main__':
44 | main()
45 |
--------------------------------------------------------------------------------
/apprise_api/static/css/base.css:
--------------------------------------------------------------------------------
1 | .nav h1 {
2 | margin: 0.4rem;
3 | font-size: 2.1rem;
4 | font-weight: bold;
5 | text-transform: uppercase;
6 | float: left;
7 | }
8 |
9 | /* Apprise Version */
10 | .nav ul {
11 | float: right;
12 | font-style: normal;
13 | font-size: 0.7rem;
14 | }
15 | .theme {
16 | text-align: right;
17 | display: block;
18 | float:right;
19 | }
20 |
21 | input {
22 | display: block;
23 | }
24 |
25 | .tabs .tab.disabled a,.tabs .tab.disabled a:hover{font-weight: inherit}
26 |
27 | code {
28 | font-family: monospace;
29 | white-space: normal;
30 | padding: 0.2rem;
31 | }
32 |
33 | h1, h2, h3, h4, h5 {
34 | margin-top: 0;
35 | }
36 |
37 | td, th {
38 | vertical-align: top;
39 | padding-top: 0;
40 | }
41 | .api-details ol {
42 | margin-top: 0;
43 | margin-bottom: 0;
44 | }
45 |
46 | ul.detail-buttons strong {
47 | font-weight: 800;
48 | }
49 |
50 | h4 em {
51 | font-size: 2.0rem;
52 | display: inline-block;
53 | margin: 0;
54 | padding: 0;
55 | word-break: break-all;
56 | line-height: 1.0em;
57 | }
58 |
59 | em {
60 | color: #004d40;
61 | font-weight:bold;
62 | }
63 |
64 | .no-config .info {
65 | color: #004d40;
66 | font-size: 3.3rem;
67 |
68 | }
69 |
70 | textarea {
71 | height: 16rem;
72 | font-family: monospace;
73 | }
74 |
75 | .collapsible-body {
76 | padding: 1rem 2rem;
77 | }
78 |
79 | #overview strong {
80 | color: #004d40;
81 | display: inline-block;
82 | background-color: #eee;
83 | }
84 |
85 | .tabs .tab a{
86 | border-radius: 25px 25px 0 0;
87 | color:#2bbbad;
88 | }
89 | .collection a.collection-item:not(.active):hover,
90 | .tabs .tab a:focus, .tabs .tab a:focus.active {
91 | background-color: #eee;
92 | }
93 | .tabs .tab a:hover,.tabs .tab a.active {
94 | background-color:transparent;
95 | color:#004d40;
96 | font-weight: bold;
97 | background-color: #eee;
98 | }
99 | .tabs .tab.disabled a,.tabs .tab.disabled a:hover {
100 | color:rgba(102,147,153,0.7);
101 | }
102 | .tabs .indicator {
103 | background-color:#004d40;
104 | }
105 | .tabs .tab-locked a {
106 | /* Handle locked out tabs */
107 | color:rgba(212, 161, 157, 0.7);
108 | }
109 | .tabs .tab-locked a:hover,.tabs .tab-locked a.active {
110 | /* Handle locked out tabs */
111 | color: #6b0900;
112 | }
113 |
114 | .material-icons{
115 | display: inline-flex;
116 | vertical-align: middle;
117 | }
118 |
119 | #url-list .card-panel {
120 | padding: 0.5rem;
121 | margin: 0.1rem 0;
122 | border-radius: 12px;
123 | width: 50%;
124 | min-width: 35rem;
125 | min-height: 4em;
126 | float: left;
127 | position: relative;
128 | }
129 |
130 | .chip {
131 | margin: 0.3rem;
132 | background-color: inherit;
133 | border: 1px solid #464646;
134 | color: #464646;
135 | cursor: pointer;
136 | }
137 |
138 |
139 | #url-list code {
140 | overflow-x: hidden;
141 | overflow-y: hidden;
142 | white-space: wrap;
143 | text-wrap: wrap;
144 | overflow-wrap: break-word;
145 | border-radius: 5px;
146 | margin-top: 0.8rem;
147 | display: block;
148 | }
149 |
150 | #url-list .card-panel .url-id {
151 | width: auto;
152 | margin: 0.3rem;
153 | background-color: inherit;
154 | color: #aaa;
155 | padding: 0.2em;
156 | font-size: 0.7em;
157 | border: 0px;
158 | /* Top Justified */
159 | position: absolute;
160 | top: -0.5em;
161 | right: 0.3em;
162 | }
163 |
164 | /* Notification Details */
165 | ul.logs {
166 | font-family: monospace, monospace;
167 | height: 60%;
168 | overflow: auto;
169 | }
170 |
171 | ul.logs li {
172 | display: flex;
173 | text-align: left;
174 | width: 50em;
175 | }
176 |
177 | ul.logs li div.log_time {
178 | font-weight: normal;
179 | flex: 0 15em;
180 | }
181 |
182 | ul.logs li div.log_level {
183 | font-weight: bold;
184 | align: right;
185 | flex: 0 5em;
186 | }
187 |
188 | ul.logs li div.log_msg {
189 | flex: 1;
190 | }
191 |
192 | .url-enabled {
193 | color:#004d40;
194 | }
195 | .url-disabled {
196 | color: #8B0000;
197 | }
198 | h6 {
199 | font-weight: bold;
200 | }
201 | #overview pre {
202 | margin-left: 2.0rem
203 | }
204 |
205 | code.config-id {
206 | font-size: 0.7em;
207 | }
208 |
209 | /* file button styled */
210 | .btn-file {
211 | position: relative;
212 | overflow: hidden;
213 | text-transform: uppercase;
214 | }
215 | .btn-file input[type=file] {
216 | position: absolute;
217 | top: 0;
218 | right: 0;
219 | min-width: 100%;
220 | min-height: 100%;
221 | font-size: 100px;
222 | text-align: right;
223 | filter: alpha(opacity=0);
224 | opacity: 0;
225 | outline: none;
226 | background: white;
227 | cursor: inherit;
228 | display: block;
229 | }
230 |
231 | .file-selected {
232 | line-height: 2.0em;
233 | font-size: 1.2rem;
234 | border-radius: 5px;
235 | padding: 0 1em;
236 | overflow: hidden;
237 | }
238 |
239 | .chip.selected {
240 | font-weight: 600;
241 | }
242 |
243 | #health-check {
244 | background-color: #f883791f;
245 | border-radius: 25px;
246 | padding: 2em;
247 | margin-bottom: 2em;
248 | }
249 | #health-check h4 {
250 | font-size: 30px;
251 | }
252 | #health-check h4 .material-icons {
253 | margin-top: -0.2em;
254 | }
255 |
256 | #health-check li .material-icons {
257 | font-size: 30px;
258 | margin-top: -0.2em;
259 | }
260 |
261 | #health-check ul {
262 | list-style-type: disc;
263 | padding-left: 2em;
264 | }
265 |
266 | #health-check ul strong {
267 | font-weight: 600;
268 | font-size: 1.2rem;
269 | display: block;
270 | }
271 |
272 |
--------------------------------------------------------------------------------
/apprise_api/static/css/highlight.min.css:
--------------------------------------------------------------------------------
1 | /* GitHub highlight.js style (c) Ivan Sagalaev */
2 | .nav.nav-color{background:#8fbcbb!important}.hljs{display:block;overflow-x:auto;padding:.5em;color:#333;background:#f8f8f8}.hljs-comment,.hljs-quote{color:#998;font-style:italic}.hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#333;font-weight:700}.hljs-literal,.hljs-number,.hljs-tag .hljs-attr,.hljs-template-variable,.hljs-variable{color:teal}.hljs-doctag,.hljs-string{color:#d14}.hljs-section,.hljs-selector-id,.hljs-title{color:#900;font-weight:700}.hljs-subst{font-weight:400}.hljs-class .hljs-title,.hljs-type{color:#458;font-weight:700}.hljs-attribute,.hljs-name,.hljs-tag{color:navy;font-weight:400}.hljs-link,.hljs-regexp{color:#009926}.hljs-bullet,.hljs-symbol{color:#990073}.hljs-built_in,.hljs-builtin-name{color:#0086b3}.hljs-meta{color:#999;font-weight:700}.hljs-deletion{background:#fdd}.hljs-addition{background:#dfd}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}a i {color: #333}
3 |
--------------------------------------------------------------------------------
/apprise_api/static/css/theme-light.min.css:
--------------------------------------------------------------------------------
1 | .tabs .tab a {background-color: #f3f3f3;}
2 | .tabs.tabs-transparent .tab a,
3 | .tabs.tabs-transparent .tab.disabled a,
4 | .tabs.tabs-transparent .tab.disabled a:hover,
5 | .tab.disabled i.material-icons{
6 | color:#a7a7a7
7 | }
8 | .tabs .tab.disabled a,
9 | .tabs .tab.disabled a:hover {
10 | background-color: #f3f3f3;
11 | color:#a7a7a7
12 | }
13 | .file-selected {
14 | color: #039be5;
15 | background-color: #f3f3f3;
16 | }
17 |
18 | #url-list .card-panel.selected {
19 | background-color: #fff8dc;
20 | }
21 |
22 | .chip {
23 | background-color: #fff!important;
24 | }
25 |
26 | .chip.selected {
27 | color: #fff;
28 | background-color: #258528!important;
29 | }
30 |
31 |
32 | ul.logs li.log_INFO {
33 | color: black;
34 | }
35 |
36 | ul.logs li.log_DEBUG {
37 | color: #606060;
38 | }
39 |
40 | ul.logs li.log_WARNING {
41 | color: orange;
42 | }
43 |
44 | ul.logs li.log_ERROR {
45 | color: #8B0000;
46 | }
47 |
--------------------------------------------------------------------------------
/apprise_api/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/static/favicon.ico
--------------------------------------------------------------------------------
/apprise_api/static/iconfont/MaterialIcons-Regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/static/iconfont/MaterialIcons-Regular.eot
--------------------------------------------------------------------------------
/apprise_api/static/iconfont/MaterialIcons-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/static/iconfont/MaterialIcons-Regular.ttf
--------------------------------------------------------------------------------
/apprise_api/static/iconfont/MaterialIcons-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/static/iconfont/MaterialIcons-Regular.woff
--------------------------------------------------------------------------------
/apprise_api/static/iconfont/MaterialIcons-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/static/iconfont/MaterialIcons-Regular.woff2
--------------------------------------------------------------------------------
/apprise_api/static/iconfont/README.md:
--------------------------------------------------------------------------------
1 | The recommended way to use the Material Icons font is by linking to the web font hosted on Google Fonts:
2 |
3 | ```html
4 |
6 | ```
7 |
8 | Read more in our full usage guide:
9 | http://google.github.io/material-design-icons/#icon-font-for-the-web
10 |
--------------------------------------------------------------------------------
/apprise_api/static/iconfont/material-icons.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Material Icons';
3 | font-style: normal;
4 | font-weight: 400;
5 | src: url(MaterialIcons-Regular.eot); /* For IE6-8 */
6 | src: local('Material Icons'),
7 | local('MaterialIcons-Regular'),
8 | url(MaterialIcons-Regular.woff2) format('woff2'),
9 | url(MaterialIcons-Regular.woff) format('woff'),
10 | url(MaterialIcons-Regular.ttf) format('truetype');
11 | }
12 |
13 | .material-icons {
14 | font-family: 'Material Icons';
15 | font-weight: normal;
16 | font-style: normal;
17 | font-size: 24px; /* Preferred icon size */
18 | display: inline-block;
19 | line-height: 1;
20 | text-transform: none;
21 | letter-spacing: normal;
22 | word-wrap: normal;
23 | white-space: nowrap;
24 | direction: ltr;
25 |
26 | /* Support for all WebKit browsers. */
27 | -webkit-font-smoothing: antialiased;
28 | /* Support for Safari and Chrome. */
29 | text-rendering: optimizeLegibility;
30 |
31 | /* Support for Firefox. */
32 | -moz-osx-font-smoothing: grayscale;
33 |
34 | /* Support for IE. */
35 | font-feature-settings: 'liga';
36 | }
37 |
--------------------------------------------------------------------------------
/apprise_api/static/licenses/highlight-9.17.1.LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2006, Ivan Sagalaev.
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | * Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | * Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | * Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/apprise_api/static/licenses/material-design-icons-3.0.1.LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
--------------------------------------------------------------------------------
/apprise_api/static/licenses/materialize-1.0.0.LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014-2018 Materialize
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apprise_api/static/licenses/sweetalert2-11.17.2.LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Tristan Edwards & Limon Monte
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/apprise_api/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caronc/apprise-api/3fde6d00914b6a538f9003c5d940b909fdce42a7/apprise_api/static/logo.png
--------------------------------------------------------------------------------
/apprise_api/supervisord-startup:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Copyright (C) 2024 Chris Caron
3 | # All rights reserved.
4 | #
5 | # This code is licensed under the MIT License.
6 | #
7 | # Permission is hereby granted, free of charge, to any person obtaining a copy
8 | # of this software and associated documentation files(the "Software"), to deal
9 | # in the Software without restriction, including without limitation the rights
10 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
11 | # copies of the Software, and to permit persons to whom the Software is
12 | # furnished to do so, subject to the following conditions :
13 | #
14 | # The above copyright notice and this permission notice shall be included in
15 | # all copies or substantial portions of the Software.
16 | #
17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 | # THE SOFTWARE.
24 |
25 | if [ $(id -u) -eq 0 ]; then
26 | #
27 | # Root User
28 | #
29 | echo "Apprise API Super User Startup"
30 |
31 | # Default values
32 | PUID=${PUID:=1000}
33 | PGID=${PGID:=1000}
34 |
35 | # lookup our identifier
36 | GROUP=$(getent group $PGID 2>/dev/null | cut -d: -f1)
37 | [ -z "$GROUP" ] && groupadd --force -g $PGID apprise &>/dev/null && \
38 | GROUP=apprise
39 |
40 | USER=$(id -un $PUID 2>/dev/null)
41 | [ $? -ne 0 ] && useradd -M -N \
42 | -o -u $PUID -G $GROUP -c "Apprise API User" -d /opt/apprise apprise && \
43 | USER=apprise
44 |
45 | if [ -z "$USER" ]; then
46 | echo "The specified User ID (PUID) of $PUID is invalid; Aborting operation."
47 | exit 1
48 |
49 | elif [ -z "$GROUP" ]; then
50 | echo "The specified Group ID (PGID) of $PGID is invalid; Aborting operation."
51 | exit 1
52 | fi
53 |
54 | # Ensure our group has been correctly assigned
55 | usermod -a -G $GROUP $USER &>/dev/null
56 | chmod o+w /dev/stdout /dev/stderr
57 |
58 | else
59 | #
60 | # Non-Root User
61 | #
62 | echo "Apprise API Non-Super User Startup"
63 | USER=$(id -un 2>/dev/null)
64 | GROUP=$(id -gn 2>/dev/null)
65 | fi
66 |
67 | [ ! -d /attach ] && mkdir -p /attach
68 | chown -R $USER:$GROUP /attach
69 | [ ! -d /config/store ] && mkdir -p /config/store
70 | chown $USER:$GROUP /config
71 | chown -R $USER:$GROUP /config/store
72 | [ ! -d /plugin ] && mkdir -p /plugin
73 | [ ! -d /run/apprise ] && mkdir -p /run/apprise
74 |
75 | # Some Directories require enforced permissions to play it safe
76 | chown $USER:$GROUP -R /run/apprise /var/lib/nginx /opt/apprise
77 | sed -i -e "s/^\(user[ \t]*=[ \t]*\).*$/\1$USER/g" \
78 | /opt/apprise/webapp/etc/supervisord.conf
79 | sed -i -e "s/^\(group[ \t]*=[ \t]*\).*$/\1$GROUP/g" \
80 | /opt/apprise/webapp/etc/supervisord.conf
81 |
82 | if [ "${IPV4_ONLY+x}" ] && [ "${IPV6_ONLY+x}" ]; then
83 | echo -n "Warning: both IPV4_ONLY and IPV6_ONLY flags set; ambigious; no changes made."
84 |
85 | # Handle IPV4_ONLY flag
86 | elif [ "${IPV4_ONLY+x}" ]; then
87 | echo -n "Enforcing Exclusive IPv4 environment... "
88 | sed -ibak -e '/IPv6 Support/d' /opt/apprise/webapp/etc/nginx.conf /opt/apprise/webapp/gunicorn.conf.py && \
89 | echo "Done." || echo "Failed!"
90 |
91 | # Handle IPV6_ONLY flag
92 | elif [ "${IPV6_ONLY+x}" ]; then
93 | echo -n "Enforcing Exclusive IPv6 environment... "
94 | sed -ibak -e '/IPv4 Support/d' /opt/apprise/webapp/etc/nginx.conf /opt/apprise/webapp/gunicorn.conf.py && \
95 | echo "Done." || echo "Failed!"
96 | fi
97 |
98 | # Working directory
99 | cd /opt/apprise
100 |
101 | # Launch our SupervisorD
102 | /usr/local/bin/supervisord -c /opt/apprise/webapp/etc/supervisord.conf
103 |
104 | # Always return our SupervisorD return code
105 | exit $?
106 |
--------------------------------------------------------------------------------
/apprise_api/var/plugin/README.md:
--------------------------------------------------------------------------------
1 | # Custom Plugin Directory
2 |
3 | Apprise supports the ability to define your own `schema://` definitions and load them. To read more about how you can create your own customizations, check out [this link here](https://github.com/caronc/apprise/wiki/decorator_notify).
4 |
--------------------------------------------------------------------------------
/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | flake8
2 | mock
3 | pytest-django
4 | pytest
5 | pytest-cov
6 | tox
7 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | apprise:
5 | build: .
6 | container_name: apprise
7 | environment:
8 | - APPRISE_STATEFUL_MODE=simple
9 | ports:
10 | - 8000:8000
11 | volumes:
12 | - ./apprise_api:/opt/apprise/webapp:ro
13 | # if uncommenting the below, you will need to type the following
14 | # Note: if you opt for bind mount config file consider setting env var APPRISE_STATEFUL_MODE to simple with appropriate file format
15 | # otherwise the django instance won't have permissions to write
16 | # to the directory correctly:
17 | # $> chown -R 33:33 ./config
18 | # $> chmod -R 775 ./config
19 | # - ./config:/config:rw
20 | # Note: The attachment directory can be exposed outside of the container if required
21 | # $> chown -R 33:33 ./attach
22 | # $> chmod -R 775 ./attach
23 | # - ./attach:/attach:rw
24 |
25 | ## Un-comment the below and then access a testing environment with:
26 | ## docker-compose run test.py310 build
27 | ## docker-compose run --service-ports --rm test.py310 bash
28 | ##
29 | ## From here you
30 | ## > Check for any lint errors
31 | ## flake8 apprise_api
32 | ##
33 | ## > Run unit tests
34 | ## pytest apprise_api
35 | ##
36 | ## > Host service (visit http://localhost on host pc to access):
37 | ## ./manage.py runserver 0.0.0.0:8000
38 | # test.py312:
39 | # ports:
40 | # - 8000:8000
41 | # build:
42 | # context: .
43 | # dockerfile: Dockerfile.py312
44 | # volumes:
45 | # - ./:/apprise-api
46 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | #
4 | # Copyright (C) 2019 Chris Caron
5 | # All rights reserved.
6 | #
7 | # This code is licensed under the MIT License.
8 | #
9 | # Permission is hereby granted, free of charge, to any person obtaining a copy
10 | # of this software and associated documentation files(the "Software"), to deal
11 | # in the Software without restriction, including without limitation the rights
12 | # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
13 | # copies of the Software, and to permit persons to whom the Software is
14 | # furnished to do so, subject to the following conditions :
15 | #
16 | # The above copyright notice and this permission notice shall be included in
17 | # all copies or substantial portions of the Software.
18 | #
19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 | # THE SOFTWARE.
26 | import os
27 | import sys
28 |
29 | # Update our path so it will see our apprise_api content
30 | sys.path.insert(
31 | 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'apprise_api'))
32 |
33 |
34 | def main():
35 | # Unless otherwise specified, default to a debug mode
36 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug')
37 | try:
38 | from django.core.management import execute_from_command_line
39 | except ImportError as exc:
40 | raise ImportError(
41 | "Couldn't import Django. Are you sure it's installed and "
42 | "available on your PYTHONPATH environment variable? Did you "
43 | "forget to activate a virtual environment?"
44 | ) from exc
45 | execute_from_command_line(sys.argv)
46 |
47 |
48 | if __name__ == '__main__':
49 | main()
50 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | ##
2 | ## Apprise Backend Installation
3 | ##
4 | ## You should only have 1 of the 3 items uncommented below.
5 |
6 | ## 1. Uncomment the below line to pull the main branch of Apprise:
7 | # apprise @ git+https://github.com/caronc/apprise
8 |
9 | ## 2. Uncomment the below line instead if you wish to focus on a tag:
10 | # apprise @ git+https://github.com/caronc/apprise@custom-tag-or-version
11 |
12 | ## 3. The below grabs our stable version (generally the best choice):
13 | apprise == 1.9.3
14 |
15 | ## Apprise API Minimum Requirements
16 | django
17 | gevent
18 | gunicorn
19 |
20 | ## for webhook support
21 | requests
22 |
23 | ## 3rd Party Service support
24 | paho-mqtt < 2.0.0
25 | gntp
26 | cryptography
27 |
28 | # prometheus metrics
29 | django-prometheus
30 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | # ensure LICENSE is included in wheel metadata
3 | license_file = LICENSE
4 |
5 | [flake8]
6 | # We exclude packages we don't maintain
7 | exclude = .eggs,.tox
8 | ignore = E741,E722,W503,W504,W605
9 | statistics = true
10 | builtins = _
11 | max-line-length = 160
12 |
13 | [aliases]
14 | test=pytest
15 |
16 | [tool:pytest]
17 | DJANGO_SETTINGS_MODULE = core.settings.pytest
18 | addopts = --ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing
19 | filterwarnings =
20 | once::Warning
21 |
--------------------------------------------------------------------------------
/swagger.yaml:
--------------------------------------------------------------------------------
1 | openapi: '3.0.3'
2 | info:
3 | title: Apprise API
4 | description: https://github.com/caronc/apprise-api
5 | version: 0.7.0
6 | paths:
7 | /notify:
8 | post:
9 | operationId: Stateless_SendNotification
10 | summary: Sends one or more notifications to the URLs identified as part of the payload, or those identified in the environment variable APPRISE_STATELESS_URLS.
11 | requestBody:
12 | required: true
13 | content:
14 | application/json:
15 | schema:
16 | $ref: '#/components/schemas/StatelessNotificationRequest'
17 | responses:
18 | 200:
19 | description: OK
20 | tags:
21 | - Stateless
22 | /add/{key}:
23 | post:
24 | operationId: Persistent_AddConfiguration
25 | summary: Saves Apprise Configuration (or set of URLs) to the persistent store.
26 | parameters:
27 | - in: path
28 | name: key
29 | required: true
30 | schema:
31 | type: string
32 | description: Configuration key
33 | requestBody:
34 | content:
35 | application/json:
36 | schema:
37 | $ref: '#/components/schemas/AddConfigurationRequest'
38 | responses:
39 | 200:
40 | description: OK
41 | tags:
42 | - Persistent
43 | /del/{key}:
44 | post:
45 | operationId: Persistent_RemoveConfiguration
46 | summary: Removes Apprise Configuration from the persistent store.
47 | parameters:
48 | - $ref: '#/components/parameters/key'
49 | responses:
50 | 200:
51 | description: OK
52 | tags:
53 | - Persistent
54 | /get/{key}:
55 | post:
56 | operationId: Persistent_GetConfiguration
57 | summary: Returns the Apprise Configuration from the persistent store.
58 | parameters:
59 | - $ref: '#/components/parameters/key'
60 | responses:
61 | 200:
62 | description: OK
63 | content:
64 | text/plain:
65 | schema:
66 | type: string
67 | tags:
68 | - Persistent
69 | /notify/{key}:
70 | post:
71 | operationId: Persistent_SendNotification
72 | summary: Sends notification(s) to all of the end points you've previously configured associated with a {KEY}.
73 | parameters:
74 | - $ref: '#/components/parameters/key'
75 | requestBody:
76 | content:
77 | application/json:
78 | schema:
79 | $ref: '#/components/schemas/PersistentNotificationRequest'
80 | responses:
81 | 200:
82 | description: OK
83 | tags:
84 | - Persistent
85 | /json/urls/{key}:
86 | get:
87 | operationId: Persistent_GetUrls
88 | summary: Returns a JSON response object that contains all of the URLS and Tags associated with the key specified.
89 | parameters:
90 | - $ref: '#/components/parameters/key'
91 | - in: query
92 | name: privacy
93 | schema:
94 | type: integer
95 | enum: [0, 1]
96 | # This should be changed to use 'oneOf' when upgrading to OpenApi 3.1
97 | x-enumNames: ["ShowSecrets", "HideSecrets"]
98 | required: false
99 | - in: query
100 | name: tag
101 | schema:
102 | type: string
103 | default: all
104 | required: false
105 | responses:
106 | 200:
107 | description: OK
108 | content:
109 | application/json:
110 | schema:
111 | $ref: '#/components/schemas/JsonUrlsResponse'
112 | tags:
113 | - Persistent
114 |
115 | components:
116 | parameters:
117 | key:
118 | in: path
119 | name: key
120 | required: true
121 | schema:
122 | type: string
123 | minLength: 1
124 | maxLength: 64
125 | description: Configuration key
126 | schemas:
127 | NotificationType:
128 | type: string
129 | enum: [info, warning, failure]
130 | default: info
131 | NotificationFormat:
132 | type: string
133 | enum: [text, markdown, html]
134 | default: text
135 | StatelessNotificationRequest:
136 | properties:
137 | urls:
138 | type: array
139 | items:
140 | type: string
141 | body:
142 | type: string
143 | title:
144 | type: string
145 | type:
146 | $ref: '#/components/schemas/NotificationType'
147 | format:
148 | $ref: '#/components/schemas/NotificationFormat'
149 | tag:
150 | type: string
151 | required:
152 | - body
153 | AddConfigurationRequest:
154 | properties:
155 | urls:
156 | type: array
157 | items:
158 | type: string
159 | default: null
160 | config:
161 | type: string
162 | format:
163 | type: string
164 | enum: [text, yaml]
165 | PersistentNotificationRequest:
166 | properties:
167 | body:
168 | type: string
169 | title:
170 | type: string
171 | type:
172 | $ref: '#/components/schemas/NotificationType'
173 | format:
174 | $ref: '#/components/schemas/NotificationFormat'
175 | tag:
176 | type: string
177 | default: all
178 | required:
179 | - body
180 | JsonUrlsResponse:
181 | properties:
182 | tags:
183 | type: array
184 | items:
185 | type: string
186 | urls:
187 | type: array
188 | items:
189 | type: object
190 | $ref: '#/components/schemas/url'
191 | url:
192 | properties:
193 | url:
194 | type: string
195 | tags:
196 | type: array
197 | items:
198 | type: string
199 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py312,coverage-report
3 | skipsdist = true
4 |
5 | [testenv]
6 | # Prevent random setuptools/pip breakages like
7 | # https://github.com/pypa/setuptools/issues/1042 from breaking our builds.
8 | setenv =
9 | VIRTUALENV_NO_DOWNLOAD=1
10 | deps=
11 | -r{toxinidir}/requirements.txt
12 | -r{toxinidir}/dev-requirements.txt
13 | commands =
14 | coverage run --parallel -m pytest {posargs} apprise_api
15 | flake8 apprise_api --count --show-source --statistics
16 |
17 | [testenv:py312]
18 | deps=
19 | -r{toxinidir}/requirements.txt
20 | -r{toxinidir}/dev-requirements.txt
21 | commands =
22 | coverage run --parallel -m pytest {posargs} apprise_api
23 | flake8 apprise_api --count --show-source --statistics
24 |
25 | [testenv:coverage-report]
26 | deps = coverage
27 | skip_install = true
28 | commands=
29 | coverage combine apprise_api
30 | coverage report apprise_api
31 |
--------------------------------------------------------------------------------