use ::axum::extract::Request;
use ::axum::middleware::Next;
use ::axum::response::{IntoResponse, Redirect, Response};
use ::http::{Method, Uri};
use mf2_catalog::Dir;
use super::negotiate::lookup;
use alloc::string::String;
use alloc::vec::Vec;
pub async fn path_prefix_redirect(request: Request, next: Next) -> Response {
let name = query_name(request.extensions());
if matches!(*request.method(), Method::GET | Method::HEAD)
&& let Some(to) = path_for_query(request.uri(), name, super::locales())
{
return Redirect::to(&to).into_response();
}
next.run(request).await
}
fn query_name(extensions: &::http::Extensions) -> &'static str {
let Some(request) = extensions.get::<super::layer::RequestLocale>() else {
crate::warn::once(crate::warn::Kind::RedirectOutside, || {
String::from(
"mf2: path_prefix_redirect ran on a request the Negotiator has not seen, so it \
reads the query parameter `lang`; add its .layer before the negotiator's, so \
that it runs under it",
)
});
return crate::links::LOCALE_QUERY;
};
request.query().unwrap_or(crate::links::LOCALE_QUERY)
}
pub(crate) fn path_for_query(
uri: &Uri,
param: &str,
locales: &[(&'static str, Dir)],
) -> Option<String> {
let rest = uri.path().strip_prefix('/')?;
let (first, tail) = match rest.split_once('/') {
Some((first, tail)) => (first, Some(tail)),
None => (rest, None),
};
let (current, _) = lookup(first, locales)?;
let mut wanted = None;
let mut kept: Vec<&str> = Vec::new();
for pair in uri.query()?.split('&') {
match pair.split_once('=') {
Some((name, value)) if name == param => {
if wanted.is_none() && !value.is_empty() {
wanted = Some(value);
}
}
_ if pair.is_empty() => {}
_ => kept.push(pair),
}
}
let (target, _) = lookup(wanted?, locales)?;
if target == current {
return None;
}
let mut to = String::with_capacity(uri.path().len() + 8);
to.push('/');
to.push_str(target);
if let Some(tail) = tail {
to.push('/');
to.push_str(tail);
}
if !kept.is_empty() {
to.push('?');
to.push_str(&kept.join("&"));
}
Some(to)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "a test")]
mod tests {
use super::{path_for_query, query_name};
use crate::warn::{Kind, given};
use ::http::Uri;
use alloc::string::String;
use mf2_catalog::Dir;
static LOCALES: &[(&str, Dir)] = &[("en", Dir::Ltr), ("fr", Dir::Ltr), ("ar", Dir::Rtl)];
fn to(uri: &str) -> Option<String> {
path_for_query(&uri.parse::<Uri>().expect("a URI"), "lang", LOCALES)
}
#[test]
fn a_query_that_disagrees_with_the_path_goes_to_its_url() {
assert_eq!(to("/en/about?lang=fr").as_deref(), Some("/fr/about"));
assert_eq!(
to("/en/a/b?x=1&lang=ar&y=2").as_deref(),
Some("/ar/a/b?x=1&y=2")
);
assert_eq!(to("/en/?lang=fr").as_deref(), Some("/fr/"));
assert_eq!(to("/en?lang=fr").as_deref(), Some("/fr"));
assert_eq!(to("/en-GB/about?lang=fr-CA").as_deref(), Some("/fr/about"));
}
#[test]
fn outside_the_negotiator_it_says_so_once() {
for _ in 0..3 {
assert_eq!(query_name(&::http::Extensions::new()), "lang");
}
let lines = given(Kind::RedirectOutside);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("path_prefix_redirect"));
}
#[test]
fn anything_else_passes_through() {
assert_eq!(to("/fr/about?lang=fr"), None);
assert_eq!(to("/about?lang=fr"), None);
assert_eq!(to("/?lang=fr"), None);
assert_eq!(to("/en/about"), None);
assert_eq!(to("/en/about?lang="), None);
assert_eq!(to("/en/about?lang=de"), None);
}
}