feat: add HTML error pages with browser alert popup for IP query errors

This commit is contained in:
NetworkCats
2026-03-04 03:21:32 +08:00
parent 5458098e8c
commit 6fbfe15b08
7 changed files with 362 additions and 17 deletions
+34 -7
View File
@@ -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;
}
}
+84 -9
View File
@@ -43,6 +43,25 @@ struct IndexTemplate {
is_tor: bool,
}
#[derive(Template)]
#[template(path = "error_alert.html")]
struct ErrorAlertTemplate {
site_domain: Arc<str>,
css_version: u64,
query: String,
error_message: String,
}
#[derive(Template)]
#[template(path = "error_page.html")]
struct ErrorPageTemplate {
site_domain: Arc<str>,
css_version: u64,
status_code: u16,
status_text: String,
error_message: String,
}
fn resolve_ip(headers: &HeaderMap, query: &IpQuery, dev_mode: bool) -> Result<IpAddr, AppError> {
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<str>, 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<AppState>,
headers: HeaderMap,
Query(query): Query<IpQuery>,
) -> Result<Response, AppError> {
) -> 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<AppState>) -> Response {
)
.into_response()
}
pub async fn not_found(State(state): State<AppState>) -> Response {
render_error_page(
&state.site_domain,
StatusCode::NOT_FOUND,
"The page you are looking for does not exist.",
)
}
+2 -1
View File
@@ -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)
}
+72
View File
@@ -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) {
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Error - {{ site_domain }}</title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;600&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/simple-line-icons/2.5.5/css/simple-line-icons.min.css">
<link rel="stylesheet" href="/static/style.css?v={{ css_version }}">
<script>alert("{{ error_message }}");</script>
</head>
<body>
<main>
<form method="GET" action="/" class="search-form" role="search">
<div class="search-input-wrap">
<label for="ip-search" class="sr-only">Search IP address</label>
<i class="icon-magnifier search-icon" aria-hidden="true"></i>
<input
id="ip-search"
type="text"
name="ip"
placeholder="Type to search IP data"
value="{{ query }}"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
autofocus
>
</div>
</form>
<div class="error-banner" role="alert">
<p>{{ error_message }}</p>
</div>
</main>
<footer class="site-footer">
<div class="footer-inner">
<p class="footer-license">Website source code licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a></p>
<nav class="footer-links" aria-label="Project links">
<a href="https://github.com/NetworkCats/rustyip"><i class="icon-social-github" aria-hidden="true"></i> Website Source</a>
<span class="footer-sep" aria-hidden="true">|</span>
<a href="https://github.com/NetworkCats/Merged-IP-Data"><i class="icon-social-github" aria-hidden="true"></i> IP Database</a>
</nav>
</div>
</footer>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ status_code }} {{ status_text }} - {{ site_domain }}</title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;600&display=swap">
<link rel="stylesheet" href="/static/style.css?v={{ css_version }}">
</head>
<body>
<main>
<div class="error-page">
<h1 class="error-code">{{ status_code }}</h1>
<p class="error-status">{{ status_text }}</p>
<p class="error-detail">{{ error_message }}</p>
<a href="/" class="error-home-link">Go to Home</a>
</div>
</main>
</body>
</html>
+95
View File
@@ -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("<!DOCTYPE html>"));
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("<!DOCTYPE html>"));
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("<!DOCTYPE html>"));
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("<!DOCTYPE html>"));
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("<!DOCTYPE html>"));
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("<form"));
assert!(body.contains("value=\"bad-ip\""));
assert!(body.contains("autofocus"));
}