diff --git a/ocg-server/src/handlers/community.rs b/ocg-server/src/handlers/community.rs index 84f9b2e68..b42fe3e92 100644 --- a/ocg-server/src/handlers/community.rs +++ b/ocg-server/src/handlers/community.rs @@ -18,7 +18,7 @@ use crate::{ activity_tracker::{Activity, DynActivityTracker}, db::DynDB, handlers::{ - error::HandlerError, extractors::CommunityId, prepare_headers, request_matches_site, + error::HandlerError, prepare_headers, request_matches_site, site::not_found, trim_public_gallery_images, }, templates::{PageId, auth::User, community}, @@ -34,21 +34,22 @@ mod tests; #[instrument(skip_all, err)] pub(crate) async fn page( State(db): State, - CommunityId(community_id): CommunityId, + Path(community_name): Path, uri: Uri, ) -> Result { + // Get community and site settings + let (community_id, site_settings) = tokio::try_join!( + db.get_community_id_by_name(&community_name), + db.get_site_settings() + )?; + let Some(community_id) = community_id else { + return not_found::render(site_settings); + }; + // Prepare template - let ( - mut community, - recently_added_groups, - site_settings, - upcoming_in_person_events, - upcoming_virtual_events, - stats, - ) = tokio::try_join!( + let (mut community, recently_added_groups, upcoming_in_person_events, upcoming_virtual_events, stats) = tokio::try_join!( db.get_community_full(community_id), db.get_community_recently_added_groups(community_id), - db.get_site_settings(), db.get_community_upcoming_events(community_id, vec![EventKind::InPerson, EventKind::Hybrid]), db.get_community_upcoming_events(community_id, vec![EventKind::Virtual, EventKind::Hybrid]), db.get_community_site_stats(community_id), @@ -78,7 +79,7 @@ pub(crate) async fn page( // Prepare response headers let headers = prepare_headers(Duration::hours(1), &[])?; - Ok((headers, Html(template.render()?))) + Ok((headers, Html(template.render()?)).into_response()) } // Actions handlers. diff --git a/ocg-server/src/handlers/community/tests.rs b/ocg-server/src/handlers/community/tests.rs index cd49f160c..cdbe7367d 100644 --- a/ocg-server/src/handlers/community/tests.rs +++ b/ocg-server/src/handlers/community/tests.rs @@ -78,6 +78,47 @@ async fn test_page_success() { assert!(!bytes.is_empty()); } +#[tokio::test] +async fn test_page_community_not_found() { + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_community_id_by_name() + .times(1) + .withf(|name| name == "missing-community") + .returning(|_| Ok(None)); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); + + // Setup notifications manager mock + let nm = MockNotificationsManager::new(); + + // Setup router and send request + let router = TestRouterBuilder::new(db, nm).build().await; + let request = Request::builder() + .method("GET") + .uri("/missing-community") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); + assert!(body.contains("Go to home page")); +} + #[tokio::test] async fn test_page_db_error() { // Setup identifiers and data structures diff --git a/ocg-server/src/handlers/event.rs b/ocg-server/src/handlers/event.rs index e900cb538..f25d90a0f 100644 --- a/ocg-server/src/handlers/event.rs +++ b/ocg-server/src/handlers/event.rs @@ -21,7 +21,9 @@ use crate::{ db::{DynDB, payments::PrepareEventCheckoutPurchaseInput}, handlers::{ extractors::{CurrentUser, ValidatedForm, ValidatedFormQs}, - prepare_headers, request_matches_site, trim_public_gallery_images, + prepare_headers, request_matches_site, + site::not_found, + trim_public_gallery_images, }, router::CACHE_CONTROL_NO_CACHE, services::{ @@ -55,17 +57,30 @@ mod tests; #[instrument(skip_all)] pub(crate) async fn page( State(db): State, - CommunityId(community_id): CommunityId, - Path((_, group_slug, event_slug)): Path<(String, String, String)>, + Path((community_name, group_slug, event_slug)): Path<(String, String, String)>, uri: Uri, ) -> Result { - // Prepare template - let (event, site_settings) = tokio::try_join!( - db.get_event_full_by_slug(community_id, &group_slug, &event_slug), + // Get community and site settings + let (community_id, site_settings) = tokio::try_join!( + db.get_community_id_by_name(&community_name), db.get_site_settings() )?; - let mut event = event.ok_or(HandlerError::NotFound)?; + let Some(community_id) = community_id else { + return not_found::render(site_settings); + }; + + // Fetch event page data + let event = db + .get_event_full_by_slug(community_id, &group_slug, &event_slug) + .await?; + let Some(mut event) = event else { + return not_found::render(site_settings); + }; + + // Trim gallery media trim_public_gallery_images(&mut event.photos_urls); + + // Prepare template let template = Page { event, page_id: PageId::Event, @@ -77,7 +92,7 @@ pub(crate) async fn page( // Prepare response headers let headers = prepare_headers(Duration::hours(1), &[])?; - Ok((headers, Html(template.render()?))) + Ok((headers, Html(template.render()?)).into_response()) } /// Handler that renders the check-in page. diff --git a/ocg-server/src/handlers/event/tests.rs b/ocg-server/src/handlers/event/tests.rs index b6d3cbb58..3e607a396 100644 --- a/ocg-server/src/handlers/event/tests.rs +++ b/ocg-server/src/handlers/event/tests.rs @@ -119,15 +119,15 @@ async fn test_page_success() { .times(1) .withf(|name| name == "test-community") .returning(move |_| Ok(Some(community_id))); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); db.expect_get_event_full_by_slug() .times(1) .withf(move |id, group_slug, event_slug| { *id == community_id && group_slug == "test-group" && event_slug == "test-event" }) .returning(move |_, _, _| Ok(Some(sample_event_full(community_id, event_id, group_id)))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); // Setup notifications manager mock let nm = MockNotificationsManager::new(); @@ -156,6 +156,47 @@ async fn test_page_success() { assert!(!bytes.is_empty()); } +#[tokio::test] +async fn test_page_community_not_found() { + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_community_id_by_name() + .times(1) + .withf(|name| name == "missing-community") + .returning(|_| Ok(None)); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); + + // Setup notifications manager mock + let nm = MockNotificationsManager::new(); + + // Setup router and send request + let router = TestRouterBuilder::new(db, nm).build().await; + let request = Request::builder() + .method("GET") + .uri("/missing-community/group/test-group/event/test-event") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); + assert!(body.contains("Go to home page")); +} + #[tokio::test] async fn test_page_not_found() { // Setup identifiers and data structures @@ -193,7 +234,17 @@ async fn test_page_not_found() { // Check response matches expectations assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert!(bytes.is_empty()); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); + assert!(body.contains("Go to home page")); } #[tokio::test] @@ -207,6 +258,9 @@ async fn test_page_db_error() { .times(1) .withf(|name| name == "test-community") .returning(move |_| Ok(Some(community_id))); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); db.expect_get_event_full_by_slug() .times(1) .withf(move |id, group_slug, event_slug| { diff --git a/ocg-server/src/handlers/group.rs b/ocg-server/src/handlers/group.rs index e057f884e..38c1d18ee 100644 --- a/ocg-server/src/handlers/group.rs +++ b/ocg-server/src/handlers/group.rs @@ -16,7 +16,10 @@ use crate::{ activity_tracker::{Activity, DynActivityTracker}, config::HttpServerConfig, db::DynDB, - handlers::{extractors::CurrentUser, prepare_headers, request_matches_site, trim_public_gallery_images}, + handlers::{ + extractors::CurrentUser, prepare_headers, request_matches_site, site::not_found, + trim_public_gallery_images, + }, services::notifications::{DynNotificationsManager, NewNotification, NotificationKind}, templates::{ PageId, @@ -38,19 +41,28 @@ mod tests; #[instrument(skip_all)] pub(crate) async fn page( State(db): State, - CommunityId(community_id): CommunityId, - Path((_, group_slug)): Path<(String, String)>, + Path((community_name, group_slug)): Path<(String, String)>, uri: Uri, ) -> Result { + // Get community and site settings + let (community_id, site_settings) = tokio::try_join!( + db.get_community_id_by_name(&community_name), + db.get_site_settings() + )?; + let Some(community_id) = community_id else { + return not_found::render(site_settings); + }; + // Fetch the group page data let event_kinds = vec![EventKind::InPerson, EventKind::Virtual, EventKind::Hybrid]; - let (group, past_events, site_settings, upcoming_events) = tokio::try_join!( + let (group, past_events, upcoming_events) = tokio::try_join!( db.get_group_full_by_slug(community_id, &group_slug), db.get_group_past_events(community_id, &group_slug, event_kinds.clone(), 9), - db.get_site_settings(), db.get_group_upcoming_events(community_id, &group_slug, event_kinds, 9) )?; - let mut group = group.ok_or(HandlerError::NotFound)?; + let Some(mut group) = group else { + return not_found::render(site_settings); + }; // Trim gallery media trim_public_gallery_images(&mut group.photos_urls); @@ -78,7 +90,7 @@ pub(crate) async fn page( // Prepare response headers let headers = prepare_headers(Duration::hours(1), &[])?; - Ok((headers, Html(template.render()?))) + Ok((headers, Html(template.render()?)).into_response()) } // Actions handlers. diff --git a/ocg-server/src/handlers/group/tests.rs b/ocg-server/src/handlers/group/tests.rs index ed70ac016..63030d60d 100644 --- a/ocg-server/src/handlers/group/tests.rs +++ b/ocg-server/src/handlers/group/tests.rs @@ -42,6 +42,9 @@ async fn test_page_success() { .times(1) .withf(|name| name == "test-community") .returning(move |_| Ok(Some(community_id))); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); db.expect_get_group_full_by_slug() .times(1) .withf(move |id, slug| *id == community_id && slug == "test-group") @@ -64,9 +67,6 @@ async fn test_page_success() { && *limit == 9 }) .returning(move |_, _, _, _| Ok(vec![sample_event_summary(event_id, group_id)])); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); // Setup notifications manager mock let nm = MockNotificationsManager::new(); @@ -95,6 +95,47 @@ async fn test_page_success() { assert!(!bytes.is_empty()); } +#[tokio::test] +async fn test_page_community_not_found() { + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_community_id_by_name() + .times(1) + .withf(|name| name == "missing-community") + .returning(|_| Ok(None)); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); + + // Setup notifications manager mock + let nm = MockNotificationsManager::new(); + + // Setup router and send request + let router = TestRouterBuilder::new(db, nm).build().await; + let request = Request::builder() + .method("GET") + .uri("/missing-community/group/test-group") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); + assert!(body.contains("Go to home page")); +} + #[tokio::test] async fn test_page_not_found() { // Setup identifiers and data structures @@ -148,7 +189,17 @@ async fn test_page_not_found() { // Check response matches expectations assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert!(bytes.is_empty()); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); + assert!(body.contains("Go to home page")); } #[tokio::test] @@ -163,6 +214,9 @@ async fn test_page_db_error() { .times(1) .withf(|name| name == "test-community") .returning(move |_| Ok(Some(community_id))); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); db.expect_get_group_full_by_slug() .times(1) .withf(move |id, slug| *id == community_id && slug == "test-group") diff --git a/ocg-server/src/handlers/meetings/tests.rs b/ocg-server/src/handlers/meetings/tests.rs index 098a820dc..006b6731c 100644 --- a/ocg-server/src/handlers/meetings/tests.rs +++ b/ocg-server/src/handlers/meetings/tests.rs @@ -1,14 +1,17 @@ use anyhow::anyhow; use axum::{ body::{Body, to_bytes}, - http::{Request, StatusCode, header::CONTENT_TYPE}, + http::{ + HeaderValue, Request, StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, + }, }; use serde_json::{Value, json}; use tower::ServiceExt; use crate::{ db::mock::MockDB, - handlers::tests::{TestRouterBuilder, sample_zoom_meetings_cfg}, + handlers::tests::{TestRouterBuilder, sample_site_settings, sample_zoom_meetings_cfg}, services::notifications::MockNotificationsManager, }; @@ -169,7 +172,10 @@ async fn test_zoom_event_returns_internal_server_error_when_recording_update_fai #[tokio::test] async fn test_zoom_event_returns_not_found_when_zoom_is_disabled() { // Setup database mock - let db = MockDB::new(); + let mut db = MockDB::new(); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); // Setup notifications manager mock let nm = MockNotificationsManager::new(); @@ -192,7 +198,16 @@ async fn test_zoom_event_returns_not_found_when_zoom_is_disabled() { // Check response matches expectations assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert!(bytes.is_empty()); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); } #[tokio::test] diff --git a/ocg-server/src/handlers/site.rs b/ocg-server/src/handlers/site.rs index 027bc1dd5..a75104785 100644 --- a/ocg-server/src/handlers/site.rs +++ b/ocg-server/src/handlers/site.rs @@ -3,4 +3,5 @@ pub(crate) mod docs; pub(crate) mod explore; pub(crate) mod home; +pub(crate) mod not_found; pub(crate) mod stats; diff --git a/ocg-server/src/handlers/site/not_found.rs b/ocg-server/src/handlers/site/not_found.rs new file mode 100644 index 000000000..5b0bbc0a9 --- /dev/null +++ b/ocg-server/src/handlers/site/not_found.rs @@ -0,0 +1,61 @@ +//! HTTP handlers for the global site not found page. + +use askama::Template; +use axum::{ + extract::State, + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; +use chrono::Duration; +use tracing::instrument; + +use crate::{ + db::DynDB, + handlers::{error::HandlerError, prepare_headers}, + templates::{PageId, auth::User, site::not_found::Page}, + types::site::SiteSettings, +}; + +/// Header marking a 404 response as the shared site not found page. +const NOT_FOUND_PAGE_HEADER: &str = "X-OCG-Not-Found"; + +/// Stable template path for not found pages. +const NOT_FOUND_PATH: &str = "/404"; + +// Pages handlers. + +/// Handler that renders the global site not found page. +#[instrument(skip_all, err)] +pub(crate) async fn page(State(db): State) -> Result { + // Load site settings + let site_settings = db.get_site_settings().await?; + + // Render not found page + render(site_settings) +} + +// Helpers. + +/// Renders the global site not found page. +#[instrument(skip_all, err)] +pub(crate) fn render(site_settings: SiteSettings) -> Result { + // Prepare template + let template = Page { + page_id: PageId::SiteNotFound, + path: NOT_FOUND_PATH.to_string(), + site_settings, + user: User::default(), + }; + + // Prepare response headers + let headers = prepare_headers( + Duration::minutes(5), + &[ + (NOT_FOUND_PAGE_HEADER, "true"), + ("HX-Retarget", "body"), + ("HX-Reswap", "innerHTML"), + ], + )?; + + Ok((StatusCode::NOT_FOUND, headers, Html(template.render()?)).into_response()) +} diff --git a/ocg-server/src/router.rs b/ocg-server/src/router.rs index 4084d9164..128e6bd74 100644 --- a/ocg-server/src/router.rs +++ b/ocg-server/src/router.rs @@ -215,7 +215,8 @@ pub(crate) async fn setup( // Page view tracking routes .route("/communities/{community_id}/views", post(community::track_view)) .route("/events/{event_id}/views", post(event::track_view)) - .route("/groups/{group_id}/views", post(group::track_view)); + .route("/groups/{group_id}/views", post(group::track_view)) + .fallback(site::not_found::page); // Setup some routes based on the login options enabled if server_cfg.login.email { diff --git a/ocg-server/src/router/tests.rs b/ocg-server/src/router/tests.rs index 3a04ba14c..3e29bd0f8 100644 --- a/ocg-server/src/router/tests.rs +++ b/ocg-server/src/router/tests.rs @@ -108,10 +108,53 @@ async fn test_health_check_returns_ok() { assert!(to_bytes(body, usize::MAX).await.unwrap().is_empty()); } +#[tokio::test] +async fn test_missing_route_returns_not_found_page() { + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); + + // Setup notifications manager mock + let nm = MockNotificationsManager::new(); + + // Setup router and send request + let router = TestRouterBuilder::new(db, nm).build().await; + let request = Request::builder() + .method("GET") + .uri("/missing/page") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + assert_eq!(parts.headers.get("X-OCG-Not-Found").unwrap(), "true"); + assert_eq!(parts.headers.get("HX-Retarget").unwrap(), "body"); + assert_eq!(parts.headers.get("HX-Reswap").unwrap(), "innerHTML"); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); + assert!(body.contains("Go to home page")); +} + #[tokio::test] async fn test_payments_webhook_route_is_not_mounted_without_payments_config() { // Setup database mock - let db = MockDB::new(); + let mut db = MockDB::new(); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); // Setup notifications manager mock let nm = MockNotificationsManager::new(); @@ -129,7 +172,16 @@ async fn test_payments_webhook_route_is_not_mounted_without_payments_config() { // Check response matches expectations assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert!(bytes.is_empty()); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); } #[tokio::test] @@ -193,7 +245,10 @@ async fn test_static_handler_missing_asset_returns_not_found() { #[tokio::test] async fn test_zoom_webhook_route_is_not_mounted_when_zoom_is_disabled() { // Setup database mock - let db = MockDB::new(); + let mut db = MockDB::new(); + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); // Setup disabled Zoom configuration let mut meetings_cfg = sample_zoom_meetings_cfg("zoom-secret"); @@ -220,5 +275,14 @@ async fn test_zoom_webhook_route_is_not_mounted_when_zoom_is_disabled() { // Check response matches expectations assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert!(bytes.is_empty()); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/html; charset=utf-8") + ); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + &HeaderValue::from_static("max-age=300") + ); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("We could not find that page")); } diff --git a/ocg-server/src/templates.rs b/ocg-server/src/templates.rs index d07a8fa1b..a3496559a 100644 --- a/ocg-server/src/templates.rs +++ b/ocg-server/src/templates.rs @@ -40,6 +40,7 @@ pub(crate) enum PageId { SiteDocs, SiteExplore, SiteHome, + SiteNotFound, SiteStats, UserDashboard, } diff --git a/ocg-server/src/templates/site.rs b/ocg-server/src/templates/site.rs index f0b32874a..f4d617e06 100644 --- a/ocg-server/src/templates/site.rs +++ b/ocg-server/src/templates/site.rs @@ -6,5 +6,7 @@ pub(crate) mod docs; pub(crate) mod explore; /// Templates for the home page. pub(crate) mod home; +/// Templates for the not found page. +pub(crate) mod not_found; /// Templates for the stats page. pub(crate) mod stats; diff --git a/ocg-server/src/templates/site/not_found.rs b/ocg-server/src/templates/site/not_found.rs new file mode 100644 index 000000000..e33c1b5ae --- /dev/null +++ b/ocg-server/src/templates/site/not_found.rs @@ -0,0 +1,25 @@ +//! Templates for the global site not found page. + +use askama::Template; +use serde::{Deserialize, Serialize}; + +use crate::{ + templates::{PageId, auth::User, filters, helpers::user_initials}, + types::site::SiteSettings, +}; + +// Pages templates. + +/// Template for rendering the not found page. +#[derive(Debug, Clone, Template, Serialize, Deserialize)] +#[template(path = "site/not_found/page.html")] +pub(crate) struct Page { + /// Identifier for the current page. + pub page_id: PageId, + /// Stable path used by the shared base and header templates. + pub path: String, + /// Global site settings. + pub site_settings: SiteSettings, + /// Authenticated user information. + pub user: User, +} diff --git a/ocg-server/static/js/common/htmx-extensions.js b/ocg-server/static/js/common/htmx-extensions.js index f9c70daed..b3336ee25 100644 --- a/ocg-server/static/js/common/htmx-extensions.js +++ b/ocg-server/static/js/common/htmx-extensions.js @@ -68,6 +68,25 @@ export const createNoEmptyValuesExtension = (dropZero) => ({ }, }); +/** + * Allows the shared HTML not found page to replace the current body on boosted requests. + * @param {CustomEvent} event HTMX beforeSwap event. + * @returns {void} + */ +export const handleNotFoundBeforeSwap = (event) => { + const xhr = event.detail?.xhr; + if (!xhr || xhr.status !== 404 || typeof xhr.getResponseHeader !== "function") { + return; + } + + if (xhr.getResponseHeader("X-OCG-Not-Found") !== "true") { + return; + } + + event.detail.shouldSwap = true; + event.detail.isError = false; +}; + /** * Registers the shared HTMX parameter filtering extensions. * @param {{defineExtension?: Function}|undefined|null} htmxInstance Global HTMX instance. @@ -81,3 +100,12 @@ export const registerHtmxNoEmptyValuesExtensions = (htmxInstance) => { htmxInstance.defineExtension("no-empty-vals", createNoEmptyValuesExtension(true)); htmxInstance.defineExtension("no-empty-vals-keep-zero", createNoEmptyValuesExtension(false)); }; + +/** + * Registers shared HTMX response handling hooks. + * @param {Document|undefined|null} root Event listener root. + * @returns {void} + */ +export const registerHtmxResponseHandlers = (root = document) => { + root?.body?.addEventListener("htmx:beforeSwap", handleNotFoundBeforeSwap); +}; diff --git a/ocg-server/templates/common/base.html b/ocg-server/templates/common/base.html index 48d635fdd..936d1f3fd 100644 --- a/ocg-server/templates/common/base.html +++ b/ocg-server/templates/common/base.html @@ -64,10 +64,12 @@ @@ -106,7 +108,7 @@ {# End content -#} {# Footer -#} - {% if !path.starts_with("/dashboard") && path != "/docs" && path != "/log-in" && path != "/sign-up" && !path.contains("/check-in/") -%} + {% if !path.starts_with("/dashboard") && path != "/404" && path != "/docs" && path != "/log-in" && path != "/sign-up" && !path.contains("/check-in/") -%} {% include "common/footer.html" -%} {% endif -%} {# End footer -#} diff --git a/ocg-server/templates/common/header.html b/ocg-server/templates/common/header.html index 4dfbe9b38..1f802465d 100644 --- a/ocg-server/templates/common/header.html +++ b/ocg-server/templates/common/header.html @@ -34,7 +34,7 @@ {# Desktop user menu -#}
- {% if page_id == PageId::SiteHome || page_id == PageId::SiteExplore || page_id == PageId::SiteStats || page_id == PageId::SiteDocs || page_id == PageId::Community || page_id == PageId::Group || page_id == PageId::Event -%} + {% if page_id == PageId::SiteHome || page_id == PageId::SiteExplore || page_id == PageId::SiteStats || page_id == PageId::SiteDocs || page_id == PageId::SiteNotFound || page_id == PageId::Community || page_id == PageId::Group || page_id == PageId::Event -%}
+
+
+
+

Page not found

+

+ We could not find that page +

+

+ The page you requested may have moved, been deleted, or the link may be incorrect. +

+ +
+
+
+
+{% endblock content -%} diff --git a/tests/unit/common/htmx-extensions.test.js b/tests/unit/common/htmx-extensions.test.js index b49e2a898..d5a22d19b 100644 --- a/tests/unit/common/htmx-extensions.test.js +++ b/tests/unit/common/htmx-extensions.test.js @@ -2,7 +2,9 @@ import { expect } from "@open-wc/testing"; import { createNoEmptyValuesExtension, + handleNotFoundBeforeSwap, registerHtmxNoEmptyValuesExtensions, + registerHtmxResponseHandlers, } from "/static/js/common/htmx-extensions.js"; const formDataToEntries = (formData) => Array.from(formData.entries()); @@ -92,4 +94,53 @@ describe("htmx extensions", () => { ]); expect(event.detail.parameters).to.equal(event.detail.formData); }); + + it("allows marked not found pages to swap on htmx 404 responses", () => { + const event = { + detail: { + isError: true, + shouldSwap: false, + xhr: { + status: 404, + getResponseHeader: (name) => (name === "X-OCG-Not-Found" ? "true" : null), + }, + }, + }; + + handleNotFoundBeforeSwap(event); + + expect(event.detail.shouldSwap).to.equal(true); + expect(event.detail.isError).to.equal(false); + }); + + it("keeps unmarked 404 responses on the default htmx error path", () => { + const event = { + detail: { + isError: true, + shouldSwap: false, + xhr: { + status: 404, + getResponseHeader: () => null, + }, + }, + }; + + handleNotFoundBeforeSwap(event); + + expect(event.detail.shouldSwap).to.equal(false); + expect(event.detail.isError).to.equal(true); + }); + + it("registers the not found beforeSwap handler", () => { + const listeners = []; + const root = { + body: { + addEventListener: (name, handler) => listeners.push([name, handler]), + }, + }; + + registerHtmxResponseHandlers(root); + + expect(listeners).to.deep.equal([["htmx:beforeSwap", handleNotFoundBeforeSwap]]); + }); });