Skip to content

Commit f87f4c3

Browse files
committed
Persistent Storage Support (Apprise v1.9.0+) Added
1 parent e50b2db commit f87f4c3

File tree

15 files changed

+360
-27
lines changed

15 files changed

+360
-27
lines changed

Dockerfile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,17 @@ FROM base AS runtime
4747
COPY ./requirements.txt /etc/requirements.txt
4848
COPY --from=builder /build/*.whl ./
4949
RUN set -eux && \
50-
echo "Installing cryptography" && \
51-
pip3 install *.whl && \
52-
echo "Installing python requirements" && \
53-
pip3 install --no-cache-dir -q -r /etc/requirements.txt gunicorn supervisor && \
5450
echo "Installing nginx" && \
5551
apt-get update -qq && \
5652
apt-get install -y -qq \
5753
nginx && \
54+
echo "Installing cryptography" && \
55+
pip3 install *.whl && \
5856
echo "Installing tools" && \
5957
apt-get install -y -qq \
60-
curl sed && \
58+
curl sed git && \
59+
echo "Installing python requirements" && \
60+
pip3 install --no-cache-dir -q -r /etc/requirements.txt gunicorn supervisor && \
6161
echo "Cleaning up" && \
6262
apt-get --yes autoremove --purge && \
6363
apt-get clean --yes && \

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ Using [dockerhub](https://hub.docker.com/r/caronc/apprise) you can do the follow
4343
docker pull caronc/apprise:latest
4444

4545
# Start it up:
46-
# /config is used for a persistent store, you do not have to mount
47-
# this if you don't intend to use it.
46+
# /config/store is used for a persistent store, you do not have to mount
47+
# this if you don't intend to use it.
48+
# /config is used for a spot to write all of the configuration files
49+
# generated through the API
4850
# /plugin is used for a location you can add your own custom apprise plugins.
4951
# You do not have to mount this if you don't intend to use it.
5052
# /attach is used for file attachments
@@ -90,6 +92,18 @@ docker run --name apprise \
9092
-e APPRISE_WORKER_COUNT=1 \
9193
-v /etc/apprise:/config \
9294
-d apprise/local:latest
95+
96+
# Change your paths to what you want them to be, you may also wish to
97+
# just do the following:
98+
mkdir -p config store
99+
docker run --name apprise \
100+
-p 8000:8000 \
101+
-e PUID=$(id -u) \
102+
-e PGID=$(id -g) \
103+
-e APPRISE_STATEFUL_MODE=simple \
104+
-e APPRISE_WORKER_COUNT=1 \
105+
-v ./config:/config \
106+
-d apprise/local:latest
93107
```
94108
A `docker-compose.yml` file is already set up to grant you an instant production ready simulated environment:
95109

@@ -125,6 +139,7 @@ Below is a sample of just a simple text response:
125139
# one or more of the following separated by a comma:
126140
# - ATTACH_PERMISSION_ISSUE: Can not write attachments (likely a permission issue)
127141
# - CONFIG_PERMISSION_ISSUE: Can not write configuration (likely a permission issue)
142+
# - STORE_PERMISSION_ISSUE: Can not write to persistent storage (likely a permission issue)
128143
curl -X GET http://localhost:8000/status
129144
```
130145

@@ -383,6 +398,9 @@ The use of environment variables allow you to provide over-rides to default sett
383398
| `APPRISE_DEFAULT_THEME` | Can be set to `light` or `dark`; it defaults to `light` if not otherwise provided. The theme can be toggled from within the website as well.
384399
| `APPRISE_DEFAULT_CONFIG_ID` | Defaults to `apprise`. This is the presumed configuration ID you always default to when accessing the configuration manager via the website.
385400
| `APPRISE_CONFIG_DIR` | Defines an (optional) persistent store location of all configuration files saved. By default:<br/> - Configuration is written to the `apprise_api/var/config` directory when just using the _Django_ `manage runserver` script. However for the path for the container is `/config`.
401+
| `APPRISE_STORAGE_DIR` | Defines an (optional) persistent store location of all cache files saved. By default persistent storage is written into the `<APPRISE_CONFIG_DIR>/store`.
402+
| `APPRISE_STORAGE_MODE` | Defines the storage mode to use. If no `APPRISE_STORGE_DIR` is identified, then this is set to `memory` in all circumtances reguardless what it might otherwise be set to. The possible options are:<br/>📌 **auto**: This is also the default. Writes cache files on demand only. <br/>📌 **memory**: Persistent storage is disabled; local memory is used for simple internal references. This is effectively the behavior of Apprise of versions 1.8.1 and earlier.<br/>📌 **flush**: A bit more i/o intensive then `auto`. Content is written to disk constantly if changed in anyway. This mode is still experimental.
403+
| `APPRISE_STORAGE_UID_LENGTH` | Defines the unique key lengths used to identify an Apprise URL. By default this is set to `8`. Value can not be set to a smaller value then `2` or larger then `64`.
386404
| `APPRISE_ATTACH_DIR` | The directory the uploaded attachments are placed in. By default:<br/> - Attachments are written to the `apprise_api/var/attach` directory when just using the _Django_ `manage runserver` script. However for the path for the container is `/attach`.
387405
| `APPRISE_ATTACH_SIZE` | Over-ride the attachment size (defined in MB). By default it is set to `200` (Megabytes). You can set this up to a maximum value of `500` which is the restriction in place for NginX (internal hosting ervice) at this time. If you set this to zero (`0`) then attachments will not be passed along even if provided.
388406
| `APPRISE_UPLOAD_MAX_MEMORY_SIZE` | Over-ride the in-memory accepted payload size (defined in MB). By default it is set to `3` (Megabytes). There is no reason the HTTP payload (excluding attachments) should exceed this limit. This value is only configurable for those who have edge cases where there are exceptions to this rule.

apprise_api/api/management/__init__.py

Whitespace-only changes.

apprise_api/api/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
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.core.management.base import BaseCommand
27+
from django.conf import settings
28+
import apprise
29+
30+
31+
class Command(BaseCommand):
32+
help = f"Prune all persistent content older then {settings.APPRISE_STORAGE_PRUNE_DAYS} days()"
33+
34+
def add_arguments(self, parser):
35+
parser.add_argument("-d", "--days", type=int, default=settings.APPRISE_STORAGE_PRUNE_DAYS)
36+
37+
def handle(self, *args, **options):
38+
# Persistent Storage cleanup
39+
apprise.PersistentStore.disk_prune(
40+
path=settings.APPRISE_STORAGE_DIR,
41+
expires=options["days"] * 86400, action=True,
42+
)
43+
self.stdout.write(
44+
self.style.SUCCESS('Successfully pruned persistent storeage (days: %d)' % options["days"])
45+
)

apprise_api/api/templates/config.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,14 @@ <h4>{% trans "Persistent Store Endpoints" %}</h4>
351351
let code = document.createElement('code');
352352
let li = document.createElement('li');
353353
code.textContent = entry.url;
354-
li.setAttribute('class', 'card-panel');
355354
li.appendChild(code);
355+
li.setAttribute('class', 'card-panel');
356+
if (entry.id) {
357+
let url_id = document.createElement('code');
358+
url_id.setAttribute('class', 'url-id');
359+
url_id.textContent = entry.id;
360+
li.appendChild(url_id);
361+
}
356362

357363
urlList.appendChild(li);
358364
// Store `all` tag

apprise_api/api/tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
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)

apprise_api/api/tests/test_healthecheck.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def test_healthcheck_simple(self):
6666
'config_lock': False,
6767
'attach_lock': False,
6868
'status': {
69+
'persistent_storage': True,
6970
'can_write_config': True,
7071
'can_write_attach': True,
7172
'details': ['OK']
@@ -90,6 +91,7 @@ def test_healthcheck_simple(self):
9091
'config_lock': True,
9192
'attach_lock': False,
9293
'status': {
94+
'persistent_storage': True,
9395
'can_write_config': False,
9496
'can_write_attach': True,
9597
'details': ['OK']
@@ -113,6 +115,7 @@ def test_healthcheck_simple(self):
113115
'config_lock': False,
114116
'attach_lock': False,
115117
'status': {
118+
'persistent_storage': True,
116119
'can_write_config': False,
117120
'can_write_attach': True,
118121
'details': ['OK']
@@ -136,6 +139,7 @@ def test_healthcheck_simple(self):
136139
'config_lock': False,
137140
'attach_lock': True,
138141
'status': {
142+
'persistent_storage': True,
139143
'can_write_config': True,
140144
'can_write_attach': False,
141145
'details': ['OK']
@@ -159,6 +163,7 @@ def test_healthcheck_simple(self):
159163
'config_lock': False,
160164
'attach_lock': False,
161165
'status': {
166+
'persistent_storage': True,
162167
'can_write_config': True,
163168
'can_write_attach': True,
164169
'details': ['OK']
@@ -172,6 +177,7 @@ def test_healthcheck_library(self):
172177

173178
result = healthcheck(lazy=True)
174179
assert result == {
180+
'persistent_storage': True,
175181
'can_write_config': True,
176182
'can_write_attach': True,
177183
'details': ['OK']
@@ -180,6 +186,7 @@ def test_healthcheck_library(self):
180186
# A Double lazy check
181187
result = healthcheck(lazy=True)
182188
assert result == {
189+
'persistent_storage': True,
183190
'can_write_config': True,
184191
'can_write_attach': True,
185192
'details': ['OK']
@@ -192,35 +199,80 @@ def test_healthcheck_library(self):
192199
# We still succeed; we just don't leverage our lazy check
193200
# which prevents addition (unnessisary) writes
194201
assert result == {
202+
'persistent_storage': True,
195203
'can_write_config': True,
196204
'can_write_attach': True,
197205
'details': ['OK'],
198206
}
199207

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+
200223
# Force a non-lazy check
201224
with mock.patch('os.makedirs') as mock_makedirs:
202225
mock_makedirs.side_effect = OSError()
203226
result = healthcheck(lazy=False)
204227
assert result == {
228+
'persistent_storage': False,
205229
'can_write_config': False,
206230
'can_write_attach': False,
207231
'details': [
208232
'CONFIG_PERMISSION_ISSUE',
209233
'ATTACH_PERMISSION_ISSUE',
234+
'STORE_PERMISSION_ISSUE',
210235
]}
211236

212-
mock_makedirs.side_effect = (None, OSError())
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+
mock_makedirs.side_effect = (OSError(), OSError(), None, None, None, None)
213262
result = healthcheck(lazy=False)
214263
assert result == {
215-
'can_write_config': True,
264+
'persistent_storage': True,
265+
'can_write_config': False,
216266
'can_write_attach': False,
217267
'details': [
268+
'CONFIG_PERMISSION_ISSUE',
218269
'ATTACH_PERMISSION_ISSUE',
219270
]}
220271

221-
mock_makedirs.side_effect = (OSError(), None)
272+
mock_makedirs.side_effect = (OSError(), None, None, None, None)
222273
result = healthcheck(lazy=False)
223274
assert result == {
275+
'persistent_storage': True,
224276
'can_write_config': False,
225277
'can_write_attach': True,
226278
'details': [
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
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

0 commit comments

Comments
 (0)