use axum::extract::{Request, State};
use axum::http::{HeaderValue, Uri, header};
use axum::middleware::Next;
use axum::response::Response;
use crate::ServerUrl;
pub(crate) fn path_of(url: Option<&str>) -> String {
let Some(url) = url else {
return String::new();
};
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let rest = rest.split(['?', '#']).next().unwrap_or("");
match rest.find('/') {
Some(at) => rest[at..].trim_end_matches('/').to_owned(),
None => String::new(),
}
}
pub(crate) fn href(url: Option<&str>) -> String {
format!("{}/", path_of(url))
}
const ROUTES: &[&str] = &[
".well-known",
"account",
"album",
"albums",
"apple-touch-icon.png",
"artist",
"artists",
"auth",
"connect",
"favicon.ico",
"favourite",
"favourites",
"graphql",
"history",
"keys",
"library",
"login",
"mcp",
"oauth",
"pair",
"playlist",
"playlists",
"queue",
"recent",
"rest",
"scrobbling",
"search",
"setup",
"share",
"track",
"tracks",
"ui",
"users",
];
pub(crate) fn problem(url: &str) -> Option<String> {
let path = path_of(Some(url));
if !path
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"/-._~".contains(&b))
{
return Some(format!(
"the path {path:?} may use only letters, digits, '/', '-', '.', '_' and '~'"
));
}
let first = path.trim_start_matches('/').split('/').next().unwrap_or("");
ROUTES.contains(&first).then(|| {
format!("the path {path:?} begins with /{first}, which is one of kōan's own routes")
})
}
#[derive(Clone)]
pub(crate) enum Base {
Fixed(std::sync::Arc<str>),
Live,
}
impl Base {
pub(crate) fn of(url: &ServerUrl) -> Self {
match url {
ServerUrl::Fixed(url) => Self::Fixed(path_of(url.as_deref()).into()),
ServerUrl::Live => Self::Live,
}
}
fn get(&self) -> std::borrow::Cow<'_, str> {
match self {
Self::Fixed(path) => std::borrow::Cow::Borrowed(path),
Self::Live => path_of(ServerUrl::Live.get().as_deref()).into(),
}
}
}
pub(crate) async fn edge(State(base): State<Base>, mut req: Request, next: Next) -> Response {
let base = base.get();
if base.is_empty() {
return next.run(req).await;
}
if let Some(uri) = strip(req.uri(), &base) {
*req.uri_mut() = uri;
}
let mut response = next.run(req).await;
restore(&mut response, &base);
response
}
fn strip(uri: &Uri, base: &str) -> Option<Uri> {
let rest = uri.path().strip_prefix(base)?;
let path = match rest {
"" => "/",
rest if rest.starts_with('/') => rest,
_ => return None,
};
let mut parts = uri.clone().into_parts();
let path_and_query = match uri.query() {
Some(q) => format!("{path}?{q}"),
None => path.to_owned(),
};
parts.path_and_query = Some(path_and_query.parse().ok()?);
Uri::from_parts(parts).ok()
}
fn restore(response: &mut Response, base: &str) {
let headers = response.headers_mut();
if let Some(location) = headers.get(header::LOCATION).and_then(|v| v.to_str().ok())
&& location.starts_with('/')
&& !location.starts_with("//")
&& let Ok(v) = HeaderValue::from_str(&format!("{base}{location}"))
{
headers.insert(header::LOCATION, v);
}
let cookies: Vec<HeaderValue> = headers
.get_all(header::SET_COOKIE)
.iter()
.flat_map(|v| {
let Some(c) = v.to_str().ok() else {
return vec![v.clone()];
};
let scoped = HeaderValue::from_str(&cookie_path(c, base)).unwrap_or_else(|_| v.clone());
if c.to_ascii_lowercase().contains("max-age=0") {
vec![scoped, v.clone()]
} else {
vec![scoped]
}
})
.collect();
if !cookies.is_empty() {
headers.remove(header::SET_COOKIE);
for c in cookies {
headers.append(header::SET_COOKIE, c);
}
}
}
fn cookie_path(cookie: &str, base: &str) -> String {
cookie
.split(';')
.map(|attr| {
let trimmed = attr.trim_start();
match trimmed.get(..5) {
Some(name) if name.eq_ignore_ascii_case("path=") => {
let path = &trimmed[5..];
let lead = &attr[..attr.len() - trimmed.len()];
match path {
"/" => format!("{lead}Path={base}"),
p if p.starts_with('/') => format!("{lead}Path={base}{p}"),
p => format!("{lead}Path={p}"),
}
}
_ => attr.to_owned(),
}
})
.collect::<Vec<_>>()
.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::routing::get;
use tower::ServiceExt;
#[test]
fn the_path_comes_from_the_address() {
assert_eq!(path_of(None), "");
assert_eq!(path_of(Some("https://host")), "");
assert_eq!(path_of(Some("https://host/")), "");
assert_eq!(path_of(Some("https://host/koan")), "/koan");
assert_eq!(path_of(Some("https://host/koan/")), "/koan");
assert_eq!(path_of(Some("https://host:8443/a/b?x=1")), "/a/b");
assert_eq!(href(Some("https://host")), "/");
assert_eq!(href(Some("https://host/koan")), "/koan/");
}
#[test]
fn a_path_that_routes_could_mistake_is_refused() {
assert_eq!(problem("https://host"), None);
assert_eq!(problem("https://host/koan/music"), None);
assert!(problem("https://host/library").is_some());
assert!(problem("https://host/ui/koan").is_some());
assert!(problem("https://host/{koan}").is_some());
assert!(problem("https://host/music/*x").is_some());
}
#[tokio::test]
async fn a_cleared_cookie_is_cleared_at_the_root_too() {
let mut r = Response::new(axum::body::Body::empty());
r.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_static("a=; Path=/; Max-Age=0"),
);
restore(&mut r, "/koan");
let set: Vec<_> = r.headers().get_all(header::SET_COOKIE).iter().collect();
assert_eq!(set, ["a=; Path=/koan; Max-Age=0", "a=; Path=/; Max-Age=0"]);
}
#[test]
fn cookies_are_scoped_under_the_path() {
assert_eq!(
cookie_path("r=1; HttpOnly; Path=/auth; Max-Age=3", "/koan"),
"r=1; HttpOnly; Path=/koan/auth; Max-Age=3"
);
assert_eq!(cookie_path("s=1; Path=/", "/koan"), "s=1; Path=/koan");
assert_eq!(cookie_path("s=1; Secure", "/koan"), "s=1; Secure");
}
fn app() -> axum::Router {
axum::Router::new()
.route("/", get(|| async { "root" }))
.route(
"/go",
get(|uri: Uri| async move {
(
axum::http::StatusCode::SEE_OTHER,
[
(header::LOCATION, "/login?next=/go".to_owned()),
(header::SET_COOKIE, "a=1; Path=/auth".to_owned()),
],
uri.to_string(),
)
}),
)
}
async fn call(base: &str, path: &str) -> Response {
let url = ServerUrl::Fixed(Some(format!("https://host{base}")));
let wrapped = tower::Layer::layer(
&axum::middleware::from_fn_with_state(Base::of(&url), edge),
app(),
);
wrapped
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap()
}
async fn text(r: Response) -> String {
let bytes = axum::body::to_bytes(r.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn a_forwarded_path_and_a_stripped_one_reach_the_same_route() {
for path in ["/koan/go?x=1", "/go?x=1"] {
let r = call("/koan", path).await;
assert_eq!(r.headers()[header::LOCATION], "/koan/login?next=/go");
assert_eq!(r.headers()[header::SET_COOKIE], "a=1; Path=/koan/auth");
assert_eq!(text(r).await, "/go?x=1");
}
assert_eq!(text(call("/koan", "/koan").await).await, "root");
assert_eq!(text(call("/koan", "/koan/").await).await, "root");
}
#[tokio::test]
async fn a_neighbouring_path_is_not_taken_for_the_base() {
let r = call("/koan", "/koanx/go").await;
assert_eq!(r.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn at_the_root_nothing_changes() {
let r = call("", "/go").await;
assert_eq!(r.headers()[header::LOCATION], "/login?next=/go");
assert_eq!(r.headers()[header::SET_COOKIE], "a=1; Path=/auth");
}
}