diff --git a/LICENSE b/LICENSE index 44af43b..a4d8fcd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Chris Caron +Copyright (c) 2025 Chris Caron Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6588593..ddbf329 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ docker run --name apprise \ -v /path/to/local/attach:/attach \ -e APPRISE_STATEFUL_MODE=simple \ -e APPRISE_WORKER_COUNT=1 \ + -e APPRISE_ADMIN=y \ -d caronc/apprise:latest ``` @@ -89,6 +90,7 @@ docker run --name apprise \ --user "$(id -u):$(id -g)" \ -e APPRISE_STATEFUL_MODE=simple \ -e APPRISE_WORKER_COUNT=1 \ + -e APPRISE_ADMIN=y \ -v /etc/apprise:/config \ -d apprise/local:latest @@ -100,6 +102,7 @@ docker run --name apprise \ --user "$(id -u):$(id -g)" \ -e APPRISE_STATEFUL_MODE=simple \ -e APPRISE_WORKER_COUNT=1 \ + -e APPRISE_ADMIN=y \ -v ./config:/config \ -d apprise/local:latest ``` @@ -119,6 +122,7 @@ services: environment: APPRISE_STATEFUL_MODE: simple APPRISE_WORKER_COUNT: "1" + APPRISE_ADMIN: "y" volumes: - ./config:/config - ./plugin:/plugin @@ -157,6 +161,7 @@ services: environment: APPRISE_STATEFUL_MODE: simple APPRISE_WORKER_COUNT: "1" + APPRISE_ADMIN: "y" # Persistent state volumes: @@ -300,7 +305,7 @@ curl -X POST -d '{"urls": "mailto://user:pass@gmail.com", "body":"test message"} # Send a notification with a URL based attachment curl -X POST \ -F 'urls=mailto://user:pass@gmail.com' \ - -F attach=attach=https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png \ + -F attach=https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png \ http://localhost:8000/notify ``` @@ -336,7 +341,8 @@ You can pre-save all of your Apprise configuration and/or set of Apprise URLs an |------------- | ------ | ----------- | | `/add/{KEY}` | POST | Saves Apprise Configuration (or set of URLs) to the persistent store.
*Payload Parameters*
๐Ÿ“Œ **urls**: Define one or more Apprise URL(s) here. Use a comma and/or space to separate one URL from the next.
๐Ÿ“Œ **config**: Provide the contents of either a YAML or TEXT based Apprise configuration.
๐Ÿ“Œ **format**: This field is only required if you've specified the *config* parameter. Used to tell the server which of the supported (Apprise) configuration types you are passing. Valid options are *text* and *yaml*. This path does not work if `APPRISE_CONFIG_LOCK` is set. | `/del/{KEY}` | POST | Removes Apprise Configuration from the persistent store. This path does not work if `APPRISE_CONFIG_LOCK` is set. -| `/get/{KEY}` | POST | Returns the Apprise Configuration from the persistent store. This can be directly used with the *Apprise CLI* and/or the *AppriseConfig()* object ([see here for details](https://github.com/caronc/apprise/wiki/config)). This path does not work if `APPRISE_CONFIG_LOCK` is set. +| `/cfg/{KEY}` | POST | Returns the Apprise Configuration from the persistent store. This can be directly used with the *Apprise CLI* and/or the *AppriseConfig()* object ([see here for details](https://github.com/caronc/apprise/wiki/config)). This path does not work if `APPRISE_CONFIG_LOCK` is set. This is an alias of `/get/{KEY}` (identified next). +| `/get/{KEY}` | POST | Returns the Apprise Configuration from the persistent store. This can be directly used with the *Apprise CLI* and/or the *AppriseConfig()* object ([see here for details](https://github.com/caronc/apprise/wiki/config)). This path does not work if `APPRISE_CONFIG_LOCK` is set. This is also provided via `/cfg/{KEY}` as an alias. | `/notify/{KEY}` | POST | Sends notification(s) to all of the end points you've previously configured associated with a *{KEY}*.
*Payload Parameters*
๐Ÿ“Œ **body**: Your message body. This is the *only* required field.
๐Ÿ“Œ **title**: Optionally define a title to go along with the *body*.
๐Ÿ“Œ **type**: Defines the message type you want to send as. The valid options are `info`, `success`, `warning`, and `failure`. If no *type* is specified then `info` is the default value used.
๐Ÿ“Œ **tag**: Optionally notify only those tagged accordingly. Use a comma (`,`) to `OR` your tags and a space (` `) to `AND` them. More details on this can be seen documented below.
๐Ÿ“Œ **format**: Optionally identify the text format of the data you're feeding Apprise. The valid options are `text`, `markdown`, `html`. The default value if nothing is specified is `text`. | `/json/urls/{KEY}` | GET | Returns a JSON response object that contains all of the URLS and Tags associated with the key specified. | `/details` | GET | Set the `Accept` Header to `application/json` and retrieve a JSON response object that contains all of the supported Apprise URLs. See [here for more details](https://github.com/caronc/apprise/wiki/Development_Apprise_Details#apprise-details) @@ -392,7 +398,7 @@ curl -X POST \ # Send a notification with a URL based attachment curl -X POST \ -F 'urls=mailto://user:pass@gmail.com' \ - -F attach=attach=https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png \ + -F attach=https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png \ http://localhost:8000/notify/abc123 ``` @@ -483,6 +489,8 @@ The use of environment variables allow you to provide over-rides to default sett | `IPV4_ONLY` | Force an all IPv4 only environment (default supports both IPV4 and IPv6). Nothing is done if `IPV6_ONLY` is also set as this creates an ambigious setup. **Note**: This only works if the container is not explicitly started with `--user` or `user:`. | `IPV6_ONLY` | Force an all IPv6 only environment (default supports both IPv4 and IPv6). Nothing is done if `IPV4_ONLY` is also set as this creates an ambigious setup. **Note**: This only works if the container is not explicitly started with `--user` or `user:`. | `HTTP_PORT` | Force the default listening port to be something other then `8000` within the Docker container. **Note**: This only works if the container is not explicitly started with `--user` or `user:`. +| `STRICT_MODE` | Applicable only to container deployments; if this is set to `yes`, the NginX instance will not return content on any invalid or unsupported request. This is incredibly useful for those hosting Apprise publicly and pairs nicely with fail2ban. By default, the system does not operate in this strict mode. +| `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. | `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. | `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. | `APPRISE_CONFIG_DIR` | Defines an (optional) persistent store location of all configuration files saved. By default:
- 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`. @@ -502,7 +510,6 @@ The use of environment variables allow you to provide over-rides to default sett | `APPRISE_ALLOW_SERVICES` | A comma separated set of entries identifying what plugins to allow access to. You may only use alpha-numeric characters as is the restriction of Apprise Schemas (schema://) anyway. To exclusively include more the one upstream service, simply specify additional entries separated by a `,` (comma) or ` ` (space). The `APPRISE_DENY_SERVICES` entries are ignored if the `APPRISE_ALLOW_SERVICES` is identified. | `APPRISE_ATTACH_ALLOW_URLS` | A comma separated set of entries identifying the HTTP Attach URLs the Apprise API shall always accept. Use wildcards such as `*` and `?` to help construct the URL/Hosts you identify. Use a space and/or a comma to identify more then one entry. By default this is set to `*` (Accept all provided URLs). | `APPRISE_ATTACH_DENY_URLS` | A comma separated set of entries identifying the HTTP Attach URLs the Apprise API shall always reject. Use wildcards such as `*` and `?` to help construct the URL/Hosts you identify. The `APPRISE_ATTACH_DENY_URLS` is always processed before the `APPRISE_ATTACH_ALLOW_URLS` list. Use a space and/or a comma to identify more then one entry. By default this is set to `127.0.* localhost*`. - By default this | `SECRET_KEY` | A Django variable acting as a *salt* for most things that require security. This API uses it for the hash sequences when writing the configuration files to disk (`hash` mode only). | `ALLOWED_HOSTS` | A list of strings representing the host/domain names that this API can serve. This is a security measure to prevent HTTP Host header attacks, which are possible even under many seemingly-safe web server configurations. By default this is set to `*` allowing any host. Use space to delimit more than one host. | `APPRISE_PLUGIN_PATHS` | 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). You may define one or more paths (separated by comma `,`) here. By default the `apprise_api/var/plugin` directory is scanned (which does not include anything). Feel free to set this to an empty string to disable any custom plugin loading. @@ -556,6 +563,7 @@ docker run --name apprise \ -v ./apprise_api.htpasswd:/etc/nginx/.htpasswd:ro \ -e APPRISE_STATEFUL_MODE=simple \ -e APPRISE_WORKER_COUNT=1 \ + -e APPRISE_ADMIN=y \ -d caronc/apprise:latest ``` @@ -747,6 +755,8 @@ spec: value: simple - name: APPRISE_WORKER_COUNT value: "1" + - name: APPRISE_ADMIN + value: "y" ports: - containerPort: 8000 name: http @@ -783,28 +793,23 @@ spec: ``` ## Development Environment -The following should get you a working development environment (min requirements are Python v3.12) to test with. - -### Setup - -```bash -# Create and activate a Python 3.12 virtual environment: -python3.12 -m venv .venv -. .venv/bin/activate - -# Install core dependencies: - -pip install -e '.[dev]' -``` - -### Running the Dev Server +The following should get you a working development server to test with: +### Bare Metal ```bash # Start the development server in debug mode: -./manage.py runserver +tox -e runserver # Then visit: http://localhost:8000/ + +# If you want to run on a different port: +tox -e runserver -- "localhost:8080" +# Then visit: http://localhost:8000/ + +# You can also bind it to all of your interfaces like so: +tox -e runserver -- "0.0.0.0:8080" ``` +### Docker Containers For development, the repository includes a `docker-compose.override.yml` file that extends `docker-compose.yml` to build from source and bind-mount the code. Running: @@ -999,3 +1004,17 @@ The colon `:` prefix is the switch that starts the re-mapping rule engine. You Basic Prometheus support added through `/metrics` reference point. +## OpenAPI / Swagger Specification + +Apprise API includes an OpenAPI 3 specification in `swagger.yaml` at the root +of the repository. + +For local development you can bring up a standalone Swagger UI that reads the +checked-in spec file without changing how Apprise API runs: + +```bash +docker compose -f docker-compose.swagger.yml up -d +# Then browse to: +# http://localhost:8001 +``` + diff --git a/Screenshot-1.png b/Screenshot-1.png index df6bf51..527f64d 100644 Binary files a/Screenshot-1.png and b/Screenshot-1.png differ diff --git a/Screenshot-2.png b/Screenshot-2.png index d2d58f0..74435f8 100644 Binary files a/Screenshot-2.png and b/Screenshot-2.png differ diff --git a/Screenshot-3.png b/Screenshot-3.png index f6f9984..7d9353c 100644 Binary files a/Screenshot-3.png and b/Screenshot-3.png differ diff --git a/Screenshot-4.png b/Screenshot-4.png index c711250..d12ac66 100644 Binary files a/Screenshot-4.png and b/Screenshot-4.png differ diff --git a/apprise_api/__init__.py b/apprise_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apprise_api/api/apps.py b/apprise_api/api/apps.py index 8550c8b..8bee632 100644 --- a/apprise_api/api/apps.py +++ b/apprise_api/api/apps.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/context_processors.py b/apprise_api/api/context_processors.py index 99e4456..d943b56 100644 --- a/apprise_api/api/context_processors.py +++ b/apprise_api/api/context_processors.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2020 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -49,11 +49,22 @@ def admin_enabled(request): return {"APPRISE_ADMIN": settings.APPRISE_ADMIN} -def apprise_version(request): +def apprise_metadata(request): """ - Returns the current version of apprise loaded under the hood + Returns the current details of the Apprise Library and API under the hood """ - return {"APPRISE_VERSION": apprise.__version__} + + return { + "APPRISE_LIB_VERSION": apprise.__version__, + "APPRISE_LIB_URL": "http://github.com/caronc/apprise", + + "APPRISE_API_VERSION": settings.APP_VERSION, + "APPRISE_API_URL": settings.APP_URL, + "APPRISE_API_LICENSE": settings.APP_LICENSE, + "APPRISE_API_COPYRIGHT": settings.APP_COPYRIGHT, + + "APPRISE_AUTHOR": settings.APP_AUTHOR, + } def default_config_id(request): diff --git a/apprise_api/api/forms.py b/apprise_api/api/forms.py index 1651dc6..b40833d 100644 --- a/apprise_api/api/forms.py +++ b/apprise_api/api/forms.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/payload_mapper.py b/apprise_api/api/payload_mapper.py index 58f8207..3bff578 100644 --- a/apprise_api/api/payload_mapper.py +++ b/apprise_api/api/payload_mapper.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2024 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/templates/base.html b/apprise_api/api/templates/base.html index f265f8d..68069dc 100644 --- a/apprise_api/api/templates/base.html +++ b/apprise_api/api/templates/base.html @@ -1,156 +1,369 @@ {% load static %} {% load i18n %} + - - - - - - - - - - - - - - - - - - - - - - - {% block title %}{% trans "Apprise API" %}{% endblock %} - - - -
- -
-{% else %} -
-

{% trans "Persistent Store Endpoints" %}

-

{% blocktrans %}The administrator of this system has disabled persistent storage.{% endblocktrans %}

