From 895cca26d0aaf5734c9031947fce4a62b672d0cf Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 23 May 2026 10:08:26 +0200 Subject: [PATCH] Upate --- devplacepy/push.py | 277 ++++++++++++++++++++++++ devplacepy/routers/push.py | 64 ++++++ devplacepy/static/icon-192.png | Bin 0 -> 2440 bytes devplacepy/static/icon-512.png | Bin 0 -> 7026 bytes devplacepy/static/icon-maskable-512.png | Bin 0 -> 5515 bytes devplacepy/static/js/PushManager.js | 76 +++++++ devplacepy/static/js/PwaInstaller.js | 51 +++++ devplacepy/static/manifest.json | 34 +++ devplacepy/static/offline.html | 69 ++++++ devplacepy/static/service-worker.js | 76 +++++++ 10 files changed, 647 insertions(+) create mode 100644 devplacepy/push.py create mode 100644 devplacepy/routers/push.py create mode 100644 devplacepy/static/icon-192.png create mode 100644 devplacepy/static/icon-512.png create mode 100644 devplacepy/static/icon-maskable-512.png create mode 100644 devplacepy/static/js/PushManager.js create mode 100644 devplacepy/static/js/PwaInstaller.js create mode 100644 devplacepy/static/manifest.json create mode 100644 devplacepy/static/offline.html create mode 100644 devplacepy/static/service-worker.js diff --git a/devplacepy/push.py b/devplacepy/push.py new file mode 100644 index 00000000..bd8b8414 --- /dev/null +++ b/devplacepy/push.py @@ -0,0 +1,277 @@ +# retoor + +import base64 +import json +import logging +import os +import random +import time +import uuid +from datetime import datetime, timezone +from typing import Any +from urllib.parse import urlparse + +import httpx +import jwt +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from devplacepy.config import ( + VAPID_PRIVATE_KEY_FILE, + VAPID_PRIVATE_KEY_PKCS8_FILE, + VAPID_PUBLIC_KEY_FILE, + VAPID_SUB, +) +from devplacepy.database import get_table +from devplacepy.utils import generate_uid + +logger = logging.getLogger(__name__) + +JWT_LIFETIME_SECONDS = 60 * 60 +PUSH_TTL_SECONDS = "86400" +DEAD_SUBSCRIPTION_STATUSES = (404, 410) +ACCEPTED_STATUSES = (200, 201) + + +def generate_private_key() -> None: + if not VAPID_PRIVATE_KEY_FILE.exists(): + private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + VAPID_PRIVATE_KEY_FILE.write_bytes(pem) + logger.info("Generated VAPID private key at %s", VAPID_PRIVATE_KEY_FILE) + + +def generate_pkcs8_private_key() -> None: + if not VAPID_PRIVATE_KEY_PKCS8_FILE.exists(): + private_key = serialization.load_pem_private_key( + VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() + ) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + VAPID_PRIVATE_KEY_PKCS8_FILE.write_bytes(pem) + logger.info("Generated VAPID PKCS8 private key at %s", VAPID_PRIVATE_KEY_PKCS8_FILE) + + +def generate_public_key() -> None: + if not VAPID_PUBLIC_KEY_FILE.exists(): + private_key = serialization.load_pem_private_key( + VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() + ) + pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + VAPID_PUBLIC_KEY_FILE.write_bytes(pem) + logger.info("Generated VAPID public key at %s", VAPID_PUBLIC_KEY_FILE) + + +def ensure_certificates() -> None: + generate_private_key() + generate_pkcs8_private_key() + generate_public_key() + + +def hkdf(input_key: bytes, salt: bytes, info: bytes, length: int) -> bytes: + return HKDF( + algorithm=SHA256(), + length=length, + salt=salt, + info=info, + backend=default_backend(), + ).derive(input_key) + + +def browser_base64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") + + +_keys: dict[str, Any] = {} + + +def _load_keys() -> dict[str, Any]: + if _keys: + return _keys + ensure_certificates() + private_key = serialization.load_pem_private_key( + VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() + ) + public_key = serialization.load_pem_public_key( + VAPID_PUBLIC_KEY_FILE.read_bytes(), backend=default_backend() + ) + uncompressed_point = public_key.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + _keys["private_key_pem"] = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + _keys["public_key_point"] = uncompressed_point + _keys["public_key_base64"] = browser_base64(uncompressed_point) + logger.debug("Loaded VAPID key material into cache") + return _keys + + +def public_key_standard_b64() -> str: + point = _load_keys()["public_key_point"] + return base64.b64encode(point).decode("utf-8").rstrip("=") + + +def create_notification_authorization(push_url: str) -> str: + target = urlparse(push_url) + audience = f"{target.scheme}://{target.netloc}" + issued_at = int(time.time()) + return jwt.encode( + { + "sub": VAPID_SUB, + "aud": audience, + "exp": issued_at + JWT_LIFETIME_SECONDS, + "nbf": issued_at, + "iat": issued_at, + "jti": str(uuid.uuid4()), + }, + _load_keys()["private_key_pem"], + algorithm="ES256", + ) + + +def create_notification_info_with_payload( + endpoint: str, auth: str, p256dh: str, payload: str +) -> dict[str, Any]: + message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + message_public_key_bytes = message_private_key.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + + salt = os.urandom(16) + + user_key_bytes = base64.urlsafe_b64decode(p256dh + "==") + shared_secret = message_private_key.exchange( + ec.ECDH(), + ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), user_key_bytes), + ) + + encryption_key = hkdf( + shared_secret, + base64.urlsafe_b64decode(auth + "=="), + b"Content-Encoding: auth\x00", + 32, + ) + + context = ( + b"P-256\x00" + + len(user_key_bytes).to_bytes(2, "big") + + user_key_bytes + + len(message_public_key_bytes).to_bytes(2, "big") + + message_public_key_bytes + ) + + nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12) + content_encryption_key = hkdf( + encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16 + ) + + padding_length = random.randint(0, 16) + padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length + data = AESGCM(content_encryption_key).encrypt( + nonce, padding + payload.encode("utf-8"), None + ) + + return { + "headers": { + "Authorization": f"WebPush {create_notification_authorization(endpoint)}", + "Crypto-Key": f"dh={browser_base64(message_public_key_bytes)}; p256ecdsa={_load_keys()['public_key_base64']}", + "Encryption": f"salt={browser_base64(salt)}", + "Content-Encoding": "aesgcm", + "Content-Length": str(len(data)), + "Content-Type": "application/octet-stream", + }, + "data": data, + } + + +def _mark_subscription_dead(subscription_id: int) -> None: + get_table("push_registration").update( + {"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()}, + ["id"], + ) + logger.info("Soft-deleted dead push subscription id=%s", subscription_id) + + +async def notify_user(user_uid: str, payload: dict[str, Any]) -> None: + registrations = list( + get_table("push_registration").find(user_uid=user_uid, deleted_at=None) + ) + if not registrations: + logger.debug("No active push subscriptions for user %s", user_uid) + return + + body = json.dumps(payload) + async with httpx.AsyncClient(timeout=10.0) as client: + for subscription in registrations: + endpoint = subscription["endpoint"] + try: + notification_info = create_notification_info_with_payload( + endpoint, + subscription["key_auth"], + subscription["key_p256dh"], + body, + ) + headers = {**notification_info["headers"], "TTL": PUSH_TTL_SECONDS} + response = await client.post( + endpoint, headers=headers, content=notification_info["data"] + ) + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc) + continue + + if response.status_code in ACCEPTED_STATUSES: + logger.debug("Push delivered to %s via %s", user_uid, endpoint) + elif response.status_code in DEAD_SUBSCRIPTION_STATUSES: + _mark_subscription_dead(subscription["id"]) + else: + logger.warning( + "Push rejected (%s) for %s via %s", response.status_code, user_uid, endpoint + ) + + +async def register( + user_uid: str, endpoint: str, key_auth: str, key_p256dh: str +) -> dict[str, Any]: + table = get_table("push_registration") + existing = table.find_one( + user_uid=user_uid, + endpoint=endpoint, + key_auth=key_auth, + key_p256dh=key_p256dh, + deleted_at=None, + ) + if existing: + logger.debug("Push subscription already registered for user %s", user_uid) + return existing + + record = { + "uid": generate_uid(), + "user_uid": user_uid, + "endpoint": endpoint, + "key_auth": key_auth, + "key_p256dh": key_p256dh, + "created_at": datetime.now(timezone.utc).isoformat(), + "deleted_at": None, + } + table.insert(record) + logger.info("Registered push subscription for user %s", user_uid) + return record diff --git a/devplacepy/routers/push.py b/devplacepy/routers/push.py new file mode 100644 index 00000000..ca646e03 --- /dev/null +++ b/devplacepy/routers/push.py @@ -0,0 +1,64 @@ +# retoor + +import logging +from fastapi import APIRouter, Request +from fastapi.responses import FileResponse, JSONResponse +from devplacepy import push +from devplacepy.config import STATIC_DIR +from devplacepy.utils import require_user_api + +logger = logging.getLogger(__name__) +router = APIRouter() + +WELCOME_PAYLOAD = { + "title": "DevPlace", + "message": "Push notifications enabled.", + "icon": "/static/apple-touch-icon.png", + "url": "/notifications", +} + + +@router.get("/push.json") +async def push_public_key() -> JSONResponse: + return JSONResponse({"publicKey": push.public_key_standard_b64()}) + + +@router.post("/push.json") +async def push_register(request: Request) -> JSONResponse: + user = require_user_api(request) + try: + body = await request.json() + except ValueError: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + keys = body.get("keys") if isinstance(body, dict) else None + if not (isinstance(keys, dict) and body.get("endpoint") and keys.get("p256dh") and keys.get("auth")): + return JSONResponse({"error": "Invalid request"}, status_code=400) + + await push.register( + user_uid=user["uid"], + endpoint=body["endpoint"], + key_auth=keys["auth"], + key_p256dh=keys["p256dh"], + ) + + try: + await push.notify_user(user["uid"], WELCOME_PAYLOAD) + except Exception as exc: + logger.warning("Welcome push failed for %s: %s", user["uid"], exc) + + return JSONResponse({"registered": True}) + + +@router.get("/service-worker.js") +async def service_worker() -> FileResponse: + return FileResponse( + STATIC_DIR / "service-worker.js", + media_type="application/javascript", + headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"}, + ) + + +@router.get("/manifest.json") +async def manifest() -> FileResponse: + return FileResponse(STATIC_DIR / "manifest.json", media_type="application/manifest+json") diff --git a/devplacepy/static/icon-192.png b/devplacepy/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..41d69119037c679486d62de92615c4b387bb6a89 GIT binary patch literal 2440 zcmc&$`9Bkm8#i+mA|iJylcHQHWLTm}x%;@U+{e(zwJ=Okh)gx*YIB7qV$G4o%{#zB0i(%hc}=bh zp65M&{(}I|Q8AU%si^{BW1QIAWRP6?=uruFwG|u>)OpLYhlg`f1CQI=!melep8%8U z{wlN#>A_bzhqb7pXnWa)N15dJ!_rqiDn2U0gxLrM3@=C%5Rhs8*R70MR8eTaCOl{- zq4dj$q)PKRVg1l^enEPB!gFiF)7Iui4z88h(-7~AYkBMH?)caDgU&eAgWNt3z4RxoC9VEN}WV!*nw9!I}^kK#Bh zE9D5Ka1}3TbboGNlEV^}HS(t&PWfFc(2gqjTQg@mQk%gt>-(LAlR|sHvE64LUZqX+ zG>!n1G(gI}+kMq1y_thwbq&y+nxN%_Kdkim6VtwfxUU_?-#vB`ldi_Q*)JY~n`OZbMwTs!#~iye*{7Ce`-X%8dQ2Ls%vB zsRpL6P4BsZPPU#-F7=6@TCY}dZ?r|Td&aa3XXsB-XZ|je6IELu?j%xQJ2w>boG@*Y z8;r|JJ~M2weohtLRRv znQ4bdFMoNp+2#BgAh8fH!PqktU1i@snE7Gc>Ccjj*Qed5A@#aZN0-OO!lL;HL-FiS zE2^^_djq%2(!Ck{K;yAkA~vy+p_AV^rf^r???NH_S~jwogz9cA@7e2AUc1`WX$U=F z5dZYsQ4&_f9{`SwNDX249S0b(S?H2q7QR*+AJEsG(gA5F!h;_-m|VG>7Pzt)u=+K5 zoiHec_Ed0-_wqIwjhj?w__nompt|uIqu28l=?U^iquEK__aKi?edb^-CY*5KzlPuZ z>9gI};ZSyHnF?_7jUem^{sUm?w#6%|ZI{Rik&KyfD#7zVvB^BVsw*ee)#iJ=xl|cMKwhw%A0e4bo&PGExkj z#dk3~qCl01IjzN)Wd%~*sP&Ol3(Z?gUytnP#QH*a15wN8EX$>>rGo`AkGJt|#(tVjf06q27<)K!nYC8)4O~lAGQ#-d4%b9{fqHzTPd{Zu~r%OPbC0T z92uH2D`F%Xa-I+D?xEeXhvlx;2ua9Q$JTg3 z&j^!$Q4I`F`^am${na|G#QY7r3+1MJd_l5uD=IEpo`Q>1y$I)<2b31GhFG8ujeF?TKJ$Zz5(Jc?Te+|d?70$WpNtMiH7Gszp# z2W?SNRCNR{ZglbyKw@RoF_t}w$EltZ}oE0ufxaox8j%Arp@(miomq- za>1snHy6L|S8+~;ciL3hB;0k#A1}KmBx}^v()Y*%Y=54ARR1+q{$*r2i8l|(C>l79 zooZ;QuGBSD&8ZK{ivB~DK`QdVG(eA248CVK2QMPw`+g_Z8gF%pVGnm;ig^4`%Zgk# zN^ZpcSL{|bN$dQ{kfxX(1I7ZeY?3)qIFGYIIt&PEfSQ}MeG!NN1mb;aYiKKg&j(+R zUYmQgDnc=5yb1(xNH&%CkwEZ5Ags6a$WMAu>-QX<@4A1-%>V`oVgc;NqBrMe|~`iZwtTWGDrY;=D!tEBG>kkd!}wcB!%-~aosev KG^sZBeDFW!)oW}3 literal 0 HcmV?d00001 diff --git a/devplacepy/static/icon-512.png b/devplacepy/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..559fbf470c10544e3def16a944bc2bb5e96a4b94 GIT binary patch literal 7026 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 literal 0 HcmV?d00001 diff --git a/devplacepy/static/js/PushManager.js b/devplacepy/static/js/PushManager.js new file mode 100644 index 00000000..e2193cb5 --- /dev/null +++ b/devplacepy/static/js/PushManager.js @@ -0,0 +1,76 @@ +// retoor + +export class PushManager { + constructor() { + this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; + if (!this.supported) { + return; + } + this.triggers = Array.from(document.querySelectorAll("[data-push-enable]")); + this.bindTriggers(); + this.refreshTriggerVisibility(); + this.register(true).catch((error) => console.error("Push silent register failed:", error)); + } + + bindTriggers() { + this.triggers.forEach((trigger) => { + trigger.addEventListener("click", (event) => { + event.preventDefault(); + this.optIn(); + }); + }); + } + + refreshTriggerVisibility() { + const granted = Notification.permission === "granted"; + this.triggers.forEach((trigger) => { + trigger.hidden = granted; + }); + } + + async optIn() { + const permission = await Notification.requestPermission(); + if (permission === "granted") { + await this.register(false); + } + } + + async register(silent) { + try { + const registration = await navigator.serviceWorker.register("/service-worker.js"); + await registration.update(); + await navigator.serviceWorker.ready; + + if (Notification.permission !== "granted") { + this.refreshTriggerVisibility(); + return; + } + + const keyResponse = await fetch("/push.json"); + const keyData = await keyResponse.json(); + const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0)); + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey, + }); + + const response = await fetch("/push.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscription.toJSON()), + }); + + if (!response.ok) { + throw new Error("Bad status code from server."); + } + + this.refreshTriggerVisibility(); + } catch (error) { + console.error("Error registering push notifications:", error); + if (!silent) { + alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error); + } + } + } +} diff --git a/devplacepy/static/js/PwaInstaller.js b/devplacepy/static/js/PwaInstaller.js new file mode 100644 index 00000000..b94cb921 --- /dev/null +++ b/devplacepy/static/js/PwaInstaller.js @@ -0,0 +1,51 @@ +// retoor + +export class PwaInstaller { + constructor() { + this.deferredPrompt = null; + this.triggers = Array.from(document.querySelectorAll("[data-pwa-install]")); + if (!this.triggers.length) { + return; + } + this.bindTriggers(); + window.addEventListener("beforeinstallprompt", (event) => this.onPromptAvailable(event)); + window.addEventListener("appinstalled", () => this.hideTriggers()); + } + + bindTriggers() { + this.triggers.forEach((trigger) => { + trigger.addEventListener("click", (event) => { + event.preventDefault(); + this.install(); + }); + }); + } + + onPromptAvailable(event) { + event.preventDefault(); + this.deferredPrompt = event; + this.showTriggers(); + } + + showTriggers() { + this.triggers.forEach((trigger) => { + trigger.hidden = false; + }); + } + + hideTriggers() { + this.triggers.forEach((trigger) => { + trigger.hidden = true; + }); + } + + async install() { + if (!this.deferredPrompt) { + return; + } + this.deferredPrompt.prompt(); + await this.deferredPrompt.userChoice; + this.deferredPrompt = null; + this.hideTriggers(); + } +} diff --git a/devplacepy/static/manifest.json b/devplacepy/static/manifest.json new file mode 100644 index 00000000..2ac1a21f --- /dev/null +++ b/devplacepy/static/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "/?source=pwa", + "name": "DevPlace", + "short_name": "DevPlace", + "description": "The Developer Social Network", + "start_url": "/feed?source=pwa", + "scope": "/", + "display": "standalone", + "orientation": "any", + "lang": "en", + "dir": "ltr", + "background_color": "#0f0a1a", + "theme_color": "#0f0a1a", + "icons": [ + { + "src": "/static/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/devplacepy/static/offline.html b/devplacepy/static/offline.html new file mode 100644 index 00000000..ea40835c --- /dev/null +++ b/devplacepy/static/offline.html @@ -0,0 +1,69 @@ + + + + + + + Offline - DevPlace + + + +
+
D
+

