diff --git a/src/admin.rs b/src/admin.rs index c4e9649..30baed3 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -438,6 +438,8 @@ struct MediaTemplate<'a> { items: Vec, clients: Vec, 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 { + 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::().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 = 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) diff --git a/src/public.rs b/src/public.rs index 4e73f0b..3c41d49 100644 --- a/src/public.rs +++ b/src/public.rs @@ -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) -> cot::Resu async fn service_worker(_request: Request) -> cot::Result { 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) } diff --git a/src/web_push.rs b/src/web_push.rs index b396822..9404c16 100644 --- a/src/web_push.rs +++ b/src/web_push.rs @@ -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 { let settings = Setting::objects().all(db).await.ok()?; let value = |key: &str| { @@ -23,30 +43,22 @@ pub async fn load_config(db: &Database) -> Option { .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, diff --git a/templates/admin/media.html b/templates/admin/media.html index 4058b27..4ce499f 100644 --- a/templates/admin/media.html +++ b/templates/admin/media.html @@ -55,6 +55,19 @@ {% endfor %} + {% if total_pages > 1 %} + + {% endif %} {% endif %}