-
-{% endif %} -{% endblock %} +{% endblock body %} diff --git a/apprise_api/api/tests/test_add.py b/apprise_api/api/tests/test_add.py index 888f3d2..755b054 100644 --- a/apprise_api/api/tests/test_add.py +++ b/apprise_api/api/tests/test_add.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_attachment.py b/apprise_api/api/tests/test_attachment.py index b826589..d4ba0bd 100644 --- a/apprise_api/api/tests/test_attachment.py +++ b/apprise_api/api/tests/test_attachment.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -33,6 +33,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import SimpleTestCase from django.test.utils import override_settings +from django.utils.datastructures import MultiValueDict import requests from .. import utils @@ -420,6 +421,24 @@ def test(*args, **kwargs): assert isinstance(result, list) assert len(result) == 2 + # Support remote URL payload combined with uploaded FILES + with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name): + attachment_payload = ["https://example.com/logo.png"] + + files_request = MultiValueDict( + { + "attachment": [ + SimpleUploadedFile("a.txt", b"a", content_type="text/plain"), + SimpleUploadedFile("b.txt", b"b", content_type="text/plain"), + ] + } + ) + + result = parse_attachments(attachment_payload, files_request) + assert isinstance(result, list) + # 1 remote + 2 local uploads + assert len(result) == 3 + def test_direct_attachment_parsing_nw(self): """ Test the parsing of file attachments with network availability @@ -447,3 +466,85 @@ def test_direct_attachment_parsing_nw(self): ] with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) + + + def test_form_file_attachment_parsing_multivalue_single_key(self): + """ + Regression: MultiValueDict under a single key must not lose files. + """ + with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name): + files_request = MultiValueDict( + { + "attachment": [ + SimpleUploadedFile("a.txt", b"a", content_type="text/plain"), + SimpleUploadedFile("b.txt", b"b", content_type="text/plain"), + SimpleUploadedFile("c.txt", b"c", content_type="text/plain"), + ] + } + ) + + result = parse_attachments(None, files_request) + assert isinstance(result, list) + assert len(result) == 3 + + + def test_form_file_attachment_parsing_unique_keys(self): + """ + Support curl: -F attach1=@... -F attach2=@... + """ + with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name): + files_request = { + "attach1": SimpleUploadedFile("a.txt", b"a", content_type="text/plain"), + "attach2": SimpleUploadedFile("b.txt", b"b", content_type="text/plain"), + } + + result = parse_attachments(None, files_request) + assert isinstance(result, list) + assert len(result) == 2 + + + def test_form_file_attachment_parsing_payload_dict_plus_files(self): + """ + Base64 dict payload should count as 1 attachment, plus all file uploads. + """ + with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name): + attachment_payload = { + "base64": base64.b64encode(b"data to be encoded").decode("utf-8"), + } + files_request = MultiValueDict( + { + "attachment": [ + SimpleUploadedFile("a.txt", b"a", content_type="text/plain"), + SimpleUploadedFile("b.txt", b"b", content_type="text/plain"), + ] + } + ) + + result = parse_attachments(attachment_payload, files_request) + assert isinstance(result, list) + assert len(result) == 3 + + + def test_form_file_attachment_parsing_max_attachments_payload_plus_files(self): + """ + Verify APPRISE_MAX_ATTACHMENTS enforcement accounts for payload + all FILES. + """ + with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name, APPRISE_MAX_ATTACHMENTS=3): + attachment_payload = [ + {"base64": base64.b64encode(b"one").decode("utf-8")}, + {"base64": base64.b64encode(b"two").decode("utf-8")}, + ] + files_request = MultiValueDict( + { + "attachment": [ + SimpleUploadedFile("a.txt", b"a", content_type="text/plain"), + SimpleUploadedFile("b.txt", b"b", content_type="text/plain"), + ] + } + ) + + # 2 (payload) + 2 (files) = 4 > max=3 + with self.assertRaises(ValueError): + parse_attachments(attachment_payload, files_request) + + diff --git a/apprise_api/api/tests/test_config_cache.py b/apprise_api/api/tests/test_config_cache.py index d4a97c5..78b27ea 100644 --- a/apprise_api/api/tests/test_config_cache.py +++ b/apprise_api/api/tests/test_config_cache.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_del.py b/apprise_api/api/tests/test_del.py index 8f58d25..7dfc635 100644 --- a/apprise_api/api/tests/test_del.py +++ b/apprise_api/api/tests/test_del.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_get.py b/apprise_api/api/tests/test_get.py index 0c94048..6648bd9 100644 --- a/apprise_api/api/tests/test_get.py +++ b/apprise_api/api/tests/test_get.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_healthecheck.py b/apprise_api/api/tests/test_healthecheck.py index 84084de..a13f330 100644 --- a/apprise_api/api/tests/test_healthecheck.py +++ b/apprise_api/api/tests/test_healthecheck.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2024 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_json_urls.py b/apprise_api/api/tests/test_json_urls.py index 0b3687f..da95552 100644 --- a/apprise_api/api/tests/test_json_urls.py +++ b/apprise_api/api/tests/test_json_urls.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_manager.py b/apprise_api/api/tests/test_manager.py index 00c2009..f9e43d8 100644 --- a/apprise_api/api/tests/test_manager.py +++ b/apprise_api/api/tests/test_manager.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -21,7 +21,11 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import json +from unittest.mock import patch + from django.test import SimpleTestCase, override_settings +from django.urls import resolve class ManagerPageTests(SimpleTestCase): @@ -65,3 +69,103 @@ def test_manage_status_code(self): # An invalid key was specified response = self.client.get("/cfg/valid-key") assert response.status_code == 200 + + def test_get_config(self): + """ + Test retrieving configuration + """ + + # our key to use + key = "test_cfg_config_" + + # No content saved to the location yet + response = self.client.post("/cfg/{}".format(key)) + self.assertEqual(response.status_code, 204) + + # Add some content + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) + assert response.status_code == 200 + + # Handle case when we try to retrieve our content but we have no idea + # what the format is in. Essentially there had to have been disk + # corruption here or someone meddling with the backend. + with patch("gzip.open", side_effect=OSError): + response = self.client.post("/cfg/{}".format(key)) + assert response.status_code == 500 + + # Now we should be able to see our content + response = self.client.post("/cfg/{}".format(key)) + assert response.status_code == 200 + + # Add a YAML file + response = self.client.post( + "/add/{}".format(key), + { + "format": "yaml", + "config": """ + urls: + - dbus://""", + }, + ) + assert response.status_code == 200 + + # Now retrieve our YAML configuration + response = self.client.post("/cfg/{}".format(key)) + assert response.status_code == 200 + + # Verify that the correct Content-Type is set in the header of the + # response + assert "Content-Type" in response + assert response["Content-Type"].startswith("text/yaml") + + def test_manage_cfg_list_content_type_defaults_to_html(self): + """ + /cfg/ should render HTML by default when allowed. + """ + with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="simple"): + response = self.client.get("/cfg/") + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/html") + + def test_manage_cfg_list_json_when_requested(self): + """ + /cfg/ should return JSON list when requested via Accept header. + """ + with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="simple"): + response = self.client.get("/cfg/", HTTP_ACCEPT="application/json") + assert response.status_code == 200 + assert response["Content-Type"].startswith("application/json") + payload = json.loads(response.content.decode("utf-8")) + assert isinstance(payload, list) + + def test_manage_cfg_list_denied_content_type_plain_text(self): + """ + /cfg/ denied case should be plain text when JSON is not requested. + """ + response = self.client.get("/cfg/") + assert response.status_code == 403 + assert response["Content-Type"].startswith("text/plain") + + def test_manage_cfg_list_denied_content_type_json(self): + """ + /cfg/ denied case should return JSON when requested. + """ + response = self.client.get("/cfg/", HTTP_ACCEPT="application/json") + assert response.status_code == 403 + assert response["Content-Type"].startswith("application/json") + payload = json.loads(response.content.decode("utf-8")) + assert "error" in payload + + def test_manage_cfg_list_json_returns_store_keys(self): + with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="simple"): + mod = resolve("/cfg/").func.__module__ + with patch(f"{mod}.ConfigCache.keys", return_value=["abc", "def"]) as m: + response = self.client.get("/cfg/", HTTP_ACCEPT="application/json") + assert response.status_code == 200 + assert response["Content-Type"].startswith("application/json") + + payload = json.loads(response.content.decode("utf-8")) + assert payload == ["abc", "def"] + m.assert_called_once_with() + + diff --git a/apprise_api/api/tests/test_payload_mapper.py b/apprise_api/api/tests/test_payload_mapper.py index e93c9e2..8f665bd 100644 --- a/apprise_api/api/tests/test_payload_mapper.py +++ b/apprise_api/api/tests/test_payload_mapper.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2024 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_stateful_notify.py b/apprise_api/api/tests/test_stateful_notify.py index f077bf4..e864fb1 100644 --- a/apprise_api/api/tests/test_stateful_notify.py +++ b/apprise_api/api/tests/test_stateful_notify.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2020 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_stateless_notify.py b/apprise_api/api/tests/test_stateless_notify.py index 40fe0e0..f6b1cc4 100644 --- a/apprise_api/api/tests/test_stateless_notify.py +++ b/apprise_api/api/tests/test_stateless_notify.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_utils.py b/apprise_api/api/tests/test_utils.py index 5be3b44..d5a174f 100644 --- a/apprise_api/api/tests/test_utils.py +++ b/apprise_api/api/tests/test_utils.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2024 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_webhook.py b/apprise_api/api/tests/test_webhook.py index db41d37..623bcd0 100644 --- a/apprise_api/api/tests/test_webhook.py +++ b/apprise_api/api/tests/test_webhook.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/tests/test_welcome.py b/apprise_api/api/tests/test_welcome.py index c7a77b5..207d70d 100644 --- a/apprise_api/api/tests/test_welcome.py +++ b/apprise_api/api/tests/test_welcome.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/urls.py b/apprise_api/api/urls.py index 95832d4..01569be 100644 --- a/apprise_api/api/urls.py +++ b/apprise_api/api/urls.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/api/utils.py b/apprise_api/api/utils.py index 9d6bea1..0ee7b37 100644 --- a/apprise_api/api/utils.py +++ b/apprise_api/api/utils.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -47,6 +47,13 @@ # Get an instance of a logger logger = logging.getLogger("django") +# Support JSON formats +# text/json +# text/x-json +# application/json +# application/x-json +MIME_IS_JSON = re.compile(r"(text|application)/(x-)?json", re.I) + class AppriseStoreMode: """ @@ -267,12 +274,18 @@ def parse_attachments(attachment_payload, files_request): # Otherwise we need to raise an error raise ValueError("Attachment support has been disabled") + # Determine how many files we have in the request.FILES + file_count = 0 + if hasattr(files_request, "lists"): + file_count = sum(len(v) for _, v in files_request.lists()) + elif isinstance(files_request, dict): + # conservative fallback + file_count = len(files_request) + # Attachment Count - count = sum( - [ - (0 if not isinstance(attachment_payload, set | tuple | list) else len(attachment_payload)), - 0 if not isinstance(files_request, dict) else len(files_request), - ] + count = ( + (len(attachment_payload) if isinstance(attachment_payload, (set, tuple, list)) else 0) + + file_count ) if isinstance(attachment_payload, dict | str | bytes): @@ -383,43 +396,49 @@ def parse_attachments(attachment_payload, files_request): # # Now handle the request.FILES # - if isinstance(files_request, dict): - for no, (_key, meta) in enumerate(files_request.items(), start=len(attachments) + 1): - try: - # Filetype is presumed to be of base class - # django.core.files.UploadedFile - filename = meta.name.strip() + if hasattr(files_request, "lists"): + iterable = ((k, f) for k, lst in files_request.lists() for f in lst) + elif isinstance(files_request, dict): + iterable = files_request.items() + else: + iterable = () + + for no, (_, meta) in enumerate(iterable, start=len(attachments) + 1): + try: + # Filetype is presumed to be of base class + # django.core.files.UploadedFile + filename = meta.name.strip() - # Max filename size is 250 - if len(filename) > 250: - raise ValueError(f"The filename associated with attachment {no} is too long") + # Max filename size is 250 + if len(filename) > 250: + raise ValueError(f"The filename associated with attachment {no} is too long") - elif not filename: - filename = f"attachment.{no:03d}" + elif not filename: + filename = f"attachment.{no:03d}" - except (AttributeError, TypeError): - raise ValueError(f"An invalid filename was provided for attachment {no}") from None + except (AttributeError, TypeError): + raise ValueError(f"An invalid filename was provided for attachment {no}") from None - # - # Prepare our Attachment - # - attachment = Attachment(filename) - try: - with open(attachment.path, "wb") as f: - # Write our content to disk - f.write(meta.read()) + # + # Prepare our Attachment + # + attachment = Attachment(filename) + try: + with open(attachment.path, "wb") as f: + # Write our content to disk + f.write(meta.read()) - except OSError: - raise ValueError(f"Could not write attachment {filename} to disk") from None + except OSError: + raise ValueError(f"Could not write attachment {filename} to disk") from None - # - # Some Validation - # - if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE: - raise ValueError(f"attachment {filename}'s filesize is to large") + # + # Some Validation + # + if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE: + raise ValueError(f"attachment {filename}'s filesize is to large") - # Add our attachment - attachments.append(attachment) + # Add our attachment + attachments.append(attachment) return attachments diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py index ac152b4..c204be2 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -1,5 +1,4 @@ -# -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -49,6 +48,7 @@ ) from .payload_mapper import remap_fields from .utils import ( + MIME_IS_JSON, AppriseStoreMode, ConfigCache, apply_global_filters, @@ -65,12 +65,7 @@ # multipart/form-data MIME_IS_FORM = re.compile(r"(multipart|application)/(x-www-)?form-(data|urlencoded)", re.I) -# Support JSON formats -# text/json -# text/x-json -# application/json -# application/x-json -MIME_IS_JSON = re.compile(r"(text|application)/(x-)?json", re.I) + # Parsing of Accept; the following amounts to Accept All # */* @@ -88,8 +83,6 @@ # Break apart our objects anded together TAG_AND_DELIM_RE = re.compile(r"[\s&+]+") -MIME_IS_JSON = re.compile(r"(text|application)/(x-)?json", re.I) - class JSONEncoder(DjangoJSONEncoder): """ @@ -124,6 +117,122 @@ class ResponseCode: internal_server_error = 500 +def _get_config_response(request, key): + """ + Shared implementation for returning stored configuration for a key. + + Used by both POST /get/ and POST /cfg/. + """ + + # Detect the format our response should be in + json_response = ( + MIME_IS_JSON.match( + request.content_type + if request.content_type + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) + + if settings.APPRISE_CONFIG_LOCK: + # General Access Control + logger.warning( + "VIEW - %s - Config Lock Active - Request Denied", + request.META["REMOTE_ADDR"], + ) + + msg = _("The site has been configured to deny this request") + status = ResponseCode.no_access + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + {"error": msg}, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) + + config, format = ConfigCache.get(key) + if config is None: + # The returned value of config and format tell a rather cryptic + # story; this portion could probably be updated in the future. + # but for now it reads like this: + # config == None and format == None: We had an internal error + # config == None and format != None: we simply have no data + # config != None: we simply have no data + if format is not None: + # no content to return + logger.warning( + "VIEW - %s - No configuration associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("There was no configuration found") + status = ResponseCode.no_content + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + {"error": msg}, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) + + # Something went very wrong; return 500 + logger.error( + "VIEW - %s - Configuration could not be accessed associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("An error occurred accessing configuration") + status = ResponseCode.internal_server_error + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) + + # Our configuration was retrieved; now our response varies on whether + # we are a YAML configuration or a TEXT based one. This allows us to + # be compatible with those using the AppriseConfig() library or the + # reference to it through the --config (-c) option in the CLI. + content_type = ( + "text/yaml; charset=utf-8" if format == apprise.ConfigFormat.YAML.value else "text/plain; charset=utf-8" + ) + + # Return our retrieved content + logger.info( + "VIEW - %s - Retrieved configuration associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + return ( + HttpResponse( + config, + content_type=content_type, + status=ResponseCode.okay, + ) + if not json_response + else JsonResponse( + {"format": format, "config": config}, + encoder=JSONEncoder, + safe=False, + status=ResponseCode.okay, + ) + ) + + class WelcomeView(View): """ A simple welcome/index page @@ -283,6 +392,15 @@ def get(self, request, key): }, ) + def post(self, request, key): + """ + Handle a POST request. + + This mirrors POST /get/, allowing clients to retrieve the + stored configuration via /cfg/ as well. + """ + return _get_config_response(request, key) + @method_decorator(never_cache, name="dispatch") class ConfigListView(View): @@ -322,12 +440,23 @@ def get(self, request): ) ) - return render( - request, - self.template_name, - { - "keys": ConfigCache.keys(), - }, + status = ResponseCode.okay + return ( + render( + request, + self.template_name, + { + "keys": ConfigCache.keys(), + }, + status=status, + ) + if not json_response + else JsonResponse( + ConfigCache.keys(), + encoder=JSONEncoder, + safe=False, + status=status, + ) ) @@ -609,7 +738,7 @@ def post(self, request, key): request.META["REMOTE_ADDR"], key, ) - msg = _("An error occured saving configuration") + msg = _("An error occurred saving configuration") status = ResponseCode.internal_server_error return ( HttpResponse(msg, status=status, content_type="text/plain") @@ -790,114 +919,7 @@ def post(self, request, key): """ Handle a POST request """ - - # Detect the format our response should be in - json_response = ( - MIME_IS_JSON.match( - request.content_type - if request.content_type - else request.headers.get("accept", request.headers.get("content-type", "")) - ) - is not None - ) - - if settings.APPRISE_CONFIG_LOCK: - # General Access Control - logger.warning( - "VIEW - %s - Config Lock Active - Request Denied", - request.META["REMOTE_ADDR"], - ) - - msg = _("The site has been configured to deny this request") - status = ResponseCode.no_access - return ( - HttpResponse(msg, status=status, content_type="text/plain") - if not json_response - else JsonResponse( - {"error": msg}, - encoder=JSONEncoder, - safe=False, - status=status, - ) - ) - - config, format = ConfigCache.get(key) - if config is None: - # The returned value of config and format tell a rather cryptic - # story; this portion could probably be updated in the future. - # but for now it reads like this: - # config == None and format == None: We had an internal error - # config == None and format != None: we simply have no data - # config != None: we simply have no data - if format is not None: - # no content to return - logger.warning( - "VIEW - %s - No configuration associated using KEY: %s", - request.META["REMOTE_ADDR"], - key, - ) - msg = _("There was no configuration found") - status = ResponseCode.no_content - return ( - HttpResponse(msg, status=status, content_type="text/plain") - if not json_response - else JsonResponse( - {"error": msg}, - encoder=JSONEncoder, - safe=False, - status=status, - ) - ) - - # Something went very wrong; return 500 - logger.error( - "VIEW - %s - Configuration could not be accessed associated using KEY: %s", - request.META["REMOTE_ADDR"], - key, - ) - msg = _("An error occured accessing configuration") - status = ResponseCode.internal_server_error - return ( - HttpResponse(msg, status=status, content_type="text/plain") - if not json_response - else JsonResponse( - { - "error": msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, - ) - ) - - # Our configuration was retrieved; now our response varies on whether - # we are a YAML configuration or a TEXT based one. This allows us to - # be compatible with those using the AppriseConfig() library or the - # reference to it through the --config (-c) option in the CLI. - content_type = ( - "text/yaml; charset=utf-8" if format == apprise.ConfigFormat.YAML.value else "text/plain; charset=utf-8" - ) - - # Return our retrieved content - logger.info( - "VIEW - %s - Retrieved configuration associated using KEY: %s", - request.META["REMOTE_ADDR"], - key, - ) - return ( - HttpResponse( - config, - content_type=content_type, - status=ResponseCode.okay, - ) - if not json_response - else JsonResponse( - {"format": format, "config": config}, - encoder=JSONEncoder, - safe=False, - status=ResponseCode.okay, - ) - ) + return _get_config_response(request, key) @method_decorator((gzip_page, never_cache), name="dispatch") @@ -1275,7 +1297,7 @@ def post(self, request, key): ) # Something went very wrong; return 500 - msg = _("An error occured accessing configuration") + msg = _("An error occurred accessing configuration") status = ResponseCode.internal_server_error return ( HttpResponse(msg, status=status, content_type="text/plain") diff --git a/apprise_api/core/context_processors.py b/apprise_api/core/context_processors.py index 614461a..af43a2a 100644 --- a/apprise_api/core/context_processors.py +++ b/apprise_api/core/context_processors.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2020 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/core/middleware/theme.py b/apprise_api/core/middleware/theme.py index b7de549..89c6a09 100644 --- a/apprise_api/core/middleware/theme.py +++ b/apprise_api/core/middleware/theme.py @@ -24,9 +24,8 @@ # import datetime -from django.conf import settings - from core.themes import SITE_THEMES, SiteTheme +from django.conf import settings class AutoThemeMiddleware: diff --git a/apprise_api/core/settings/__init__.py b/apprise_api/core/settings/__init__.py index 4f3dd46..03be840 100644 --- a/apprise_api/core/settings/__init__.py +++ b/apprise_api/core/settings/__init__.py @@ -25,6 +25,13 @@ from core.themes import SiteTheme +# Project metadata for templates +APP_AUTHOR = "Chris Caron" +APP_COPYRIGHT = "Copyright (C) 2025 Chris Caron " +APP_LICENSE = "MIT" +APP_URL = "https://github.com/caronc/apprise-api" +APP_VERSION = "1.3.0" + # Disable Timezones USE_TZ = False @@ -64,6 +71,8 @@ "django.contrib.staticfiles", # Apprise API "api", + # Error Responses + "error", # Prometheus "django_prometheus", ] @@ -92,7 +101,7 @@ "api.context_processors.stateful_mode", "api.context_processors.config_lock", "api.context_processors.admin_enabled", - "api.context_processors.apprise_version", + "api.context_processors.apprise_metadata", ], }, }, diff --git a/apprise_api/core/settings/debug/__init__.py b/apprise_api/core/settings/debug/__init__.py index 688b321..970328d 100644 --- a/apprise_api/core/settings/debug/__init__.py +++ b/apprise_api/core/settings/debug/__init__.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/core/settings/debug/urls.py b/apprise_api/core/settings/debug/urls.py index b3ef146..7dc71fe 100644 --- a/apprise_api/core/settings/debug/urls.py +++ b/apprise_api/core/settings/debug/urls.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/core/settings/pytest/__init__.py b/apprise_api/core/settings/pytest/__init__.py index da99511..9c438a8 100644 --- a/apprise_api/core/settings/pytest/__init__.py +++ b/apprise_api/core/settings/pytest/__init__.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/core/settings/pytest/runner.py b/apprise_api/core/settings/pytest/runner.py index 552b819..284d1d0 100644 --- a/apprise_api/core/settings/pytest/runner.py +++ b/apprise_api/core/settings/pytest/runner.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/core/themes.py b/apprise_api/core/themes.py index 87be15a..010ebe9 100644 --- a/apprise_api/core/themes.py +++ b/apprise_api/core/themes.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/core/urls.py b/apprise_api/core/urls.py index bec0d3a..adb09ae 100644 --- a/apprise_api/core/urls.py +++ b/apprise_api/core/urls.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -24,8 +24,10 @@ from api import urls as api_urls from django.conf.urls import include from django.urls import path +from error import urls as error_urls urlpatterns = [ path("", include(api_urls)), + path("", include(error_urls)), path("", include("django_prometheus.urls")), ] diff --git a/apprise_api/core/wsgi.py b/apprise_api/core/wsgi.py index 874426a..488973e 100644 --- a/apprise_api/core/wsgi.py +++ b/apprise_api/core/wsgi.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/error/__init__.py b/apprise_api/error/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apprise_api/error/apps.py b/apprise_api/error/apps.py new file mode 100644 index 0000000..60bb8f2 --- /dev/null +++ b/apprise_api/error/apps.py @@ -0,0 +1,28 @@ +# +# Copyright (C) 2025 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from django.apps import AppConfig + + +class ErrorConfig(AppConfig): + name = "error" diff --git a/apprise_api/error/migrations/__init__.py b/apprise_api/error/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apprise_api/error/models.py b/apprise_api/error/models.py new file mode 100644 index 0000000..e69de29 diff --git a/apprise_api/error/templates/404.html b/apprise_api/error/templates/404.html new file mode 100644 index 0000000..413898b --- /dev/null +++ b/apprise_api/error/templates/404.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% load i18n %} + +{# Remove the left sidebar entirely #} +{% block sidebar %} +{% endblock sidebar %} +{# Remove scripts used only by โ€œfull UIโ€ pages #} +{% block head_js_optional %} +{% endblock head_js_optional %} +{% block scripts_optional %} +{% endblock scripts_optional %} +{% block onload %} +{% endblock onload %} +{# Make the main page full width #} +{% block main_page %} +
+ {% block body %} +
+

