use axum::{
Extension, Router,
extract::{Path, RawQuery},
response::IntoResponse,
routing::get,
};
use super::*;
pub(super) fn routes(config: Config) -> Router {
Router::new()
.route("/v1", get(home))
.route("/v1/contract/web/{key}", get(web_root_redirect_v1))
.route("/v1/contract/web/{key}/", get(web_home_v1))
.route("/v1/contract/web/{key}/{*path}", get(web_subpages_v1))
.with_state(config)
}
async fn web_home_v1(
key: Path<String>,
rs: Extension<HttpClientApiRequest>,
config: axum::extract::State<Config>,
headers: axum::http::HeaderMap,
axum::extract::RawQuery(query): axum::extract::RawQuery,
) -> Result<axum::response::Response, WebSocketApiError> {
web_home(key, rs, config, headers, ApiVersion::V1, query).await
}
async fn web_subpages_v1(
Path((key, last_path)): Path<(String, String)>,
axum::extract::RawQuery(query): axum::extract::RawQuery,
headers: axum::http::HeaderMap,
Extension(rs): Extension<HttpClientApiRequest>,
) -> Result<axum::response::Response, WebSocketApiError> {
web_subpages(key, last_path, ApiVersion::V1, query, headers, rs).await
}
async fn web_root_redirect_v1(
Path(key): Path<String>,
RawQuery(query): RawQuery,
) -> Result<axum::response::Response, WebSocketApiError> {
let canonical = build_canonical_shell_url(&key, ApiVersion::V1, query.as_deref())?;
Ok(axum::response::Redirect::permanent(&canonical).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{StatusCode, header::LOCATION};
fn valid_key() -> &'static str {
"EqJ5YpEEV3XLqEvKWLQHFhGAac2qXzSUoE6k2zbdnXBr"
}
#[tokio::test]
async fn no_trailing_slash_redirects_to_canonical_root_v1() {
let response = web_root_redirect_v1(Path(valid_key().to_string()), RawQuery(None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
let location = response
.headers()
.get(LOCATION)
.expect("redirect must set Location")
.to_str()
.unwrap();
assert_eq!(
location,
format!("/v1/contract/web/{}/", valid_key()),
"Location must be the canonical trailing-slash form"
);
}
#[tokio::test]
async fn no_trailing_slash_redirect_preserves_query_string_v1() {
let response = web_root_redirect_v1(
Path(valid_key().to_string()),
RawQuery(Some("invite=abc&room=42".into())),
)
.await
.unwrap();
let location = response
.headers()
.get(LOCATION)
.expect("redirect must set Location")
.to_str()
.unwrap();
assert_eq!(
location,
format!("/v1/contract/web/{}/?invite=abc&room=42", valid_key()),
"query string must be carried through the redirect so \
`?invite=…` no-slash links keep working"
);
}
#[tokio::test]
async fn no_trailing_slash_redirect_strips_sensitive_query_params_v1() {
let response = web_root_redirect_v1(
Path(valid_key().to_string()),
RawQuery(Some("authToken=evil&invite=ok&__sandbox=1".into())),
)
.await
.unwrap();
let location = response.headers().get(LOCATION).unwrap().to_str().unwrap();
assert!(
location.contains("invite=ok"),
"non-sensitive query params must survive: {location}"
);
assert!(
!location.contains("authToken"),
"authToken must be stripped: {location}"
);
assert!(
!location.contains("__sandbox"),
"__sandbox must be stripped: {location}"
);
}
#[tokio::test]
async fn no_trailing_slash_redirect_rejects_invalid_key_v1() {
assert!(matches!(
web_root_redirect_v1(Path("AAAA\r\nInjected: x".into()), RawQuery(None)).await,
Err(WebSocketApiError::InvalidParam { .. })
));
assert!(matches!(
web_root_redirect_v1(Path("not-a-real-contract-key".into()), RawQuery(None)).await,
Err(WebSocketApiError::InvalidParam { .. })
));
assert!(matches!(
web_root_redirect_v1(Path(String::new()), RawQuery(None)).await,
Err(WebSocketApiError::InvalidParam { .. })
));
}
}