From d6e6075469f2f50bc6b6fb2e2b3ff842ceddc5ee Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sat, 8 Aug 2026 11:21:23 +0100 Subject: [PATCH] Fixed notifications VAPID --- Cargo.lock | 2 +- src/bin/generate_vapid.rs | 15 ++++----- src/i18n.rs | 9 ++++++ src/public.rs | 14 +++++++- src/web_push.rs | 27 ++++++++++++++-- templates/client_portal.html | 62 ++++++++++++++++++++++++++++++------ 6 files changed, 105 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46897a6..58cc6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "1.0.2" +version = "1.0.3" dependencies = [ "async-trait", "base64", diff --git a/src/bin/generate_vapid.rs b/src/bin/generate_vapid.rs index 4960427..d1719ca 100644 --- a/src/bin/generate_vapid.rs +++ b/src/bin/generate_vapid.rs @@ -11,13 +11,10 @@ fn main() { .expect("generated key must be valid") .public_key() .to_encoded_point(false); - println!( - "WEB_PETTING_VAPID_PRIVATE_KEY={}", - URL_SAFE_NO_PAD.encode(&private) - ); - println!( - "WEB_PETTING_VAPID_PUBLIC_KEY={}", - URL_SAFE_NO_PAD.encode(public.as_bytes()) - ); - println!("WEB_PETTING_VAPID_SUBJECT=mailto:admin@example.com"); + println!("VAPID private key (copy only the next line):"); + println!("{}", URL_SAFE_NO_PAD.encode(&private)); + println!("\nVAPID public key (copy only the next line):"); + println!("{}", URL_SAFE_NO_PAD.encode(public.as_bytes())); + println!("\nVAPID subject:"); + println!("mailto:admin@example.com"); } diff --git a/src/i18n.rs b/src/i18n.rs index b0f4394..76c32db 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -310,6 +310,9 @@ pub struct Translations { pub portal_notifications_enable: &'static str, pub portal_notifications_disable: &'static str, pub portal_notifications_denied: &'static str, + pub portal_notifications_active: &'static str, + pub portal_notifications_error: &'static str, + pub portal_notifications_unsupported: &'static str, pub portal_calendar: &'static str, pub portal_future_visit: &'static str, pub portal_previous: &'static str, @@ -456,6 +459,9 @@ static RU: Translations = Translations { portal_notifications_enable: "Включить уведомления", portal_notifications_disable: "Отключить уведомления", portal_notifications_denied: "Уведомления заблокированы в настройках браузера.", + portal_notifications_active: "Уведомления подключены на этом устройстве.", + portal_notifications_error: "Не удалось сохранить подписку. Обновите страницу и попробуйте ещё раз.", + portal_notifications_unsupported: "Этот браузер не поддерживает фоновые уведомления.", portal_calendar: "Календарь визитов", portal_future_visit: "Будущий визит", portal_previous: "Назад", @@ -703,6 +709,9 @@ static EN: Translations = Translations { portal_notifications_enable: "Enable notifications", portal_notifications_disable: "Disable notifications", portal_notifications_denied: "Notifications are blocked in your browser settings.", + portal_notifications_active: "Notifications are enabled on this device.", + portal_notifications_error: "The subscription could not be saved. Reload the page and try again.", + portal_notifications_unsupported: "This browser does not support background notifications.", portal_calendar: "Visit calendar", portal_future_visit: "Future visit", portal_previous: "Previous", diff --git a/src/public.rs b/src/public.rs index 19e198b..4e73f0b 100644 --- a/src/public.rs +++ b/src/public.rs @@ -507,6 +507,7 @@ async fn portal_push_subscribe( db: Database, Path(token): Path, ) -> cot::Result { + tracing::info!("client Web Push subscription request"); if crate::web_push::load_config(&db).await.is_none() { return Html::new("404").into_response(); } @@ -522,8 +523,19 @@ async fn portal_push_subscribe( || form.keys.p256dh.len() > 512 || form.keys.auth.len() > 256 { - return Html::new("400").into_response(); + let mut response = Response::new(cot::Body::fixed( + "{\"ok\":false,\"error\":\"invalid subscription\"}", + )); + *response.status_mut() = cot::StatusCode::BAD_REQUEST; + response + .headers_mut() + .insert("content-type", "application/json".parse().unwrap()); + return Ok(response); } + tracing::info!( + client_id = client.id.unwrap(), + "client Web Push subscription saved" + ); let endpoint = form.endpoint.clone(); let now = chrono::Utc::now().naive_utc(); if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint) diff --git a/src/web_push.rs b/src/web_push.rs index 464b9ea..b396822 100644 --- a/src/web_push.rs +++ b/src/web_push.rs @@ -23,9 +23,30 @@ pub async fn load_config(db: &Database) -> Option { .map(|setting| setting.value.trim().to_string()) .filter(|value| !value.is_empty()) }; - let public_key = value("vapid_public_key")?; - let private_key = value("vapid_private_key")?; - let subject = value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string()); + let strip_assignment = |value: String, name: &str| { + value + .strip_prefix(&format!("{name}=")) + .unwrap_or(&value) + .trim() + .to_string() + }; + let public_key = strip_assignment(value("vapid_public_key")?, "WEB_PETTING_VAPID_PUBLIC_KEY"); + let private_key = + strip_assignment(value("vapid_private_key")?, "WEB_PETTING_VAPID_PRIVATE_KEY"); + let subject = strip_assignment( + value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string()), + "WEB_PETTING_VAPID_SUBJECT", + ); + let public_bytes = URL_SAFE_NO_PAD.decode(&public_key).ok()?; + let private_bytes = URL_SAFE_NO_PAD.decode(&private_key).ok()?; + if public_bytes.len() != 65 || public_bytes.first() != Some(&4) || private_bytes.len() != 32 { + tracing::warn!( + public_key_bytes = public_bytes.len(), + private_key_bytes = private_bytes.len(), + "invalid VAPID configuration in database" + ); + return None; + } Some(VapidConfig { public_key, private_key, diff --git a/templates/client_portal.html b/templates/client_portal.html index e1b02b3..432caea 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -293,6 +293,7 @@

{{ t.portal_notifications }}

{{ t.portal_notifications_text }}

+
@@ -328,20 +329,60 @@ renderCalendar(); var toggle = document.getElementById('notificationToggle'); var registration; var subscription; + var status = document.getElementById('notificationStatus'); function decodeKey(value) { var padding = '='.repeat((4 - value.length % 4) % 4); var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(raw, function(char) { return char.charCodeAt(0); }); } + function sameKey(left, right) { + if (!left || left.byteLength !== right.byteLength) return false; + var a = new Uint8Array(left), b = new Uint8Array(right); + return a.every(function(value, index) { return value === b[index]; }); + } + function showStatus(message, error) { + status.textContent = message; + status.style.display = ''; + status.style.color = error ? '#b42318' : '#067647'; + } + async function saveSubscription(value) { + var payload = value.toJSON(); + payload.language = '{{ lang.code() }}'; + var response = await fetch('/client/{{ client.media_token }}/push/subscribe', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }); + if (!response.ok) throw new Error('Subscription API returned HTTP ' + response.status); + } async function refresh() { - if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; + if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { + toggle.disabled = true; + showStatus('{{ t.portal_notifications_unsupported }}', true); + return; + } registration = await navigator.serviceWorker.register('/service-worker.js'); + await navigator.serviceWorker.ready; subscription = await registration.pushManager.getSubscription(); + var expectedKey = decodeKey('{{ vapid_public_key }}'); + if (subscription && !sameKey(subscription.options.applicationServerKey, expectedKey)) { + await subscription.unsubscribe(); + subscription = null; + } + if (subscription) { + await saveSubscription(subscription); + showStatus('{{ t.portal_notifications_active }}', false); + } toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}'; } - window.openNotificationSettings = function() { document.getElementById('notificationModal').classList.add('open'); refresh().catch(console.error); }; + window.openNotificationSettings = function() { + document.getElementById('notificationModal').classList.add('open'); + refresh().catch(function(error) { + console.error(error); + showStatus('{{ t.portal_notifications_error }}', true); + }); + }; window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); }; toggle.addEventListener('click', async function() { + toggle.disabled = true; try { if (!registration) await refresh(); if (subscription) { @@ -361,20 +402,21 @@ renderCalendar(); userVisibleOnly: true, applicationServerKey: decodeKey('{{ vapid_public_key }}') }); - var payload = subscription.toJSON(); - payload.language = '{{ lang.code() }}'; - var response = await fetch('/client/{{ client.media_token }}/push/subscribe', { - method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) - }); - if (!response.ok) { await subscription.unsubscribe(); subscription = null; throw new Error('subscribe failed'); } - closeNotificationSettings(); + await saveSubscription(subscription); + showStatus('{{ t.portal_notifications_active }}', false); } toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}'; } catch (error) { console.error(error); + showStatus('{{ t.portal_notifications_error }}', true); + } finally { + toggle.disabled = false; } }); - refresh().catch(console.error); + refresh().catch(function(error) { + console.error(error); + showStatus('{{ t.portal_notifications_error }}', true); + }); })(); {% endif %}