diff --git a/.gitattributes b/.gitattributes index 9fd71a11cb..b3103a1d79 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,7 @@ # Excludes locale files that have been automatically trimmed during prepr # from the diff viewer + diff calculation on GitHub. **/locales/*/index.json linguist-generated=true -!**/locales/en-US/index.json linguist-generated +**/locales/en-US/index.json !linguist-generated # SQLx calculates a checksum of migration scripts at build time to compare # it with the checksum of the applied migration for the same version at diff --git a/.gitignore b/.gitignore index d442251099..5eecee60aa 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ apps/frontend/src/public/robots.txt /test_result.md packages/tooling-config/script-utils/import-sort.js +references/ diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 132ed5131b..1f55e846d9 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -13,7 +13,7 @@ use crate::util::error::{ApiContext, Context}; use actix_cors::Cors; use actix_files::Files; use actix_web::http::{StatusCode, header}; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{FromRequest, HttpRequest, HttpResponse, web}; use ariadne::ids::base62_impl::parse_base62; use futures::FutureExt; use std::collections::{HashMap, HashSet}; @@ -354,7 +354,23 @@ pub async fn redirect_ref( session_queue: &AuthQueue, required_scopes: Scopes, ) -> Result, ApiError> { - let Some(project_ref) = req.match_info().get(parameter_name) else { + let Some(parameter_index) = req + .match_info() + .iter() + .position(|(name, _)| name == parameter_name) + else { + return Ok(None); + }; + // `match_info` preserves encoded `+`, `/`, and `%`; the path extractor fully decodes them. + let parameters = + web::Path::>::extract(req) + .await + .map_err(|err| { + ApiError::Request(eyre::eyre!( + "decoding project redirect path: {err}" + )) + })?; + let Some(project_ref) = parameters.get(parameter_index) else { return Ok(None); }; let Some(target_project_id) = resolve_visible_refs( diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 978638dbd5..368785c325 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -370,6 +370,59 @@ async fn project_slug_edit_invalidates_redirect_cache() { .await; } +#[actix_rt::test] +async fn project_redirect_accepts_literal_and_encoded_plus() { + with_test_environment_all(None, |env| async move { + let target_id = &env.dummy.project_alpha.project_id; + let target = parse_base62(target_id).unwrap(); + let percent_target_id = &env.dummy.project_beta.project_id; + let percent_target = parse_base62(percent_target_id).unwrap(); + let fixture_sql = format!( + r#"INSERT INTO project_redirects (slug, target_project_id) + VALUES ('redirect+alias', {target}), ('redirect%2balias', {percent_target})"# + ); + sqlx::raw_sql(&fixture_sql) + .execute(&env.db.pool) + .await + .unwrap(); + + for project_ref in [ + "redirect+alias", + "redirect%2Balias", + "redirect%2balias", + "redirect%252Balias", + ] { + for suffix in ["", "/check", "/check?test=foo%2Bbar"] { + let response = env + .api + .get_project( + &format!("{project_ref}{suffix}"), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let expected_target = if project_ref == "redirect%252Balias" { + percent_target_id + } else { + target_id + }; + assert!( + location.ends_with(&format!( + "/project/{expected_target}{suffix}" + )) + ); + } + } + }) + .await; +} + #[actix_rt::test] async fn project_redirect_cache_preserves_case_sensitive_ids() { with_test_environment_all(None, |env| async move {