Skip to main content

cloudillo_push/
send.rs

1//! Web Push notification sending
2//!
3//! Implements RFC 8030 (HTTP/2 Push), RFC 8188 (Encrypted Content-Encoding),
4//! RFC 8291 (Message Encryption for Web Push), and RFC 8292 (VAPID).
5
6use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
7use http_body_util::{BodyExt, Full};
8use hyper::body::Bytes;
9use hyper_rustls::HttpsConnectorBuilder;
10use hyper_util::client::legacy::Client;
11use hyper_util::rt::TokioExecutor;
12use serde::{Deserialize, Serialize};
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use crate::prelude::*;
16use cloudillo_types::meta_adapter::PushSubscriptionData;
17
18/// Notification payload to send to the client
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct NotificationPayload {
21	/// Notification title
22	pub title: String,
23	/// Notification body text
24	pub body: String,
25	/// URL path to open when clicked (optional)
26	#[serde(skip_serializing_if = "Option::is_none")]
27	pub path: Option<String>,
28	/// Image URL (optional)
29	#[serde(skip_serializing_if = "Option::is_none")]
30	pub image: Option<String>,
31	/// Tag for grouping notifications (optional)
32	#[serde(skip_serializing_if = "Option::is_none")]
33	pub tag: Option<String>,
34}
35
36/// Result of sending a push notification
37#[derive(Debug)]
38pub enum PushResult {
39	/// Successfully sent
40	Success,
41	/// Subscription is no longer valid (should be deleted)
42	SubscriptionGone,
43	/// Temporary error (can retry)
44	TemporaryError(String),
45	/// Permanent error (don't retry)
46	PermanentError(String),
47}
48
49/// Send a push notification to a subscription
50///
51/// # Arguments
52/// * `app` - Application state (for HTTP client and VAPID keys)
53/// * `tn_id` - Tenant ID (for VAPID keys)
54/// * `subscription` - Push subscription data (endpoint and keys)
55/// * `payload` - Notification payload
56///
57/// # Returns
58/// * `PushResult` indicating success or failure type
59pub async fn send_notification(
60	app: &App,
61	tn_id: TnId,
62	subscription: &PushSubscriptionData,
63	payload: &NotificationPayload,
64) -> PushResult {
65	// Get VAPID keys for this tenant
66	let vapid_keys = match app.auth_adapter.read_vapid_key(tn_id).await {
67		Ok(keys) => keys,
68		Err(e) => {
69			tracing::error!(tn_id = %tn_id.0, error = %e, "Failed to get VAPID keys");
70			return PushResult::PermanentError(format!("VAPID key error: {}", e));
71		}
72	};
73
74	// Serialize payload
75	let payload_json = match serde_json::to_string(payload) {
76		Ok(json) => json,
77		Err(e) => return PushResult::PermanentError(format!("Payload serialization error: {}", e)),
78	};
79
80	// Encrypt the payload using ECE (Encrypted Content-Encoding)
81	let encrypted =
82		match encrypt_payload(&payload_json, &subscription.keys.p256dh, &subscription.keys.auth) {
83			Ok(enc) => enc,
84			Err(e) => return PushResult::PermanentError(format!("Encryption error: {}", e)),
85		};
86
87	// Get tenant id_tag for VAPID subject
88	let id_tag = match app.auth_adapter.read_id_tag(tn_id).await {
89		Ok(tag) => tag,
90		Err(e) => {
91			tracing::error!(tn_id = %tn_id.0, error = %e, "Failed to get tenant id_tag");
92			return PushResult::PermanentError(format!("Tenant lookup error: {}", e));
93		}
94	};
95
96	// Create VAPID JWT
97	let vapid_jwt = match create_vapid_jwt(&subscription.endpoint, &id_tag, &vapid_keys.private_key)
98	{
99		Ok(jwt) => jwt,
100		Err(e) => return PushResult::PermanentError(format!("VAPID JWT error: {}", e)),
101	};
102
103	// Send the HTTP/2 POST request
104	send_push_request(
105		&subscription.endpoint,
106		&encrypted.body,
107		&encrypted.salt,
108		&encrypted.public_key,
109		&vapid_jwt,
110		&vapid_keys.public_key,
111	)
112	.await
113}
114
115/// Encrypted payload data
116struct EncryptedPayload {
117	body: Vec<u8>,
118	salt: Vec<u8>,
119	public_key: Vec<u8>,
120}
121
122/// Encrypt payload using ECE (RFC 8188, 8291)
123fn encrypt_payload(
124	payload: &str,
125	p256dh_base64: &str,
126	auth_base64: &str,
127) -> Result<EncryptedPayload, String> {
128	// Decode the subscription's public key and auth secret
129	let p256dh = URL_SAFE_NO_PAD
130		.decode(p256dh_base64)
131		.map_err(|e| format!("Invalid p256dh: {}", e))?;
132	let auth = URL_SAFE_NO_PAD
133		.decode(auth_base64)
134		.map_err(|e| format!("Invalid auth: {}", e))?;
135
136	// Encrypt using ece crate with aes128gcm scheme
137	// The ece::encrypt function takes: remote_public_key, auth_secret, plaintext
138	let encrypted = ece::encrypt(&p256dh, &auth, payload.as_bytes())
139		.map_err(|e| format!("ECE encryption failed: {:?}", e))?;
140
141	// The encrypted result is already in aes128gcm format
142	// Format: salt (16 bytes) || rs (4 bytes) || keyid_len (1 byte) || keyid || ciphertext
143	let body = encrypted.to_vec();
144
145	// Extract salt (first 16 bytes)
146	let salt = body.get(0..16).ok_or("Encrypted data too short")?.to_vec();
147
148	// The record size is at bytes 16-20, key ID length at byte 20
149	let keyid_len = *body.get(20).ok_or("Missing keyid length")? as usize;
150	let public_key = body.get(21..21 + keyid_len).ok_or("Missing public key")?.to_vec();
151
152	Ok(EncryptedPayload { body, salt, public_key })
153}
154
155/// Create VAPID JWT (RFC 8292)
156///
157/// private_key_raw is the raw 32-byte P-256 scalar, base64url encoded
158/// (compatible with TypeScript version storage format)
159fn create_vapid_jwt(endpoint: &str, id_tag: &str, private_key_raw: &str) -> Result<String, String> {
160	use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
161	use p256::pkcs8::EncodePrivateKey;
162	use p256::pkcs8::LineEnding;
163
164	// Decode the raw private key scalar from base64url
165	let private_key_bytes = URL_SAFE_NO_PAD
166		.decode(private_key_raw)
167		.map_err(|e| format!("Invalid base64url private key: {}", e))?;
168
169	// Load the raw scalar into p256 SecretKey
170	let secret_key = p256::SecretKey::from_bytes(private_key_bytes.as_slice().into())
171		.map_err(|e| format!("Invalid P-256 private key: {:?}", e))?;
172
173	// Convert to PEM format for jsonwebtoken
174	let pem = secret_key
175		.to_pkcs8_pem(LineEnding::LF)
176		.map_err(|e| format!("Failed to encode private key: {:?}", e))?;
177
178	// Parse endpoint to get the audience (origin)
179	let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid endpoint URL: {}", e))?;
180	let audience = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
181
182	// JWT claims for VAPID
183	#[derive(Serialize)]
184	struct VapidClaims {
185		aud: String,
186		exp: u64,
187		sub: String,
188	}
189
190	let exp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs()
191		+ 12 * 3600; // 12 hours
192
193	let claims = VapidClaims { aud: audience, exp, sub: format!("mailto:admin@{}", id_tag) };
194
195	// VAPID uses ES256 (P-256 curve, SHA-256)
196	let encoding_key = EncodingKey::from_ec_pem(pem.as_bytes())
197		.map_err(|e| format!("Invalid VAPID private key: {}", e))?;
198
199	let header = Header::new(Algorithm::ES256);
200	encode(&header, &claims, &encoding_key).map_err(|e| format!("JWT encoding failed: {}", e))
201}
202
203/// Send the encrypted push request
204async fn send_push_request(
205	endpoint: &str,
206	body: &[u8],
207	_salt: &[u8],
208	_public_key: &[u8],
209	vapid_jwt: &str,
210	vapid_public_key: &str,
211) -> PushResult {
212	// Create HTTP/2 client for push service
213	let connector = match HttpsConnectorBuilder::new()
214		.with_native_roots()
215		.map_err(|e| format!("TLS error: {}", e))
216	{
217		Ok(c) => c.https_only().enable_http2().build(),
218		Err(e) => return PushResult::PermanentError(e),
219	};
220
221	let client: Client<_, Full<Bytes>> =
222		Client::builder(TokioExecutor::new()).http2_only(true).build(connector);
223
224	// Build the request
225	// For aes128gcm, the body already contains salt and public key in the header
226	let request = match hyper::Request::builder()
227		.method(hyper::Method::POST)
228		.uri(endpoint)
229		.header("Content-Type", "application/octet-stream")
230		.header("Content-Encoding", "aes128gcm")
231		.header("TTL", "86400") // 24 hours
232		.header(
233			"Authorization",
234			format!("vapid t={},k={}", vapid_jwt, vapid_public_key),
235		)
236		.body(Full::new(Bytes::copy_from_slice(body)))
237	{
238		Ok(req) => req,
239		Err(e) => return PushResult::PermanentError(format!("Request build error: {}", e)),
240	};
241
242	// Send the request
243	match client.request(request).await {
244		Ok(response) => {
245			let status = response.status();
246			if status.is_success() {
247				PushResult::Success
248			} else if status == hyper::StatusCode::GONE || status == hyper::StatusCode::NOT_FOUND {
249				// 404/410 = subscription no longer valid
250				PushResult::SubscriptionGone
251			} else if status.is_client_error() {
252				// 4xx (except 404/410) = permanent error
253				let body_bytes = response.into_body().collect().await.ok().map(|b| b.to_bytes());
254				let body_str =
255					body_bytes.as_ref().and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
256				PushResult::PermanentError(format!("HTTP {}: {}", status, body_str))
257			} else {
258				// 5xx = temporary error
259				PushResult::TemporaryError(format!("HTTP {}", status))
260			}
261		}
262		Err(e) => PushResult::TemporaryError(format!("Network error: {}", e)),
263	}
264}
265
266/// Send notification to all subscriptions for a tenant
267///
268/// Returns the number of successfully sent notifications and removes invalid subscriptions.
269pub async fn send_to_tenant(
270	app: &App,
271	tn_id: TnId,
272	payload: &NotificationPayload,
273) -> ClResult<usize> {
274	let subscriptions = app.meta_adapter.list_push_subscriptions(tn_id).await?;
275	let mut success_count = 0;
276
277	for subscription in subscriptions {
278		let result = send_notification(app, tn_id, &subscription.subscription, payload).await;
279
280		match result {
281			PushResult::Success => {
282				success_count += 1;
283				tracing::debug!(
284					tn_id = %tn_id.0,
285					subscription_id = %subscription.id,
286					"Push notification sent successfully"
287				);
288			}
289			PushResult::SubscriptionGone => {
290				// Delete the invalid subscription
291				tracing::info!(
292					tn_id = %tn_id.0,
293					subscription_id = %subscription.id,
294					"Deleting invalid push subscription"
295				);
296				let _ = app.meta_adapter.delete_push_subscription(tn_id, subscription.id).await;
297			}
298			PushResult::TemporaryError(e) => {
299				tracing::warn!(
300					tn_id = %tn_id.0,
301					subscription_id = %subscription.id,
302					error = %e,
303					"Temporary push notification error"
304				);
305			}
306			PushResult::PermanentError(e) => {
307				tracing::error!(
308					tn_id = %tn_id.0,
309					subscription_id = %subscription.id,
310					error = %e,
311					"Permanent push notification error"
312				);
313			}
314		}
315	}
316
317	Ok(success_count)
318}
319
320// vim: ts=4