diff --git a/backend/Pipfile b/backend/Pipfile index c5d784dfc..ba2ed94e5 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -55,6 +55,7 @@ numpy = "1.26.0" django-debug-toolbar = "*" inotify-simple = "*" Twisted = {extras = ["tls", "http2"], version = "*" } +pywebpush = "2.0.0" [dev-packages] diff --git a/backend/app/urls.py b/backend/app/urls.py index a4d7b9fd2..87560118d 100644 --- a/backend/app/urls.py +++ b/backend/app/urls.py @@ -44,6 +44,8 @@ path('publictimelapses/', RedirectView.as_view(url='/ent_pub/publictimelapses/', permanent=True), name='publictimelapse_list'), path('slack_oauth_callback/', web_views.slack_oauth_callback, name='slack_oauth_callback'), path('printer_events/', web_views.printer_events), + + path('service-worker.js', web_views.service_worker), # tunnel v2 redirect and page with iframe re_path( diff --git a/backend/app/views/web_views.py b/backend/app/views/web_views.py index 49901fae1..1d909ca8f 100644 --- a/backend/app/views/web_views.py +++ b/backend/app/views/web_views.py @@ -267,3 +267,9 @@ def health_check(request): User.objects.all()[:1] cache.printer_pic_get(0) return HttpResponse('Okay') + +# Service worker must be located at root to get the correct scope, therefor served as a web view +def service_worker(request): + sw_path = f'{settings.BASE_DIR}/static_build/js/service-worker.js' + response = HttpResponse(open(sw_path).read(), content_type='application/javascript') + return response \ No newline at end of file diff --git a/backend/notifications/plugins/browser/__init__.py b/backend/notifications/plugins/browser/__init__.py new file mode 100644 index 000000000..6590bcfe4 --- /dev/null +++ b/backend/notifications/plugins/browser/__init__.py @@ -0,0 +1,125 @@ +from typing import Dict, Optional, Any +import logging +import io +import os +from enum import IntEnum +from rest_framework.serializers import ValidationError +from pywebpush import webpush, WebPushException +import json +from lib import site as site + +from notifications.plugin import ( + BaseNotificationPlugin, + FailureAlertContext, + PrinterNotificationContext, + TestMessageContext, +) + +LOGGER = logging.getLogger(__name__) + + +class BrowserException(Exception): + pass + +class BrowserNotificationPlugin(BaseNotificationPlugin): + + def validate_config(self, data: Dict) -> Dict: + if 'subscriptions' in data: + return {'subscriptions': data['subscriptions']} + raise ValidationError('subscriptions are missing from config') + + def env_vars(self) -> Dict: + return { + 'VAPID_PUBLIC_KEY': { + 'is_required': True, + 'is_set': 'VAPID_PUBLIC_KEY' in os.environ, + 'value': os.environ.get('VAPID_PUBLIC_KEY'), + }, + } + + def send_notification( + self, + config: Dict, + title: str, + message: str, + link: str, + tag: str, + image: str, + ) -> None: + vapid_subject = os.environ.get('VAPID_SUBJECT') + vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY') + if not vapid_subject or not vapid_private_key or not config['subscriptions']: + LOGGER.warn("Missing configuration, won't send notifications to browser") + return + + for subscription in config['subscriptions']: + try: + webpush( + subscription_info={ + "endpoint": subscription['endpoint'], + "keys": subscription['keys'], + }, + data=json.dumps({ + "title": title, + "message": message, + "image": image, + "url": link, + "tag": tag, + }), + vapid_private_key=vapid_private_key, + vapid_claims={ + "sub": f'mailto:{vapid_subject}', + } + ) + except WebPushException as ex: + LOGGER.warn("Failed to send browser push notification: {}", repr(ex)) + # Mozilla returns additional information in the body of the response. + if ex.response and ex.response.json(): + extra = ex.response.json() + LOGGER.warn("Remote service replied with a {}:{}, {}", + extra.code, + extra.errno, + extra.message + ) + + def send_failure_alert(self, context: FailureAlertContext) -> None: + title = self.get_failure_alert_title(context=context, link=link) + text = self.get_failure_alert_text(context=context, link=link) + link = site.build_full_url(f'/printers/{context.printer.id}/control') + + self.send_notification( + config=context.config, + title=title, + message=message, + image=context.img_url, + link=link, + tag=context.printer.name, + ) + + def send_printer_notification(self, context: PrinterNotificationContext) -> None: + title = self.get_printer_notification_title(context=context) + message = self.get_printer_notification_text(context=context) + link = site.build_full_url(f'/printers/{context.printer.id}/control') + + self.send_notification( + config=context.config, + title=title, + message=message, + image=context.img_url, + link=link, + tag=context.printer.name, + ) + + def send_test_message(self, context: TestMessageContext) -> None: + self.send_notification( + config=context.config, + title='Test Notification', + message='It works!', + image="", + link="", + tag="test" + ) + + +def __load_plugin__(): + return BrowserNotificationPlugin() diff --git a/backend/requirements.txt b/backend/requirements.txt index 19a4feece..2065266bb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -116,6 +116,7 @@ python-dateutil==2.8.2 ; python_version >= '2.7' and python_version not in '3.0, python-magic==0.4.27 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' python3-openid==3.2.0 pytz==2023.3.post1 +pywebpush==2.0.0 redis==4.6.0 requests==2.31.0 ; python_version >= '3.7' requests-oauthlib==1.3.1 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' diff --git a/docker-compose.yml b/docker-compose.yml index da2c041b7..0b5d32004 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,9 @@ x-web-defaults: &web-defaults PUSHOVER_APP_TOKEN: '${PUSHOVER_APP_TOKEN-}' SLACK_CLIENT_ID: '${SLACK_CLIENT_ID-}' SLACK_CLIENT_SECRET: '${SLACK_CLIENT_SECRET-}' + VAPID_PUBLIC_KEY: '${VAPID_PUBLIC_KEY-}' + VAPID_PRIVATE_KEY: '${VAPID_PRIVATE_KEY-}' + VAPID_SUBJECT: '${VAPID_SUBJECT-}' DJANGO_SECRET_KEY: '${DJANGO_SECRET_KEY-}' VERSION: diff --git a/dotenv.example b/dotenv.example index 0b8c6f9b4..6a411023c 100644 --- a/dotenv.example +++ b/dotenv.example @@ -65,3 +65,13 @@ # https://api.slack.com/legacy/oauth # SLACK_CLIENT_SECRET= + +# VAPID_PUBLIC_KEY= + +# VAPID_PRIVATE_KEY= +# Vapid keys are used to encrypt Browser notifications. Keys can be generated with this command: `npx web-push generate-vapid-keys`. +# Both public and private keys are required for the plugin to work. +# NOTE: Replacing the keys with fresh ones will make notification to all previously registered devices stop working. + +# VAPID_SUBJECT= +# Vapid subject is a valid email address that will be used as "sender". `mailto:` will be added by the plugin itself. \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index d92d6156c..1971a2f8b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@fortawesome/vue-fontawesome": "^2.0.10", "axios": "^1.3.3", "bootstrap-vue": "^2.15.0", + "bowser": "^2.11.0", "core-js": "^3.6.4", "d3": "^7.8.2", "filesize": "^3.6.1", diff --git a/frontend/src/mount.js b/frontend/src/mount.js index 4265ee31a..159366b50 100644 --- a/frontend/src/mount.js +++ b/frontend/src/mount.js @@ -109,3 +109,11 @@ export default (router, components) => { }, }) } + +if ('serviceWorker' in navigator) { + window.addEventListener('load', function() { + navigator.serviceWorker.register('/service-worker.js').catch(err => { + console.error('ServiceWorker registration failed: ', err); + }); + }); +} \ No newline at end of file diff --git a/frontend/src/notifications/plugins.js b/frontend/src/notifications/plugins.js index 4db9764e7..e01836b6c 100644 --- a/frontend/src/notifications/plugins.js +++ b/frontend/src/notifications/plugins.js @@ -31,4 +31,8 @@ export default { displayName: 'Webhook', componentName: 'WebhookPlugin', }, + browser: { + displayName: 'Browser', + componentName: 'BrowserPlugin', + } } diff --git a/frontend/src/notifications/plugins/BrowserPlugin.vue b/frontend/src/notifications/plugins/BrowserPlugin.vue new file mode 100644 index 000000000..15126cfbe --- /dev/null +++ b/frontend/src/notifications/plugins/BrowserPlugin.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/frontend/static/js/service-worker.js b/frontend/static/js/service-worker.js new file mode 100644 index 000000000..4e9cad792 --- /dev/null +++ b/frontend/static/js/service-worker.js @@ -0,0 +1,24 @@ +self.addEventListener("push", (event) => { + if (!(self.Notification && self.Notification.permission === "granted")) { + return; + } + let data = event.data.json(); + const icon = "https://obico.io/img/favicon.png"; + const options = { + body: data.message, + icon, + image: data.image, + tag: data.tag, + renotify: true, + requireInteraction: true, + data: { + url: data.url + } + }; + self.registration.showNotification(data.title, options); +}); + +self.addEventListener("notificationClick", (event) => { + event.notification.close(); + event.waitUntil(self.clients.openWindow(event.notification.data.url)); +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e2919b358..c907564c7 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2253,6 +2253,11 @@ bootstrap@^4.6.1: resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.6.2.tgz#8e0cd61611728a5bf65a3a2b8d6ff6c77d5d7479" integrity sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ== +bowser@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"