404 - {% trans "Page Not Found" %}

+ info +

+ {% blocktrans %} + The resource you requested could not be found on this Apprise API + instance. + {% endblocktrans %} +

+
+
cloud{{ remote_ip }} - {{original_method}} - {{ original_uri }}
+
+ {% endblock body %} + {% endblock main_page %} diff --git a/apprise_api/error/templates/50x.html b/apprise_api/error/templates/50x.html new file mode 100644 index 0000000..fca3b8c --- /dev/null +++ b/apprise_api/error/templates/50x.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% load i18n %} + +{# Remove the left sidebar entirely #} +{% block sidebar %} +{% endblock sidebar %} +{# Remove scripts used only by โ€œfull UIโ€ pages #} +{% block head_js_optional %} +{% endblock head_js_optional %} +{% block scripts_optional %} +{% endblock scripts_optional %} +{% block onload %} +{% endblock onload %} +{# Make the main page full width #} +{% block main_page %} +
+ {% block body %} +
+

500 - {% trans "Service Temporarily Unavailable" %}

+ error +

+ {% blocktrans %} + The Apprise API encountered an internal error while processing + your request. + {% endblocktrans %} +

+
+
cloud{{ remote_ip }} - {{original_method}} - {{ original_uri }}
+
+ {% endblock body %} + {% endblock main_page %} diff --git a/apprise_api/error/tests/__init__.py b/apprise_api/error/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apprise_api/error/tests/test_errors.py b/apprise_api/error/tests/test_errors.py new file mode 100644 index 0000000..972d3b9 --- /dev/null +++ b/apprise_api/error/tests/test_errors.py @@ -0,0 +1,41 @@ +# +# Copyright (C) 2025 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from django.test import SimpleTestCase + + +class ErrorTests(SimpleTestCase): + def test_get_404(self): + """ + Test 404 + """ + response = self.client.get("/_/404") + assert response.status_code == 404 + + def test_get_50x(self): + """ + Test 50x + """ + response = self.client.get("/_/50x") + assert response.status_code == 500 diff --git a/apprise_api/error/urls.py b/apprise_api/error/urls.py new file mode 100644 index 0000000..77e9e4d --- /dev/null +++ b/apprise_api/error/urls.py @@ -0,0 +1,31 @@ +# +# Copyright (C) 2025 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path(r"^_/404/?$", views.Error404View.as_view(), name="http_404"), + re_path(r"^_/50x/?$", views.Error50xView.as_view(), name="http_50x"), +] diff --git a/apprise_api/error/views.py b/apprise_api/error/views.py new file mode 100644 index 0000000..1887647 --- /dev/null +++ b/apprise_api/error/views.py @@ -0,0 +1,124 @@ +# Copyright (C) 2025 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +from api.utils import MIME_IS_JSON +from django.http import JsonResponse +from django.shortcuts import render +from django.utils.translation import gettext_lazy as _ +from django.views import View + + +class Error404View(View): + """ + Render a 404 page for errors + + Proxy must pass: + - HTTP_X_ERROR_CODE + - HTTP_X_ORIGINAL_URI + - HTTP_X_ORIGINAL_METHOD + """ + + template_name = "404.html" + + def get(self, request): + + original_uri = request.META.get("HTTP_X_ORIGINAL_URI", request.path) + original_method = request.META.get("HTTP_X_ORIGINAL_METHOD", request.method) + remote_ip = request.META.get("HTTP_X_REAL_IP") or request.META.get( + "REMOTE_ADDR" + ) + + context = { + "original_uri": original_uri, + "original_method": original_method, + "remote_ip": remote_ip, + } + + # Detect the format our response should be in + json_response = ( + MIME_IS_JSON.match( + request.content_type + if request.content_type + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) + + return ( + render(request, self.template_name, context=context, status=404) + if not json_response + else JsonResponse( + {"error": _("Page not found")}, + safe=False, + status=404, + ) + ) + + +class Error50xView(View): + """ + 50x Error Code Response + + Proxy must pass: + - HTTP_X_ERROR_CODE + - HTTP_X_ORIGINAL_URI + - HTTP_X_ORIGINAL_METHOD + """ + + template_name = "50x.html" + + def get(self, request): + + original_uri = request.META.get("HTTP_X_ORIGINAL_URI", request.path) + original_method = request.META.get("HTTP_X_ORIGINAL_METHOD", request.method) + remote_ip = request.META.get("HTTP_X_REAL_IP") or request.META.get( + "REMOTE_ADDR" + ) + + context = { + "original_uri": original_uri, + "original_method": original_method, + "remote_ip": remote_ip, + } + + # Detect the format our response should be in + json_response = ( + MIME_IS_JSON.match( + request.content_type + if request.content_type + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) + + return ( + render(request, self.template_name, context=context, status=500) + if not json_response + else JsonResponse( + {"error": _("System error")}, + safe=False, + status=500, + ) + ) + diff --git a/apprise_api/etc/nginx-strict.conf b/apprise_api/etc/nginx-strict.conf new file mode 100644 index 0000000..1320781 --- /dev/null +++ b/apprise_api/etc/nginx-strict.conf @@ -0,0 +1,362 @@ +daemon off; +worker_processes auto; +pid /tmp/apprise/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 4096; +} + +http { + # Upstream Configuration + upstream apprise_upstream { + server unix:/tmp/apprise/gunicorn.sock max_fails=0; + keepalive 16; + } + + # Basic Settings + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + server_tokens off; + + # Upload Restriction + client_max_body_size 500M; + + # Logging Settings + access_log /dev/stdout; + error_log /dev/stdout info; + + # Centralize configuration so docker containers can easily make use + # of tmpfs mounted to /tmp + client_body_temp_path /tmp/nginx_client_temp 1 2; + proxy_temp_path /tmp/nginx_proxy_temp 1 2; + fastcgi_temp_path /tmp/nginx_fastcgi_temp 1 2; + uwsgi_temp_path /tmp/nginx_uwsgi_temp 1 2; + scgi_temp_path /tmp/nginx_scgi_temp 1 2; + + # Gzip Settings + gzip on; + gzip_proxied any; + gzip_vary on; + gzip_min_length 1024; + gzip_types application/json text/plain text/css application/javascript text/xml application/xml; + + # Host Configuration + client_body_buffer_size 256k; + client_body_in_file_only off; + + # Retry cushions + proxy_next_upstream error timeout http_502 http_503 http_504; + proxy_next_upstream_tries 2; + + # Rate Limiting for /status + limit_req_zone $binary_remote_addr zone=status:10m rate=5r/s; + + server { + listen 8000; # IPv4 Support + listen [::]:8000; # IPv6 Support + + # Allow users to map to this file and provide their own custom + # overrides such as + include /etc/nginx/server-override.conf; + + # Error handling + proxy_intercept_errors on; + error_page 404 = /_/404/; + error_page 500 = /_/50x/; + error_page 502 503 504 /50x.html; + + # + # 1. Root welcome page: GET / + # Django: WelcomeView at "^$" + # + location = / { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + + include /etc/nginx/location-override.conf; + + # Only allow GET/HEAD here, drop everything else + if ($request_method !~ ^(GET|HEAD)$) { + return 444; + } + } + + # + # 2. Stateless notify: POST /notify + # Django: StatelessNotifyView at "^notify/?$" + # + location = /notify { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Expect $http_expect; + + client_max_body_size 500M; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + include /etc/nginx/location-override.conf; + + # Only POST is valid for this endpoint + if ($request_method != POST) { + return 444; + } + } + + # + # 3. Stateful notify with key: POST /notify/ + # Django: NotifyView at "^notify/(?P[\w_-]{1,128})/?$" + # + location ~ "^/notify/[\w_-]{1,128}/?$" { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Expect $http_expect; + + client_max_body_size 500M; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + include /etc/nginx/location-override.conf; + + if ($request_method != POST) { + return 444; + } + } + + # + # 4. Health check: GET /status or /status/ + # Django: HealthCheckView at "^status/?$" + # + location ~ ^/status/?$ { + # normalise internally to /status/ (no client redirect) + rewrite ^/status/?$ /status/ break; + + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 1s; + proxy_send_timeout 2s; + proxy_read_timeout 2s; + proxy_buffering off; + + access_log off; + limit_req zone=status burst=10 nodelay; + add_header Cache-Control "no-store" always; + + include /etc/nginx/location-override.conf; + + if ($request_method !~ ^(GET|HEAD)$) { + return 444; + } + } + + # + # 5. Service discovery and metadata + # Django: DetailsView "^details/?$" + # JsonUrlView "^json/urls/(?P[\w_-]{1,128})/?$" + # + location ~ "^/(details|json/urls/[\w_-]{1,128})/?$" { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + + include /etc/nginx/location-override.conf; + + if ($request_method !~ ^(GET|HEAD)$) { + return 444; + } + } + + # + # 6. Config list view: GET /cfg + # Django: ConfigListView "^cfg/?$" + # + location = /cfg { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + include /etc/nginx/location-override.conf; + + if ($request_method !~ ^(GET|HEAD)$) { + return 444; + } + } + + # + # 7. Django Error Handling: GET /_/ + # 404 /_/404/ + # 50x /_/40x/ + location /_/ { + proxy_intercept_errors off; + + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + } + + # + # 8. Config management (HTML UI + API) + # Django: + # ConfigView "^cfg/(?P[\w_-]{1,128})/?$" [GET, POST] + # AddView "^add/(?P[\w_-]{1,128})/?$" [POST] + # DelView "^del/(?P[\w_-]{1,128})/?$" [POST] + # GetView "^get/(?P[\w_-]{1,128})/?$" [POST] + # + location ~ "^/(cfg|add|del|get)/[\w_-]{1,128}/?$" { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 300s; + + include /etc/nginx/location-override.conf; + + # /cfg/ supports GET+POST (HTML and API), + # /add|/del|/get are POST-only at the Django view level. + # We allow GET+POST here, Django enforces specifics. + if ($request_method !~ ^(GET|POST)$) { + return 444; + } + } + + # + # 9. Static content: /s/ + # + location /s/ { + # root points to /usr/share/nginx/html + # so /s/... maps to /usr/share/nginx/html/s/... + root /usr/share/nginx/html; + index index.html; + include /etc/nginx/location-override.conf; + + if ($request_method !~ ^(GET|HEAD)$) { + return 444; + } + } + + # + # 10. Serve favicon.ico + # + location = /favicon.ico { + root /usr/share/nginx/html/s; + access_log off; + log_not_found off; + } + + # + # 11. Serve robots.txt + # + location = /robots.txt { + access_log off; + log_not_found off; + default_type text/plain; + return 200 "User-agent: *\nDisallow: /\n"; + } + + # + # 12. Catch-all: anything not explicitly whitelisted above + # is handled here. This is considered "invalid" and gets 444 + # (connection closed). + # + location / { + return 444; + } + } +} diff --git a/apprise_api/etc/nginx.conf b/apprise_api/etc/nginx.conf index d8c7146..44eec63 100644 --- a/apprise_api/etc/nginx.conf +++ b/apprise_api/etc/nginx.conf @@ -4,155 +4,335 @@ pid /tmp/apprise/nginx.pid; include /etc/nginx/modules-enabled/*.conf; events { - worker_connections 4096; + worker_connections 4096; } http { - # Upstream Configuration - upstream apprise_upstream { - server unix:/tmp/apprise/gunicorn.sock max_fails=0; - keepalive 16; - } - # Basic Settings - sendfile on; - tcp_nopush on; - types_hash_max_size 2048; - include /etc/nginx/mime.types; - default_type application/octet-stream; - server_tokens off; - - # Upload Restriction - client_max_body_size 500M; - - # Logging Settings - access_log /dev/stdout; - error_log /dev/stdout info; - - # Centralize configuration so docker containers can easily make use - # of tmpfs mounted to /tmp - client_body_temp_path /tmp/nginx_client_temp 1 2; - proxy_temp_path /tmp/nginx_proxy_temp 1 2; - fastcgi_temp_path /tmp/nginx_fastcgi_temp 1 2; - uwsgi_temp_path /tmp/nginx_uwsgi_temp 1 2; - scgi_temp_path /tmp/nginx_scgi_temp 1 2; - - # Gzip Settings - gzip on; - gzip_proxied any; - gzip_vary on; - gzip_min_length 1024; - gzip_types application/json text/plain text/css application/javascript text/xml application/xml; - - # Host Configuration - client_body_buffer_size 256k; - client_body_in_file_only off; - - # Retry cushions - proxy_next_upstream error timeout http_502 http_503 http_504; - proxy_next_upstream_tries 2; - - # Rate Limiting for /status - limit_req_zone $binary_remote_addr zone=status:10m rate=5r/s; - - server { - listen 8000; # IPv4 Support - listen [::]:8000; # IPv6 Support - - # Allow users to map to this file and provide their own custom - # overrides such as - include /etc/nginx/server-override.conf; - - # Main Website - location / { - proxy_pass http://apprise_upstream; - proxy_http_version 1.1; - proxy_set_header Connection ""; - - proxy_set_header Accept-Encoding ""; - proxy_set_header Transfer-Encoding ""; - - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - # See https://github.com/caronc/apprise-api/issues/275 - # Avoid - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - - proxy_read_timeout 120s; - - include /etc/nginx/location-override.conf; - } - - location = /notify { - proxy_pass http://apprise_upstream; - - proxy_http_version 1.1; - proxy_request_buffering off; - proxy_set_header Connection ""; - - proxy_set_header Accept-Encoding ""; - proxy_set_header Transfer-Encoding ""; - - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - # See https://github.com/caronc/apprise-api/issues/275 - # Avoid - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Expect $http_expect; - - client_max_body_size 500M; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - - include /etc/nginx/location-override.conf; - } - - # handling of status (/status or /status/) - location ~ ^/status/?$ { - # normalise internally to /status/ (no client redirect) - rewrite ^/status/?$ /status/ break; - - proxy_pass http://apprise_upstream; - proxy_http_version 1.1; - proxy_set_header Connection ""; - - proxy_set_header Accept-Encoding ""; - proxy_set_header Transfer-Encoding ""; - - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - # See https://github.com/caronc/apprise-api/issues/275 - # Avoid - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - - proxy_connect_timeout 1s; - proxy_send_timeout 2s; - proxy_read_timeout 2s; - proxy_buffering off; - - access_log off; - limit_req zone=status burst=10 nodelay; - add_header Cache-Control "no-store" always; - - include /etc/nginx/location-override.conf; - } - - # Static Content - location /s/ { - root /usr/share/nginx/html; - index index.html; - include /etc/nginx/location-override.conf; - } - - # 404 error handling - error_page 404 /404.html; - - # redirect server error pages to the static page /50x.html - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } - } + # Upstream Configuration + upstream apprise_upstream { + server unix:/tmp/apprise/gunicorn.sock max_fails=0; + keepalive 16; + } + + # Basic Settings + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + server_tokens off; + + # Upload Restriction + client_max_body_size 500M; + + # Logging Settings + access_log /dev/stdout; + error_log /dev/stdout info; + + # Centralize configuration so docker containers can easily make use + # of tmpfs mounted to /tmp + client_body_temp_path /tmp/nginx_client_temp 1 2; + proxy_temp_path /tmp/nginx_proxy_temp 1 2; + fastcgi_temp_path /tmp/nginx_fastcgi_temp 1 2; + uwsgi_temp_path /tmp/nginx_uwsgi_temp 1 2; + scgi_temp_path /tmp/nginx_scgi_temp 1 2; + + # Gzip Settings + gzip on; + gzip_proxied any; + gzip_vary on; + gzip_min_length 1024; + gzip_types application/json text/plain text/css application/javascript text/xml application/xml; + + # Host Configuration + client_body_buffer_size 256k; + client_body_in_file_only off; + + # Retry cushions + proxy_next_upstream error timeout http_502 http_503 http_504; + proxy_next_upstream_tries 2; + + # Rate Limiting for /status + limit_req_zone $binary_remote_addr zone=status:10m rate=5r/s; + + server { + listen 8000; # IPv4 Support + listen [::]:8000; # IPv6 Support + + # Allow users to map to this file and provide their own custom + # overrides such as + include /etc/nginx/server-override.conf; + + # Error handling + proxy_intercept_errors on; + error_page 404 = /_/404/; + error_page 500 = /_/50x/; + error_page 502 503 504 /50x.html; + + # + # 1. Root welcome page: GET / + # Django: WelcomeView at "^$" + # + location = / { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + + include /etc/nginx/location-override.conf; + } + + # + # 2. Stateless notify: POST /notify + # Django: StatelessNotifyView at "^notify/?$" + # + location = /notify { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Expect $http_expect; + + client_max_body_size 500M; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + include /etc/nginx/location-override.conf; + } + + # + # 3. Stateful notify with key: POST /notify/ + # Django: NotifyView at "^notify/(?P[\w_-]{1,128})/?$" + # + location ~ "^/notify/[\w_-]{1,128}/?$" { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Expect $http_expect; + + client_max_body_size 500M; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + include /etc/nginx/location-override.conf; + } + + # + # 4. Health check: GET /status or /status/ + # Django: HealthCheckView at "^status/?$" + # + location ~ ^/status/?$ { + # normalise internally to /status/ (no client redirect) + rewrite ^/status/?$ /status/ break; + + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 1s; + proxy_send_timeout 2s; + proxy_read_timeout 2s; + proxy_buffering off; + + access_log off; + limit_req zone=status burst=10 nodelay; + add_header Cache-Control "no-store" always; + + include /etc/nginx/location-override.conf; + } + + # + # 5. Service discovery and metadata + # Django: DetailsView "^details/?$" + # JsonUrlView "^json/urls/(?P[\w_-]{1,128})/?$" + # + location ~ "^/(details|json/urls/[\w_-]{1,128})/?$" { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + + include /etc/nginx/location-override.conf; + } + + # + # 6. Config list view: GET /cfg + # Django: ConfigListView "^cfg/?$" + # + location = /cfg { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + include /etc/nginx/location-override.conf; + } + + # + # 7. Django Error Handling: GET /_/ + # 404 /_/404/ + # 50x /_/40x/ + location /_/ { + proxy_intercept_errors off; + + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 120s; + } + + # + # 8. Config management (HTML UI + API) + # Django: + # ConfigView "^cfg/(?P[\w_-]{1,128})/?$" [GET, POST] + # AddView "^add/(?P[\w_-]{1,128})/?$" [POST] + # DelView "^del/(?P[\w_-]{1,128})/?$" [POST] + # GetView "^get/(?P[\w_-]{1,128})/?$" [POST] + # + location ~ "^/(cfg|add|del|get)/[\w_-]{1,128}/?$" { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 300s; + + include /etc/nginx/location-override.conf; + } + + # + # 9. Static content: /s/ + # + location /s/ { + # root points to /usr/share/nginx/html + # so /s/... maps to /usr/share/nginx/html/s/... + root /usr/share/nginx/html; + index index.html; + include /etc/nginx/location-override.conf; + } + + # + # 10. Serve favicon.ico + # + location = /favicon.ico { + root /usr/share/nginx/html/s; + access_log off; + log_not_found off; + } + + # + # 11. Serve robots.txt + # + location = /robots.txt { + access_log off; + log_not_found off; + default_type text/plain; + return 200 "User-agent: *\nDisallow: /\n"; + } + + # + # 12. Catch-all: anything not explicitly whitelisted above + # is handled here. + # + location / { + proxy_pass http://apprise_upstream; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Transfer-Encoding ""; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + # See https://github.com/caronc/apprise-api/issues/275 + # Avoid - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + include /etc/nginx/location-override.conf; + } + } } diff --git a/apprise_api/manage.py b/apprise_api/manage.py index 6d7d805..9a42f5b 100755 --- a/apprise_api/manage.py +++ b/apprise_api/manage.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/apprise_api/static/css/base.css b/apprise_api/static/css/base.css index 0e841e8..25c84f6 100644 --- a/apprise_api/static/css/base.css +++ b/apprise_api/static/css/base.css @@ -1,10 +1,10 @@ -/* === Main layout: side menu vs content === */ - +/* ========================================= +1. Main Layout & Navigation +========================================= */ .main-layout { margin-top: 0.5rem; } -/* Small screens: main content first, menu below */ @media (max-width: 992px) { .main-layout { display: flex; @@ -27,43 +27,128 @@ } } -.nav h1 { - margin: 0.4rem; - font-size: 2.1rem; - font-weight: bold; - text-transform: uppercase; - float: left; +.nav ul li { + list-style: none; } -/* Apprise Version */ .nav ul { float: right; font-style: normal; - font-size: 0.7rem; + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + align-items: flex-end; +} + +/* Apprise Library text โ€“ tight to the top-right */ +.nav ul li .apprise-lib-ver { + line-height: 1.1; + margin-top: 0.1rem; +} + +.nav ul li.api-status-nav, +.nav ul li .apprise-lib-ver { + font-size: 0.75rem; } + .theme { text-align: right; display: block; - float:right; + float: none; + margin-top: 0.4rem; } -a.apprise-lib-ver, a.apprise-lib-ver:hover { - color: inherit; +.nav ul i { + font-size: 2.0rem; } -input { +.error-page { + font-weight: bold; +} + +.error-page h1 { + font-size: 5em; + font-weight: inherit; +} + +.error-page i { + color: inherit !important; +} + +.error-page i.spotlight { + font-size: 10em; +} + +.error-page pre code i { + /* Better alignment */ + margin: -0.3em .5em 0 0; +} + +img.apprise-logo, +img.apprise-mini-logo { + max-width: 100%; display: block; } -.tabs .tab.disabled a,.tabs .tab.disabled a:hover{font-weight: inherit} +img.apprise-logo { + height: 6em; +} + +img.apprise-mini-logo { + height: 2em; + padding: 0 0.25rem; +} + +.theme { + text-align: right; + display: block; + float: right; +} + +a.apprise-api-ver, +a.apprise-lib-ver { + color: inherit; + text-decoration: none; +} + +.page-footer-legal { + margin-top: 2rem; + padding: 0.75rem 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + text-align: center; + font-size: 0.95rem; + opacity: 0.8; +} + +.page-footer-legal a { + text-decoration: none; +} + +.page-footer-legal a:hover { + text-decoration: underline; +} + +/* ========================================= +2. Global Typography & Elements +========================================= */ +input { + display: block; +} code { font-family: monospace; white-space: normal; padding: 0.2rem; + border-radius: 4px; } -h1, h2, h3, h4, h5 { +h1, +h2, +h3, +h4, +h5 { margin-top: 0; } @@ -71,19 +156,11 @@ h4 { margin-bottom: 0.75rem; } -td, th { - vertical-align: top; - padding-top: 0; -} -.api-details ol { - margin-top: 0; - margin-bottom: 0; -} - -ul.detail-buttons strong { - font-weight: 800; +h6 { + font-weight: bold; } +/* Specific H4 fix for welcome/details pages */ h4 em { font-size: 2.0rem; display: inline-block; @@ -91,58 +168,193 @@ h4 em { padding: 0; word-break: break-all; line-height: 1.0em; + font-style: normal; + font-weight: bold; +} + +td, +th { + vertical-align: top; + padding-top: 0; } -em { - color: #004d40; - font-weight:bold; +textarea { + height: 16rem; + font-family: monospace; } -.no-config .info { - color: #004d40; - font-size: 3.3rem; +.material-icons { + display: inline-flex; + vertical-align: middle; +} +/* ========================================= +3. Page Specific: Welcome & Details +========================================= */ +.api-welcome { + margin-top: 0.5rem; } -textarea { - height: 16rem; +.api-welcome .section { + max-width: 80rem; +} + +.api-welcome .table-wrapper { + width: 100%; + overflow-x: auto; +} + +.api-welcome pre { + white-space: pre-wrap; + word-break: break-word; +} + +.api-details { + margin-top: 0.5rem; +} + +.api-details .details-header p, +.api-details .details-summary { + max-width: 70rem; +} + +.api-details .collapsible-header { + display: flex; + align-items: center; + padding: 0.6rem 1rem; +} + +.api-details .plugin-index { font-family: monospace; + font-size: 0.9rem; + width: 3rem; + text-align: right; + margin-right: 0.5rem; +} + +.api-details .plugin-chevron, +.api-details .plugin-status { + margin-right: 0.4rem; +} + +/* Apprise API status indicator */ +.api-status-icon { + vertical-align: middle; + margin-left: 0.25rem; + font-size: 1.1rem; +} + +/* Loading spinner */ +.api-status-icon.status-loading { + animation: api-spin 1s linear infinite; +} + +/* Error details text under the line */ +.api-status-details { + margin-left: 1.8rem; + margin-top: 0.2rem; + font-size: 0.85rem; +} + +.nav ul li.api-status-nav { + margin-top: 0.15rem; +} + +.nav ul li.api-status-nav i { + font-size: 1.4em; +} + +.api-status-icon { + vertical-align: middle; + margin-left: 0.25rem; + font-size: 1.1rem; +} + +.api-status-icon.status-loading { + animation: api-spin 1s linear infinite; +} + +@keyframes api-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.api-details .service_name { + width: auto; + flex: 1 1 auto; + margin: 0; + padding: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.api-details .plugin-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-left: 0.5rem; +} + +.api-details .plugin-actions .material-icons { + font-size: 1.2rem; +} + +.getting-started li { + padding: 0.3em 0; +} + +.getting-started p { + padding: 0.2em 0; + margin: 0; +} + +.detail-buttons--width { + width: 90%; + min-width: 60%; + padding-left: 0; } +/* ========================================= +4. Config Page, Tabs & Cards +========================================= */ .collapsible-body { padding: 1rem 2rem; } +.no-config .info { + font-size: 3.3rem; +} + #overview strong { display: inline-block; } -.tabs .tab a{ - border-radius: 25px 25px 0 0; +#overview pre { + margin-left: 2.0rem; } -.collection a.collection-item:not(.active):hover, -.tabs .tab a:focus, .tabs .tab a:focus.active { - background-color: #eee; + +/* Base Tab Structure */ +.tabs .tab a { + border-radius: 25px 25px 0 0; } -.tabs .tab a:hover,.tabs .tab a.active { - background-color:transparent; - color:#004d40; + +body .tabs .tab a:hover, +body .tabs .tab a.active { font-weight: bold; - background-color: #eee; } -.tabs .tab.disabled a,.tabs .tab.disabled a:hover { - color:rgba(102,147,153,0.7); -} -.tabs .indicator { - background-color:#004d40; -} -.tabs .tab-locked a { - /* Handle locked out tabs */ - color:rgba(212, 161, 157, 0.7); -} -.tabs .tab-locked a:hover,.tabs .tab-locked a.active { - /* Handle locked out tabs */ - color: #6b0900; + +body .tabs .tab.disabled a, +body .tabs .tab.disabled a:hover, +body .tabs .tab.disabled a i, +body .tabs .tab.disabled a:hover i { + font-weight: inherit; + cursor: default; } .tabs.config-overview { @@ -155,50 +367,55 @@ textarea { overflow-x: auto; white-space: nowrap; } + .tabs.config-overview .tab { flex: 0 0 auto; } } -.material-icons{ - display: inline-flex; - vertical-align: middle; +/* URL List Grid */ +#url-list { + display: block; + /* Removes the Grid restriction */ + column-count: 1; + /* Default to 1 column (Mobile) */ + column-gap: 1rem; + /* Space between the two columns */ + padding: 0; + margin: 0; } -.chip { - margin: 0.3rem; - background-color: inherit; - cursor: pointer; +#url-list ul { + margin: 0; +} + +/* Force 2 columns on screens wider than tablets (768px) */ +@media (min-width: 768px) { + #url-list { + column-count: 2; + } } #url-list .card-panel { - margin: 0.1rem 0; + break-inside: avoid; + page-break-inside: avoid; + display: inline-block; + width: 100%; + margin-bottom: 0.8rem; border-radius: 12px; - width: 50%; - min-width: 35rem; min-height: 4em; - float: left; position: relative; padding: 0.5rem 0.75rem 1.8rem 0.75rem; + box-sizing: border-box; + float: none; } -/* When there is not enough space for 2 columns, use full width */ -@media (max-width: 1450px) { - #url-list .card-panel { - width: 100%; - min-width: 0; - float: none; - } -} - -/* Header row: URL + copy icon */ #url-list .url-header { display: flex; align-items: flex-start; gap: 0.4rem; } -/* Main URL text */ #url-list .url-header code.url-text { flex: 1 1 auto; margin-top: 0.2rem; @@ -211,19 +428,6 @@ textarea { word-break: break-all; } -/* Copy button */ -#url-list .copy-url-btn { - flex: 0 0 auto; - padding: 0 0.35rem; - min-width: 0; - margin-top: 0.15rem; - border-radius: 999px; -} - -#url-list .copy-url-btn .material-icons { - font-size: 1.1rem; -} - #url-list code { overflow-x: hidden; overflow-y: hidden; @@ -235,42 +439,20 @@ textarea { display: block; } -/* URL ID stays pinned above, right, as before */ -#url-list { - display: grid; - /* On wide screens, as many columns as fit, min 28rem each */ - grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr)); - grid-auto-rows: auto; - grid-column-gap: 0.8rem; - grid-row-gap: 0.6rem; - padding: 0; - margin: 0; -} - -/* On narrower screens, force a single column so cards stretch nicely */ -@media (max-width: 1100px) { - #url-list { - grid-template-columns: minmax(0, 1fr); - } -} - -/* Chips remain below the header */ -#url-list .card-panel .chip { - margin: 0.3rem 0.35rem 0.9rem 0; -} - -#url-list .copy-url-btn { - border-radius: 999px; +#url-list .service_name { + width: 40rem; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -/* URL ID badge pinned to bottom-right of each card */ #url-list .card-panel .url-id { width: auto; margin: 0; background-color: inherit; - color: #aaa; padding: 0.15em 0.35em; - font-size: 0.65em; /* feel free to shrink further */ + font-size: 0.65em; border: 0; position: absolute; bottom: 0.45rem; @@ -278,61 +460,102 @@ textarea { opacity: 0.9; } -#url-list .copy-url-btn:hover { - background-color: rgba(255,255,255,0.06); +/* ========================================= +5. Buttons, Chips & Inputs +========================================= */ +.chip { + margin: 0.3rem; + cursor: pointer; } -/* Notification Details */ -ul.logs { - font-family: monospace, monospace; - height: 60%; - overflow: auto; +.chip.selected { + font-weight: 600; } -ul.logs li { - display: flex; - text-align: left; - width: 50em; +#url-list .card-panel .url-selected-icon { + position: absolute; + bottom: 0.45rem; + left: 0.75rem; + opacity: 0.9; + line-height: 1; + pointer-events: none; } -ul.logs li div.log_time { - font-weight: normal; - flex: 0 15em; +#url-list .card-panel:not(.selected) .url-selected-icon { + opacity: 0.55; } -ul.logs li div.log_level { - font-weight: bold; - align: right; - flex: 0 5em; +#url-list .card-panel .url-selected-icon .material-icons { + font-size: 1.0rem; +} + +#url-list .card-panel .chip { + margin: 0.3rem 0.35rem 0.9rem 0; } -ul.logs li div.log_msg { - flex: 1; +#url-list .copy-url-btn, +.config-id-copy.btn-flat, +.snippet-copy-btn { + border-radius: 999px !important; + flex: 0 0 auto; + padding: 0 0.35rem; + min-width: 0; + background-color: transparent; } -.url-enabled { - color:#004d40; +#url-list .copy-url-btn { + margin-top: 0.15rem; } -.url-disabled { - color: #8B0000; + +#url-list .copy-url-btn .material-icons, +.config-id-copy.btn-flat .material-icons, +.snippet-copy-btn .material-icons { + font-size: 1.1rem; } -h6 { - font-weight: bold; + +.config-id-wrapper { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin-left: 0.25rem; } -#overview pre { - margin-left: 2.0rem + +.config-id-copy.btn-flat { + margin-left: 0.2rem; } code.config-id { - font-size: 0.7em; + font-size: 1em; + font-weight: 700; +} + +.code-snippet { + position: relative; + margin-bottom: 1rem; +} + +.code-snippet pre { + margin: 0; +} + +.snippet-copy-btn { + position: absolute; + top: 0.35rem; + right: 0.5rem; } -/* file button styled */ +.snippet-copy-btn i { + /* Better alignment */ + margin: -0.3em 0 0 0; +} + +/* File Input Hack */ .btn-file { position: relative; overflow: hidden; text-transform: uppercase; } + .btn-file input[type=file] { position: absolute; top: 0; @@ -357,26 +580,24 @@ code.config-id { overflow: hidden; } -.chip.selected { - font-weight: 600; -} - +/* Health Check */ #health-check { - background-color: #f883791f; border-radius: 25px; padding: 2em; margin-bottom: 2em; } + #health-check h4 { font-size: 30px; } -#health-check h4 .material-icons { + +#health-check h4 .material-icons, +#health-check li .material-icons { margin-top: -0.2em; } #health-check li .material-icons { font-size: 30px; - margin-top: -0.2em; } #health-check ul { @@ -389,3 +610,233 @@ code.config-id { font-size: 1.2rem; display: block; } + +.notify-top-row .input-field>label { + position: static; + transform: none; + display: block; + margin: 0 0 0.2rem 0; + font-size: 0.9rem; +} + +.notify-attachments-field input[type=file] { + /* Hidden, we trigger it from our Browse button */ + display: none; +} + +.notify-attachments-field .btn-file { + display: inline-flex; + margin-top: 0.25rem; +} + +.notify-attachments-list { + margin-top: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.notify-attachments-list .attachment-chip { + border-radius: 16px; + padding: 0 0.75rem; + line-height: 1.8; + cursor: pointer; + font-size: 0.9rem; + display: inline-flex; + align-items: center; +} + +.notify-attachments-list .attachment-chip i.material-icons { + font-size: 1.1rem; + margin-left: 0.25rem; +} + +#notify .row, +#notify .row .input-field { + margin-top: 0.2em; + margin-bottom: 0; + padding-bottom: 0; + padding-top: 0.3rem; +} + +.notify-top-row .input-field label[for="id_tag"] { + position: static; + transform: none; + display: block; + margin-top: -0.1rem; + margin-bottom: 0; + font-size: 0.9rem; +} + +.btn-clear-form { + min-height: 4rem; + height: auto; + width: 100%; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.9rem; + line-height: 1; +} + +.btn-clear-form label { + display: block; + text-align: left; + font-size: 0.95rem; + color: inherit; + max-width: 10.5ch; + white-space: normal; + word-break: keep-all; + line-height: 1.15; +} + +.btn-clear-form i { + margin: 0; + flex: 0 0 auto; + font-size: 2.6rem; + line-height: 1; + opacity: 0.95; +} + +/* SweetAlert2 โ€“ notification summary and logs */ +.swal2-popup .notify-summary { + text-align: center; + margin: 0 auto 1rem; + font-size: 1rem; + font-weight: 500; +} + +.swal2-popup .notify-log-panel { + margin: 0 auto 0.5rem; + padding: 0.75rem 1rem; + max-height: 28rem; + overflow-y: auto; + overflow-x: auto; + border-radius: 4px; + text-align: left; +} + +.swal2-popup .notify-log-panel ul.logs { + margin: 0; + padding: 0; + list-style: none; + text-align: left; +} + +/* log lines */ +.swal2-popup .notify-log-panel ul.logs li { + display: flex; + flex-direction: row; + gap: 0.8rem; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.95rem; + line-height: 1.4; + padding: 0.05rem 0; + white-space: pre-wrap; +} + +.swal2-popup .notify-log-panel ul.logs li .log_time, +.swal2-popup .notify-log-panel ul.logs li .log_level { + flex-shrink: 0; +} + +/* ========================================= +Apprise Configuration Editor (Grid Overlay) +========================================= */ +.apprise-config-editor { + display: grid; + position: relative; + margin-top: 0.5rem; +} + +/* Shared styles for perfect alignment */ +.apprise-config-editor textarea, +.apprise-config-highlight { + grid-area: 1 / 1; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; + font-size: 14px !important; + line-height: 22px !important; + letter-spacing: 0px !important; + padding: 15px !important; + width: 100% !important; + box-sizing: border-box !important; + border-radius: 4px; + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + overflow-y: hidden !important; + margin: 0 !important; + min-height: 250px; +} + +.apprise-config-editor textarea { + height: initial !important; + background: transparent !important; + color: transparent !important; + z-index: 2; + resize: vertical; + border: 1px solid #444 !important; +} + +.apprise-config-editor .apprise-config-highlight { + z-index: 1; + pointer-events: none; + height: 100% !important; + border: 1px solid transparent !important; +} + +.apprise-config-highlight code { + font-family: inherit !important; + font-size: inherit !important; + background: transparent !important; + padding: 0 !important; + color: inherit; +} + +.apprise-config-highlight code, +.apprise-config-highlight .hljs { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + background: transparent !important; + padding: 0 !important; + margin: 0 !important; + font-family: inherit !important; + font-size: inherit !important; + line-height: inherit !important; +} + +.apprise-config-editor textarea, +.apprise-config-highlight { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; + font-size: 14px !important; + line-height: 22px !important; + letter-spacing: 0px !important; + padding: 15px !important; + width: 100% !important; + box-sizing: border-box !important; +} + +.apprise-config-editor textarea { + border: 1px solid #444 !important; + border-radius: 4px; +} + +.apprise-config-highlight { + border: 1px solid transparent !important; + border-radius: 4px; +} + +.config-format-field { + margin-bottom: 0.75rem; +} + +.config-format-field .select-wrapper { + max-width: 10rem; + width: 100%; +} + +.config-format-field label[for="id_format"] { + font-size: 0.9rem; +} \ No newline at end of file diff --git a/apprise_api/static/css/theme-dark.css b/apprise_api/static/css/theme-dark.css new file mode 100644 index 0000000..d5f9e65 --- /dev/null +++ b/apprise_api/static/css/theme-dark.css @@ -0,0 +1,446 @@ +/* ========================================= +Global Dark Theme Variables & Overrides +========================================= */ +html { + color: #d8dee9; + background-color: #2e3440; +} + +h1 { + color: #eceff4 !important; +} + +h4 { + color: #e5e9f0; +} + +h5 { + color: #8fbcbb; +} + +a { + color: #ebcb8b; +} + +a:hover { + color: #d08770; +} + +em { + color: #5e81ac !important; +} + +.dropdown-content li>span:hover { + background-color: #4c566a !important; +} + +.select-wrapper input.select-dropdown:focus { + border-bottom: 1px solid #4c566a !important; +} + +.dropdown-content li>a, +.dropdown-content li>span { + background-color: #171b23 !important; + color: #eceff4; +} + +i.material-icons { + color: #6e7d9c; +} + +i.material-icons:hover { + color: #57637a; +} + +/* Status Colors */ +.url-enabled { + color: #02c7a6; +} + +.url-disabled { + color: #e00202; +} + +.no-config .info { + color: #4c566a; +} + +.api-details .plugin-index { + color: #666; +} + +/* ========================================= +Navigation & Footer +========================================= */ +.nav.nav-color, +nav { + background: #171b23 !important; + color: #d8dee9; +} + +nav a, +nav .brand-logo, +nav ul a { + color: #d8dee9; +} + +nav ul li.active, +nav ul a:hover { + background-color: rgba(0, 0, 0, .1); +} + +.page-footer { + color: #d8dee9; + background-color: #ee6e73; +} + +/* ========================================= +Components: Cards, Collections, Collapsibles +========================================= */ +.card-panel, +.card { + background-color: #4c566a; +} + +.card .card-image .card-title { + color: #d8dee9; +} + +.card .card-reveal { + background-color: #d8dee9; +} + +#url-list .card-panel { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + background: #2f3946; +} + +#url-list .url-header code.url-text { + background-color: #212733; + color: #e0e6f0; +} + +#url-list .card-panel .url-id { + color: #777; +} + +.collection { + border-color: #525252; +} + +.collection .collection-item { + background-color: #2e3440; + border-color: #666; + color: #d8dee9; +} + +.collection a.collection-item { + color: #d8dee9; +} + +a, +a:hover, +a.active { + color: #6e7d9c; +} + +.collection a.collection-item:not(.active):hover { + background-color: #1b1e26 !important; +} + +.collapsible { + border: 1px solid #525252; +} + +.collapsible-header { + background-color: #2e3440; + border-bottom: 1px solid #4c566a; +} + +.collapsible-body { + border-bottom: 1px solid #434c5e; + background-color: #2e3440; +} + +/* ========================================= +Tabs - Prefixed with 'body' to override Materialize +========================================= */ +.tabs { + background-color: transparent; + border-bottom: none; +} + +.tabs .tab { + background-color: transparent; +} + +body .tabs .tab a { + background-color: #4c566a !important; + color: #d8dee9 !important; + border-bottom: 1px solid #464646 !important; +} + +body .tabs .tab a:hover, +body .tabs .tab a.active { + color: #eceff4; + background-color: #8fbcbb; +} + +body .tabs .tab.disabled a, +body .tabs .tab.disabled a:hover, +body .tabs .tab.disabled a i, +body .tabs .tab.disabled a:hover i { + background-color: #171b23 !important; + color: #666 !important; +} + +body .tabs .tab a:focus, +body.tabs .tab a:focus.active { + background-color: #1b1e26 !important; +} + +body .tabs .indicator { + background-color: #d8dee9; +} + +/* ========================================= +Inputs & Code +========================================= */ +/* Added body prefix to ensure dark input backgrounds win */ +body input, +body textarea { + color: #e5e9f0; + border: 1px solid #2e3440; + background-color: #1b1e26; +} + +/* Materialize Input Focus overrides */ +input:not([type]):focus:not([readonly]), +textarea.materialize-textarea:focus:not([readonly]) { + border-bottom: 1px solid #8fbcbb; +} + +input:not([type]):focus:not([readonly])+label, +textarea.materialize-textarea:focus:not([readonly])+label { + color: #8fbcbb; +} + +pre code { + background-color: #111 !important; + color: #d8dee9; +} + +#overview strong { + color: #d8dee9 !important; + background-color: #373e4d; +} + +.file-selected { + color: #ebcb8b; + background-color: #2e3440; +} + +/* ========================================= +Buttons & Chips +========================================= */ +.btn, +.btn-large, +.btn-small { + color: #e5e9f0; + background-color: #2e3440; +} + +.btn:focus, +.btn-large:focus, +.btn-small:focus, +.btn-floating:focus, +.btn-large:hover, +.btn-small:hover, +.btn:hover { + background-color: #1b1e26; +} + +.btn-flat { + background-color: transparent; + color: #d8dee9; +} + +#url-list .copy-url-btn:hover, +.config-id-copy.btn-flat:hover, +.snippet-copy-btn:hover { + background-color: rgba(255, 255, 255, 0.06); +} + +.chip { + color: #d8dee9; + background-color: #2e3440 !important; + border: 1px solid #434c5e !important; +} + +.card-panel.selected { + background-color: #3f523f !important; + +} + +.chip.selected { + color: #fff; + background-color: #258528 !important; +} + +.url-selected-icon i { + color: #bbb; +} + +/* ========================================= +Health Check & Notifications +========================================= */ +#health-check { + background-color: #2f3946; + /* Darker bg for health check */ + color: #d8dee9; +} + +.swal2-popup { + background-color: #2e3440; + color: #e5e9f0; +} + +.copy-toast.swal2-popup { + background-color: #2f3946; + color: #e5e9f0; +} + +.copy-toast .swal2-title { + margin: 0; + font-size: .9rem; + font-weight: 500; +} + +/* HighlightJS Overrides */ +.hljs { + color: #d8dee9; + background: #4c566a; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: #4fa1ba; +} + +.hljs-literal, +.hljs-number, +.hljs-variable { + color: teal; +} + +.hljs-string { + color: #d8dee9; +} + +.hljs-section, +.hljs-title { + color: #900; +} + +.hljs-attribute, +.hljs-name, +.hljs-tag { + color: navy; +} + +/* Loading spinner */ +.api-status-icon.status-loading { + color: #90caf9; +} + +/* Error details text under the line */ +.api-status-details { + color: #ffcdd2; +} + +.api-status-icon.status-ok { + color: #66bb6a; +} + +.api-status-icon.status-error { + color: #ef5350; +} + +.apprise-config-editor textarea { + caret-color: white !important; +} + +.apprise-config-editor .apprise-config-highlight { + background-color: #1b1e26; + color: #d4d4d4; +} + +/* 1. URLs */ +.apprise-config-highlight .hljs-string, +.apprise-config-highlight .hljs-symbol, +.apprise-config-highlight .hljs-link { + color: #9cdcfe !important; +} + +/* 2. Comments */ +.apprise-config-highlight .hljs-comment { + color: #666 !important; + font-style: italic !important; +} + +/* 3. Tags / Keywords */ +.apprise-config-highlight .hljs-keyword, +.apprise-config-highlight .hljs-section, +.apprise-config-highlight .hljs-name { + color: #c586c0 !important; +} + +/* 4. Numbers / Constants */ +.apprise-config-highlight .hljs-number, +.apprise-config-highlight .hljs-literal { + color: #b5cea8 !important; +} + +/* 5. Standard Text */ +.apprise-config-highlight { + color: #d4d4d4 !important; +} + +.notify-attachments-list .attachment-chip { + background-color: #171b23; + color: #eceff4; +} + +/* SweetAlert2 โ€“ log colouring */ +.swal2-popup .notify-log-panel { + background-color: #1b1e26; +} + +.swal2-popup .logs .log_DEBUG { + color: #7f9bb3; +} + +.swal2-popup .logs .log_INFO { + color: #b5e07a; +} + +.swal2-popup .logs .log_WARNING { + color: #ffc861; +} + +.swal2-popup .logs .log_ERROR { + color: #ff8080; + font-weight: 600; +} + +.swal2-popup .logs .log_CRITICAL, +.swal2-popup .logs .log_FATAL { + color: #ff5555; + font-weight: 700; +} + +.swal2-popup .logs .log_TRACE { + color: #6a9fb5; + font-style: italic; +} \ No newline at end of file diff --git a/apprise_api/static/css/theme-dark.min.css b/apprise_api/static/css/theme-dark.min.css deleted file mode 100644 index 98e4930..0000000 --- a/apprise_api/static/css/theme-dark.min.css +++ /dev/null @@ -1,1466 +0,0 @@ -/* highlightjs overrides */ - -.nav.nav-color { - background: #4c566a !important -} - -.hljs { - color: #d8dee9; - background: #4c566a -} - -.hljs-comment, -.hljs-quote { - color: #998 -} - -.hljs-keyword, -.hljs-selector-tag, -.hljs-subst { - color: #4fa1ba -} - -.hljs-literal, -.hljs-number, -.hljs-tag .hljs-attr, -.hljs-template-variable, -.hljs-variable { - color: teal -} - -.hljs-doctag, -.hljs-string { - color: #d8dee9 -} - -.hljs-section, -.hljs-selector-id, -.hljs-title { - color: #900 -} - -.hljs-class .hljs-title, -.hljs-type { - color: #458 -} - -.hljs-attribute, -.hljs-name, -.hljs-tag { - color: navy -} - -.hljs-link, -.hljs-regexp { - color: #d8dee9 -} - -.hljs-bullet, -.hljs-symbol { - color: #b48ead -} - -.hljs-built_in, -.hljs-builtin-name { - color: #0086b3 -} - -.hljs-meta { - color: #999 -} - -.hljs-deletion { - background: #fdd -} - -.hljs-addition { - background: #dfd -} - -.no-config .info { - color: #4c566a -} - -.tabs .tab a:hover, -.tabs .tab a.active { - color: #4c566a -} - -.theme a { - color: #d8dee9 -} - -.url-enabled { - color: #02c7a6 -} - -.url-disabled { - color: #e00202 -} - -.btn, -.btn-large, -.btn-small { - background-color: #333 -} - -.btn-large:hover, -.btn-small:hover, -.btn:hover { - background-color: #222 -} - -i.material-icons { - color: #6e7d9c -} - -i.material-icons:hover { - color: #57637a -} - -.nav h1 { - color: #d8dee9 !important -} - -button.waves-light:hover { - background-color: #81a1c1 -} - -/* materialui overrides */ - -a:hover { - color: #d08770 -} - -#overview strong { - color: #d8dee9 !important; - background-color: #373e4d; -} - -pre code { - background-color: #111 !important; - color: #d8dee9 -} - -code.config-id { - background-color: transparent !important -} - -a { - color: #ebcb8b -} - -.page-footer { - color: #d8dee9; - background-color: #ee6e73 -} - -.collection { - border-color: #525252 -} - -.collection .collection-item { - background-color: #2e3440; - border-color: #666 -} - -.collection .collection-item.avatar i.circle { - color: #d8dee9; - background-color: #525252 -} - -.collection .collection-item.active { - background-color: #8fbcbb; - color: #eafaf9 -} - -.collection .collection-item.active .secondary-content { - color: #d8dee9 -} - -.collection a.collection-item { - color: #d8dee9 -} - -.collection a.collection-item:not(.active):hover { - background-color: #81a1c1 !important -} - -.collection.with-header .collection-header { - background-color: #d8dee9; -} - -.secondary-content { - color: #8fbcbb -} - -.progress .indeterminate { - background-color: #8fbcbb -} - -span.badge { - color: #757575 -} - -span.badge.new { - color: #d8dee9; - background-color: #8fbcbb; -} - -.row .col.s5 { - background-color: #3b4252 -} - -.row .col.s9 { - background-color: #3b4252 -} - -nav { - color: #d8dee9; - background-color: #ee6e73 -} - -nav a { - color: #fff -} - -.nav i { - color: #2e3440 -} - -nav .brand-logo { - color: #d8dee9 -} - -nav ul li.active { - background-color: rgba(0, 0, 0, .1) -} - -nav ul a { - color: #d8dee9 -} - -nav ul a:hover { - background-color: rgba(0, 0, 0, .1) -} - -nav .input-field label i { - color: rgba(255, 255, 255, .7) -} - -nav .input-field label.active i { - color: #fff -} - -html { - color: #d8dee9; - background-color: #2e3440 -} - -h1 { - color: #eceff4 !important -} - -h4 { - color: #e5e9f0 -} - -h5 { - color: #8fbcbb -} - -em { - color: #5e81ac !important; -} - -.card-panel { - background-color: #4c566a -} - -.card { - background-color: #4c566a -} - -.card .card-image .card-title { - color: #d8dee9 -} - -.card .card-action a:not(.btn):not(.btn-large):not(.btn-small):not(.btn-large):not(.btn-floating) { - color: #ffab40 -} - -.card .card-action a:not(.btn):not(.btn-large):not(.btn-small):not(.btn-large):not(.btn-floating):hover { - color: #fff -} - -.card .card-reveal { - background-color: #d8dee9 -} - -.toast { - background-color: #323232; - color: #d8dee9 -} - -.toast .toast-action { - color: #eeff41 -} - -.tabs { - background-color: #3b4252 -} - -.tabs.tabs-transparent { - background-color: transparent -} - -.tabs.tabs-transparent .tab a, -.tabs.tabs-transparent .tab.disabled a, -.tabs.tabs-transparent .tab.disabled a:hover, -.tab.disabled i.material-icons { - color: #6e7d9c -} - -.tabs.tabs-transparent .tab a.active, -.tabs.tabs-transparent .tab a:hover { - color: #fff -} - -.tabs.tabs-transparent .indicator { - background-color: #fff -} - -.tabs .tab { - background-color: #3b4252 -} - -.tabs .tab a { - color: #d8dee9; - background-color: #373e4d; - border-left: 1px solid #3b4252; - border-bottom: 1px solid #81a1c1 -} - -.tabs .tab a:focus, -.tabs .tab a:focus.active { - background-color: rgba(246, 178, 181, .2) -} - -.tabs .tab a.active, -.tabs .tab a:hover { - background-color: #81a1c1 !important -} - -.tabs .tab.disabled a, -.tabs .tab.disabled a:hover { - background-color: #404959 !important; - color: #6e7d9c !important -} - -.tabs .indicator { - background-color: #ebcb8b !important -} - -@media only screen and (max-width:992px).material-tooltip { - color: #d8dee9; - background-color: #323232 -} - -.backdrop { - background-color: #323232 -} - -.btn-flat.disabled, -.btn-flat:disabled, -.btn-flat[disabled], -.btn-floating.disabled, -.btn-floating:disabled, -.btn-floating[disabled], -.btn-large.disabled, -.btn-large:disabled, -.btn-large[disabled], -.btn-small.disabled, -.btn-small:disabled, -.btn-small[disabled], -.btn.disabled, -.btn:disabled, -.btn[disabled], -.disabled.btn-large, -.disabled.btn-small { - background-color: #dfdfdf !important; - color: #9f9f9f !important -} - -.btn-flat.disabled:hover, -.btn-flat:disabled:hover, -.btn-flat[disabled]:hover, -.btn-floating.disabled:hover, -.btn-floating:disabled:hover, -.btn-floating[disabled]:hover, -.btn-large.disabled:hover, -.btn-large:disabled:hover, -.btn-large[disabled]:hover, -.btn-small.disabled:hover, -.btn-small:disabled:hover, -.btn-small[disabled]:hover, -.btn.disabled:hover, -.btn:disabled:hover, -.btn[disabled]:hover, -.disabled.btn-large:hover, -.disabled.btn-small:hover { - background-color: #dfdfdf !important; - color: #9f9f9f !important -} - -.btn-floating:focus, -.btn-large:focus, -.btn-small:focus, -.btn:focus { - background-color: #2e3440 -} - -.btn, -.btn-large, -.btn-small { - color: #e5e9f0; - background-color: #2e3440 -} - -.btn-large:hover, -.btn-small:hover, -.btn:hover { - background-color: #d08770 -} - -.btn-floating { - background-color: #8fbcbb -} - -.btn-floating:hover { - background-color: #8fbcbb -} - -.btn-floating i { - color: #d8dee9 -} - -.fixed-action-btn.toolbar ul li a { - color: #d8dee9 -} - -.fixed-action-btn .fab-backdrop { - background-color: #8fbcbb -} - -.btn-flat { - background-color: transparent; - color: #343434 -} - -.btn-flat:focus { - background-color: rgba(0, 0, 0, .1) -} - -.btn-flat.btn-flat[disabled], -.btn-flat.disabled { - background-color: transparent !important; - color: #b3b2b2 !important -} - -.modal { - background-color: #fafafa -} - -.modal .modal-footer { - background-color: #fafafa -} - -.collapsible-header { - background-color: #2e3440; - border-bottom: 1px solid #4c566a -} - -.keyboard-focused .collapsible-header:focus { - background-color: #eee -} - -.collapsible-body { - border-bottom: 1px solid #434c5e; - background-color: #2e3440 -} - -.sidenav .collapsible-header:hover, -.sidenav.fixed .collapsible-header:hover { - background-color: rgba(0, 0, 0, .05) -} - -.sidenav .collapsible-body, -.sidenav.fixed .collapsible-body { - background-color: #fff -} - -#url-list .card-panel { - box-shadow: 0 2px 4px rgba(0,0,0,0.4); - background: #2f3946; -} - -#url-list .url-header code.url-text { - background-color: #212733; - color: #e0e6f0; -} - -.chip { - color: #d8dee9; - background-color: #2e3440 !important; - border: 1px solid #434c5e !important -} - -.chip:focus { - background-color: #d08770; - color: #eceff4 -} - -.chips { - border-bottom: 1px solid #4c566a -} - -.chips.focus { - border-bottom: 1px solid #8fbcbb; - -webkit-box-shadow: 0 1px 0 0 #8fbcbb; - box-shadow: 0 1px 0 0 #8fbcbb -} - -.chips .input { - color: rgba(0, 0, 0, .6) -} - -.chips .input:focus, -.chips textarea:focus { - border: 0 !important; - -webkit-box-shadow: none !important; - box-shadow: none !important; - color: #d8dee9 -} - -#materialbox-overlay { - background-color: #292929 -} - -.materialbox-caption { - color: #d8dee9 -} - -select:focus { - outline: 1px solid #c9f3ef -} - -button:focus { - background-color: #2ab7a9 -} - -label { - color: #8fbcbb -} - -::-webkit-input-placeholder { - color: #d1d1d1 -} - -::-moz-placeholder { - color: #d1d1d1 -} - -:-ms-input-placeholder { - color: #d1d1d1 -} - -::-ms-input-placeholder { - color: #d1d1d1 -} - -::placeholder { - color: #d1d1d1 -} - -input:not([type]), -input[type=date]:not(.browser-default), -input[type=datetime-local]:not(.browser-default), -input[type=datetime]:not(.browser-default), -input[type=email]:not(.browser-default), -input[type=number]:not(.browser-default), -input[type=password]:not(.browser-default), -input[type=search]:not(.browser-default), -input[type=tel]:not(.browser-default), -input[type=text]:not(.browser-default), -input[type=time]:not(.browser-default), -input[type=url]:not(.browser-default), -textarea.materialize-textarea { - border-bottom: 1px solid #4c566a - color: #e5e9f0; -} - -input:not([type]):disabled, -input:not([type])[readonly=readonly], -input[type=date]:not(.browser-default):disabled, -input[type=date]:not(.browser-default)[readonly=readonly], -input[type=datetime-local]:not(.browser-default):disabled, -input[type=datetime-local]:not(.browser-default)[readonly=readonly], -input[type=datetime]:not(.browser-default):disabled, -input[type=datetime]:not(.browser-default)[readonly=readonly], -input[type=email]:not(.browser-default):disabled, -input[type=email]:not(.browser-default)[readonly=readonly], -input[type=number]:not(.browser-default):disabled, -input[type=number]:not(.browser-default)[readonly=readonly], -input[type=password]:not(.browser-default):disabled, -input[type=password]:not(.browser-default)[readonly=readonly], -input[type=search]:not(.browser-default):disabled, -input[type=search]:not(.browser-default)[readonly=readonly], -input[type=tel]:not(.browser-default):disabled, -input[type=tel]:not(.browser-default)[readonly=readonly], -input[type=text]:not(.browser-default):disabled, -input[type=text]:not(.browser-default)[readonly=readonly], -input[type=time]:not(.browser-default):disabled, -input[type=time]:not(.browser-default)[readonly=readonly], -input[type=url]:not(.browser-default):disabled, -input[type=url]:not(.browser-default)[readonly=readonly], -textarea.materialize-textarea:disabled, -textarea.materialize-textarea[readonly=readonly] { - color: rgba(0, 0, 0, .42); - border-bottom: 1px dotted rgba(0, 0, 0, .42) -} - -input:not([type]):disabled+label, -input:not([type])[readonly=readonly]+label, -input[type=date]:not(.browser-default):disabled+label, -input[type=date]:not(.browser-default)[readonly=readonly]+label, -input[type=datetime-local]:not(.browser-default):disabled+label, -input[type=datetime-local]:not(.browser-default)[readonly=readonly]+label, -input[type=datetime]:not(.browser-default):disabled+label, -input[type=datetime]:not(.browser-default)[readonly=readonly]+label, -input[type=email]:not(.browser-default):disabled+label, -input[type=email]:not(.browser-default)[readonly=readonly]+label, -input[type=number]:not(.browser-default):disabled+label, -input[type=number]:not(.browser-default)[readonly=readonly]+label, -input[type=password]:not(.browser-default):disabled+label, -input[type=password]:not(.browser-default)[readonly=readonly]+label, -input[type=search]:not(.browser-default):disabled+label, -input[type=search]:not(.browser-default)[readonly=readonly]+label, -input[type=tel]:not(.browser-default):disabled+label, -input[type=tel]:not(.browser-default)[readonly=readonly]+label, -input[type=text]:not(.browser-default):disabled+label, -input[type=text]:not(.browser-default)[readonly=readonly]+label, -input[type=time]:not(.browser-default):disabled+label, -input[type=time]:not(.browser-default)[readonly=readonly]+label, -input[type=url]:not(.browser-default):disabled+label, -input[type=url]:not(.browser-default)[readonly=readonly]+label, -textarea.materialize-textarea:disabled+label, -textarea.materialize-textarea[readonly=readonly]+label { - color: rgba(0, 0, 0, .42) -} - -input:not([type]):focus:not([readonly]), -input[type=date]:not(.browser-default):focus:not([readonly]), -input[type=datetime-local]:not(.browser-default):focus:not([readonly]), -input[type=datetime]:not(.browser-default):focus:not([readonly]), -input[type=email]:not(.browser-default):focus:not([readonly]), -input[type=number]:not(.browser-default):focus:not([readonly]), -input[type=password]:not(.browser-default):focus:not([readonly]), -input[type=search]:not(.browser-default):focus:not([readonly]), -input[type=tel]:not(.browser-default):focus:not([readonly]), -input[type=text]:not(.browser-default):focus:not([readonly]), -input[type=time]:not(.browser-default):focus:not([readonly]), -input[type=url]:not(.browser-default):focus:not([readonly]), -textarea.materialize-textarea:focus:not([readonly]) { - border-bottom: 1px solid #8fbcbb -} - -input:not([type]):focus:not([readonly])+label, -input[type=date]:not(.browser-default):focus:not([readonly])+label, -input[type=datetime-local]:not(.browser-default):focus:not([readonly])+label, -input[type=datetime]:not(.browser-default):focus:not([readonly])+label, -input[type=email]:not(.browser-default):focus:not([readonly])+label, -input[type=number]:not(.browser-default):focus:not([readonly])+label, -input[type=password]:not(.browser-default):focus:not([readonly])+label, -input[type=search]:not(.browser-default):focus:not([readonly])+label, -input[type=tel]:not(.browser-default):focus:not([readonly])+label, -input[type=text]:not(.browser-default):focus:not([readonly])+label, -input[type=time]:not(.browser-default):focus:not([readonly])+label, -input[type=url]:not(.browser-default):focus:not([readonly])+label, -textarea.materialize-textarea:focus:not([readonly])+label { - color: #8fbcbb -} - -input:not([type]):focus.valid~label, -input[type=date]:not(.browser-default):focus.valid~label, -input[type=datetime-local]:not(.browser-default):focus.valid~label, -input[type=datetime]:not(.browser-default):focus.valid~label, -input[type=email]:not(.browser-default):focus.valid~label, -input[type=number]:not(.browser-default):focus.valid~label, -input[type=password]:not(.browser-default):focus.valid~label, -input[type=search]:not(.browser-default):focus.valid~label, -input[type=tel]:not(.browser-default):focus.valid~label, -input[type=text]:not(.browser-default):focus.valid~label, -input[type=time]:not(.browser-default):focus.valid~label, -input[type=url]:not(.browser-default):focus.valid~label, -textarea.materialize-textarea:focus.valid~label { - color: #4caf50 -} - -input:not([type]):focus.invalid~label, -input[type=date]:not(.browser-default):focus.invalid~label, -input[type=datetime-local]:not(.browser-default):focus.invalid~label, -input[type=datetime]:not(.browser-default):focus.invalid~label, -input[type=email]:not(.browser-default):focus.invalid~label, -input[type=number]:not(.browser-default):focus.invalid~label, -input[type=password]:not(.browser-default):focus.invalid~label, -input[type=search]:not(.browser-default):focus.invalid~label, -input[type=tel]:not(.browser-default):focus.invalid~label, -input[type=text]:not(.browser-default):focus.invalid~label, -input[type=time]:not(.browser-default):focus.invalid~label, -input[type=url]:not(.browser-default):focus.invalid~label, -textarea.materialize-textarea:focus.invalid~label { - color: #f44336 -} - -input:not([type]).validate+label, -input[type=date]:not(.browser-default).validate+label, -input[type=datetime-local]:not(.browser-default).validate+label, -input[type=datetime]:not(.browser-default).validate+label, -input[type=email]:not(.browser-default).validate+label, -input[type=number]:not(.browser-default).validate+label, -input[type=password]:not(.browser-default).validate+label, -input[type=search]:not(.browser-default).validate+label, -input[type=tel]:not(.browser-default).validate+label, -input[type=text]:not(.browser-default).validate+label, -input[type=time]:not(.browser-default).validate+label, -input[type=url]:not(.browser-default).validate+label, -textarea.materialize-textarea.validate+label { - width: 100% -} - -.select-wrapper.valid>input.select-dropdown, -input.valid:not([type]), -input.valid:not([type]):focus, -input.valid[type=date]:not(.browser-default), -input.valid[type=date]:not(.browser-default):focus, -input.valid[type=datetime-local]:not(.browser-default), -input.valid[type=datetime-local]:not(.browser-default):focus, -input.valid[type=datetime]:not(.browser-default), -input.valid[type=datetime]:not(.browser-default):focus, -input.valid[type=email]:not(.browser-default), -input.valid[type=email]:not(.browser-default):focus, -input.valid[type=number]:not(.browser-default), -input.valid[type=number]:not(.browser-default):focus, -input.valid[type=password]:not(.browser-default), -input.valid[type=password]:not(.browser-default):focus, -input.valid[type=search]:not(.browser-default), -input.valid[type=search]:not(.browser-default):focus, -input.valid[type=tel]:not(.browser-default), -input.valid[type=tel]:not(.browser-default):focus, -input.valid[type=text]:not(.browser-default), -input.valid[type=text]:not(.browser-default):focus, -input.valid[type=time]:not(.browser-default), -input.valid[type=time]:not(.browser-default):focus, -input.valid[type=url]:not(.browser-default), -input.valid[type=url]:not(.browser-default):focus, -textarea.materialize-textarea.valid, -textarea.materialize-textarea.valid:focus { - border-bottom: 1px solid #4caf50; - -webkit-box-shadow: 0 1px 0 0 #4caf50; - box-shadow: 0 1px 0 0 #4caf50 -} - -.select-wrapper.invalid>input.select-dropdown, -.select-wrapper.invalid>input.select-dropdown:focus, -input.invalid:not([type]), -input.invalid:not([type]):focus, -input.invalid[type=date]:not(.browser-default), -input.invalid[type=date]:not(.browser-default):focus, -input.invalid[type=datetime-local]:not(.browser-default), -input.invalid[type=datetime-local]:not(.browser-default):focus, -input.invalid[type=datetime]:not(.browser-default), -input.invalid[type=datetime]:not(.browser-default):focus, -input.invalid[type=email]:not(.browser-default), -input.invalid[type=email]:not(.browser-default):focus, -input.invalid[type=number]:not(.browser-default), -input.invalid[type=number]:not(.browser-default):focus, -input.invalid[type=password]:not(.browser-default), -input.invalid[type=password]:not(.browser-default):focus, -input.invalid[type=search]:not(.browser-default), -input.invalid[type=search]:not(.browser-default):focus, -input.invalid[type=tel]:not(.browser-default), -input.invalid[type=tel]:not(.browser-default):focus, -input.invalid[type=text]:not(.browser-default), -input.invalid[type=text]:not(.browser-default):focus, -input.invalid[type=time]:not(.browser-default), -input.invalid[type=time]:not(.browser-default):focus, -input.invalid[type=url]:not(.browser-default), -input.invalid[type=url]:not(.browser-default):focus, -textarea.materialize-textarea.invalid, -textarea.materialize-textarea.invalid:focus { - border-bottom: 1px solid #f44336; - -webkit-box-shadow: 0 1px 0 0 #f44336; - box-shadow: 0 1px 0 0 #f44336 -} - -input, textarea { - color: #e5e9f0; - border: 1px solid #2e3440; - background-color: #1b1e26 -} - -textarea:focus-visible { - border: 1px solid #5e81ac; - box-shadow: 0 0 10px #5e81ac -} - -::placeholder { - color: #81a1c1; - opacity: 1 -} - -:-ms-input-placeholder { - color: #81a1c1 -} - -::-ms-input-placeholder { - color: #81a1c1 -} - -.autocomplete-content li .highlight { - color: #444 -} - -[type=radio]:checked, -[type=radio]:not(:checked) { - position: absolute; -} - -[type=radio]:not(:checked)+span:after, -[type=radio]:not(:checked)+span:before { - border: 2px solid #5a5a5a -} - -[type=radio].with-gap:checked+span:after, -[type=radio].with-gap:checked+span:before, -[type=radio]:checked+span:after { - border: 2px solid #8fbcbb -} - -[type=radio].with-gap:checked+span:after, -[type=radio]:checked+span:after { - background-color: #8fbcbb -} - -[type=radio].tabbed:focus+span:before { - -webkit-box-shadow: 0 0 0 10px rgba(0, 0, 0, .1); - box-shadow: 0 0 0 10px rgba(0, 0, 0, .1) -} - -[type=radio].with-gap:disabled:checked+span:before { - border: 2px solid rgba(0, 0, 0, .42) -} - -[type=radio].with-gap:disabled:checked+span:after { - border: none; - background-color: rgba(0, 0, 0, .42) -} - -[type=radio]:disabled:checked+span:before, -[type=radio]:disabled:not(:checked)+span:before { - background-color: transparent; - border-color: rgba(0, 0, 0, .42) -} - -[type=radio]:disabled+span { - color: rgba(0, 0, 0, .42) -} - -[type=radio]:disabled:not(:checked)+span:before { - border-color: rgba(0, 0, 0, .42) -} - -[type=radio]:disabled:checked+span:after { - background-color: rgba(0, 0, 0, .42); - border-color: #949494 -} - -[type=checkbox]+span:not(.lever):before, -[type=checkbox]:not(.filled-in)+span:not(.lever):after { - border: 2px solid #5a5a5a; -} - -[type=checkbox]:not(:checked):disabled+span:not(.lever):before { - background-color: rgba(0, 0, 0, .42) -} - -[type=checkbox].tabbed:focus+span:not(.lever):after { - border-radius: 50%; - -webkit-box-shadow: 0 0 0 10px rgba(0, 0, 0, .1); - box-shadow: 0 0 0 10px rgba(0, 0, 0, .1); - background-color: rgba(0, 0, 0, .1) -} - -[type=checkbox]:checked+span:not(.lever):before { - border-right: 2px solid #8fbcbb; - border-bottom: 2px solid #8fbcbb; -} - -[type=checkbox]:checked:disabled+span:before { - border-right: 2px solid rgba(0, 0, 0, .42); - border-bottom: 2px solid rgba(0, 0, 0, .42) -} - -[type=checkbox]:indeterminate+span:not(.lever):before { - border-right: 2px solid #8fbcbb; -} - -[type=checkbox]:indeterminate:disabled+span:not(.lever):before { - border-right: 2px solid rgba(0, 0, 0, .42); -} - -[type=checkbox].filled-in+span:not(.lever):after { - border-radius: 2px -} - -[type=checkbox].filled-in:not(:checked)+span:not(.lever):after { - border: 2px solid #5a5a5a; -} - -[type=checkbox].filled-in:checked+span:not(.lever):before { - border-right: 2px solid #d8dee9; - border-bottom: 2px solid #d8dee9; -} - -[type=checkbox].filled-in:checked+span:not(.lever):after { - border: 2px solid #8fbcbb; - background-color: #8fbcbb; -} - -[type=checkbox].filled-in.tabbed:focus+span:not(.lever):after { - border-color: #5a5a5a; - background-color: rgba(0, 0, 0, .1) -} - -[type=checkbox].filled-in.tabbed:checked:focus+span:not(.lever):after { - background-color: #8fbcbb; - border-color: #8fbcbb -} - -[type=checkbox].filled-in:disabled:not(:checked)+span:not(.lever):after { - background-color: #949494 -} - -[type=checkbox].filled-in:disabled:checked+span:not(.lever):after { - background-color: #949494; - border-color: #949494 -} - -.switch label input[type=checkbox]:checked+.lever { - background-color: #84c7c1 -} - -.switch label input[type=checkbox]:checked+.lever:after { - background-color: #8fbcbb -} - -.switch label .lever { - background-color: rgba(0, 0, 0, .38); -} - -.switch label .lever:before { - background-color: rgba(38, 166, 154, .15) -} - -.switch label .lever:after { - background-color: #f1f1f1; - -webkit-box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .2), 0 2px 2px 0 rgba(0, 0, 0, .14), 0 1px 5px 0 rgba(0, 0, 0, .12); - box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .2), 0 2px 2px 0 rgba(0, 0, 0, .14), 0 1px 5px 0 rgba(0, 0, 0, .12) -} - -input[type=checkbox]:checked:not(:disabled).tabbed:focus~.lever::before, -input[type=checkbox]:checked:not(:disabled)~.lever:active::before { - background-color: rgba(38, 166, 154, .15) -} - -input[type=checkbox]:not(:disabled).tabbed:focus~.lever::before, -input[type=checkbox]:not(:disabled)~.lever:active:before { - background-color: rgba(0, 0, 0, .08) -} - -.switch input[type=checkbox][disabled]+.lever { - background-color: rgba(0, 0, 0, .12) -} - -.switch label input[type=checkbox][disabled]+.lever:after, -.switch label input[type=checkbox][disabled]:checked+.lever:after { - background-color: #949494 -} - -select { - background-color: rgba(255, 255, 255, .9); - border: 1px solid #f2f2f2; -} - -.select-wrapper input.select-dropdown { - color: #81a1c1; - border-bottom: 1px solid #4c566a; -} - -.select-wrapper input.select-dropdown:focus { - border-bottom: 1px solid #8fbcbb -} - -select:disabled { - color: rgba(0, 0, 0, .42) -} - -.select-wrapper.disabled+label { - color: rgba(0, 0, 0, .42) -} - -.select-wrapper.disabled .caret { - fill: rgba(0, 0, 0, .42) -} - -.select-wrapper input.select-dropdown:disabled { - color: rgba(0, 0, 0, .42); -} - -.select-wrapper i { - color: rgba(0, 0, 0, .3) -} - -.select-dropdown li.disabled, -.select-dropdown li.disabled>span, -.select-dropdown li.optgroup { - color: rgba(0, 0, 0, .3); - background-color: transparent -} - -.dropdown-content { - background-color: #2e3440; -} -.dropdown-content li { - color: rgba(0, 0, 0, .87) -} - -.dropdown-content li.active, -.dropdown-content li:hover{ - background-color: #2e3440 -} - -body.keyboard-focused .dropdown-content li:focus { - background-color: #dadada -} - -.dropdown-content li>a, -.dropdown-content li>span { - color: #d8dee9; -} - -body.keyboard-focused .dropdown-content li:focus { - background-color: #dadada -} - -.dropdown-trigger { - cursor: pointer -} - -body.keyboard-focused .select-dropdown.dropdown-content li:focus { - background-color: rgba(0, 0, 0, .08) -} - -.select-dropdown.dropdown-content li:hover { - background-color: rgba(0, 0, 0, .08) -} - -.select-dropdown.dropdown-content li.selected { - background-color: rgba(0, 0, 0, .03) -} - -.select-dropdown li.optgroup { - border-top: 1px solid #eee -} - -.select-dropdown li.optgroup.selected>span { - color: rgba(0, 0, 0, .7) -} - -.select-dropdown li.optgroup>span { - color: rgba(0, 0, 0, .4) -} - -input[type=range]+.thumb { - background-color: #8fbcbb; -} - -input[type=range]+.thumb .value { - color: #8fbcbb; -} - -input[type=range]+.thumb.active .value { - color: #d8dee9; -} - -input[type=range]::-webkit-slider-runnable-track { - background: #c2c0c2; -} - -input[type=range]::-webkit-slider-thumb { - background: #8fbcbb; - background-color: #8fbcbb; -} - -.keyboard-focused input[type=range]:focus:not(.active)::-webkit-slider-thumb { - -webkit-box-shadow: 0 0 0 10px rgba(38, 166, 154, .26); - box-shadow: 0 0 0 10px rgba(38, 166, 154, .26) -} - -input[type=range] { - border: 1px solid #fff -} - -input[type=range]::-moz-range-track { - background: #c2c0c2; -} - -input[type=range]::-moz-range-thumb { - background: #8fbcbb; -} - -input[type=range]:-moz-focusring { - outline: 1px solid #d8dee9; -} - -.keyboard-focused input[type=range]:focus:not(.active)::-moz-range-thumb { - box-shadow: 0 0 0 10px rgba(38, 166, 154, .26) -} - -input[type=range]::-ms-track { - background: 0 0; -} - -input[type=range]::-ms-fill-lower { - background: #777 -} - -input[type=range]::-ms-fill-upper { - background: #ddd -} - -input[type=range]::-ms-thumb { - background: #8fbcbb; -} - -.keyboard-focused input[type=range]:focus:not(.active)::-ms-thumb { - box-shadow: 0 0 0 10px rgba(38, 166, 154, .26) -} - -.table-of-contents a { - color: #757575; -} - -.table-of-contents a:hover { - color: #a8a8a8; - border-left: 1px solid #ee6e73 -} - -.table-of-contents a.active { - border-left: 2px solid #ee6e73 -} - -.sidenav { - background-color: #d8dee9; -} - -.sidenav li.active { - background-color: rgba(0, 0, 0, .05) -} - -.sidenav li>a { - color: rgba(0, 0, 0, .87); -} - -.sidenav li>a:hover { - background-color: rgba(0, 0, 0, .05) -} - -.sidenav li>a.btn, -.sidenav li>a.btn-floating, -.sidenav li>a.btn-large, -.sidenav li>a.btn-small { - color: #fff -} - -.sidenav li>a.btn-flat { - color: #343434 -} - -.sidenav li>a.btn-large:hover, -.sidenav li>a.btn-small:hover, -.sidenav li>a.btn:hover { - background-color: #2bbbad -} - -.sidenav li>a.btn-floating:hover { - background-color: #8fbcbb -} - -.sidenav li>a li>a>[class*=mdi-], -.sidenav li>a>[class^=mdi-], -.sidenav li>a>i, -.sidenav li>a>i.material-icons { - color: rgba(0, 0, 0, .54) -} - -.sidenav .divider { - margin: 8px 0 0 0 -} - -.sidenav .subheader { - color: rgba(0, 0, 0, .54); -} - -.sidenav .collapsible-body>ul:not(.collapsible)>li.active, -.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active { - background-color: #ee6e73 -} - -.sidenav .collapsible-body>ul:not(.collapsible)>li.active a, -.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active a { - color: #fff -} - -.sidenav-overlay { - background-color: rgba(0, 0, 0, .5); -} - -.spinner-layer { - border-color: #8fbcbb -} - -.spinner-blue, -.spinner-blue-only { - border-color: #4285f4 -} - -.spinner-red, -.spinner-red-only { - border-color: #db4437 -} - -.spinner-yellow, -.spinner-yellow-only { - border-color: #f4b400 -} - -.spinner-green, -.spinner-green-only { - border-color: #0f9d58 -} - -.slider .slides li .caption { - color: #d8dee9; -} - -.slider .slides li .caption p { - color: #e0e0e0 -} - -.slider .slides li.active { - z-index: 2 -} - -.slider .indicators .indicator-item { - background-color: #e0e0e0; -} - -.slider .indicators .indicator-item.active { - background-color: #4caf50 -} - -.carousel .indicators .indicator-item.active { - background-color: #fff -} - -.tap-target { - background-color: #ee6e73; - -webkit-box-shadow: 0 20px 20px 0 rgba(0, 0, 0, .14), 0 10px 50px 0 rgba(0, 0, 0, .12), 0 30px 10px -20px rgba(0, 0, 0, .2); - box-shadow: 0 20px 20px 0 rgba(0, 0, 0, .14), 0 10px 50px 0 rgba(0, 0, 0, .12), 0 30px 10px -20px rgba(0, 0, 0, .2); -} - -.tap-target-wave::after, -.tap-target-wave::before { - background-color: #fff -} - -.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small), -.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small):hover { - background: 0 0 -} - -.datepicker-table abbr { - color: #999 -} - -.datepicker-table td.is-today { - color: #8fbcbb -} - -.datepicker-table td.is-selected { - background-color: #8fbcbb; - color: #fff -} - -.datepicker-day-button:focus { - background-color: rgba(43, 161, 150, .25) -} - -.datepicker-cancel, -.datepicker-clear, -.datepicker-done, -.datepicker-today { - color: #8fbcbb; -} - -.datepicker-clear { - color: #f44336 -} - -.file-selected { - color: #ebcb8b; - background-color: #2e3440; -} - -.chip.selected { - color: #fff; - background-color: #258528!important; -} - -.swal2-popup { - background-color: #2e3440; - color: #e5e9f0; -} - - -.swal-icon--success__ring { - border: 4px solid rgba(194, 26, 90, 0.2); -} - -/* spin color */ -.swal-icon--success { - border-color: rgb(62, 16, 226); -} - -/* V color */ -.swal-icon--success__line { - background-color: rgb(30, 206, 53); -} - -/* Warning */ -/* ! */ -.swal-icon--warning__body, -.swal-icon--warning__dot { - background-color: #1816ac; -} - -/* Error */ -/* outer ring */ -.swal-icon--error { - border-color: #19e645; -} - -/* X */ -.swal-icon--error__line { - background-color: #af13df; -} - -/* Info */ -/* outer ring */ -.swal-icon--info { - border-color: #020404; -} - -/* i */ -.swal-icon--info:after, -.swal-icon--info:before { - background-color: #d119c8; -} - -ul.logs { - background-color: #1b1e26; - color: #e5e9f0; -} - -ul.logs li.log_INFO { - color: #e5e9f0; -} - -ul.logs li.log_DEBUG { - color: #606060; -} - -ul.logs li.log_WARNING { - color: orange; -} - -ul.logs li.log_ERROR { - color: #8B0000; -} - -/* Apprise dark theme โ€“ tab clean-up */ - -.tabs { - /* Let the page background show through behind tabs */ - background-color: transparent; - border-bottom: none; -} - -.tabs .tab { - /* No extra slab behind each tab */ - background-color: transparent; -} - -/* Base.css already gives us the rounded corners. - * Here we just define dark colours and avoid square backgrounds. - */ -.tabs .tab a { - background-color: #4c566a; - border-left: none; - border-bottom: none; - color: #d8dee9; -} - -/* Hover / active โ€“ subtle pill highlight */ -.tabs .tab a:hover, -.tabs .tab a.active { - background-color: #4c566a; - color: #eceff4; -} - -/* Disabled tabs โ€“ keep them obviously disabled, but no slab */ -.tabs .tab.disabled a, -.tabs .tab.disabled a:hover { - background-color: transparent; - color: #6e7d9c !important; -} - -/* Indicator bar under active tab */ -.tabs .indicator { - background-color: #ebcb8b !important; - height: 3px; -} - -/* Copy-toast for dark theme */ -.copy-toast.swal2-popup { - background-color: #2f3946; - color: #e5e9f0; - padding: .35rem .8rem; -} - -.copy-toast .swal2-title { - margin: 0; - font-size: .9rem; - font-weight: 500; -} diff --git a/apprise_api/static/css/theme-light.css b/apprise_api/static/css/theme-light.css new file mode 100644 index 0000000..9721fd0 --- /dev/null +++ b/apprise_api/static/css/theme-light.css @@ -0,0 +1,255 @@ +/* Typography & Icons */ +em { + color: #004d40; +} + +.url-enabled { + color: #004d40; +} + +.url-disabled { + color: #8B0000; +} + +.no-config .info { + color: #004d40; +} + +.api-details .plugin-index { + color: #666; +} + +#overview strong { + color: #004d40; + background-color: #eee; +} + +/* Tabs - Prefixed with 'body' to override Materialize */ +body .tabs .tab a { + color: #004d40; + background-color: #f3f3f3; + border-bottom: 1px solid #464646; +} + +body .tabs .tab a:hover, +body .tabs .tab a.active { + color: #004d40; + background-color: #eee; +} + +body .tabs .tab.disabled a, +body .tabs .tab.disabled a:hover, +body .tabs .tab.disabled a i, +body .tabs .tab.disabled a:hover i { + background-color: #ccc !important; + color: #666 !important; +} + +body .tabs .tab a:focus, +body.tabs .tab a:focus.active { + background-color: #eee !important; +} + +body .tabs .indicator { + background-color: #004d40; +} + +/* Standard Hovers */ +.collection a.collection-item:not(.active):hover, +.tabs .tab a:focus, +.tabs .tab a:focus.active { + background-color: #eee !important; +} + +.collection a.collection-item, +a, +a:hover, +a.active { + color: #004d40 !important; +} + +/* Cards & Lists */ +#url-list .card-panel { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + background-color: #f3f3f3; +} + +#url-list .url-header code.url-text { + background-color: #fff; + color: #464646; +} + +#url-list .card-panel .url-id { + color: #aaa; +} + +/* Buttons & Chips */ +.btn, +.btn-large, +.btn-small { + color: #004d40; + background-color: #e5e9f0; +} + +.btn:focus, +.btn-large:focus, +.btn-small:focus, +.btn-floating:focus, +.btn-large:hover, +.btn-small:hover, +.btn:hover { + color: #000 !important; + background-color: #8fbcbb; +} + +.btn-flat { + background-color: #e5e9f0; + color: #004d40; +} + +.chip { + background-color: #fff !important; + border: 1px solid #464646 !important; + color: #464646 !important; +} + +.card-panel.selected { + background-color: #d8e9d8 !important; + +} + +.url-selected-icon i { + color: #666 +} + +.chip.selected { + background-color: #258528 !important; + color: white !important; +} + +.file-selected { + color: #039be5; + background-color: #f3f3f3; +} + +#url-list .copy-url-btn:hover, +.config-id-copy.btn-flat:hover, +.snippet-copy-btn:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +/* Health Check */ +#health-check { + background-color: #f883791f; +} + +/* Notifications */ +.copy-toast.swal2-popup { + background-color: #f3f3f3; + color: #464646; + box-shadow: 0 2px 4px rgba(0, 0, 0, .25); + border-radius: 4px; + padding: .35rem .8rem; +} + +.copy-toast .swal2-title { + margin: 0; + font-size: .9rem; + font-weight: 500; +} + +/* Loading spinner */ +.api-status-icon.status-loading { + color: #90caf9; +} + +/* Error details text under the line */ +.api-status-details { + color: #ffcdd2; +} + +.api-status-icon.status-ok { + color: #265228; +} + +.api-status-icon.status-error { + color: #ef5350; +} + +.apprise-config-editor textarea { + caret-color: black !important; +} + +.apprise-config-editor .apprise-config-highlight { + background-color: #fff !important; + color: #d4d4d4; +} + +/* 1. URLs */ +.apprise-config-highlight .hljs-string, +.apprise-config-highlight .hljs-symbol, +.apprise-config-highlight .hljs-link { + color: #1b1e26 !important; +} + +/* 2. Comments */ +.apprise-config-highlight .hljs-comment { + color: #777 !important; + font-style: italic !important; +} + +/* 3. Tags / Keywords */ +.apprise-config-highlight .hljs-keyword, +.apprise-config-highlight .hljs-section, +.apprise-config-highlight .hljs-name { + color: #6a9955 !important; +} + +/* 4. Numbers / Constants */ +.apprise-config-highlight .hljs-number, +.apprise-config-highlight .hljs-literal { + color: #c586c0 !important; +} + +/* 5. Standard Text */ +.apprise-config-highlight { + color: #d4d4d4 !important; +} + +.notify-attachments-list .attachment-chip { + background-color: #eee; + color: #004d40; +} + +/* SweetAlert2 โ€“ log colouring */ +.swal2-popup .notify-log-panel { + background-color: #eee; +} + +.swal2-popup .logs .log_DEBUG { + color: #607d8b; +} + +.swal2-popup .logs .log_INFO { + color: #2e7d32; +} + +.swal2-popup .logs .log_WARNING { + color: #f9a825; +} + +.swal2-popup .logs .log_ERROR { + color: #d32f2f; + font-weight: 600; +} + +.swal2-popup .logs .log_CRITICAL, +.swal2-popup .logs .log_FATAL { + color: #b71c1c; + font-weight: 700; +} + +.swal2-popup .logs .log_TRACE { + color: #1976d2; + font-style: italic; +} \ No newline at end of file diff --git a/apprise_api/static/css/theme-light.min.css b/apprise_api/static/css/theme-light.min.css deleted file mode 100644 index 91d0efd..0000000 --- a/apprise_api/static/css/theme-light.min.css +++ /dev/null @@ -1,76 +0,0 @@ -.tabs .tab a { - color:#2bbbad; - background-color: #f3f3f3; - border-bottom: 1px solid #464646; -} -.tabs.tabs-transparent .tab a, -.tabs.tabs-transparent .tab.disabled a, -.tabs.tabs-transparent .tab.disabled a:hover, -.tab.disabled i.material-icons{ - color:#a7a7a7 -} -.tabs .tab.disabled a, -.tabs .tab.disabled a:hover { - background-color: #f3f3f3; - color:#a7a7a7 -} -.file-selected { - color: #039be5; - background-color: #f3f3f3; -} - -#url-list .card-panel { - box-shadow: 0 2px 4px rgba(0,0,0,0.4); - background-color: #f3f3f3; -} - -#url-list .url-header code.url-text { - background-color: #fff; - color: #464646; -} - -.chip { - background-color: #fff !important; - border: 1px solid #464646 !important; - color: #464646 !important; -} - -.chip.selected { - background-color: #258528!important; - color: white !important; -} - -#overview strong { - color: #004d40; - background-color: #eee; -} - -ul.logs li.log_INFO { - color: black; -} - -ul.logs li.log_DEBUG { - color: #606060; -} - -ul.logs li.log_WARNING { - color: orange; -} - -ul.logs li.log_ERROR { - color: #8B0000; -} -/* Copy-toast for light theme */ -.copy-toast.swal2-popup { - background-color: #f3f3f3; - color: #464646; - box-shadow: 0 2px 4px rgba(0,0,0,.25); - border-radius: 4px; - padding: .35rem .8rem; -} - -.copy-toast .swal2-title { - margin: 0; - font-size: .9rem; - font-weight: 500; -} diff --git a/apprise_api/static/logo-dark.png b/apprise_api/static/logo-dark.png new file mode 100644 index 0000000..f6ef612 Binary files /dev/null and b/apprise_api/static/logo-dark.png differ diff --git a/apprise_api/static/logo-light.png b/apprise_api/static/logo-light.png new file mode 100644 index 0000000..b967f06 Binary files /dev/null and b/apprise_api/static/logo-light.png differ diff --git a/apprise_api/supervisord-startup b/apprise_api/supervisord-startup index aea5923..49c94c5 100755 --- a/apprise_api/supervisord-startup +++ b/apprise_api/supervisord-startup @@ -1,5 +1,5 @@ #!/bin/bash -# Copyright (C) 2024 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -22,6 +22,22 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +# Nginx configuration file +NGINX_CONF=/opt/apprise/webapp/etc/nginx.conf +echo "$STRICT_MODE" | cut -c1 | grep -E -m1 -q -i '\(a|y|1|e|t|+)\)' 2>/dev/null +STRICT_MODE=$? +if [ $STRICT_MODE -eq 0 ]; then + # Strict Mode + echo "Apprise-API operating in Strict Mode" + NGINX_CONF=/opt/apprise/webapp/etc/nginx-strict.conf +else + echo "Apprise-API operating in Normal Mode" +fi +NGINX_FILENAME=$(basename "$NGINX_CONF") + +# supervisord configuration file +SUPERVISORD_CONF="/opt/apprise/webapp/etc/supervisord.conf" + if [ $(id -u) -eq 0 ]; then # # Root User @@ -61,9 +77,9 @@ if [ $(id -u) -eq 0 ]; then # Update our configuration sed -i -e "s/^#\?[ \t]*\(user[ \t]*=[ \t]*\).*$/\1${USER}/g" \ - /opt/apprise/webapp/etc/supervisord.conf + "$SUPERVISORD_CONF" sed -i -e "s/^#\?[ \t]*\(group[ \t]*=[ \t]*\).*$/\1${GROUP}/g" \ - /opt/apprise/webapp/etc/supervisord.conf + "$SUPERVISORD_CONF" else # @@ -88,11 +104,11 @@ chown -R "$USER:$GROUP" /attach /config /config/store \ if [ ! -z "${HTTP_PORT}" ]; then echo -n "Nginx/HTTP over-ride TCP/IP Listen Port: ${HTTP_PORT} ... " - if [ -w /opt/apprise/webapp/etc/nginx.conf ]; then + if [ -w "$NGINX_CONF" ]; then sed -i -e "s/^\([ \t]*listen[ \t]\+[^0-9]*\)\([^;]\+\);\(.*\)/\1${HTTP_PORT};\3/g" \ - /opt/apprise/webapp/etc/nginx.conf &>/dev/null && echo "Done." || echo "Failed!" + "$NGINX_CONF" &>/dev/null && echo "Done." || echo "Failed!" else - echo "Skipped (nginx.conf not writable)." + echo "Skipped ($NGINX_FILENAME not writable)." fi fi @@ -103,21 +119,31 @@ if [ "${IPV4_ONLY+x}" ] && [ "${IPV6_ONLY+x}" ]; then # Handle IPV4_ONLY flag elif [ "${IPV4_ONLY+x}" ]; then echo -n "Network mode: IPv4-only (IPV4_ONLY set) ... " - if [ -w /opt/apprise/webapp/etc/nginx.conf ]; then - sed -ibak -e '/IPv6 Support/d' /opt/apprise/webapp/etc/nginx.conf && \ + if [ -w "$NGINX_CONF" ]; then + sed -ibak -e '/IPv6 Support/d' "$NGINX_CONF" && \ echo "Done." || echo "Failed!" else - echo "Skipped (nginx.conf not writable)." + echo "Skipped ($NGINX_FILENAME not writable)." fi # Handle IPV6_ONLY flag elif [ "${IPV6_ONLY+x}" ]; then echo -n "Network mode: IPv6-only (IPV6_ONLY set) ... " - if [ -w /opt/apprise/webapp/etc/nginx.conf ]; then - sed -ibak -e '/IPv4 Support/d' /opt/apprise/webapp/etc/nginx.conf && \ + if [ -w "$NGINX_CONF" ]; then + sed -ibak -e '/IPv4 Support/d' "$NGINX_CONF" && \ echo "Done." || echo "Failed!" else - echo "Skipped (nginx.conf not writable)." + echo "Skipped ($NGINX_FILENAME not writable)." + fi +fi + +if [ $STRICT_MODE -eq 0 ]; then + echo -n "Enforcing nginx strict mode ... " + if [ -w "$SUPERVISORD_CONF" ]; then + sed -i -e "s/nginx\.conf/nginx-strict.conf/g" \ + "$SUPERVISORD_CONF" &>/dev/null && echo "Done." || echo "Failed!" + else + echo "Skipped ($NGINX_FILENAME not writable)." fi fi @@ -128,7 +154,7 @@ echo "Resolution of 'localhost' inside container:" getent ahosts localhost || true # Get our Port Information -PORT=$(grep -m1 -E '^[ \t]*listen[ \t]+' /opt/apprise/webapp/etc/nginx.conf 2>/dev/null | \ +PORT=$(grep -m1 -E '^[ \t]*listen[ \t]+' "$NGINX_CONF" 2>/dev/null | \ sed -e 's/^[^0-9]\+\([0-9]\+\).*/\1/g') PORT=${PORT:=Unknown} @@ -137,7 +163,7 @@ echo "Listening on TCP/IP Port: ${PORT}" cd /opt/apprise # Launch our SupervisorD as PID 1 -exec /usr/local/bin/supervisord -c /opt/apprise/webapp/etc/supervisord.conf +exec /usr/local/bin/supervisord -c "$SUPERVISORD_CONF" # Always return our SupervisorD return code exit $? diff --git a/dev-requirements.txt b/dev-requirements.txt index 6e98eaf..70c918d 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,5 +2,8 @@ mock pytest-django pytest pytest-cov +djlint +jsbeautifier +openapi-spec-validator tox ruff diff --git a/docker-compose.swagger.yml b/docker-compose.swagger.yml new file mode 100644 index 0000000..aa0ef5f --- /dev/null +++ b/docker-compose.swagger.yml @@ -0,0 +1,12 @@ +version: "3.9" + +services: + swagger-ui: + image: swaggerapi/swagger-ui + container_name: apprise-api-swagger-ui + ports: + - "${SWAGGER_PORT:-8001}:8080" + environment: + SWAGGER_JSON: /docs/swagger.yaml + volumes: + - ./swagger.yaml:/docs/swagger.yaml:ro diff --git a/manage.py b/manage.py index 4403840..6a3bec5 100755 --- a/manage.py +++ b/manage.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. diff --git a/pyproject.toml b/pyproject.toml index 7dbf4ca..3249375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,11 +27,11 @@ build-backend = "setuptools.build_meta" [project] name = "apprise-api" -version = "1.2.1" +version = "1.3.0" authors = [{ name = "Chris Caron", email = "lead2gold@gmail.com" }] license = "MIT" dependencies = [ - "apprise == 1.9.4", + "apprise == 1.9.6", "django", "gevent", "gunicorn", @@ -48,6 +48,9 @@ dev = [ "pytest-cov", "pytest-django", "ruff", + "djlint", + "jsbeautifier", + "openapi-spec-validator", "tox", "mock" ] @@ -108,7 +111,7 @@ exclude = [ [tool.black] # Added for backwards support and alternative cleanup # when needed -line-length = 79 # Respecting BSD-style 79-char limit +line-length = 120 target-version = ['py39'] extend-exclude = ''' /( @@ -124,6 +127,9 @@ extend-exclude = ''' ) ''' +[tool.ruff.format] +indent-style = "space" + [tool.ruff.lint] select = [ "E", # pycodestyle errors @@ -177,3 +183,24 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo [tool.ruff.lint.flake8-builtins] builtins-ignorelist = ["_"] +[tool.djlint] +# Define our profile +profile = "django" + +# Match existing Python line length +max_line_length = 120 +indent = 2 + +format_js = true +format_css = true +blank_line_after_tag = "load,extends,include" + +extend_exclude = ".venv,venv,.tox,node_modules,build,dist,lib,lib64,static" +ignore = "H006,H021" + +[tool.djlint.js] +indent_size = 2 +preserve_newlines = true + +[tool.djlint.css] +indent_size = 2 diff --git a/requirements.txt b/requirements.txt index ee367f7..ec30578 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ # apprise @ git+https://github.com/caronc/apprise@custom-tag-or-version ## 3. The below grabs our stable version (generally the best choice): -apprise == 1.9.5 +apprise == 1.9.6 ## Apprise API Minimum Requirements django diff --git a/swagger.yaml b/swagger.yaml index e8e0316..28a4568 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -1,91 +1,336 @@ openapi: '3.0.3' + info: title: Apprise API - description: https://github.com/caronc/apprise-api - version: 0.7.0 + description: > + A lightweight REST framework that wraps the Apprise API Notification + Library. See https://github.com/caronc/apprise-api for details. + version: 1.2.6 + +servers: + - url: http://localhost:8000 + description: Local hosting of Apprise API + - url: / + description: Relative path (Current Host of Apprise API instance) + +tags: + - name: Stateless + description: Stateless notification endpoints that do not use a stored key. + - name: Persistent + description: Endpoints that work with pre-saved configurations identified by a {key}. + - name: Meta + description: Introspection and system endpoints. + paths: + /status: + get: + operationId: Meta_GetStatus + summary: Server Health Check + description: > + Performs a health check on the server configuration. + Returns 200 if OK, 417 if there is a configuration/permission issue. + tags: + - Meta + responses: + '200': + description: Server is healthy. + content: + text/plain: + schema: + type: string + example: "OK" + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '417': + description: Expectation Failed (Configuration or Permission issues). + content: + text/plain: + schema: + type: string + example: "CONFIG_PERMISSION_ISSUE" + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + + /details: + get: + operationId: Meta_GetDetails + summary: Returns details of all supported Apprise URLs and their options. + description: > + Returns a list of supported notification services. + If Accept header is 'text/html', renders a UI page. + If Accept header is 'application/json', returns the JSON structure. + tags: + - Meta + parameters: + - in: query + name: all + schema: + type: string + enum: ["yes", "no"] + description: Show all plugins (including disabled ones). + responses: + '200': + description: Details of supported services. + content: + application/json: + schema: + type: object + text/html: + schema: + type: string + /notify: post: operationId: Stateless_SendNotification - 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. + summary: > + Sends one or more notifications to the URLs identified as part + of the payload or in APPRISE_STATELESS_URLS. + tags: + - Stateless + parameters: + - $ref: '#/components/parameters/RecursionHeader' + - $ref: '#/components/parameters/IdHeader' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/StatelessNotificationRequest' + $ref: '#/components/schemas/StatelessNotificationRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/StatelessNotificationMultipart' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/StatelessNotificationForm' responses: - 200: - description: OK + '200': + description: > + Notification accepted. Response contains processing logs. + content: + text/plain: + schema: + type: string + example: "2025-01-01 12:00:00,000 - INFO - Sent to Telegram" + text/html: + schema: + type: string + description: Rendered HTML logs. + application/json: + schema: + $ref: '#/components/schemas/LogResponse' + '204': + description: No Content (No valid URLs provided). + '400': + description: Bad Request (Invalid payload or format). + '406': + description: Method Not Accepted (Recursion limit reached). + '424': + description: One or more notifications could not be sent. + '431': + description: Request Header Fields Too Large (JSON Payload too big). + + /cfg: + get: + operationId: Persistent_ListConfigurations + summary: List all stored configuration keys. + description: > + Returns a list of all Config Keys currently stored. + Requires APPRISE_ADMIN to be enabled in server settings. tags: - - Stateless + - Persistent + responses: + '200': + description: List of keys retrieved. + content: + application/json: + schema: + type: array + items: + type: string + text/html: + schema: + type: string + '403': + description: Access Denied (APPRISE_ADMIN not enabled). + /add/{key}: post: operationId: Persistent_AddConfiguration - summary: Saves Apprise Configuration (or set of URLs) to the persistent store. + summary: Saves Apprise configuration (or set of URLs) to the persistent store. + tags: + - Persistent parameters: - - in: path - name: key - required: true - schema: - type: string - description: Configuration key + - $ref: '#/components/parameters/key' requestBody: + required: false content: application/json: schema: - $ref: '#/components/schemas/AddConfigurationRequest' + $ref: '#/components/schemas/AddConfigurationRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AddConfigurationForm' responses: - 200: - description: OK - tags: - - Persistent + '200': + description: Configuration stored (or updated). + content: + text/plain: + schema: + type: string + example: "Successfully saved configuration" + application/json: + schema: + type: object + properties: + error: + type: string + nullable: true + '400': + description: Bad Request (Invalid format or payload). + '403': + description: Forbidden (Config Lock enabled). + '431': + description: Payload Too Large. + '500': + description: Internal Error (Could not write to disk). + /del/{key}: post: operationId: Persistent_RemoveConfiguration - summary: Removes Apprise Configuration from the persistent store. + summary: Removes Apprise configuration from the persistent store. + tags: + - Persistent parameters: - $ref: '#/components/parameters/key' responses: - 200: - description: OK - tags: - - Persistent + '200': + description: Configuration removed. + '204': + description: No content (Configuration did not exist). + '403': + description: Forbidden (Config Lock enabled). + '500': + description: Internal Error (Could not delete from disk). + /get/{key}: post: operationId: Persistent_GetConfiguration - summary: Returns the Apprise Configuration from the persistent store. + summary: > + Returns the Apprise configuration for {key}. + description: > + Depending on the Accept header and how the config was stored, + returns TEXT, YAML, or JSON. + tags: + - Persistent parameters: - $ref: '#/components/parameters/key' responses: - 200: - description: OK + '200': + description: Configuration returned. content: text/plain: schema: type: string + description: Returned if config was saved as TEXT. + text/yaml: + schema: + type: string + description: Returned if config was saved as YAML. + application/json: + schema: + $ref: '#/components/schemas/ConfigResponse' + '204': + description: No content (Key found but empty). + '403': + description: Forbidden (Config Lock enabled). + '500': + description: Internal Server Error. + + /cfg/{key}: + post: + operationId: Persistent_GetConfigurationAlias + summary: > + Alias for /get/{key}. Returns the Apprise configuration. tags: - Persistent + parameters: + - $ref: '#/components/parameters/key' + responses: + '200': + description: Configuration returned. + content: + text/plain: + schema: + type: string + text/yaml: + schema: + type: string + application/json: + schema: + $ref: '#/components/schemas/ConfigResponse' + '204': + description: No content. + '403': + description: Forbidden. + '500': + description: Internal Server Error. + /notify/{key}: post: operationId: Persistent_SendNotification - summary: Sends notification(s) to all of the end points you've previously configured associated with a {KEY}. + summary: > + Sends notification(s) to all of the endpoints previously + configured under {key}, optionally filtered by tag. + tags: + - Persistent parameters: - $ref: '#/components/parameters/key' + - $ref: '#/components/parameters/RecursionHeader' + - $ref: '#/components/parameters/IdHeader' requestBody: + required: false content: application/json: schema: - $ref: '#/components/schemas/PersistentNotificationRequest' + $ref: '#/components/schemas/PersistentNotificationRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PersistentNotificationMultipart' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PersistentNotificationForm' responses: - 200: - description: OK - tags: - - Persistent + '200': + description: Notification accepted. Response contains logs. + content: + text/plain: + schema: + type: string + text/html: + schema: + type: string + application/json: + schema: + $ref: '#/components/schemas/LogResponse' + '204': + description: No configuration found for this key. + '406': + description: Method Not Accepted (Recursion limit reached). + '424': + description: One or more notifications could not be sent. + '431': + description: Payload Too Large. + /json/urls/{key}: get: operationId: Persistent_GetUrls - summary: Returns a JSON response object that contains all of the URLS and Tags associated with the key specified. + summary: > + Returns a JSON object describing the URLs and tags associated + with {key}. + tags: + - Persistent parameters: - $ref: '#/components/parameters/key' - in: query @@ -93,25 +338,30 @@ paths: schema: type: integer enum: [0, 1] - # This should be changed to use 'oneOf' when upgrading to OpenApi 3.1 x-enumNames: ["ShowSecrets", "HideSecrets"] required: false + description: > + When set to 1, secrets within URLs are redacted. - in: query name: tag schema: type: string default: all required: false + description: > + Filter URLs by tag expression. + Comma (",") or Pipe ("|") for OR, space (" ") for AND. + Use "all" to select everything. responses: - 200: - description: OK + '200': + description: URL listing. content: application/json: schema: $ref: '#/components/schemas/JsonUrlsResponse' - tags: - - Persistent - + '204': + description: No content. + components: parameters: key: @@ -121,27 +371,104 @@ components: schema: type: string minLength: 1 - maxLength: 64 - description: Configuration key + maxLength: 128 + description: Configuration key (1-128 chars, alphanumeric, _, -). + RecursionHeader: + in: header + name: X-Apprise-Recursion-Count + schema: + type: integer + default: 0 + description: Internal header to track recursion depth. + IdHeader: + in: header + name: X-Apprise-ID + schema: + type: string + description: Optional Unique ID to associate with the notification. + schemas: NotificationType: type: string - enum: [info, warning, failure] + description: Logical type of the notification. + enum: + - info + - success + - warning + - failure default: info + NotificationFormat: type: string - enum: [text, markdown, html] + description: Text format of the message body. + enum: + - text + - markdown + - html default: text + + StatusResponse: + type: object + properties: + attach_lock: + type: boolean + config_lock: + type: boolean + status: + type: object + properties: + persistent_storage: + type: boolean + can_write_config: + type: boolean + can_write_attach: + type: boolean + details: + type: array + items: + type: string + + ConfigResponse: + type: object + properties: + format: + type: string + description: The format of the returned config (text or yaml). + config: + type: string + description: The raw configuration content. + + LogResponse: + type: object + properties: + error: + type: string + nullable: true + details: + type: array + description: A list of log entries [Level, Date, Message]. + items: + type: array + items: + type: string + + # JSON Request StatelessNotificationRequest: + type: object properties: urls: type: array + description: > + One or more Apprise URLs. If omitted, the environment + variable APPRISE_STATELESS_URLS is used (if set). items: type: string body: type: string + description: Message body to send. title: type: string + description: Optional message title. type: $ref: '#/components/schemas/NotificationType' format: @@ -150,19 +477,74 @@ components: type: string required: - body - AddConfigurationRequest: + + # Multipart Request (Files) + StatelessNotificationMultipart: + type: object properties: urls: + type: string + description: Comma separated list of Apprise URLs. + body: + type: string + title: + type: string + type: + $ref: '#/components/schemas/NotificationType' + format: + $ref: '#/components/schemas/NotificationFormat' + attach: type: array items: type: string - default: null + format: binary + description: File attachments. + required: + - body + + # Form URL Encoded Request + StatelessNotificationForm: + type: object + properties: + urls: + type: string + body: + type: string + title: + type: string + type: + $ref: '#/components/schemas/NotificationType' + format: + $ref: '#/components/schemas/NotificationFormat' + required: + - body + + AddConfigurationRequest: + type: object + description: > + Either supply urls, or supply config (with format). + properties: config: type: string + description: TEXT or YAML Apprise configuration. format: type: string - enum: [text, yaml] + enum: [text, yaml, auto] + + AddConfigurationForm: + type: object + properties: + urls: + type: string + description: Comma separated URLs. + config: + type: string + format: + type: string + enum: [text, yaml, auto] + PersistentNotificationRequest: + type: object properties: body: type: string @@ -177,7 +559,46 @@ components: default: all required: - body + + PersistentNotificationMultipart: + type: object + properties: + body: + type: string + title: + type: string + type: + $ref: '#/components/schemas/NotificationType' + format: + $ref: '#/components/schemas/NotificationFormat' + tag: + type: string + attach: + type: array + items: + type: string + format: binary + required: + - body + + PersistentNotificationForm: + type: object + properties: + body: + type: string + title: + type: string + type: + $ref: '#/components/schemas/NotificationType' + format: + $ref: '#/components/schemas/NotificationFormat' + tag: + type: string + required: + - body + JsonUrlsResponse: + type: object properties: tags: type: array @@ -186,10 +607,13 @@ components: urls: type: array items: - type: object $ref: '#/components/schemas/url' + url: + type: object properties: + id: + type: string url: type: string tags: diff --git a/tox.ini b/tox.ini index 8b8851c..18ae5d2 100644 --- a/tox.ini +++ b/tox.ini @@ -16,26 +16,60 @@ changedir = {toxinidir} allowlist_externals = * ensurepip = true setenv = + DJLINT_TARGETS = \ + apprise_api/api/templates \ + apprise_api/error/templates + CSS_TARGETS = \ + apprise_api/static/css/base.css \ + apprise_api/static/css/theme-dark.css \ + apprise_api/static/css/theme-light.css COVERAGE_RCFILE = {toxinidir}/pyproject.toml +[testenv:clean] +description = Remove build artifacts and cache files +skip_install = true +allowlist_externals = + find + rm +commands = + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*.orig" -delete + rm -rf .cache .ruff_cache .coverage-reports .coverage coverage.xml .mypy_cache build .pytest_cache apprise_api.egg-info "__pycache__" + find . -type d -name "__pycache__" -delete + [testenv:lint] -description = Run Ruff in check-only mode -deps = ruff -commands = ruff check apprise_api +description = Run Ruff and djlint in check-only mode +deps = + ruff + djlint + openapi-spec-validator +skip_install = true +commands = + ruff check apprise_api + djlint {env:DJLINT_TARGETS} --check + python -m openapi_spec_validator swagger.yaml [testenv:format] -description = Auto-format code using Ruff -deps = ruff +description = Auto-format Python (Ruff) and templates (djlint) +deps = + ruff + djlint + jsbeautifier skip_install = true -commands = ruff check --fix apprise_api +commands = + ruff check --fix apprise_api + djlint {env:DJLINT_TARGETS} --reformat + css-beautify --indent-size 2 --replace {env:CSS_TARGETS} [testenv:qa] description = Run test suite and linting with coverage -deps = - .[dev] +deps = .[dev] commands = - coverage run --parallel -m pytest apprise_api ruff check apprise_api + djlint {env:DJLINT_TARGETS} --check + python -m openapi_spec_validator swagger.yaml + coverage run --parallel -m pytest apprise_api [testenv:test] description = Run test suite only @@ -52,3 +86,14 @@ commands = coverage combine apprise_api coverage report apprise_api +[testenv:runserver] +description = Run Django development server (manage.py runserver) +deps = .[dev] +commands = + python manage.py runserver {posargs} + +[testenv:shell] +description = Run Django development shell (manage.py shell) +deps = .[dev] +commands = + python manage.py shell {posargs}