fix: resolve trailing slash 404 and untranslated escaped-quote strings

This commit is contained in:
NetworkCats
2026-03-04 20:48:59 +08:00
parent 17ea2a4c6a
commit 199384a280
8 changed files with 181 additions and 31 deletions
+14 -2
View File
@@ -461,6 +461,18 @@ pub async fn root_redirect(
Redirect::temporary(&redirect_path).into_response()
}
pub async fn root_trailing_slash(
Path(lang): Path<String>,
Query(query): Query<IpQuery>,
) -> Response {
let mut redirect_path = format!("/{lang}");
if let Some(ref ip) = query.ip {
redirect_path.push_str("?ip=");
redirect_path.push_str(&urlencoding::encode(ip));
}
Redirect::permanent(&redirect_path).into_response()
}
pub async fn root(
State(state): State<AppState>,
Path(lang): Path<String>,
@@ -759,12 +771,12 @@ pub async fn sitemap_xml(State(state): State<AppState>) -> Response {
for locale in Locale::ALL {
let tag = locale.tag();
urls.push_str(&format!(
" <url>\n <loc>https://{domain}/{tag}/</loc>\n"
" <url>\n <loc>https://{domain}/{tag}</loc>\n"
));
for alt in Locale::ALL {
let alt_tag = alt.tag();
urls.push_str(&format!(
" <xhtml:link rel=\"alternate\" hreflang=\"{alt_tag}\" href=\"https://{domain}/{alt_tag}/\"/>\n"
" <xhtml:link rel=\"alternate\" hreflang=\"{alt_tag}\" href=\"https://{domain}/{alt_tag}\"/>\n"
));
}
urls.push_str(
+72 -7
View File
@@ -185,13 +185,18 @@ fn insert_entry(catalog: &mut Catalog, msgid: &str, msgstr: &str, source: &'stat
if msgstr.is_empty() {
return;
}
// Find the msgstr in the static source to get a &'static str reference.
// This avoids allocating owned Strings at runtime.
let id_static = find_in_static(source, msgid);
let str_static = find_in_static(source, msgstr);
if let (Some(id), Some(s)) = (id_static, str_static) {
catalog.insert(id, s);
}
// Try to find the strings in the static source to get &'static str references,
// avoiding heap allocation. This works for most strings, but fails for strings
// containing escape sequences (e.g. \") since the unescaped text differs from
// the raw PO source. In that case, leak a heap-allocated copy. This is acceptable
// because translations are loaded once at startup.
let id_static = find_in_static(source, msgid).unwrap_or_else(|| leak_string(msgid));
let str_static = find_in_static(source, msgstr).unwrap_or_else(|| leak_string(msgstr));
catalog.insert(id_static, str_static);
}
fn leak_string(s: &str) -> &'static str {
Box::leak(s.to_owned().into_boxed_str())
}
fn find_in_static(source: &'static str, needle: &str) -> Option<&'static str> {
@@ -452,4 +457,64 @@ mod tests {
// static-reference optimization, but we can verify the PO files load.
let _ = translate(Locale::Es, "FAQ");
}
#[test]
fn translate_strings_with_escaped_quotes() {
// These strings contain \" in PO files. The parser must correctly unescape
// them and produce valid translations for all locales.
let msgid_rug_pull = "Rest assured, this service will definitely not \"Rug Pull.\"";
for locale in Locale::ALL {
let result = translate(*locale, msgid_rug_pull);
assert!(
!result.is_empty(),
"locale {:?} has no translation for Rug Pull string",
locale
);
if *locale != Locale::En {
assert_ne!(
result, msgid_rug_pull,
"locale {:?} should have a non-English translation for Rug Pull string",
locale
);
}
}
}
#[test]
fn translate_strings_with_html_href() {
// Strings containing <a href=\"...\"> have escaped quotes in PO files.
let msgid_api = "Our API is only suitable for manual calls or small-scale projects. If your website uses our API to query visitor IPs, please ensure you use a message queue to send requests to avoid blocking. If your project has high traffic or is latency-sensitive, please use our open-source offline database: <a href=\"https://github.com/NetworkCats/Merged-IP-Data\">Merged IP Database</a>, which is the same database used by this project.";
let msgid_data = "IP geographic data primarily comes from the free databases of MaxMind and DB-IP; AS data comes from IPinfo's free database; and IP proxy data comes from my own <a href=\"https://github.com/NetworkCats/OpenProxyDB\">OpenProxyDB</a> database.";
for locale in Locale::ALL {
let api_result = translate(*locale, msgid_api);
assert!(
!api_result.is_empty(),
"locale {:?} has no translation for API string",
locale
);
if *locale != Locale::En {
assert_ne!(
api_result, msgid_api,
"locale {:?} should have a non-English translation for API string",
locale
);
}
let data_result = translate(*locale, msgid_data);
assert!(
!data_result.is_empty(),
"locale {:?} has no translation for data source string",
locale
);
if *locale != Locale::En {
assert_ne!(
data_result, msgid_data,
"locale {:?} should have a non-English translation for data source string",
locale
);
}
}
}
}
+3 -6
View File
@@ -1,16 +1,12 @@
use axum::Router;
use axum::middleware;
use axum::routing::get;
use axum::Router;
use crate::handlers::{self, AppState};
use crate::middleware::security_headers;
use crate::static_assets;
pub fn build_router(state: AppState) -> Router {
let localized = Router::new()
.route("/", get(handlers::root))
.with_state(state.clone());
Router::new()
.route("/", get(handlers::root_redirect))
.route("/health", get(handlers::health))
@@ -28,7 +24,8 @@ 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))
.nest("/{lang}", localized)
.route("/{lang}", get(handlers::root))
.route("/{lang}/", get(handlers::root_trailing_slash))
.fallback(handlers::not_found)
.layer(middleware::from_fn(security_headers))
.with_state(state)
+1 -1
View File
@@ -2,7 +2,7 @@
<label for="lang-select" class="sr-only">{{ t_language }}</label>
<select id="lang-select" class="lang-select" autocomplete="off" onchange="if(this.value)window.location.href=this.value">
{% for l in all_locales %}
<option value="/{{ l.tag() }}/"{% if *l == locale %} selected{% endif %}>{{ l.name() }}</option>
<option value="/{{ l.tag() }}"{% if *l == locale %} selected{% endif %}>{{ l.name() }}</option>
{% endfor %}
</select>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<form method="GET" action="/{{ lang_tag }}/" class="search-form" role="search">
<form method="GET" action="/{{ lang_tag }}" class="search-form" role="search">
<div class="search-input-wrap">
<label for="ip-search" class="sr-only">{{ t_search_label }}</label>
<i class="icon-magnifier search-icon" aria-hidden="true"></i>
+1 -1
View File
@@ -11,6 +11,6 @@
<h1 class="error-code">{{ status_code }}</h1>
<p class="error-status">{{ status_text }}</p>
<p class="error-detail">{{ error_message }}</p>
<a href="/{{ locale.tag() }}/" class="error-home-link">{{ t_go_home }}</a>
<a href="/{{ locale.tag() }}" class="error-home-link">{{ t_go_home }}</a>
</div>
{% endblock %}
+3 -3
View File
@@ -4,18 +4,18 @@
{% block head_meta %}
<meta name="description" content="{{ t_description }}">
<link rel="canonical" href="https://{{ site_domain }}/{{ lang_tag }}/">
<link rel="canonical" href="https://{{ site_domain }}/{{ lang_tag }}">
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0A84FF" media="(prefers-color-scheme: dark)">
<meta property="og:title" content="{{ t_title }} - {{ site_domain }}">
<meta property="og:description" content="{{ t_description }}">
<meta property="og:type" content="website">
<meta property="og:url" content="https://{{ site_domain }}/{{ lang_tag }}/">
<meta property="og:url" content="https://{{ site_domain }}/{{ lang_tag }}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ t_title }} - {{ site_domain }}">
<meta name="twitter:description" content="{{ t_description }}">
{% for l in all_locales %}
<link rel="alternate" hreflang="{{ l.tag() }}" href="https://{{ site_domain }}/{{ l.tag() }}/">
<link rel="alternate" hreflang="{{ l.tag() }}" href="https://{{ site_domain }}/{{ l.tag() }}">
{% endfor %}
<link rel="alternate" hreflang="x-default" href="https://{{ site_domain }}/">
{% endblock %}
+86 -10
View File
@@ -546,9 +546,9 @@ async fn sitemap_xml_returns_valid_response() {
let (status, body) = get(&app, "/sitemap.xml").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<?xml version=\"1.0\""));
assert!(body.contains("<loc>https://test.example.com/en/</loc>"));
assert!(body.contains("<loc>https://test.example.com/es/</loc>"));
assert!(body.contains("<loc>https://test.example.com/ja/</loc>"));
assert!(body.contains("<loc>https://test.example.com/en</loc>"));
assert!(body.contains("<loc>https://test.example.com/es</loc>"));
assert!(body.contains("<loc>https://test.example.com/ja</loc>"));
assert!(body.contains("hreflang=\"en\""));
assert!(body.contains("hreflang=\"x-default\""));
assert!(body.contains("hreflang=\"zh-Hant\""));
@@ -622,11 +622,11 @@ async fn html_contains_language_selector() {
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("lang-select"));
assert!(body.contains("value=\"/en/\""));
assert!(body.contains("value=\"/es/\""));
assert!(body.contains("value=\"/ja/\""));
assert!(body.contains("value=\"/zh-Hant/\""));
assert!(body.contains("value=\"/ar/\""));
assert!(body.contains("value=\"/en\""));
assert!(body.contains("value=\"/es\""));
assert!(body.contains("value=\"/ja\""));
assert!(body.contains("value=\"/zh-Hant\""));
assert!(body.contains("value=\"/ar\""));
}
#[tokio::test]
@@ -639,7 +639,7 @@ async fn search_form_posts_to_lang_path() {
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("action=\"/fr/\""));
assert!(body.contains("action=\"/fr\""));
}
#[tokio::test]
@@ -840,6 +840,53 @@ async fn error_alert_page_has_search_form() {
assert!(body.contains("autofocus"));
}
// --- Trailing slash tests ---
#[tokio::test]
async fn trailing_slash_redirects_to_canonical() {
let app = build_test_app_with_dev_mode(true);
let tags = [
"en", "es", "de", "ja", "ko", "id", "fr", "ru", "pt", "it", "zh-Hant", "zh-Hans", "nl",
"ar",
];
for tag in tags {
let uri = format!("/{tag}/");
let response = get_response(&app, &uri, vec![("User-Agent", "Mozilla/5.0")]).await;
assert_eq!(
response.status(),
StatusCode::PERMANENT_REDIRECT,
"/{tag}/ should redirect with 308, got {}",
response.status()
);
let location = response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap();
assert_eq!(
location,
format!("/{tag}"),
"/{tag}/ should redirect to /{tag}"
);
}
}
#[tokio::test]
async fn trailing_slash_preserves_query_params() {
let app = build_test_app_with_dev_mode(true);
let response =
get_response(&app, "/de/?ip=1.2.3.4", vec![("User-Agent", "Mozilla/5.0")]).await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
let location = response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap();
assert_eq!(location, "/de?ip=1.2.3.4");
}
// --- i18n-specific tests ---
#[tokio::test]
@@ -897,5 +944,34 @@ async fn error_page_has_localized_go_home_link() {
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("Go to Home"));
assert!(body.contains("href=\"/en/\""));
assert!(body.contains("href=\"/en\""));
}
#[tokio::test]
async fn german_page_contains_translated_escaped_quote_strings() {
let app = build_test_app_with_dev_mode(true);
let (status, body) = get_with_headers(
&app,
"/de",
vec![("User-Agent", "Mozilla/5.0")],
)
.await;
assert_eq!(status, StatusCode::OK);
// These strings were previously untranslated due to escaped quotes in PO files.
assert!(
!body.contains("Rest assured, this service will definitely not"),
"German page should not contain English Rug Pull string"
);
assert!(
body.contains("Rug Pull"),
"German page should still contain Rug Pull term"
);
assert!(
!body.contains("Our API is only suitable for manual calls"),
"German page should not contain English API string"
);
assert!(
!body.contains("IP geographic data primarily comes from"),
"German page should not contain English data source string"
);
}