diff --git a/src/snek/app.py b/src/snek/app.py index c092b41..cb6ca45 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -4,9 +4,9 @@ import pathlib import time import uuid from datetime import datetime -from snek import snode +from snek import snode from snek.view.threads import ThreadsView -import json +import json logging.basicConfig(level=logging.DEBUG) from concurrent.futures import ThreadPoolExecutor @@ -22,6 +22,8 @@ from app.app import Application as BaseApplication from jinja2 import FileSystemLoader from snek.sssh import start_ssh_server + +from snek.system.notification import get_notifications from snek.docs.app import Application as DocsApplication from snek.mapper import get_mappers from snek.service import get_services @@ -38,6 +40,7 @@ from snek.view.drive import DriveView from snek.view.drive import DriveApiView from snek.view.index import IndexView from snek.view.login import LoginView +from snek.view.push import PushView from snek.view.logout import LogoutView from snek.view.register import RegisterView from snek.view.rpc import RPCView @@ -101,6 +104,8 @@ class Application(BaseApplication): self.time_start = datetime.now() self.ssh_host = "0.0.0.0" self.ssh_port = 2242 + + get_notifications() self.setup_router() self.ssh_server = None self.sync_service = None @@ -110,20 +115,20 @@ class Application(BaseApplication): self.mappers = get_mappers(app=self) self.broadcast_service = None self.user_availability_service_task = None - + self.on_startup.append(self.prepare_asyncio) self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.prepare_database) - + @property def uptime_seconds(self): return (datetime.now() - self.time_start).total_seconds() - @property + @property def uptime(self): return self._format_uptime(self.uptime_seconds) - + def _format_uptime(self,seconds): seconds = int(seconds) days, seconds = divmod(seconds, 86400) @@ -147,7 +152,7 @@ class Application(BaseApplication): app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service()) async def snode_sync(self, app): self.sync_service = asyncio.create_task(snode.sync_service(app)) - + async def start_ssh_server(self, app): app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port) if app.ssh_server: @@ -208,6 +213,7 @@ class Application(BaseApplication): self.router.add_view("/settings/index.html", SettingsIndexView) self.router.add_view("/settings/profile.html", SettingsProfileView) self.router.add_view("/settings/profile.json", SettingsProfileView) + self.router.add_view("/push.json", PushView) self.router.add_view("/web.html", WebView) self.router.add_view("/login.html", LoginView) self.router.add_view("/login.json", LoginView) @@ -248,9 +254,9 @@ class Application(BaseApplication): self.git = GitApplication(self) self.add_subapp("/webdav", self.webdav) self.add_subapp("/git",self.git) - + #self.router.add_get("/{file_path:.*}", self.static_handler) - + async def handle_test(self, request): return await self.render_template( @@ -279,9 +285,9 @@ class Application(BaseApplication): async for subscribed_channel in self.services.channel_member.find( user_uid=request.session.get("uid"), deleted_at=None, is_banned=False ): - + parent_object = await subscribed_channel.get_channel() - + item = {} other_user = await self.services.channel_member.get_other_dm_user( subscribed_channel["channel_uid"], request.session.get("uid") @@ -340,12 +346,12 @@ class Application(BaseApplication): user_static_path = await self.services.user.get_static_path(uid) if user_static_path: paths.append(user_static_path) - + for admin_uid in self.services.user.get_admin_uids(): user_static_path = await self.services.user.get_static_path(admin_uid) if user_static_path: paths.append(user_static_path) - + paths.append(self.static_path) for path in paths: diff --git a/src/snek/static/push.js b/src/snek/static/push.js index f9917f4..c644210 100644 --- a/src/snek/static/push.js +++ b/src/snek/static/push.js @@ -1,34 +1,54 @@ -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. -}; +// 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. +// }; + +function arrayBufferToBase64(buffer) { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) +} + +const keyResponse = await fetch('/push.json') +const keyData = await keyResponse.json() + +const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0)) navigator.serviceWorker - .register("/service-worker.js") - .then((serviceWorkerRegistration) => { - serviceWorkerRegistration.pushManager.subscribe().then( - (pushSubscription) => { - const subscriptionObject = { - endpoint: pushSubscription.endpoint, - keys: { - p256dh: pushSubscription.getKey("p256dh"), - auth: pushSubscription.getKey("auth"), - }, - encoding: PushManager.supportedContentEncodings, - /* other app-specific data, such as user identity */ - }; - console.log( - pushSubscription.endpoint, - pushSubscription, - subscriptionObject, + .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); + // The push subscription details needed by the application + // server are now available, and can be sent to it using, + // for example, the fetch() API. + + 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); + }, ); - // The push subscription details needed by the application - // server are now available, and can be sent to it using, - // for example, the fetch() API. - }, - (error) => { - console.error(error); - }, - ); - }); + }); diff --git a/src/snek/static/service-worker.js b/src/snek/static/service-worker.js index c89b668..05dd7af 100644 --- a/src/snek/static/service-worker.js +++ b/src/snek/static/service-worker.js @@ -24,18 +24,14 @@ async function subscribeUser() { } // 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, - }); -}); +// self.addEventListener("push", (event) => { +// const data = event.data.json(); +// self.registration.showNotification(data.title, { +// body: data.message, +// icon: data.icon, +// }); +// }); -/* -self.addEventListener("install", (event) => { - console.log("Service worker installed"); -}); self.addEventListener("push", (event) => { if (!(self.Notification && self.Notification.permission === "granted")) { @@ -62,4 +58,4 @@ self.addEventListener("notificationclick", (event) => { event.notification.close(); event.waitUntil(clients.openWindow( "https://snek.molodetz.nl",)); -});*/ +}); diff --git a/src/snek/system/notification.py b/src/snek/system/notification.py new file mode 100644 index 0000000..c35def3 --- /dev/null +++ b/src/snek/system/notification.py @@ -0,0 +1,125 @@ +import time +import base64 +import uuid +from functools import cache + +import jwt +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +from urllib.parse import urlparse +import os.path + + +PRIVATE_KEY_FILE = './notification-private.pem' +PRIVATE_KEY_PKCS8_FILE = './notification-private.pkcs8.pem' +PUBLIC_KEY_FILE = './notification-public.pem' + +def generate_private_key(): + if not os.path.isfile(PRIVATE_KEY_FILE): + 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) + +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()) + + # 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) + +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()) + + # 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) + +def ensure_certificates(): + generate_private_key() + generate_pcks8_private_key() + generate_public_key() + +class Notifications: + private_key_pem = None + public_key = None + public_key_jwk = 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()) + 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) + aud = f"{target.scheme}://{target.netloc}" + sub = "mailto:admin@molodetz.nl" + + identifier = str(uuid.uuid4()) + + print(f"Creating notification authorization for {aud} with identifier {identifier}") + + return jwt.encode({ + "sub": sub, + "aud": aud, + "exp": int(time.time()) + 60 * 60, + "nbf": int(time.time()), + "iat": int(time.time()), + "jti": identifier, + }, self.private_key_pem, algorithm='ES256') + + +@cache +def get_notifications(): + return Notifications() diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index c92ae76..ba9d0d1 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -7,9 +7,7 @@ Snek - + @@ -34,9 +32,6 @@ - - -