Fixed notifications VAPID
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s
This commit is contained in:
@@ -438,6 +438,8 @@ struct MediaTemplate<'a> {
|
|||||||
items: Vec<MediaItem>,
|
items: Vec<MediaItem>,
|
||||||
clients: Vec<Client>,
|
clients: Vec<Client>,
|
||||||
filter_client_id: i64,
|
filter_client_id: i64,
|
||||||
|
page: usize,
|
||||||
|
total_pages: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[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> {
|
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 lang = detect_lang(&request);
|
||||||
let admin_name = match require_auth(&session, lang).await {
|
let admin_name = match require_auth(&session, lang).await {
|
||||||
Ok(name) => name,
|
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())
|
.and_then(|v| v.parse().ok())
|
||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.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 clients_all = Client::objects().all(&db).await?;
|
||||||
let visits_all = Visit::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));
|
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
|
let items: Vec<MediaItem> = media_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.skip(page_start)
|
||||||
|
.take(MEDIA_PER_PAGE)
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let cid: i64 = m.client_id.primary_key().unwrap();
|
let cid: i64 = m.client_id.primary_key().unwrap();
|
||||||
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
|
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,
|
items,
|
||||||
clients: active_clients,
|
clients: active_clients,
|
||||||
filter_client_id,
|
filter_client_id,
|
||||||
|
page,
|
||||||
|
total_pages,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
|
|||||||
+33
-10
@@ -460,12 +460,14 @@ async fn client_portal(
|
|||||||
.await
|
.await
|
||||||
.map(|config| config.public_key)
|
.map(|config| config.public_key)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let notifications_enabled = !vapid_public_key.is_empty()
|
// The administrator setting controls whether the client can see notification
|
||||||
&& query!(Setting, $key == notification_key)
|
// controls. Keep this independent from VAPID validation so a configuration
|
||||||
.get(&db)
|
// error is visible in the modal instead of silently removing the button.
|
||||||
.await?
|
let notifications_enabled = query!(Setting, $key == notification_key)
|
||||||
.map(|setting| setting.value == "true")
|
.get(&db)
|
||||||
.unwrap_or(false);
|
.await?
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
let body = ClientPortalTemplate {
|
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> {
|
async fn service_worker(_request: Request) -> cot::Result<Response> {
|
||||||
let script = r#"
|
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) {
|
self.addEventListener('push', function(event) {
|
||||||
var data = event.data ? event.data.json() : {};
|
var data = event.data ? event.data.json() : {};
|
||||||
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
|
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
|
||||||
@@ -634,12 +642,23 @@ self.addEventListener('push', function(event) {
|
|||||||
self.addEventListener('notificationclick', function(event) {
|
self.addEventListener('notificationclick', function(event) {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
|
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++) {
|
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));
|
let mut response = Response::new(cot::Body::fixed(script));
|
||||||
@@ -649,6 +668,10 @@ self.addEventListener('notificationclick', function(event) {
|
|||||||
response
|
response
|
||||||
.headers_mut()
|
.headers_mut()
|
||||||
.insert("service-worker-allowed", "/".parse().unwrap());
|
.insert("service-worker-allowed", "/".parse().unwrap());
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"cache-control",
|
||||||
|
"no-cache, no-store, must-revalidate".parse().unwrap(),
|
||||||
|
);
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-23
@@ -14,6 +14,26 @@ pub struct VapidConfig {
|
|||||||
subject: String,
|
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> {
|
pub async fn load_config(db: &Database) -> Option<VapidConfig> {
|
||||||
let settings = Setting::objects().all(db).await.ok()?;
|
let settings = Setting::objects().all(db).await.ok()?;
|
||||||
let value = |key: &str| {
|
let value = |key: &str| {
|
||||||
@@ -23,30 +43,22 @@ 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 strip_assignment = |value: String, name: &str| {
|
let raw_public_key = value("vapid_public_key")?;
|
||||||
value
|
let raw_private_key = value("vapid_private_key")?;
|
||||||
.strip_prefix(&format!("{name}="))
|
let (public_key, private_key) = match normalize_key_pair(&raw_public_key, &raw_private_key) {
|
||||||
.unwrap_or(&value)
|
Some(keys) => keys,
|
||||||
.trim()
|
None => {
|
||||||
.to_string()
|
tracing::warn!("invalid VAPID configuration in database");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let public_key = strip_assignment(value("vapid_public_key")?, "WEB_PETTING_VAPID_PUBLIC_KEY");
|
let subject_value =
|
||||||
let private_key =
|
value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string());
|
||||||
strip_assignment(value("vapid_private_key")?, "WEB_PETTING_VAPID_PRIVATE_KEY");
|
let subject = subject_value
|
||||||
let subject = strip_assignment(
|
.strip_prefix("WEB_PETTING_VAPID_SUBJECT=")
|
||||||
value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string()),
|
.unwrap_or(&subject_value)
|
||||||
"WEB_PETTING_VAPID_SUBJECT",
|
.trim()
|
||||||
);
|
.to_string();
|
||||||
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,
|
||||||
|
|||||||
@@ -55,6 +55,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -101,6 +114,13 @@
|
|||||||
.media-info {
|
.media-info {
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
.media-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
.media-meta {
|
.media-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -359,7 +359,13 @@ renderCalendar();
|
|||||||
showStatus('{{ t.portal_notifications_unsupported }}', true);
|
showStatus('{{ t.portal_notifications_unsupported }}', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!'{{ vapid_public_key }}') {
|
||||||
|
toggle.disabled = true;
|
||||||
|
showStatus('{{ t.portal_notifications_error }}', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
registration = await navigator.serviceWorker.register('/service-worker.js');
|
registration = await navigator.serviceWorker.register('/service-worker.js');
|
||||||
|
await registration.update();
|
||||||
await navigator.serviceWorker.ready;
|
await navigator.serviceWorker.ready;
|
||||||
subscription = await registration.pushManager.getSubscription();
|
subscription = await registration.pushManager.getSubscription();
|
||||||
var expectedKey = decodeKey('{{ vapid_public_key }}');
|
var expectedKey = decodeKey('{{ vapid_public_key }}');
|
||||||
|
|||||||
Reference in New Issue
Block a user