Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Browser notifications #693 #913

Open
wants to merge 10 commits into
base: release
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ numpy = "1.26.0"
django-debug-toolbar = "*"
inotify-simple = "*"
Twisted = {extras = ["tls", "http2"], version = "*" }
pywebpush = "2.0.0"

[dev-packages]

Expand Down
2 changes: 2 additions & 0 deletions backend/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions backend/app/views/web_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
125 changes: 125 additions & 0 deletions backend/notifications/plugins/browser/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 10 additions & 0 deletions dotenv.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/mount.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
4 changes: 4 additions & 0 deletions frontend/src/notifications/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ export default {
displayName: 'Webhook',
componentName: 'WebhookPlugin',
},
browser: {
displayName: 'Browser',
componentName: 'BrowserPlugin',
}
}