feat/push-notifications #34
@ -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);
|
||||
![]()
Ghost
commented
Some browsers will show the permission prompt immediately, others require a "registered" interaction Some browsers will show the permission prompt immediately, others require a "registered" interaction
|
||||
|
@ -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) => {
|
||||
![]()
Ghost
commented
Only added because somewhere while reading this was a requirement on some browsers to get push notifications to work with WPAs Only added because somewhere while reading this was a requirement on some browsers to get push notifications to work with WPAs
|
||||
// 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);
|
||||
})
|
||||
);
|
||||
})
|
@ -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")
|
||||
|
||||
![]()
Ghost
commented
These keys need to be persisted because the public key is sent to the browser's PushNotification service. The reason for the PKCS8 file is just for debugging and can be removed These keys need to be persisted because the public key is sent to the browser's PushNotification service.
The reason for the PKCS8 file is just for debugging and can be removed
|
||||
|
||||
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
|
||||
![]()
Ghost
commented
Python base64 parsing discards excessive padding ( Python base64 parsing discards excessive padding (`=`) but will not add it if it's missing, causing it to think the base64 is bad if it isn't 4 char aligned
|
||||
)
|
||||
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():
|
||||
|
@ -41,7 +41,7 @@
|
||||
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
|
||||
<a class="no-select" href="/threads.html">👥</a>
|
||||
<a class="no-select" href="/settings/index.html">⚙️</a>
|
||||
<a class="no-select" href="#" onclick="registerServiceWorker">✉️</a>
|
||||
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
|
||||
<a class="no-select" href="/logout.html">🔒</a>
|
||||
</nav>
|
||||
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user
In the future this can be replaced with
Uint8Array.fromBase64()
It's just not available in chrome/old firefox ;PThen it's easy, i prefer this. Because let's face it, that frombase64 code doesn't do so much. Don't see a reason to exclude browsers for such simple functionality.