mirror of
https://github.com/NetworkCats/rustyip.git
synced 2026-11-13 16:17:46 +00:00
feat: add HTML error pages with browser alert popup for IP query errors
This commit is contained in:
+34
-7
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user