parent
d50003ce50
commit
895cca26d0
277
devplacepy/push.py
Normal file
277
devplacepy/push.py
Normal file
@ -0,0 +1,277 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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
|
||||
64
devplacepy/routers/push.py
Normal file
64
devplacepy/routers/push.py
Normal file
@ -0,0 +1,64 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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")
|
||||
BIN
devplacepy/static/icon-192.png
Normal file
BIN
devplacepy/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
devplacepy/static/icon-512.png
Normal file
BIN
devplacepy/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
devplacepy/static/icon-maskable-512.png
Normal file
BIN
devplacepy/static/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
76
devplacepy/static/js/PushManager.js
Normal file
76
devplacepy/static/js/PushManager.js
Normal file
@ -0,0 +1,76 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
devplacepy/static/js/PwaInstaller.js
Normal file
51
devplacepy/static/js/PwaInstaller.js
Normal file
@ -0,0 +1,51 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
34
devplacepy/static/manifest.json
Normal file
34
devplacepy/static/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
69
devplacepy/static/offline.html
Normal file
69
devplacepy/static/offline.html
Normal file
@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- retoor <retoor@molodetz.nl> -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Offline - DevPlace</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f0a1a;
|
||||
--bg-card: #221436;
|
||||
--accent: #ff6b35;
|
||||
--text-primary: #f0e8f8;
|
||||
--text-muted: #9a8db0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.offline-card {
|
||||
max-width: 360px;
|
||||
text-align: center;
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.offline-mark {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 1.25rem;
|
||||
border-radius: 16px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 72px;
|
||||
}
|
||||
h1 { font-size: 1.375rem; margin: 0 0 0.5rem; }
|
||||
p { color: var(--text-muted); line-height: 1.5; margin: 0 0 1.5rem; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="offline-card">
|
||||
<div class="offline-mark">D</div>
|
||||
<h1>You are offline</h1>
|
||||
<p>DevPlace could not reach the network. Check your connection and try again.</p>
|
||||
<button type="button" onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
76
devplacepy/static/service-worker.js
Normal file
76
devplacepy/static/service-worker.js
Normal file
@ -0,0 +1,76 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
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));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user