Merge branch 'web-push' — Web Push + WhatsApp-style chat bubbles
This commit is contained in:
commit
f2970955dd
1044
kez-chat/Cargo.lock
generated
1044
kez-chat/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,10 @@ thiserror = "2"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "sync", "signal"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
web-push = "0.10"
|
||||
base64 = "0.22"
|
||||
p256 = { version = "0.13", features = ["pem"] }
|
||||
rand = "0.8"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@ -28,6 +28,8 @@ pub struct AppState {
|
||||
pub store: Store,
|
||||
pub config: Config,
|
||||
pub broker: crate::broker::Broker,
|
||||
pub vapid: crate::push::VapidKeys,
|
||||
pub push: crate::push::PushSender,
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> axum::Router {
|
||||
@ -45,6 +47,9 @@ pub fn router(state: AppState) -> axum::Router {
|
||||
.route("/v1/messages", post(crate::messages::send_message))
|
||||
.route("/v1/inbox/:handle", get(crate::messages::inbox))
|
||||
.route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox))
|
||||
.route("/v1/push/vapid-public-key", get(push_vapid_key))
|
||||
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
||||
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
||||
.route("/.well-known/webfinger", get(webfinger))
|
||||
.route("/internal/nats/auth", post(nats_auth_callout));
|
||||
|
||||
@ -239,6 +244,145 @@ fn verify_profile_auth(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Web Push — VAPID key + subscribe/unsubscribe
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Auth model is identical to the proofs endpoint: the caller signs a
|
||||
// canonical request line with their primary Ed25519 key and puts
|
||||
// `<unix_ts>:<sig_hex>` in `X-KEZ-Auth`. The subscribe/unsubscribe
|
||||
// bodies stay tiny so push.js can hand us the SubscriptionJSON
|
||||
// straight from the browser.
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VapidKeyResponse {
|
||||
/// uncompressed P-256 point, base64url-no-pad — passed straight to
|
||||
/// `PushManager.subscribe({applicationServerKey})`.
|
||||
key: String,
|
||||
}
|
||||
|
||||
async fn push_vapid_key(State(state): State<AppState>) -> Json<VapidKeyResponse> {
|
||||
Json(VapidKeyResponse {
|
||||
key: state.vapid.public_b64url.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PushSubscribeRequest {
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// Canonical message the caller signs for push subscribe/unsubscribe.
|
||||
/// Bound to the endpoint so a stolen header can't be replayed against
|
||||
/// a different subscription (e.g. attacker swapping in their own URL).
|
||||
pub fn canonical_push_message(verb: &str, handle: &str, endpoint: &str, ts: i64) -> String {
|
||||
format!("{verb}\n/v1/push/{verb}/{handle}\n{endpoint}\n{ts}")
|
||||
}
|
||||
|
||||
fn verify_push_auth(
|
||||
auth: &str,
|
||||
verb: &str,
|
||||
handle: &str,
|
||||
endpoint: &str,
|
||||
pubkey_hex: &str,
|
||||
now_ts: i64,
|
||||
) -> Result<(), ApiError> {
|
||||
let (ts_str, sig_hex) = auth
|
||||
.split_once(':')
|
||||
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
|
||||
let ts: i64 = ts_str
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
|
||||
if (now_ts - ts).abs() > 60 {
|
||||
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
||||
}
|
||||
let message = canonical_push_message(verb, handle, endpoint, ts);
|
||||
kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex)
|
||||
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn push_subscribe(
|
||||
State(state): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<PushSubscribeRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
validate_handle(&handle)
|
||||
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
||||
let record = state
|
||||
.store
|
||||
.lookup(&handle)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let auth = headers
|
||||
.get("X-KEZ-Auth")
|
||||
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
||||
verify_push_auth(
|
||||
auth,
|
||||
"subscribe",
|
||||
&handle,
|
||||
&req.endpoint,
|
||||
record.primary.value(),
|
||||
Utc::now().timestamp(),
|
||||
)?;
|
||||
|
||||
state
|
||||
.store
|
||||
.upsert_push_subscription(
|
||||
&handle,
|
||||
&crate::push::StoredSubscription {
|
||||
endpoint: req.endpoint,
|
||||
p256dh: req.p256dh,
|
||||
auth: req.auth,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PushUnsubscribeRequest {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
async fn push_unsubscribe(
|
||||
State(state): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<PushUnsubscribeRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
validate_handle(&handle)
|
||||
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
||||
let record = state
|
||||
.store
|
||||
.lookup(&handle)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let auth = headers
|
||||
.get("X-KEZ-Auth")
|
||||
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
||||
verify_push_auth(
|
||||
auth,
|
||||
"unsubscribe",
|
||||
&handle,
|
||||
&req.endpoint,
|
||||
record.primary.value(),
|
||||
Utc::now().timestamp(),
|
||||
)?;
|
||||
|
||||
state.store.delete_push_subscription(&req.endpoint).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /.well-known/webfinger — fediverse-style discovery
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -37,4 +37,21 @@ pub struct Config {
|
||||
/// output). If unset, `/` serves a built-in placeholder page.
|
||||
#[arg(long, env = "KEZ_CHAT_WEB_DIR")]
|
||||
pub web_dir: Option<PathBuf>,
|
||||
|
||||
/// Where the Web Push VAPID private key is stored. Auto-generated
|
||||
/// on first startup if the file doesn't exist (raw 32-byte P-256
|
||||
/// scalar, base64-encoded). Public key is exposed at
|
||||
/// /v1/push/vapid-public-key.
|
||||
#[arg(long, env = "KEZ_CHAT_VAPID_KEY", default_value = "vapid-key.txt")]
|
||||
pub vapid_key_path: PathBuf,
|
||||
|
||||
/// Subject for VAPID JWTs — typically a mailto: URL of the
|
||||
/// operator. Push providers (FCM / Mozilla / APNs) use it to
|
||||
/// contact the operator if subscriptions misbehave.
|
||||
#[arg(
|
||||
long,
|
||||
env = "KEZ_CHAT_VAPID_SUBJECT",
|
||||
default_value = "mailto:admin@kez.lat"
|
||||
)]
|
||||
pub vapid_subject: String,
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ pub mod config;
|
||||
pub mod error;
|
||||
pub mod handles;
|
||||
pub mod messages;
|
||||
pub mod push;
|
||||
pub mod registration;
|
||||
pub mod store;
|
||||
|
||||
|
||||
@ -26,10 +26,15 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
let store = Store::open(&config.db)?;
|
||||
let vapid =
|
||||
kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?;
|
||||
let push = kez_chat_server::push::PushSender::new(&vapid, &config.vapid_subject)?;
|
||||
let state = AppState {
|
||||
store,
|
||||
config: config.clone(),
|
||||
broker: kez_chat_server::broker::Broker::new(),
|
||||
vapid,
|
||||
push,
|
||||
};
|
||||
|
||||
let app = router(state)
|
||||
|
||||
@ -113,6 +113,22 @@ pub async fn send_message(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Web Push fanout — fire-and-forget so the HTTP request still
|
||||
// returns fast. The push payload is intentionally tiny and
|
||||
// contains only metadata: the recipient's own client will pull
|
||||
// the real (encrypted) envelope from the inbox/SSE on wake-up.
|
||||
let push = state.push.clone();
|
||||
let store = state.store.clone();
|
||||
let recipient_handle = recipient.handle.clone();
|
||||
let payload = serde_json::json!({
|
||||
"type": "kez-chat/new-message",
|
||||
"to": recipient_handle,
|
||||
"seq": seq,
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
push.fanout(&store, &recipient_handle, &payload).await;
|
||||
});
|
||||
|
||||
Ok(Json(SendMessageResponse { seq }))
|
||||
}
|
||||
|
||||
|
||||
305
kez-chat/src/push.rs
Normal file
305
kez-chat/src/push.rs
Normal file
@ -0,0 +1,305 @@
|
||||
//! Web Push (RFC 8030 / RFC 8291 / VAPID RFC 8292) for kez-chat.
|
||||
//!
|
||||
//! Lets the chat-server fire notifications to the user's browser even
|
||||
//! when the kez-chat PWA is fully closed. The browser registers a push
|
||||
//! subscription with its push provider (FCM for Chrome/Edge, Mozilla
|
||||
//! autopush for Firefox, Apple Push Notification Service for Safari);
|
||||
//! we get back an endpoint URL + a pair of opaque keys (`p256dh`, `auth`).
|
||||
//! When a new chat message lands, we POST a (tiny, possibly empty)
|
||||
//! payload to the endpoint with a VAPID JWT proving the message is from
|
||||
//! us. The push provider forwards it to the user's device; the device's
|
||||
//! service worker wakes up briefly and calls `showNotification(...)`.
|
||||
//!
|
||||
//! Trust model:
|
||||
//! - The push payload is opaque to the push provider (e.g. Google /
|
||||
//! Apple) — we encrypt it under p256dh+auth as per RFC 8291.
|
||||
//! - We deliberately send a *near-empty* payload: just the sender's
|
||||
//! handle (which the recipient already knows about anyway) and the
|
||||
//! message sequence. The actual ciphertext stays on our SSE/inbox
|
||||
//! path, where the recipient's client pulls it and decrypts with the
|
||||
//! KEZ E2E key. So even if a push provider went rogue, they wouldn't
|
||||
//! see plaintext.
|
||||
//! - 410 Gone from the provider → drop the subscription (the user
|
||||
//! removed the app, the install expired, or the OS revoked it).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
|
||||
use chrono::Utc;
|
||||
use p256::SecretKey;
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use p256::pkcs8::EncodePrivateKey;
|
||||
use rusqlite::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
// Note: WebPushClient is a trait that provides the .send() method on
|
||||
// IsahcWebPushClient — keep it in scope even though it looks "unused".
|
||||
use web_push::{
|
||||
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys,
|
||||
VapidSignatureBuilder, WebPushClient, WebPushError, WebPushMessageBuilder,
|
||||
};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::store::Store;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VAPID keys
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// In-memory VAPID material. The private key is held as PEM bytes
|
||||
/// because that's the form web-push's `VapidSignatureBuilder::from_pem`
|
||||
/// consumes. We re-parse on every send (cheap) so we don't have to
|
||||
/// juggle a long-lived signer object across an async boundary.
|
||||
#[derive(Clone)]
|
||||
pub struct VapidKeys {
|
||||
/// PEM-encoded P-256 private key (PKCS#8).
|
||||
pub private_pem: String,
|
||||
/// Uncompressed P-256 point, 65 bytes (0x04 || X || Y), encoded
|
||||
/// as base64url-no-pad — the form `applicationServerKey` expects
|
||||
/// in `PushManager.subscribe()`.
|
||||
pub public_b64url: String,
|
||||
}
|
||||
|
||||
/// Load VAPID keys from `path`, generating a new pair on first run.
|
||||
/// The on-disk file is a standard PKCS#8 PEM — `openssl ec -in <path>`
|
||||
/// will read it.
|
||||
pub fn load_or_generate_vapid<P: AsRef<Path>>(path: P) -> anyhow::Result<VapidKeys> {
|
||||
let path = path.as_ref();
|
||||
if path.exists() {
|
||||
let private_pem = std::fs::read_to_string(path)?;
|
||||
let public_b64url = derive_public_b64url(&private_pem)?;
|
||||
tracing::info!(?path, "loaded VAPID key");
|
||||
return Ok(VapidKeys {
|
||||
private_pem,
|
||||
public_b64url,
|
||||
});
|
||||
}
|
||||
|
||||
let secret = SecretKey::random(&mut rand::thread_rng());
|
||||
let private_pem = secret
|
||||
.to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
|
||||
.map_err(|e| anyhow::anyhow!("pkcs8 encode: {e}"))?
|
||||
.to_string();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(path, &private_pem)?;
|
||||
// Best-effort tightening — failures are non-fatal on Windows.
|
||||
let _ = std::fs::set_permissions(path, perms_0600());
|
||||
|
||||
let public_b64url = derive_public_b64url(&private_pem)?;
|
||||
tracing::info!(?path, public_b64url = %public_b64url, "generated new VAPID key");
|
||||
Ok(VapidKeys {
|
||||
private_pem,
|
||||
public_b64url,
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_public_b64url(private_pem: &str) -> anyhow::Result<String> {
|
||||
use p256::pkcs8::DecodePrivateKey;
|
||||
let secret = SecretKey::from_pkcs8_pem(private_pem)
|
||||
.map_err(|e| anyhow::anyhow!("pkcs8 decode: {e}"))?;
|
||||
let public_point = secret.public_key().to_encoded_point(false); // uncompressed
|
||||
Ok(B64URL.encode(public_point.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn perms_0600() -> std::fs::Permissions {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::Permissions::from_mode(0o600)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn perms_0600() -> std::fs::Permissions {
|
||||
// No-op stand-in; Windows ACLs are out of scope for v0.1.
|
||||
use std::fs::Permissions;
|
||||
Permissions::from(std::fs::Metadata::default().permissions())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subscription record + store API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredSubscription {
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
impl StoredSubscription {
|
||||
fn to_subscription_info(&self) -> SubscriptionInfo {
|
||||
SubscriptionInfo {
|
||||
endpoint: self.endpoint.clone(),
|
||||
keys: SubscriptionKeys {
|
||||
p256dh: self.p256dh.clone(),
|
||||
auth: self.auth.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Insert (or replace, by endpoint) a push subscription for `handle`.
|
||||
pub async fn upsert_push_subscription(
|
||||
&self,
|
||||
handle: &str,
|
||||
sub: &StoredSubscription,
|
||||
) -> Result<(), ApiError> {
|
||||
let conn = Store::inner_lock(self).await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO push_subscriptions (handle, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET handle = ?1, p256dh = ?3, auth = ?4",
|
||||
params![handle, sub.endpoint, sub.p256dh, sub.auth, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one subscription by endpoint (used both for explicit
|
||||
/// unsubscribe and for 410 Gone cleanup on send failure).
|
||||
pub async fn delete_push_subscription(&self, endpoint: &str) -> Result<(), ApiError> {
|
||||
let conn = Store::inner_lock(self).await;
|
||||
conn.execute(
|
||||
"DELETE FROM push_subscriptions WHERE endpoint = ?1",
|
||||
params![endpoint],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every active subscription for `handle`. Empty Vec is fine —
|
||||
/// just means the user hasn't enabled push (yet) on any device.
|
||||
pub async fn list_push_subscriptions(
|
||||
&self,
|
||||
handle: &str,
|
||||
) -> Result<Vec<StoredSubscription>, ApiError> {
|
||||
let conn = Store::inner_lock(self).await;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE handle = ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![handle], |row| {
|
||||
Ok(StoredSubscription {
|
||||
endpoint: row.get(0)?,
|
||||
p256dh: row.get(1)?,
|
||||
auth: row.get(2)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL fragment for the push_subscriptions table, run by store::init_schema.
|
||||
pub const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
handle TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_handle
|
||||
ON push_subscriptions (handle);
|
||||
";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sender
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Push notifier — clones cheaply (Arc-shared client + key material).
|
||||
#[derive(Clone)]
|
||||
pub struct PushSender {
|
||||
inner: std::sync::Arc<PushInner>,
|
||||
}
|
||||
|
||||
struct PushInner {
|
||||
client: IsahcWebPushClient,
|
||||
vapid_private_pem: String,
|
||||
vapid_subject: String,
|
||||
// Reserved for future provider tweaks without re-plumbing.
|
||||
_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl PushSender {
|
||||
pub fn new(vapid: &VapidKeys, vapid_subject: &str) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: std::sync::Arc::new(PushInner {
|
||||
client: IsahcWebPushClient::new()?,
|
||||
vapid_private_pem: vapid.private_pem.clone(),
|
||||
vapid_subject: vapid_subject.to_owned(),
|
||||
_lock: Mutex::new(()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a small payload to every subscription registered for
|
||||
/// `recipient_handle`. Subscriptions that come back 410 Gone are
|
||||
/// dropped from the store (the user removed the app, browser
|
||||
/// rotated, etc.). All other errors are logged and ignored — push
|
||||
/// is best-effort; the actual chat envelope is already in the
|
||||
/// recipient's inbox.
|
||||
pub async fn fanout(
|
||||
&self,
|
||||
store: &Store,
|
||||
recipient_handle: &str,
|
||||
payload: &serde_json::Value,
|
||||
) {
|
||||
let subs = match store.list_push_subscriptions(recipient_handle).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "push: list_subscriptions failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if subs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let body = match serde_json::to_vec(payload) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "push: payload serialize failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for sub in subs {
|
||||
if let Err(e) = self.send_one(&sub, &body).await {
|
||||
match e {
|
||||
WebPushError::EndpointNotValid | WebPushError::EndpointNotFound => {
|
||||
// 410 Gone / 404 → subscription is dead; drop it.
|
||||
tracing::info!(endpoint = %sub.endpoint, "push: dropping expired subscription");
|
||||
let _ = store.delete_push_subscription(&sub.endpoint).await;
|
||||
}
|
||||
other => {
|
||||
tracing::warn!(endpoint = %sub.endpoint, error = ?other, "push: send failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_one(
|
||||
&self,
|
||||
sub: &StoredSubscription,
|
||||
body: &[u8],
|
||||
) -> Result<(), WebPushError> {
|
||||
let sub_info = sub.to_subscription_info();
|
||||
let mut sig_builder = VapidSignatureBuilder::from_pem(
|
||||
self.inner.vapid_private_pem.as_bytes(),
|
||||
&sub_info,
|
||||
)?;
|
||||
sig_builder.add_claim("sub", self.inner.vapid_subject.as_str());
|
||||
let signature = sig_builder.build()?;
|
||||
|
||||
let mut msg = WebPushMessageBuilder::new(&sub_info);
|
||||
msg.set_payload(ContentEncoding::Aes128Gcm, body);
|
||||
msg.set_vapid_signature(signature);
|
||||
|
||||
self.inner.client.send(msg.build()?).await
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,12 @@ impl Store {
|
||||
})
|
||||
}
|
||||
|
||||
/// Crate-internal accessor to the connection mutex, for impl blocks
|
||||
/// living in sibling modules (e.g. push.rs's subscription helpers).
|
||||
pub(crate) async fn inner_lock(&self) -> tokio::sync::MutexGuard<'_, Connection> {
|
||||
self.inner.lock().await
|
||||
}
|
||||
|
||||
/// Reserve a handle for a primary key. Fails with Conflict if the
|
||||
/// handle is already taken, or if this primary key has already
|
||||
/// registered a (different) handle.
|
||||
@ -162,7 +168,12 @@ fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_recipient
|
||||
ON messages (recipient_handle, seq);",
|
||||
)
|
||||
)?;
|
||||
|
||||
// Web Push subscription store. Schema kept in push.rs so it lives
|
||||
// next to the feature it backs; spliced in here so the table is
|
||||
// created on first run.
|
||||
conn.execute_batch(crate::push::SCHEMA)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
5
kez-chat/web/package-lock.json
generated
5
kez-chat/web/package-lock.json
generated
@ -30,7 +30,10 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
"workbox-routing": "^7.4.1",
|
||||
"workbox-strategies": "^7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
|
||||
@ -32,6 +32,9 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
"workbox-routing": "^7.4.1",
|
||||
"workbox-strategies": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
223
kez-chat/web/src/lib/push.ts
Normal file
223
kez-chat/web/src/lib/push.ts
Normal file
@ -0,0 +1,223 @@
|
||||
// Web Push subscription helpers — wraps the browser's PushManager and
|
||||
// the chat-server's /v1/push/* endpoints into a small surface the
|
||||
// Settings page can call.
|
||||
//
|
||||
// Flow:
|
||||
//
|
||||
// enablePush(handle, seed)
|
||||
// 1. fetch /v1/push/vapid-public-key
|
||||
// 2. browser PushManager.subscribe({applicationServerKey})
|
||||
// 3. sign + POST the subscription JSON to /v1/push/subscribe/:handle
|
||||
//
|
||||
// disablePush(handle, seed)
|
||||
// 1. SW.pushManager.getSubscription() → unsubscribe locally
|
||||
// 2. sign + POST /v1/push/unsubscribe/:handle with the endpoint URL
|
||||
//
|
||||
// All auth uses the same X-KEZ-Auth: <ts>:<sig_hex> scheme as the rest
|
||||
// of the API (see kez-chat/src/api.rs: canonical_push_message).
|
||||
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { ApiError } from "./api.js";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
function url(path: string): string {
|
||||
return `${API_BASE}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Push is only usable if the browser supports it AND the page is
|
||||
* served from a secure context (HTTPS or localhost). On iOS Safari it
|
||||
* additionally requires the site to be installed as a PWA — Safari
|
||||
* exposes the PushManager API only after add-to-home-screen.
|
||||
*/
|
||||
export function pushSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
/** Current permission state — does NOT prompt the user. */
|
||||
export function pushPermission(): NotificationPermission | "unsupported" {
|
||||
if (!pushSupported()) return "unsupported";
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* VAPID applicationServerKey arrives as base64url-no-pad; PushManager
|
||||
* wants a Uint8Array. Pad + replace, then decode.
|
||||
*/
|
||||
function decodeB64Url(b64: string): Uint8Array {
|
||||
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
|
||||
const bin = atob(padded + pad);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Encode an ArrayBuffer as base64url-no-pad — server expects this form. */
|
||||
function encodeB64Url(buf: ArrayBuffer | null): string {
|
||||
if (!buf) return "";
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function vapidPublicKey(): Promise<string> {
|
||||
const resp = await fetch(url("/v1/push/vapid-public-key"));
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(resp.status, `vapid key fetch → ${resp.status}`);
|
||||
}
|
||||
const body = (await resp.json()) as { key: string };
|
||||
return body.key;
|
||||
}
|
||||
|
||||
interface SubscriptionPayload {
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
function subscriptionPayload(sub: PushSubscription): SubscriptionPayload {
|
||||
return {
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: encodeB64Url(sub.getKey("p256dh")),
|
||||
auth: encodeB64Url(sub.getKey("auth")),
|
||||
};
|
||||
}
|
||||
|
||||
function signPushAuth(
|
||||
verb: "subscribe" | "unsubscribe",
|
||||
handle: string,
|
||||
endpoint: string,
|
||||
seed: Uint8Array,
|
||||
): string {
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const msg = `${verb}\n/v1/push/${verb}/${handle}\n${endpoint}\n${ts}`;
|
||||
const sig = ed25519.sign(new TextEncoder().encode(msg), seed);
|
||||
return `${ts}:${bytesToHex(sig)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable Web Push for `handle`. Prompts for permission if needed,
|
||||
* subscribes to the browser's push provider, and registers the
|
||||
* subscription with the chat-server.
|
||||
*
|
||||
* Returns `false` if the user declined permission. Throws on
|
||||
* network/server failures.
|
||||
*/
|
||||
export async function enablePush(
|
||||
handle: string,
|
||||
seed: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
if (!pushSupported()) {
|
||||
throw new Error("Web Push not supported in this browser");
|
||||
}
|
||||
// iOS Safari requires the PWA to be installed (display-mode: standalone).
|
||||
// We don't *block* on this — the subscribe() call will surface a clearer
|
||||
// error than we could — but settings can use this as a hint.
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") return false;
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const appServerKey = decodeB64Url(await vapidPublicKey());
|
||||
|
||||
// If a stale subscription exists (e.g. for a different handle), drop
|
||||
// it first — we want exactly one active subscription per browser.
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (existing) {
|
||||
await existing.unsubscribe().catch(() => {});
|
||||
}
|
||||
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
// The TS DOM lib types `applicationServerKey` as ArrayBufferView<ArrayBuffer>,
|
||||
// but our Uint8Array is over the generic ArrayBufferLike. The runtime is
|
||||
// happy with either — cast to keep tsc quiet.
|
||||
applicationServerKey: appServerKey as unknown as BufferSource,
|
||||
});
|
||||
|
||||
const payload = subscriptionPayload(sub);
|
||||
const authHeader = signPushAuth("subscribe", handle, payload.endpoint, seed);
|
||||
const resp = await fetch(url(`/v1/push/subscribe/${encodeURIComponent(handle)}`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-KEZ-Auth": authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// Best-effort: drop the local subscription too so the user can retry
|
||||
// cleanly. Otherwise the browser stays subscribed but the server
|
||||
// doesn't know about it and notifications never arrive.
|
||||
await sub.unsubscribe().catch(() => {});
|
||||
throw new ApiError(resp.status, `push subscribe → ${resp.status}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Web Push for `handle`. Tells both the browser and the
|
||||
* chat-server to forget the subscription. Idempotent.
|
||||
*/
|
||||
export async function disablePush(
|
||||
handle: string,
|
||||
seed: Uint8Array,
|
||||
): Promise<void> {
|
||||
if (!pushSupported()) return;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) return;
|
||||
|
||||
// Tell the server first so we still have a valid auth-able endpoint
|
||||
// string. If the server call fails we still unsubscribe locally —
|
||||
// 410 cleanup on the server side will catch the stale row on next
|
||||
// fanout attempt anyway.
|
||||
const authHeader = signPushAuth("unsubscribe", handle, sub.endpoint, seed);
|
||||
await fetch(url(`/v1/push/unsubscribe/${encodeURIComponent(handle)}`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-KEZ-Auth": authHeader,
|
||||
},
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
}).catch(() => {});
|
||||
|
||||
await sub.unsubscribe().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this browser currently subscribed to push? Used by Settings to
|
||||
* render the toggle in the right state.
|
||||
*/
|
||||
export async function isPushSubscribed(): Promise<boolean> {
|
||||
if (!pushSupported()) return false;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
return sub !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS PWA detection — Safari only exposes PushManager once installed
|
||||
* to the home screen. We use this to render a "Tap Share → Add to Home
|
||||
* Screen" callout instead of a broken toggle.
|
||||
*/
|
||||
export function isStandalonePwa(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// iOS Safari's legacy `standalone` bool, plus the modern matchMedia.
|
||||
const legacy = (window.navigator as unknown as { standalone?: boolean }).standalone === true;
|
||||
const modern = window.matchMedia?.("(display-mode: standalone)").matches ?? false;
|
||||
return legacy || modern;
|
||||
}
|
||||
|
||||
export function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
@ -26,6 +26,20 @@ if ("serviceWorker" in navigator) {
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Bridge from the service worker's `notificationclick` handler: when
|
||||
// the user taps a push notification and we found an existing tab to
|
||||
// focus, the SW posts `{type: "kez-chat/navigate", to: <hash-path>}`
|
||||
// and we route the SPA there. Hash routing means we just set
|
||||
// `location.hash` — svelte-spa-router picks it up.
|
||||
navigator.serviceWorker.addEventListener("message", (event) => {
|
||||
const data = event.data as { type?: string; to?: string } | undefined;
|
||||
if (data?.type === "kez-chat/navigate" && typeof data.to === "string") {
|
||||
// svelte-spa-router uses #/path — convert plain path to that form.
|
||||
const target = data.to.startsWith("#") ? data.to : `#${data.to}`;
|
||||
window.location.hash = target;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@ -435,19 +435,51 @@
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-1.5" bind:this={scrollEl}>
|
||||
<div class="flex-1 overflow-y-auto px-3 py-4 space-y-1" bind:this={scrollEl}>
|
||||
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
|
||||
{@const boost = emojiOnlyBoost(m.body)}
|
||||
{@const out = m.direction === "out"}
|
||||
<div class={`max-w-[78%] ${out ? "ml-auto" : ""}`}>
|
||||
<!--
|
||||
WhatsApp-style row: the row is a flex container that pins
|
||||
the bubble to the left (incoming) or right (outgoing). The
|
||||
bubble itself is sized to its CONTENT, capped at 75% of the
|
||||
row — that's why a one-word "Boo" stays a small chip while
|
||||
a long message wraps. Timestamp lives inside the bubble,
|
||||
bottom-right, the way iMessage / WhatsApp / Telegram do it.
|
||||
-->
|
||||
<div class={`flex ${out ? "justify-end" : "justify-start"}`}>
|
||||
{#if boost}
|
||||
<div class={`whitespace-pre-wrap break-words leading-none py-1 ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
|
||||
<div class={`whitespace-pre-wrap break-words leading-none py-1 max-w-[75%] ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
|
||||
{:else}
|
||||
<div
|
||||
class={`px-3 py-2 text-sm whitespace-pre-wrap break-words ${out ? "bg-accent text-accent-contrast rounded-lg rounded-br-sm" : "bg-bubble-recv text-text border border-border rounded-lg rounded-bl-sm"}`}
|
||||
>{m.body}</div>
|
||||
class={[
|
||||
"relative max-w-[75%] px-3 pt-1.5 pb-1 text-sm shadow-sm",
|
||||
// Bubble corners: rounded everywhere except the
|
||||
// "tail" corner (matches WhatsApp shape).
|
||||
out
|
||||
? "bg-accent text-accent-contrast rounded-2xl rounded-br-md"
|
||||
: "bg-bubble-recv text-text rounded-2xl rounded-bl-md",
|
||||
].join(" ")}
|
||||
>
|
||||
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
||||
<!--
|
||||
Inline timestamp. `float-right` + a leading non-
|
||||
breaking space pulls the time onto the same baseline
|
||||
as the last line of text when there's room, and
|
||||
drops to its own line when the text wraps right up
|
||||
to it. Lower opacity so it doesn't compete.
|
||||
-->
|
||||
<span
|
||||
class={[
|
||||
"float-right ml-2 mt-1 text-[10px] leading-none select-none",
|
||||
out ? "text-accent-contrast/70" : "text-text-muted",
|
||||
].join(" ")}
|
||||
aria-hidden="true"
|
||||
>{formatTime(m.ts)}</span>
|
||||
<!-- Screen-reader timestamp (the visual one is decorative). -->
|
||||
<span class="sr-only">{formatTime(m.ts)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p class={`mt-0.5 text-[10px] text-text-muted ${out ? "text-right" : ""}`}>{formatTime(m.ts)}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{#if activeConv.messages.length === 0}
|
||||
|
||||
@ -17,6 +17,14 @@
|
||||
requestNotificationsPermission,
|
||||
fireTestNotification,
|
||||
} from "../lib/inbox-service.svelte.js";
|
||||
import {
|
||||
pushSupported,
|
||||
enablePush,
|
||||
disablePush,
|
||||
isPushSubscribed,
|
||||
isStandalonePwa,
|
||||
isIos,
|
||||
} from "../lib/push.js";
|
||||
import { theme, type ThemeChoice } from "../lib/theme.svelte.js";
|
||||
import { onboarding } from "../lib/onboarding.svelte.js";
|
||||
|
||||
@ -42,6 +50,13 @@
|
||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
||||
|
||||
let webPushOk = $state(false); // browser supports it at all
|
||||
let webPushOn = $state(false); // currently subscribed
|
||||
let webPushBusy = $state(false);
|
||||
let webPushError = $state<string | null>(null);
|
||||
let pwaInstalled = $state(false);
|
||||
let ios = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!session.unlocked) {
|
||||
push("/unlock");
|
||||
@ -50,6 +65,12 @@
|
||||
await refreshBiometricStatus();
|
||||
notifSupported = notificationsSupported();
|
||||
notifPerm = notificationsPermission();
|
||||
webPushOk = pushSupported();
|
||||
pwaInstalled = isStandalonePwa();
|
||||
ios = isIos();
|
||||
if (webPushOk) {
|
||||
webPushOn = await isPushSubscribed();
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshBiometricStatus() {
|
||||
@ -93,6 +114,26 @@
|
||||
setTimeout(() => (testNotifResult = null), 5_000);
|
||||
}
|
||||
|
||||
async function toggleWebPush() {
|
||||
if (!session.unlocked) return;
|
||||
webPushBusy = true;
|
||||
webPushError = null;
|
||||
try {
|
||||
if (webPushOn) {
|
||||
await disablePush(session.unlocked.handle, session.unlocked.seed);
|
||||
webPushOn = false;
|
||||
} else {
|
||||
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
|
||||
webPushOn = ok;
|
||||
if (!ok) webPushError = "Permission denied.";
|
||||
}
|
||||
} catch (e) {
|
||||
webPushError = (e as Error).message;
|
||||
} finally {
|
||||
webPushBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showSeed() {
|
||||
if (!session.unlocked) return;
|
||||
const phrase = session.unlocked.phrase;
|
||||
@ -230,6 +271,57 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Web Push notifications (background) -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6">
|
||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-2">
|
||||
Background notifications (Web Push)
|
||||
</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Get pinged even when kez-chat is fully closed. The push only
|
||||
carries metadata — message content is decrypted locally when you
|
||||
open the app.
|
||||
</p>
|
||||
|
||||
{#if !webPushOk}
|
||||
{#if ios && !pwaInstalled}
|
||||
<p class="mt-3 text-sm text-warning">
|
||||
iOS only supports Web Push for installed PWAs. Tap the
|
||||
<strong>Share</strong> button in Safari and choose
|
||||
<strong>Add to Home Screen</strong>, then reopen kez-chat from
|
||||
the home-screen icon to enable this.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-3 text-sm text-text-muted italic">
|
||||
Not supported in this browser.
|
||||
</p>
|
||||
{/if}
|
||||
{:else if webPushOn}
|
||||
<div class="mt-3 space-y-2">
|
||||
<p class="text-sm" style="color:var(--color-verified)">
|
||||
✓ Subscribed on this device.
|
||||
</p>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
|
||||
disabled={webPushBusy}
|
||||
onclick={toggleWebPush}
|
||||
>
|
||||
{webPushBusy ? "…" : "Disable"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="mt-3 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
|
||||
disabled={webPushBusy}
|
||||
onclick={toggleWebPush}
|
||||
>
|
||||
{webPushBusy ? "Subscribing…" : "Enable on this device"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if webPushError}
|
||||
<p class="mt-2 text-xs text-danger">{webPushError}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Account / About -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
|
||||
|
||||
121
kez-chat/web/src/sw.ts
Normal file
121
kez-chat/web/src/sw.ts
Normal file
@ -0,0 +1,121 @@
|
||||
// Custom service worker for kez-chat.
|
||||
//
|
||||
// Built by vite-plugin-pwa in `injectManifest` mode. We own this file
|
||||
// so we can handle two events the auto-generated SW doesn't:
|
||||
//
|
||||
// 1. `push` — wake on a Web Push notification from the
|
||||
// chat-server and call `showNotification`.
|
||||
// 2. `notificationclick` — focus an existing tab or open the SPA.
|
||||
//
|
||||
// Caching strategy is hand-rolled (rather than the long list of
|
||||
// runtimeCaching rules the generateSW config used) because the surface
|
||||
// is small: precache the SPA shell, network-only for /v1/* API calls,
|
||||
// SPA fallback for navigation requests.
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from "workbox-precaching";
|
||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||
import { NetworkOnly } from "workbox-strategies";
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// vite-plugin-pwa injects the precache manifest at build time.
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
// SPA fallback: same-origin navigation requests (other than /v1/*)
|
||||
// resolve to index.html so deep links (e.g. /chats/alice) work offline.
|
||||
const navigationHandler = createHandlerBoundToURL("index.html");
|
||||
const navigationRoute = new NavigationRoute(navigationHandler, {
|
||||
denylist: [/^\/v1\//, /^\/internal\//, /^\/\.well-known\//],
|
||||
});
|
||||
registerRoute(navigationRoute);
|
||||
|
||||
// Never cache API responses — they're authenticated + dynamic.
|
||||
registerRoute(({ url }) => url.pathname.startsWith("/v1/"), new NetworkOnly());
|
||||
|
||||
// Skip waiting + claim control of open pages — paired with the
|
||||
// controllerchange reload in main.ts, deploys land on the first refresh
|
||||
// instead of the second.
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// ─── Web Push ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Server fanout sends a tiny JSON payload (see kez-chat/src/messages.rs):
|
||||
// { "type": "kez-chat/new-message", "to": "<handle>", "seq": <number> }
|
||||
//
|
||||
// We DON'T put plaintext (or even ciphertext) in the push payload — the
|
||||
// recipient's client pulls the real envelope from /v1/inbox on wake-up
|
||||
// and decrypts there. This keeps the push provider (Apple/Google/Mozilla)
|
||||
// from ever seeing message content even theoretically.
|
||||
|
||||
interface PushPayload {
|
||||
type?: string;
|
||||
to?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event: PushEvent) => {
|
||||
let data: PushPayload = {};
|
||||
if (event.data) {
|
||||
try {
|
||||
data = event.data.json() as PushPayload;
|
||||
} catch {
|
||||
// Some providers send a wake-up "" payload — fall through and
|
||||
// show a generic notification so the user knows to open the app.
|
||||
}
|
||||
}
|
||||
|
||||
const title = "New kez-chat message";
|
||||
const body =
|
||||
data.to !== undefined
|
||||
? `You have a new message in @${data.to}`
|
||||
: "Open kez-chat to view it.";
|
||||
|
||||
// `renotify` is widely supported but isn't in the baseline TS DOM lib;
|
||||
// build the options as a plain object and cast.
|
||||
const options = {
|
||||
body,
|
||||
icon: "/pwa-192x192.png",
|
||||
badge: "/pwa-64x64.png",
|
||||
// Group same-conversation pings — iOS especially gets spammy
|
||||
// otherwise. `renotify` lets the next one still vibrate.
|
||||
tag: data.to ? `kez-chat:${data.to}` : "kez-chat:new",
|
||||
renotify: true,
|
||||
data,
|
||||
} as NotificationOptions;
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
const data = event.notification.data as PushPayload | undefined;
|
||||
const targetUrl = data?.to ? `/chats/${encodeURIComponent(data.to)}` : "/chats";
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientList = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
for (const client of clientList) {
|
||||
const url = new URL(client.url);
|
||||
if (url.origin === self.location.origin) {
|
||||
// Found an already-open kez-chat tab — focus it and ask the
|
||||
// SPA to navigate; cheaper than spawning a fresh window.
|
||||
await client.focus();
|
||||
client.postMessage({ type: "kez-chat/navigate", to: targetUrl });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(targetUrl);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@ -42,6 +42,18 @@ export default defineConfig({
|
||||
// stuck on an old build.
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
// We need a custom Service Worker so we can handle `push` and
|
||||
// `notificationclick` events. `injectManifest` keeps the workbox
|
||||
// precache list autogenerated but lets us own the SW source.
|
||||
strategies: "injectManifest",
|
||||
srcDir: "src",
|
||||
filename: "sw.ts",
|
||||
injectManifest: {
|
||||
// zstd wasm is ~350 KB; raise the per-file cap so the precache
|
||||
// doesn't silently skip it.
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,wasm}"],
|
||||
},
|
||||
manifest: {
|
||||
name: "kez-chat",
|
||||
short_name: "kez-chat",
|
||||
@ -65,32 +77,9 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// Activate new SW immediately + take control of existing pages
|
||||
// without waiting for them to close. Paired with the
|
||||
// controllerchange reload in main.ts, this means deploys land
|
||||
// on the first refresh instead of the second.
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
// Precache the SPA shell. Chat data is fetched live from /v1/*
|
||||
// and we DON'T want it cached — see runtimeCaching below.
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,wasm}"],
|
||||
// zstd wasm is ~350 KB; raise the per-file cap.
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
// Same-origin navigation requests fall back to the SPA shell so
|
||||
// /messages, /claims, etc. work after a refresh while offline.
|
||||
navigateFallback: "index.html",
|
||||
navigateFallbackDenylist: [/^\/v1\//, /^\/internal\//, /^\/\.well-known\//],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Never cache API responses — they're authenticated + dynamic.
|
||||
urlPattern: /\/v1\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
navigationPreload: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
// No `workbox: {...}` here — that key only applies in
|
||||
// `generateSW` mode. In injectManifest mode, the SW source itself
|
||||
// (src/sw.ts) handles caching strategies + push events.
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user