diff --git a/src/snek/static/push.js b/src/snek/static/push.js index 2e40f2e..89aacb6 100644 --- a/src/snek/static/push.js +++ b/src/snek/static/push.js @@ -1,62 +1,49 @@ -// this.onpush = (event) => { -// console.log(event.data); -// // From here we can write the data to IndexedDB, send it to any open -// // windows, display a notification, etc. -// }; +export const registerServiceWorker = async (silent = false) => { + try { + const serviceWorkerRegistration = await navigator.serviceWorker + .register("/service-worker.js") -function arrayBufferToBase64(buffer) { - return btoa(String.fromCharCode(...new Uint8Array(buffer))) + await serviceWorkerRegistration.update() + + const keyResponse = await fetch('/push.json') + const keyData = await keyResponse.json() + + const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0)) + + const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, applicationServerKey: publicKey, + }) + + const subscriptionObject = { + ...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings, + }; + console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject); + + const response = await fetch('/push.json', { + method: 'POST', headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify(subscriptionObject), + }) + + if (!response.ok) { + throw new Error('Bad status code from server.'); + } + + const responseData = await response.json(); + console.log('Registration response', responseData); + } catch (error) { + console.error("Error registering service worker:", error); + if (!silent) { + alert("Registering push notifications failed. Please check your browser settings and try again.\n\n" + error); + } + } } -const keyResponse = await fetch('/push.json') -const keyData = await keyResponse.json() -console.log("Key data", keyData); - -const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0)) - -export const registerServiceWorker = async () => { - navigator.serviceWorker - .register("/service-worker.js") - .then((serviceWorkerRegistration) => { - console.log(serviceWorkerRegistration); - serviceWorkerRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: publicKey, - }).then( - (pushSubscription) => { - const subscriptionObject = { - ...pushSubscription.toJSON(), - encoding: PushManager.supportedContentEncodings, - /* other app-specific data, such as user identity */ - }; - console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject); - - fetch('/push.json', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(subscriptionObject), - }).then((response) => { - if (!response.ok) { - throw new Error('Bad status code from server.'); - } - return response.json(); - }).then((responseData) => { - console.log(responseData); - }); - }, - (error) => { - console.error(error); - }, - ); - }); -} - -window.registerServiceWorker = () => { +window.registerNotificationsServiceWorker = () => { return Notification.requestPermission().then((permission) => { if (permission === "granted") { + console.log("Permission was granted"); return registerServiceWorker(); } else if (permission === "denied") { console.log("Permission was denied"); @@ -65,4 +52,4 @@ window.registerServiceWorker = () => { } }); }; -registerServiceWorker().catch(console.error); +registerServiceWorker(true).catch(console.error); diff --git a/src/snek/static/service-worker.js b/src/snek/static/service-worker.js index 038bb5f..310fa96 100644 --- a/src/snek/static/service-worker.js +++ b/src/snek/static/service-worker.js @@ -1,83 +1,66 @@ -async function requestNotificationPermission() { - const permission = await Notification.requestPermission(); - return permission === "granted"; -} +self.addEventListener("install", (event) => { + console.log("Service worker installing..."); + event.waitUntil( + caches.open("snek-cache").then((cache) => { + return cache.addAll([]); + }) + ); +}) -// Subscribe to Push Notifications -async function subscribeUser() { - const registration = - await navigator.serviceWorker.register("/service-worker.js"); - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY), - }); +self.addEventListener("activate", (event) => { + event.waitUntil(self.registration?.navigationPreload.enable()); +}); - // Send subscription to your backend - await fetch("/subscribe", { - method: "POST", - body: JSON.stringify(subscription), - headers: { - "Content-Type": "application/json", - }, - }); -} +self.addEventListener("push", (event) => { + if (!self.Notification || self.Notification.permission !== "granted") { + console.log("Notification permission not granted"); + return; + } -// Service Worker (service-worker.js) -// self.addEventListener("push", (event) => { -// const data = event.data.json(); -// self.registration.showNotification(data.title, { -// body: data.message, -// icon: data.icon, -// }); -// }); + const data = event.data?.json() ?? {}; + console.log("Received a push message", event, data); + const title = data.title || "Something Has Happened"; + const message = + data.message || "Here's something you might want to check out."; + const icon = data.icon || "/image/snek512.png"; -self.addEventListener("push", (event) => { - if (!self.Notification || self.Notification.permission !== "granted") { - console.log("Notification permission not granted"); - return; - } + const notificationSettings = data.notificationSettings || {}; - const data = event.data?.json() ?? {}; - console.log("Received a push message", event, data); + console.log("Showing message", title, message, icon); - const title = data.title || "Something Has Happened"; - const message = - data.message || "Here's something you might want to check out."; - const icon = data.icon || "images/new-notification.png"; - console.log("showing message", title, message, icon); - - const reg = self.registration.showNotification(title, { - body: message, - tag: "simple-push-demo-notification", - icon, - }).then(e => console.log("success", e)).catch(console.error); - - event.waitUntil(reg); + const reg = self.registration.showNotification(title, { + body: message, + tag: "message-received", + icon, + badge: icon, + ...notificationSettings, + data, + }).then(e => console.log("Showing notification", e)).catch(console.error); + event.waitUntil(reg); }); self.addEventListener("notificationclick", (event) => { console.log("Notification click Received.", event); event.notification.close(); - event.waitUntil(clients.openWindow( - "https://snek.molodetz.nl",)); + event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`)); }); self.addEventListener("notificationclose", (event) => { - + console.log("Notification closed", event); }) self.addEventListener("fetch", (event) => { // console.log("Fetch event for ", event.request.url); event.respondWith( caches.match(event.request).then((response) => { - if (response) { - // console.log("Found response in cache: ", response); - return response; - } - // console.log("No response found in cache. About to fetch from network..."); - return fetch(event.request); + if (response) { + // console.log("Found response in cache: ", response); + return response; + } + // console.log("No response found in cache. About to fetch from network..."); + return fetch(event.request); }) ); }) \ No newline at end of file diff --git a/src/snek/system/notification.py b/src/snek/system/notification.py index 3a60b7c..7f4274d 100644 --- a/src/snek/system/notification.py +++ b/src/snek/system/notification.py @@ -2,6 +2,7 @@ import time import base64 import uuid from functools import cache +from pathlib import Path import jwt from cryptography.hazmat.primitives.asymmetric import ec @@ -14,66 +15,55 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.primitives.kdf.hkdf import HKDF -PRIVATE_KEY_FILE = "./notification-private.pem" -PRIVATE_KEY_PKCS8_FILE = "./notification-private.pkcs8.pem" -PUBLIC_KEY_FILE = "./notification-public.pem" +# The only reason to persist the keys is to be able to use them in the web push + +PRIVATE_KEY_FILE = Path("./notification-private.pem") +PRIVATE_KEY_PKCS8_FILE = Path("./notification-private.pkcs8.pem") +PUBLIC_KEY_FILE = Path("./notification-public.pem") def generate_private_key(): - if not os.path.isfile(PRIVATE_KEY_FILE): + if not PRIVATE_KEY_FILE.exists(): private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) - # Serialize the private key to PEM format pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) - # Write the private key to a file - with open(PRIVATE_KEY_FILE, "wb") as pem_out: - pem_out.write(pem) + PRIVATE_KEY_FILE.write_bytes(pem) def generate_pcks8_private_key(): - # openssl pkcs8 -topk8 -nocrypt -in private.pem -out private.pkcs8.pem - if not os.path.isfile(PRIVATE_KEY_PKCS8_FILE): - with open(PRIVATE_KEY_FILE, "rb") as pem_in: - private_key = serialization.load_pem_private_key( - pem_in.read(), password=None, backend=default_backend() - ) + if not PRIVATE_KEY_PKCS8_FILE.exists(): + private_key = serialization.load_pem_private_key( + PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() + ) - # Serialize the private key to PKCS8 format pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) - # Write the private key to a file - with open(PRIVATE_KEY_PKCS8_FILE, "wb") as pem_out: - pem_out.write(pem) + PRIVATE_KEY_PKCS8_FILE.write_bytes(pem) def generate_public_key(): - if not os.path.isfile(PUBLIC_KEY_FILE): - with open(PRIVATE_KEY_FILE, "rb") as pem_in: - private_key = serialization.load_pem_private_key( - pem_in.read(), password=None, backend=default_backend() - ) + if not PUBLIC_KEY_FILE.exists(): + private_key = serialization.load_pem_private_key( + PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() + ) - # Get the public key from the private key public_key = private_key.public_key() - # Serialize the public key to PEM format pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) - # Write the public key to a file - with open(PUBLIC_KEY_FILE, "wb") as pem_out: - pem_out.write(pem) + PUBLIC_KEY_FILE.write_bytes(pem) def ensure_certificates(): @@ -92,41 +82,37 @@ def hkdf(input_key, salt, info, length): ).derive(input_key) +def _browser_base64(data): + return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") + + class Notifications: private_key_pem = None public_key = None - public_key_jwk = None + public_key_base64 = None def __init__(self): ensure_certificates() - with open(PRIVATE_KEY_FILE, "rb") as pem_in: - private_key = serialization.load_pem_private_key( - pem_in.read(), password=None, backend=default_backend() - ) - self.private_key_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - with open(PUBLIC_KEY_FILE, "rb") as pem_in: - self.public_key = serialization.load_pem_public_key( - pem_in.read(), backend=default_backend() + private_key = serialization.load_pem_private_key( + PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() + ) + self.private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + self.public_key = serialization.load_pem_public_key( + PUBLIC_KEY_FILE.read_bytes(), backend=default_backend() + ) + + self.public_key_base64 = _browser_base64( + self.public_key.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, ) - public_numbers = self.public_key.public_numbers() - - self.public_key_jwk = { - "kty": "EC", - "crv": "P-256", - "x": base64.urlsafe_b64encode( - public_numbers.x.to_bytes(32, byteorder="big") - ).decode("utf-8"), - "y": base64.urlsafe_b64encode( - public_numbers.y.to_bytes(32, byteorder="big") - ).decode("utf-8"), - } - - print(f"Public key JWK: {self.public_key_jwk}") + ) def create_notification_authorization(self, push_url): target = urlparse(push_url) @@ -152,7 +138,7 @@ class Notifications: algorithm="ES256", ) - def create_encrypted_payload(self, auth: str, p256dh:str, payload:str): + def create_encrypted_payload(self, auth: str, p256dh: str, payload: str): # 1. Generate private key private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) @@ -161,13 +147,13 @@ class Notifications: salt = os.urandom(16) - subscription_pub_key_bytes = base64.urlsafe_b64decode(p256dh+ '==') + subscription_pub_key_bytes = base64.urlsafe_b64decode(p256dh + "====") subscription_public_key = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), subscription_pub_key_bytes ) shared_secret = private_key.exchange(ec.ECDH(), subscription_public_key) - auth_dec = base64.b64decode(auth+ '==') + auth_dec = base64.urlsafe_b64decode(auth + "==") auth_enc = b"Content-Encoding: auth\x00" prk = hkdf(shared_secret, auth_dec, auth_enc, 32) @@ -175,7 +161,8 @@ class Notifications: key_label = b"P-256\x00" subscription_len = len(subscription_pub_key_bytes).to_bytes(2, "big") local_pub_bytes = public_key.public_bytes( - encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, ) local_len = len(local_pub_bytes).to_bytes(2, "big") context = ( @@ -208,18 +195,6 @@ class Notifications: 'public_key': local_pub_bytes } - @property - def public_key_base64(self): - return ( - base64.urlsafe_b64encode( - self.public_key.public_bytes( - encoding=serialization.Encoding.X962, - format=serialization.PublicFormat.UncompressedPoint, - ) - ) - .decode("utf-8") - .rstrip("=") - ) @cache def get_notifications(): diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index c27b803..0119606 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -41,7 +41,7 @@ 👥 ⚙️ - ✉️ + ✉️ 🔒 diff --git a/src/snek/view/push.py b/src/snek/view/push.py index 00581ba..bf1bca3 100644 --- a/src/snek/view/push.py +++ b/src/snek/view/push.py @@ -82,8 +82,8 @@ class PushView(BaseFormView): test_payload = { "title": "Hey retoor", "message": "Guess what? ;P", - "icon": "https://molodetz.online/image/snek192.png", - "url": "https://localhost:8081", + "icon": "/image/snek192.png", + "url": "/web.html", } encryped = notifications.create_encrypted_payload(