Fixed notifications VAPID
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s

This commit is contained in:
Ultradesu
2026-08-08 11:45:29 +01:00
parent d6e6075469
commit 2d43600066
5 changed files with 116 additions and 33 deletions
+22
View File
@@ -438,6 +438,8 @@ struct MediaTemplate<'a> {
items: Vec<MediaItem>,
clients: Vec<Client>,
filter_client_id: i64,
page: usize,
total_pages: usize,
}
#[derive(Debug, Template)]
@@ -2088,6 +2090,7 @@ async fn visit_set_cancel(
// ---------------------------------------------------------------------------
async fn media_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
const MEDIA_PER_PAGE: usize = 24;
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
@@ -2104,6 +2107,17 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
.and_then(|v| v.parse().ok())
})
.unwrap_or(0);
let requested_page = request
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find_map(|part| part.strip_prefix("page="))
.and_then(|value| value.parse::<usize>().ok())
})
.unwrap_or(1)
.max(1);
let clients_all = Client::objects().all(&db).await?;
let visits_all = Visit::objects().all(&db).await?;
@@ -2139,8 +2153,14 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
}
media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let total_pages = media_list.len().div_ceil(MEDIA_PER_PAGE).max(1);
let page = requested_page.min(total_pages);
let page_start = (page - 1) * MEDIA_PER_PAGE;
let items: Vec<MediaItem> = media_list
.into_iter()
.skip(page_start)
.take(MEDIA_PER_PAGE)
.map(|m| {
let cid: i64 = m.client_id.primary_key().unwrap();
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
@@ -2172,6 +2192,8 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
items,
clients: active_clients,
filter_client_id,
page,
total_pages,
}
.render()?;
html_response(body, lang)
+33 -10
View File
@@ -460,12 +460,14 @@ async fn client_portal(
.await
.map(|config| config.public_key)
.unwrap_or_default();
let notifications_enabled = !vapid_public_key.is_empty()
&& query!(Setting, $key == notification_key)
.get(&db)
.await?
.map(|setting| setting.value == "true")
.unwrap_or(false);
// The administrator setting controls whether the client can see notification
// controls. Keep this independent from VAPID validation so a configuration
// error is visible in the modal instead of silently removing the button.
let notifications_enabled = query!(Setting, $key == notification_key)
.get(&db)
.await?
.map(|setting| setting.value == "true")
.unwrap_or(false);
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let body = ClientPortalTemplate {
@@ -624,6 +626,12 @@ async fn web_manifest(_request: Request, Path(token): Path<String>) -> cot::Resu
async fn service_worker(_request: Request) -> cot::Result<Response> {
let script = r#"
self.addEventListener('install', function(event) {
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', function(event) {
var data = event.data ? event.data.json() : {};
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
@@ -634,12 +642,23 @@ self.addEventListener('push', function(event) {
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(list) {
event.waitUntil((async function() {
var list = await clients.matchAll({ type: 'window', includeUncontrolled: true });
for (var i = 0; i < list.length; i++) {
if ('focus' in list[i]) { list[i].navigate(target); return list[i].focus(); }
if (list[i].url === target && 'focus' in list[i]) {
return list[i].focus();
}
}
return clients.openWindow ? clients.openWindow(target) : undefined;
}));
for (var j = 0; j < list.length; j++) {
if ('navigate' in list[j] && 'focus' in list[j]) {
try {
var navigated = await list[j].navigate(target);
return navigated ? navigated.focus() : list[j].focus();
} catch (_) {}
}
}
if (clients.openWindow) return clients.openWindow(target);
})());
});
"#;
let mut response = Response::new(cot::Body::fixed(script));
@@ -649,6 +668,10 @@ self.addEventListener('notificationclick', function(event) {
response
.headers_mut()
.insert("service-worker-allowed", "/".parse().unwrap());
response.headers_mut().insert(
"cache-control",
"no-cache, no-store, must-revalidate".parse().unwrap(),
);
Ok(response)
}
+35 -23
View File
@@ -14,6 +14,26 @@ pub struct VapidConfig {
subject: String,
}
fn normalize_key_pair(public_key: &str, private_key: &str) -> Option<(String, String)> {
let strip_assignment = |value: &str, name: &str| {
value
.trim()
.strip_prefix(&format!("{name}="))
.unwrap_or(value.trim())
.trim()
.to_string()
};
let public_key = strip_assignment(public_key, "WEB_PETTING_VAPID_PUBLIC_KEY");
let private_key = strip_assignment(private_key, "WEB_PETTING_VAPID_PRIVATE_KEY");
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 {
return Some((public_key, private_key));
}
None
}
pub async fn load_config(db: &Database) -> Option<VapidConfig> {
let settings = Setting::objects().all(db).await.ok()?;
let value = |key: &str| {
@@ -23,30 +43,22 @@ pub async fn load_config(db: &Database) -> Option<VapidConfig> {
.map(|setting| setting.value.trim().to_string())
.filter(|value| !value.is_empty())
};
let strip_assignment = |value: String, name: &str| {
value
.strip_prefix(&format!("{name}="))
.unwrap_or(&value)
.trim()
.to_string()
let raw_public_key = value("vapid_public_key")?;
let raw_private_key = value("vapid_private_key")?;
let (public_key, private_key) = match normalize_key_pair(&raw_public_key, &raw_private_key) {
Some(keys) => keys,
None => {
tracing::warn!("invalid VAPID configuration in database");
return None;
}
};
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;
}
let subject_value =
value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string());
let subject = subject_value
.strip_prefix("WEB_PETTING_VAPID_SUBJECT=")
.unwrap_or(&subject_value)
.trim()
.to_string();
Some(VapidConfig {
public_key,
private_key,
+20
View File
@@ -55,6 +55,19 @@
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<nav class="media-pagination" aria-label="Pagination">
{% if page > 1 %}
<a class="button is-small" href="/admin/media?lang={{ lang.code() }}&page={{ page - 1 }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}"></a>
{% endif %}
{% for p in 1..=total_pages %}
<a class="button is-small{% if p == page %} is-link{% endif %}" href="/admin/media?lang={{ lang.code() }}&page={{ p }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}">{{ p }}</a>
{% endfor %}
{% if page < total_pages %}
<a class="button is-small" href="/admin/media?lang={{ lang.code() }}&page={{ page + 1 }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}"></a>
{% endif %}
</nav>
{% endif %}
{% endif %}
<style>
@@ -101,6 +114,13 @@
.media-info {
padding: 0.6rem 0.75rem;
}
.media-pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.35rem;
margin-top: 1.25rem;
}
.media-meta {
display: flex;
justify-content: space-between;
+6
View File
@@ -359,7 +359,13 @@ renderCalendar();
showStatus('{{ t.portal_notifications_unsupported }}', true);
return;
}
if (!'{{ vapid_public_key }}') {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_error }}', true);
return;
}
registration = await navigator.serviceWorker.register('/service-worker.js');
await registration.update();
await navigator.serviceWorker.ready;
subscription = await registration.pushManager.getSubscription();
var expectedKey = decodeKey('{{ vapid_public_key }}');