You are offline

+

DevPlace could not reach the network. Check your connection and try again.

+ +
+ + diff --git a/devplacepy/static/service-worker.js b/devplacepy/static/service-worker.js new file mode 100644 index 00000000..9324222b --- /dev/null +++ b/devplacepy/static/service-worker.js @@ -0,0 +1,76 @@ +// retoor + +const CACHE_NAME = "devplace-shell-v1"; +const OFFLINE_URL = "/static/offline.html"; +const PRECACHE_URLS = [ + OFFLINE_URL, + "/manifest.json", + "/static/icon-192.png", + "/static/icon-512.png", +]; +const DEFAULT_URL = "/notifications"; +const DEFAULT_ICON = "/static/icon-192.png"; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((names) => Promise.all(names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.mode !== "navigate") { + return; + } + event.respondWith( + fetch(event.request).catch(() => caches.match(OFFLINE_URL)) + ); +}); + +function isClientOpen(url) { + return clients.matchAll().then((matchedClients) => + matchedClients.some((client) => client.url === url && "focus" in client) + ); +} + +self.addEventListener("push", (event) => { + event.waitUntil(handlePush(event)); +}); + +async function handlePush(event) { + if (!self.Notification || self.Notification.permission !== "granted") { + return; + } + + const data = event.data ? event.data.json() : {}; + const url = data.url || DEFAULT_URL; + + if (await isClientOpen(url)) { + return; + } + + const title = data.title || "DevPlace"; + const message = data.message || "You have a new notification."; + const icon = data.icon || DEFAULT_ICON; + + await self.registration.showNotification(title, { + body: message, + icon: icon, + badge: icon, + tag: "devplace-notification", + data: data, + }); +} + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = event.notification.data && event.notification.data.url ? event.notification.data.url : DEFAULT_URL; + event.waitUntil(clients.openWindow(url)); +});