Fixed notifications VAPID
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
This commit is contained in:
Generated
+1
-1
@@ -4090,7 +4090,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ fn main() {
|
|||||||
.expect("generated key must be valid")
|
.expect("generated key must be valid")
|
||||||
.public_key()
|
.public_key()
|
||||||
.to_encoded_point(false);
|
.to_encoded_point(false);
|
||||||
println!(
|
println!("VAPID private key (copy only the next line):");
|
||||||
"WEB_PETTING_VAPID_PRIVATE_KEY={}",
|
println!("{}", URL_SAFE_NO_PAD.encode(&private));
|
||||||
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!(
|
println!("\nVAPID subject:");
|
||||||
"WEB_PETTING_VAPID_PUBLIC_KEY={}",
|
println!("mailto:admin@example.com");
|
||||||
URL_SAFE_NO_PAD.encode(public.as_bytes())
|
|
||||||
);
|
|
||||||
println!("WEB_PETTING_VAPID_SUBJECT=mailto:admin@example.com");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ pub struct Translations {
|
|||||||
pub portal_notifications_enable: &'static str,
|
pub portal_notifications_enable: &'static str,
|
||||||
pub portal_notifications_disable: &'static str,
|
pub portal_notifications_disable: &'static str,
|
||||||
pub portal_notifications_denied: &'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_calendar: &'static str,
|
||||||
pub portal_future_visit: &'static str,
|
pub portal_future_visit: &'static str,
|
||||||
pub portal_previous: &'static str,
|
pub portal_previous: &'static str,
|
||||||
@@ -456,6 +459,9 @@ static RU: Translations = Translations {
|
|||||||
portal_notifications_enable: "Включить уведомления",
|
portal_notifications_enable: "Включить уведомления",
|
||||||
portal_notifications_disable: "Отключить уведомления",
|
portal_notifications_disable: "Отключить уведомления",
|
||||||
portal_notifications_denied: "Уведомления заблокированы в настройках браузера.",
|
portal_notifications_denied: "Уведомления заблокированы в настройках браузера.",
|
||||||
|
portal_notifications_active: "Уведомления подключены на этом устройстве.",
|
||||||
|
portal_notifications_error: "Не удалось сохранить подписку. Обновите страницу и попробуйте ещё раз.",
|
||||||
|
portal_notifications_unsupported: "Этот браузер не поддерживает фоновые уведомления.",
|
||||||
portal_calendar: "Календарь визитов",
|
portal_calendar: "Календарь визитов",
|
||||||
portal_future_visit: "Будущий визит",
|
portal_future_visit: "Будущий визит",
|
||||||
portal_previous: "Назад",
|
portal_previous: "Назад",
|
||||||
@@ -703,6 +709,9 @@ static EN: Translations = Translations {
|
|||||||
portal_notifications_enable: "Enable notifications",
|
portal_notifications_enable: "Enable notifications",
|
||||||
portal_notifications_disable: "Disable notifications",
|
portal_notifications_disable: "Disable notifications",
|
||||||
portal_notifications_denied: "Notifications are blocked in your browser settings.",
|
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_calendar: "Visit calendar",
|
||||||
portal_future_visit: "Future visit",
|
portal_future_visit: "Future visit",
|
||||||
portal_previous: "Previous",
|
portal_previous: "Previous",
|
||||||
|
|||||||
+13
-1
@@ -507,6 +507,7 @@ async fn portal_push_subscribe(
|
|||||||
db: Database,
|
db: Database,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
) -> cot::Result<Response> {
|
) -> cot::Result<Response> {
|
||||||
|
tracing::info!("client Web Push subscription request");
|
||||||
if crate::web_push::load_config(&db).await.is_none() {
|
if crate::web_push::load_config(&db).await.is_none() {
|
||||||
return Html::new("404").into_response();
|
return Html::new("404").into_response();
|
||||||
}
|
}
|
||||||
@@ -522,8 +523,19 @@ async fn portal_push_subscribe(
|
|||||||
|| form.keys.p256dh.len() > 512
|
|| form.keys.p256dh.len() > 512
|
||||||
|| form.keys.auth.len() > 256
|
|| 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 endpoint = form.endpoint.clone();
|
||||||
let now = chrono::Utc::now().naive_utc();
|
let now = chrono::Utc::now().naive_utc();
|
||||||
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||||
|
|||||||
+24
-3
@@ -23,9 +23,30 @@ pub async fn load_config(db: &Database) -> Option<VapidConfig> {
|
|||||||
.map(|setting| setting.value.trim().to_string())
|
.map(|setting| setting.value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
};
|
};
|
||||||
let public_key = value("vapid_public_key")?;
|
let strip_assignment = |value: String, name: &str| {
|
||||||
let private_key = value("vapid_private_key")?;
|
value
|
||||||
let subject = value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string());
|
.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 {
|
Some(VapidConfig {
|
||||||
public_key,
|
public_key,
|
||||||
private_key,
|
private_key,
|
||||||
|
|||||||
@@ -293,6 +293,7 @@
|
|||||||
<div class="notification-modal">
|
<div class="notification-modal">
|
||||||
<h2>{{ t.portal_notifications }}</h2>
|
<h2>{{ t.portal_notifications }}</h2>
|
||||||
<p id="notificationText">{{ t.portal_notifications_text }}</p>
|
<p id="notificationText">{{ t.portal_notifications_text }}</p>
|
||||||
|
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
|
||||||
<div class="notification-actions">
|
<div class="notification-actions">
|
||||||
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
||||||
<button type="button" onclick="closeNotificationSettings()">✕</button>
|
<button type="button" onclick="closeNotificationSettings()">✕</button>
|
||||||
@@ -328,20 +329,60 @@ renderCalendar();
|
|||||||
var toggle = document.getElementById('notificationToggle');
|
var toggle = document.getElementById('notificationToggle');
|
||||||
var registration;
|
var registration;
|
||||||
var subscription;
|
var subscription;
|
||||||
|
var status = document.getElementById('notificationStatus');
|
||||||
function decodeKey(value) {
|
function decodeKey(value) {
|
||||||
var padding = '='.repeat((4 - value.length % 4) % 4);
|
var padding = '='.repeat((4 - value.length % 4) % 4);
|
||||||
var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'));
|
var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
return Uint8Array.from(raw, function(char) { return char.charCodeAt(0); });
|
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() {
|
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');
|
registration = await navigator.serviceWorker.register('/service-worker.js');
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
subscription = await registration.pushManager.getSubscription();
|
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 }}';
|
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'); };
|
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
|
||||||
toggle.addEventListener('click', async function() {
|
toggle.addEventListener('click', async function() {
|
||||||
|
toggle.disabled = true;
|
||||||
try {
|
try {
|
||||||
if (!registration) await refresh();
|
if (!registration) await refresh();
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
@@ -361,20 +402,21 @@ renderCalendar();
|
|||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: decodeKey('{{ vapid_public_key }}')
|
applicationServerKey: decodeKey('{{ vapid_public_key }}')
|
||||||
});
|
});
|
||||||
var payload = subscription.toJSON();
|
await saveSubscription(subscription);
|
||||||
payload.language = '{{ lang.code() }}';
|
showStatus('{{ t.portal_notifications_active }}', false);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
|
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user