diff --git a/src/error.rs b/src/error.rs index b8b88b9..4f33757 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,17 +6,34 @@ pub enum AppError { InvalidIp, MissingClientIp, DbLookupFailed, + TemplateRenderFailed, +} + +impl AppError { + pub fn status_code(&self) -> StatusCode { + match self { + Self::IpNotFound => StatusCode::NOT_FOUND, + Self::InvalidIp => StatusCode::BAD_REQUEST, + Self::MissingClientIp => StatusCode::BAD_REQUEST, + Self::DbLookupFailed => StatusCode::INTERNAL_SERVER_ERROR, + Self::TemplateRenderFailed => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + pub fn message(&self) -> &'static str { + match self { + Self::IpNotFound => "IP not found in database", + Self::InvalidIp => "Invalid IP address", + Self::MissingClientIp => "Missing client IP address", + Self::DbLookupFailed => "Database lookup failed", + Self::TemplateRenderFailed => "Template render failed", + } + } } impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, message) = match self { - Self::IpNotFound => (StatusCode::NOT_FOUND, "IP not found in database"), - Self::InvalidIp => (StatusCode::BAD_REQUEST, "Invalid IP address"), - Self::MissingClientIp => (StatusCode::BAD_REQUEST, "Missing client IP address"), - Self::DbLookupFailed => (StatusCode::INTERNAL_SERVER_ERROR, "Database lookup failed"), - }; - (status, message).into_response() + (self.status_code(), self.message()).into_response() } } @@ -76,4 +93,14 @@ mod tests { ) .await; } + + #[tokio::test] + async fn template_render_failed_returns_500() { + check_error_response( + AppError::TemplateRenderFailed, + StatusCode::INTERNAL_SERVER_ERROR, + "Template render failed", + ) + .await; + } } diff --git a/src/handlers.rs b/src/handlers.rs index c44025c..f28981e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -43,6 +43,25 @@ struct IndexTemplate { is_tor: bool, } +#[derive(Template)] +#[template(path = "error_alert.html")] +struct ErrorAlertTemplate { + site_domain: Arc, + css_version: u64, + query: String, + error_message: String, +} + +#[derive(Template)] +#[template(path = "error_page.html")] +struct ErrorPageTemplate { + site_domain: Arc, + css_version: u64, + status_code: u16, + status_text: String, + error_message: String, +} + fn resolve_ip(headers: &HeaderMap, query: &IpQuery, dev_mode: bool) -> Result { if let Some(ref ip_str) = query.ip { let trimmed = ip_str.trim(); @@ -110,29 +129,74 @@ fn format_city(info: &IpInfo) -> &str { .unwrap_or_default() } +fn render_error_alert(state: &AppState, query: &str, error: &AppError) -> Response { + let template = ErrorAlertTemplate { + site_domain: state.site_domain.clone(), + css_version: static_assets::asset_version("style.css"), + query: query.to_owned(), + error_message: error.message().to_owned(), + }; + let status = error.status_code(); + match template.render() { + Ok(html) => (status, Html(html)).into_response(), + Err(_) => (status, error.message()).into_response(), + } +} + +fn render_error_page(site_domain: &Arc, status: StatusCode, message: &str) -> Response { + let template = ErrorPageTemplate { + site_domain: site_domain.clone(), + css_version: static_assets::asset_version("style.css"), + status_code: status.as_u16(), + status_text: status + .canonical_reason() + .unwrap_or("Error") + .to_owned(), + error_message: message.to_owned(), + }; + match template.render() { + Ok(html) => (status, Html(html)).into_response(), + Err(_) => (status, message.to_owned()).into_response(), + } +} + pub async fn root( State(state): State, headers: HeaderMap, Query(query): Query, -) -> Result { +) -> Response { let ua = headers .get("User-Agent") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if is_cli_user_agent(ua) && query.ip.is_none() { - let ip = extract_client_ip(&headers, state.dev_mode)?; - return Ok(format!("{ip}\n").into_response()); + return match extract_client_ip(&headers, state.dev_mode) { + Ok(ip) => format!("{ip}\n").into_response(), + Err(e) => e.into_response(), + }; } if wants_json(&headers) && query.ip.is_none() { - let ip = extract_client_ip(&headers, state.dev_mode)?; - let info = lookup_ip(&state.db, ip)?; - return Ok(axum::Json(info).into_response()); + return match extract_client_ip(&headers, state.dev_mode) + .and_then(|ip| lookup_ip(&state.db, ip)) + { + Ok(info) => axum::Json(info).into_response(), + Err(e) => e.into_response(), + }; } - let ip = resolve_ip(&headers, &query, state.dev_mode)?; - let info = lookup_ip(&state.db, ip)?; + let query_str = query.ip.clone().unwrap_or_default(); + + let ip = match resolve_ip(&headers, &query, state.dev_mode) { + Ok(ip) => ip, + Err(e) => return render_error_alert(&state, &query_str, &e), + }; + + let info = match lookup_ip(&state.db, ip) { + Ok(info) => info, + Err(e) => return render_error_alert(&state, &query_str, &e), + }; let template = IndexTemplate { ip: info.ip.clone(), @@ -149,7 +213,10 @@ pub async fn root( is_tor: info.proxy.is_tor, }; - Ok(Html(template.render().map_err(|_| AppError::DbLookupFailed)?).into_response()) + match template.render() { + Ok(html) => Html(html).into_response(), + Err(_) => render_error_alert(&state, &query_str, &AppError::TemplateRenderFailed), + } } pub async fn health() -> StatusCode { @@ -303,3 +370,11 @@ pub async fn sitemap_xml(State(state): State) -> Response { ) .into_response() } + +pub async fn not_found(State(state): State) -> Response { + render_error_page( + &state.site_domain, + StatusCode::NOT_FOUND, + "The page you are looking for does not exist.", + ) +} diff --git a/src/routes.rs b/src/routes.rs index 7358f61..03d1195 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,6 +1,6 @@ -use axum::Router; use axum::middleware; use axum::routing::get; +use axum::Router; use crate::handlers::{self, AppState}; use crate::middleware::security_headers; @@ -23,6 +23,7 @@ pub fn build_router(state: AppState) -> Router { .route("/hosting", get(handlers::hosting_handler)) .route("/tor", get(handlers::tor_handler)) .route("/static/{*path}", get(static_assets::serve_static)) + .fallback(handlers::not_found) .layer(middleware::from_fn(security_headers)) .with_state(state) } diff --git a/static/style.css b/static/style.css index 5851079..ecb783e 100644 --- a/static/style.css +++ b/static/style.css @@ -264,6 +264,61 @@ main { } } +.error-banner { + text-align: center; + margin-block: 28px; + padding: 16px 20px; + border-radius: 8px; + background: light-dark(#FFF3F3, #3A1C1C); + border: 1px solid var(--color-red); + color: var(--color-red); + font-size: 15px; + font-weight: 600; +} + +.error-page { + text-align: center; + padding-block-start: 60px; +} + +.error-code { + font-size: clamp(64px, 15vw, 120px); + font-weight: 200; + letter-spacing: -2px; + color: var(--color-label-tertiary); + line-height: 1; +} + +.error-status { + font-size: 20px; + font-weight: 600; + margin-block-start: 8px; + color: var(--color-label); +} + +.error-detail { + font-size: 15px; + margin-block-start: 12px; + color: var(--color-label-secondary); +} + +.error-home-link { + display: inline-block; + margin-block-start: 28px; + padding: 10px 24px; + border-radius: 8px; + background: var(--color-blue); + color: #FFFFFF; + font-size: 15px; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: none; + opacity: 0.85; + } +} + .site-footer { max-inline-size: 540px; margin-block-start: 64px; @@ -354,6 +409,23 @@ main { padding-block-end: 32px; font-size: 12px; } + + .error-banner { + font-size: 14px; + margin-block: 20px; + } + + .error-page { + padding-block-start: 40px; + } + + .error-status { + font-size: 18px; + } + + .error-detail { + font-size: 14px; + } } @media (max-width: 360px) { diff --git a/templates/error_alert.html b/templates/error_alert.html new file mode 100644 index 0000000..b59bee6 --- /dev/null +++ b/templates/error_alert.html @@ -0,0 +1,52 @@ + + + + + + Error - {{ site_domain }} + + + + + + + + + + +
+ + + +
+ + + + diff --git a/templates/error_page.html b/templates/error_page.html new file mode 100644 index 0000000..21bdab1 --- /dev/null +++ b/templates/error_page.html @@ -0,0 +1,23 @@ + + + + + + {{ status_code }} {{ status_text }} - {{ site_domain }} + + + + + + + +
+
+

{{ status_code }}

+

{{ status_text }}

+

{{ error_message }}

+ Go to Home +
+
+ + diff --git a/tests/integration.rs b/tests/integration.rs index 88891bb..530688f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -557,3 +557,98 @@ async fn dev_mode_field_endpoints_return_fallback_data() { assert_eq!(status, StatusCode::OK); assert_eq!(body.trim(), "Cloudflare, Inc."); } + +// --- Error page tests --- + +#[tokio::test] +async fn not_found_returns_html_error_page() { + let app = build_test_app(); + let (status, body) = get(&app, "/nonexistent-page").await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert!(body.contains("")); + assert!(body.contains("404")); + assert!(body.contains("Not Found")); + assert!(body.contains("Go to Home")); +} + +#[tokio::test] +async fn browser_invalid_ip_shows_alert_html() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/?ip=not-an-ip", + vec![( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + )], + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("")); + assert!(body.contains("alert(")); + assert!(body.contains("Invalid IP address")); + assert!(body.contains("not-an-ip")); +} + +#[tokio::test] +async fn browser_db_lookup_failed_shows_alert_html() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/?ip=0.0.0.0", + vec![( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + )], + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(body.contains("")); + assert!(body.contains("alert(")); + assert!(body.contains("Database lookup failed")); +} + +#[tokio::test] +async fn browser_missing_client_ip_shows_alert_html() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/", + vec![( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + )], + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("")); + assert!(body.contains("alert(")); + assert!(body.contains("Missing client IP address")); +} + +#[tokio::test] +async fn cli_invalid_ip_returns_plain_text() { + let app = build_test_app(); + let (status, body) = get(&app, "/json?ip=not-an-ip").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!body.contains("")); + assert_eq!(body, "Invalid IP address"); +} + +#[tokio::test] +async fn error_alert_page_has_search_form() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/?ip=bad-ip", + vec![( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + )], + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("