mirror of
https://github.com/NetworkCats/rustyip.git
synced 2026-11-14 08:14:37 +00:00
fix: resolve trailing slash 404 and untranslated escaped-quote strings
This commit is contained in:
+14
-2
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user