From 12157e3ffe99be6c6f3d8d1485b9d28e4960c0e8 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:20:32 +0100 Subject: [PATCH 1/8] HTTP redirects for v3 --- apps/labrinth/src/routes/mod.rs | 64 +++++++- apps/labrinth/src/routes/v3/disclosures.rs | 32 +++- apps/labrinth/src/routes/v3/images.rs | 14 ++ apps/labrinth/src/routes/v3/organizations.rs | 11 ++ .../src/routes/v3/project_creation.rs | 12 +- apps/labrinth/src/routes/v3/projects/mod.rs | 143 +++++++++++++++++- .../src/routes/v3/projects/validate.rs | 13 +- apps/labrinth/src/routes/v3/teams.rs | 11 ++ apps/labrinth/src/routes/v3/versions.rs | 22 +++ 9 files changed, 306 insertions(+), 16 deletions(-) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 04d5ea5344b..8c46fe7a54e 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -62,7 +62,9 @@ pub async fn resolve_refs( let keys = project_refs .iter() .map(|project_ref| { - redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, project_ref) + redis + .key() + .entity(PROJECT_REDIRECTS_NAMESPACE, project_ref.to_lowercase()) }) .collect::>(); let cached_targets = redis @@ -140,6 +142,34 @@ pub async fn resolve_refs( Ok(resolved) } +pub async fn clear_project_redirect_cache( + project_refs: &[String], + redis: &RedisPool, +) -> Result<(), ApiError> { + if project_refs.is_empty() { + return Ok(()); + } + + let mut redis = redis + .connect() + .await + .wrap_internal_err("connecting to Redis to clear project redirects")?; + let keys = project_refs + .iter() + .map(|project_ref| { + redis + .key() + .entity(PROJECT_REDIRECTS_NAMESPACE, project_ref.to_lowercase()) + }) + .collect::>(); + redis + .delete_many(&keys) + .await + .wrap_internal_err("clearing cached project redirects")?; + + Ok(()) +} + pub async fn redirect_query_refs( req: &HttpRequest, parameter_name: &str, @@ -183,6 +213,38 @@ pub async fn redirect_query_refs( )) } +pub async fn redirect_query_ref( + req: &HttpRequest, + parameter_name: &str, + project_ref: &str, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let Some(target_project_id) = resolve_ref(project_ref, pool, redis).await? + else { + return Ok(None); + }; + + let target_project_id = target_project_id.to_string(); + let mut query = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in + url::form_urlencoded::parse(req.query_string().as_bytes()) + { + if name == parameter_name { + query.append_pair(&name, &target_project_id); + } else { + query.append_pair(&name, &value); + } + } + let location = format!("{}?{}", req.path(), query.finish()); + + Ok(Some( + HttpResponse::PermanentRedirect() + .append_header((header::LOCATION, location)) + .finish(), + )) +} + pub async fn redirect_ref( req: &HttpRequest, parameter_name: &str, diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 9923e7b36e0..f845399ee2d 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpRequest, get, patch, web}; +use actix_web::{HttpRequest, HttpResponse, get, patch, web}; use chrono::Utc; use eyre::eyre; use serde::{Deserialize, Serialize}; @@ -45,7 +45,18 @@ pub async fn get_project_disclosures( ro_pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result, ApiError> { +) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let (string,) = info.into_inner(); let project = db_models::DBProject::get(&string, &***ro_pool, &redis) @@ -88,7 +99,7 @@ pub async fn get_project_disclosures( .await .wrap_internal_err("failed to fetch project disclosures")?; - Ok(web::Json(GetProjectDisclosures { + Ok(HttpResponse::Ok().json(GetProjectDisclosures { disclosures: disclosures .into_iter() .map(|disclosure| { @@ -125,7 +136,18 @@ pub async fn modify_project_disclosures( search_state: web::Data, session_queue: web::Data, body: web::Json, -) -> Result<(), ApiError> { +) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let (string,) = info.into_inner(); let body = body.into_inner(); @@ -304,5 +326,5 @@ pub async fn modify_project_disclosures( .push_project_change(project.inner.id.into()) .await; - Ok(()) + Ok(HttpResponse::Ok().finish()) } diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index 4e2c903062d..0b2ff4bee55 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -63,6 +63,20 @@ pub async fn images_add( ) -> Result { let mut context = ImageContext::from_str(&data.context, None); + if matches!(&context, ImageContext::Project { .. }) + && let Some(project_ref) = data.project_id.as_deref() + && let Some(response) = crate::routes::redirect_query_ref( + &req, + "project_id", + project_ref, + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let user = get_user_from_headers( &req, &**pool, diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index f55236ce874..75de18ad494 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -1106,6 +1106,17 @@ pub async fn organization_projects_remove( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let (organization_id, project_id) = info.into_inner(); let current_user = get_user_from_headers( &req, diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index a604f3f6902..f02d301f22d 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -374,11 +374,15 @@ pub async fn project_create_internal( } } else { transaction.commit().await?; + let slug = + models::DBProject::get_id(project_id.into(), &**client, &redis) + .await? + .and_then(|project| project.inner.slug); super::projects::clear_project_cache_and_queue_search( &redis, &search_state, project_id.into(), - None, + slug, None, ) .await?; @@ -440,11 +444,15 @@ pub async fn project_create_with_id( } } else { transaction.commit().await?; + let slug = + models::DBProject::get_id(project_id.into(), &**client, &redis) + .await? + .and_then(|project| project.inner.slug); super::projects::clear_project_cache_and_queue_search( &redis, &search_state, project_id.into(), - None, + slug, None, ) .await?; diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index e9e9b962212..274eb45b426 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -90,6 +90,12 @@ pub async fn clear_project_cache_and_queue_search( slug: Option, clear_dependencies: Option, ) -> Result<(), ApiError> { + let mut project_refs = vec![ProjectId::from(project_id).to_string()]; + if let Some(slug) = &slug { + project_refs.push(slug.clone()); + } + crate::routes::clear_project_redirect_cache(&project_refs, redis).await?; + db_models::DBProject::clear_cache( project_id, slug, @@ -244,6 +250,20 @@ pub async fn projects_get_route( redis: web::Data, session_queue: web::Data, ) -> Result { + let project_refs = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing project references")?; + if let Some(response) = crate::routes::redirect_query_refs( + &req, + "ids", + &project_refs, + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + projects_get(req, ids, pool, redis, session_queue).await } @@ -291,8 +311,17 @@ pub async fn project_get( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result, ApiError> { - project_get_internal(req, info, pool, redis, session_queue).await +) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + + let project = + project_get_internal(req, info, pool, redis, session_queue).await?; + Ok(HttpResponse::Ok().json(project.into_inner())) } pub async fn project_get_internal( @@ -429,6 +458,13 @@ pub async fn project_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + project_edit_internal( req, info, @@ -1404,6 +1440,13 @@ pub async fn project_edit_internal( .await .wrap_internal_err("committing database transaction")?; + let mut project_refs = + vec![ProjectId::from(project_item.inner.id).to_string()]; + if let Some(slug) = &reloaded_project.inner.slug { + project_refs.push(slug.clone()); + } + crate::routes::clear_project_redirect_cache(&project_refs, &redis).await?; + if became_unsearchable { db_models::DBProject::clear_cache( project_item.inner.id, @@ -1691,10 +1734,18 @@ pub async fn project_search_post( )] #[get("/{id}/check")] pub async fn project_get_check( + req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + project_get_check_internal(info, pool, redis).await } @@ -1738,6 +1789,17 @@ pub async fn dependency_list( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + dependency_list_internal(req, info, pool, ro_pool, redis, session_queue) .await } @@ -1893,6 +1955,20 @@ pub async fn projects_edit_route( session_queue: web::Data, search_state: web::Data, ) -> Result { + let project_refs = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing project references")?; + if let Some(response) = crate::routes::redirect_query_refs( + &req, + "ids", + &project_refs, + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + projects_edit( req, ids, @@ -2291,6 +2367,13 @@ pub async fn project_icon_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + project_icon_edit_internal( web::Query(ext), req, @@ -2447,6 +2530,13 @@ pub async fn delete_project_icon( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + delete_project_icon_internal( req, info, @@ -2600,6 +2690,13 @@ pub async fn add_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + add_gallery_item_internal( web::Query(ext), req, @@ -3230,9 +3327,24 @@ pub async fn project_delete( redis: web::Data, session_queue: web::Data, search_state: web::Data, -) -> Result<(), ApiError> { - project_delete_internal(req, info, pool, redis, session_queue, search_state) - .await +) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + + project_delete_internal( + req, + info, + pool, + redis, + session_queue, + search_state, + ) + .await?; + Ok(HttpResponse::Ok().finish()) } pub async fn project_delete_internal( @@ -3543,6 +3655,13 @@ pub async fn project_follow( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + project_follow_internal(req, info, pool, redis, session_queue).await } @@ -3648,6 +3767,13 @@ pub async fn project_unfollow( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + project_unfollow_internal(req, info, pool, redis, session_queue).await } @@ -3745,6 +3871,13 @@ pub async fn project_get_organization( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + let current_user = get_user_from_headers( &req, &**pool, diff --git a/apps/labrinth/src/routes/v3/projects/validate.rs b/apps/labrinth/src/routes/v3/projects/validate.rs index c788fbeed7c..c427597a94b 100644 --- a/apps/labrinth/src/routes/v3/projects/validate.rs +++ b/apps/labrinth/src/routes/v3/projects/validate.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpRequest, get, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use eyre::eyre; use serde::Serialize; use xredis::RedisPool; @@ -134,7 +134,14 @@ pub async fn validate( ro_pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result, ApiError> { +) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + let user = get_user_from_headers( &req, &**pool, @@ -206,5 +213,5 @@ pub async fn validate( }) .await .wrap_internal_err("validating project")?; - Ok(web::Json(ProjectValidationResponse { nags })) + Ok(HttpResponse::Ok().json(ProjectValidationResponse { nags })) } diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index fe371e404a5..187d5f3c08d 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -48,6 +48,17 @@ pub async fn team_members_get_project( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + team_members_get_project_internal(req, info, pool, redis, session_queue) .await } diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index ff5022fa4c5..b15faa70456 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -68,6 +68,17 @@ pub async fn version_project_get( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let info = info.into_inner(); version_project_get_helper(req, info, pool, ro_pool, redis, session_queue) .await @@ -1047,6 +1058,17 @@ pub async fn version_list( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + version_list_internal( req, info, From 4ab33b1ba8f95d56a50d3af698c0531a07b30885 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:53:53 +0100 Subject: [PATCH 2/8] move v3 gallery item routes --- .../src/pages/[type]/[project]/settings/index.vue | 4 ++-- apps/labrinth/src/routes/v3/projects/mod.rs | 12 +++++------- apps/labrinth/src/test/api_v3/project.rs | 8 ++++---- .../api-client/src/modules/labrinth/projects/v3.ts | 9 ++++----- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue index 5ea4cb0e28c..bc5abc48d14 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/index.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/index.vue @@ -660,7 +660,7 @@ const uploadBanner = async () => { try { const existingBanner = project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME) if (existingBanner) { - await labrinth.projects_v3.deleteGalleryImage(project.value.id, existingBanner.url) + await labrinth.projects_v3.deleteGalleryImage(existingBanner.url) } const ext = bannerFile.value.type.split('/').pop() ?? 'png' @@ -689,7 +689,7 @@ const deleteBanner = async () => { try { const bannerImage = project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME) if (bannerImage) { - await labrinth.projects_v3.deleteGalleryImage(project.value.id, bannerImage.url) + await labrinth.projects_v3.deleteGalleryImage(bannerImage.url) await invalidate() addNotification({ title: 'Banner removed', diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 274eb45b426..46aa7aa34d2 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -60,7 +60,9 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(project_search_post) .service(projects_get_route) .service(projects_edit_route) - .service(random_projects_get_route); + .service(random_projects_get_route) + .service(edit_gallery_item) + .service(delete_gallery_item); } pub fn project_config(cfg: &mut actix_web::web::ServiceConfig) { @@ -71,8 +73,6 @@ pub fn project_config(cfg: &mut actix_web::web::ServiceConfig) { .service(project_icon_edit) .service(delete_project_icon) .service(add_gallery_item) - .service(edit_gallery_item) - .service(delete_gallery_item) .service(project_follow) .service(project_unfollow) .service(project_get_organization) @@ -2919,7 +2919,6 @@ pub struct GalleryEditQuery { /// Update a gallery item. #[utoipa::path( - context_path = "/project", tag = "projects", params( ("url" = String, Query), @@ -2930,7 +2929,7 @@ pub struct GalleryEditQuery { ), responses((status = NO_CONTENT)) )] -#[patch("/{id}/gallery")] +#[patch("/project-gallery")] pub async fn edit_gallery_item( req: HttpRequest, web::Query(item): web::Query, @@ -3151,14 +3150,13 @@ pub struct GalleryDeleteQuery { /// Delete a gallery item. #[utoipa::path( - context_path = "/project", tag = "projects", params( ("url" = String, Query) ), responses((status = NO_CONTENT)) )] -#[delete("/{id}/gallery")] +#[delete("/project-gallery")] pub async fn delete_gallery_item( req: HttpRequest, web::Query(item): web::Query, diff --git a/apps/labrinth/src/test/api_v3/project.rs b/apps/labrinth/src/test/api_v3/project.rs index 01eb43b14bb..26695b204c7 100644 --- a/apps/labrinth/src/test/api_v3/project.rs +++ b/apps/labrinth/src/test/api_v3/project.rs @@ -380,13 +380,13 @@ impl ApiProject for ApiV3 { async fn edit_gallery_item( &self, - id_or_slug: &str, + _id_or_slug: &str, image_url: &str, patch: HashMap, pat: Option<&str>, ) -> ServiceResponse { let mut url = format!( - "/v3/project/{id_or_slug}/gallery?url={image_url}", + "/v3/project-gallery?url={image_url}", image_url = urlencoding::encode(image_url) ); @@ -409,12 +409,12 @@ impl ApiProject for ApiV3 { async fn remove_gallery_item( &self, - id_or_slug: &str, + _id_or_slug: &str, url: &str, pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::delete() - .uri(&format!("/v3/project/{id_or_slug}/gallery?url={url}")) + .uri(&format!("/v3/project-gallery?url={url}")) .append_pat(pat) .to_request(); diff --git a/packages/api-client/src/modules/labrinth/projects/v3.ts b/packages/api-client/src/modules/labrinth/projects/v3.ts index 9c1832ba8de..694cdde84da 100644 --- a/packages/api-client/src/modules/labrinth/projects/v3.ts +++ b/packages/api-client/src/modules/labrinth/projects/v3.ts @@ -279,18 +279,17 @@ export class LabrinthProjectsV3Module extends AbstractModule { } /** - * Delete a gallery image from a project + * Delete a gallery image * - * @param id - Project ID or slug * @param url - URL of the gallery image to delete * * @example * ```typescript - * await client.labrinth.projects_v3.deleteGalleryImage('sodium', 'https://cdn.modrinth.com/...') + * await client.labrinth.projects_v3.deleteGalleryImage('https://cdn.modrinth.com/...') * ``` */ - public async deleteGalleryImage(id: string, url: string): Promise { - return this.client.request(`/project/${id}/gallery`, { + public async deleteGalleryImage(url: string): Promise { + return this.client.request('/project-gallery', { api: 'labrinth', version: 3, method: 'DELETE', From b19760d70691e64daa4ba461e7b177574480f51c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:29:09 +0100 Subject: [PATCH 3/8] v3 post redirects --- apps/labrinth/src/routes/mod.rs | 38 +++++++++++++++++++ .../src/routes/v3/analytics_get/mod.rs | 6 +++ apps/labrinth/src/routes/v3/collections.rs | 19 +++++++++- apps/labrinth/src/routes/v3/content/mod.rs | 5 ++- apps/labrinth/src/routes/v3/organizations.rs | 7 ++++ .../src/routes/v3/project_creation.rs | 9 ++++- apps/labrinth/src/routes/v3/reports.rs | 8 +++- .../src/routes/v3/version_creation.rs | 5 ++- apps/labrinth/src/routes/v3/versions.rs | 9 ++++- 9 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 8c46fe7a54e..91368006e16 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -142,6 +142,44 @@ pub async fn resolve_refs( Ok(resolved) } +/// Rewrites body references without redirecting the HTTP request or changing unresolved values. +pub async fn resolve_body_refs( + mut project_refs: Vec<&mut String>, + pool: &PgPool, + redis: &RedisPool, +) -> Result<(), ApiError> { + let refs = project_refs + .iter() + .map(|value| (**value).clone()) + .collect::>(); + let targets = resolve_refs(&refs, pool, redis).await?; + for (value, target) in project_refs.iter_mut().zip(targets) { + if let Some(target) = target { + **value = target.to_string(); + } + } + Ok(()) +} + +/// Resolves redirect aliases accepted by typed project ID fields. +pub async fn resolve_body_project_ids( + project_ids: Vec<&mut ProjectId>, + pool: &PgPool, + redis: &RedisPool, +) -> Result<(), ApiError> { + let refs = project_ids + .iter() + .map(|id| id.to_string()) + .collect::>(); + let targets = resolve_refs(&refs, pool, redis).await?; + for (id, target) in project_ids.into_iter().zip(targets) { + if let Some(target) = target { + *id = target; + } + } + Ok(()) +} + pub async fn clear_project_redirect_cache( project_refs: &[String], redis: &RedisPool, diff --git a/apps/labrinth/src/routes/v3/analytics_get/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/mod.rs index 72576c58e83..8e2c2170db5 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/mod.rs @@ -199,6 +199,12 @@ pub async fn fetch_analytics( .await .wrap_auth_err("authenticating API request")?; + let mut req = req.into_inner(); + let mut ids = req.project_ids.iter_mut().collect::>(); + if let Some(metrics) = &mut req.return_metrics.project_downloads { + ids.extend(metrics.filter_by.dependent_project_id.iter_mut()); + } + crate::routes::resolve_body_project_ids(ids, &pool, &redis).await?; let full_time_range = req.time_range.end - req.time_range.start; if full_time_range < TimeDelta::zero() { return Err(ApiError::Request(eyre::eyre!( diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index 2a07f6ee7e3..dcac894d492 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -66,7 +66,7 @@ pub async fn collection_create( redis: Data, session_queue: Data, ) -> Result { - let collection_create_data = collection_create_data.into_inner(); + let mut collection_create_data = collection_create_data.into_inner(); // The currently logged in user let current_user = get_user_from_headers( @@ -91,6 +91,13 @@ pub async fn collection_create( CreateError::InvalidInput(validation_errors_to_string(err, None)) })?; + crate::routes::resolve_body_refs( + collection_create_data.projects.iter_mut().collect(), + &client, + &redis, + ) + .await?; + let mut transaction = client.begin().await?; let collection_id: CollectionId = @@ -262,6 +269,7 @@ pub async fn collection_edit( redis: web::Data, session_queue: web::Data, ) -> Result { + let mut new_collection = new_collection.into_inner(); let user = get_user_from_headers( &req, &**pool, @@ -278,6 +286,15 @@ pub async fn collection_edit( .map_err(|err| eyre::eyre!(err)) .wrap_request_err("validating request")?; + if let Some(projects) = &mut new_collection.new_projects { + crate::routes::resolve_body_refs( + projects.iter_mut().collect(), + &pool, + &redis, + ) + .await?; + } + let string = info.into_inner().0; let id = database::models::DBCollectionId( parse_base62(&string).wrap_request_err("parsing collection ID")? as i64, diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs index 0ae82ee6081..cb7496b93de 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -66,7 +66,10 @@ pub async fn resolve_content( user_option: &user_option, trace: ResolveContentTrace::default(), }; - let request = request.into_inner(); + let mut request = request.into_inner(); + let mut refs = vec![&mut request.project_id]; + refs.extend(request.existing_project_ids.iter_mut()); + crate::routes::resolve_body_refs(refs, &pool, &redis).await?; let plan = if cache_public_result { resolve_content_with_cache(&mut provider, request).await } else { diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 75de18ad494..21f35d8f8c6 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -944,6 +944,13 @@ pub async fn organization_projects_add( "the specified organization does not exist!".to_string() })?; + let mut project_info = project_info.into_inner(); + crate::routes::resolve_body_refs( + vec![&mut project_info.project_id], + &pool, + &redis, + ) + .await?; let project_item = database::models::DBProject::get( &project_info.project_id, &**pool, diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index f02d301f22d..2621d5b4ddf 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -566,7 +566,14 @@ async fn project_create_inner( &chunk.map_err(CreateError::MultipartError)?, ); } - let create_data: ProjectCreateData = serde_json::from_slice(&data)?; + let mut create_data: ProjectCreateData = serde_json::from_slice(&data)?; + let ids = create_data + .initial_versions + .iter_mut() + .flat_map(|version| version.dependencies.iter_mut()) + .filter_map(|dependency| dependency.project_id.as_mut()) + .collect(); + crate::routes::resolve_body_project_ids(ids, pool, redis).await?; create_data.validate().map_err(|err| { CreateError::InvalidInput(validation_errors_to_string(err, None)) diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index d3ad4136e00..df29d77d4a6 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -90,7 +90,7 @@ pub async fn report_create( .wrap_auth_err("authenticating API request")? .1; - let new_report: CreateReport = read_typed_from_payload(&mut body) + let mut new_report: CreateReport = read_typed_from_payload(&mut body) .await .wrap_api_err("reading request payload")?; @@ -124,6 +124,12 @@ pub async fn report_create( match new_report.item_type { ItemType::Project => { + crate::routes::resolve_body_refs( + vec![&mut new_report.item_id], + &pool, + &redis, + ) + .await?; let project_id = ProjectId( parse_base62(new_report.item_id.as_str()) .wrap_request_err("parsing reported project ID")?, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 65953332e84..a7439478fc7 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -248,7 +248,10 @@ async fn version_create_inner( data.extend_from_slice(&chunk?); } - let version_create_data: InitialVersionData = serde_json::from_slice(&data)?; + let mut version_create_data: InitialVersionData = serde_json::from_slice(&data)?; + let mut ids = version_create_data.project_id.iter_mut().collect::>(); + ids.extend(version_create_data.dependencies.iter_mut().filter_map(|dependency| dependency.project_id.as_mut())); + crate::routes::resolve_body_project_ids(ids, pool, redis).await?; initial_version_data = Some(version_create_data); let version_create_data = initial_version_data.as_ref().unwrap(); if version_create_data.project_id.is_none() { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index b15faa70456..8efea5ac626 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -471,7 +471,7 @@ pub async fn version_edit_helper( info: (VersionId,), pool: web::Data, redis: web::Data, - new_version: EditVersion, + mut new_version: EditVersion, session_queue: web::Data, search_state: web::Data, ) -> Result { @@ -486,6 +486,13 @@ pub async fn version_edit_helper( .wrap_auth_err("authenticating API request")? .1; + if let Some(dependencies) = &mut new_version.dependencies { + let ids = dependencies + .iter_mut() + .filter_map(|dependency| dependency.project_id.as_mut()) + .collect(); + crate::routes::resolve_body_project_ids(ids, &pool, &redis).await?; + } new_version .validate() .map_err(|err| eyre::eyre!(err)) From cc1f3360621ecff5ae593b760f2201f1af3d276c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:43:18 +0100 Subject: [PATCH 4/8] ensure redirects check user auth --- apps/labrinth/src/routes/mod.rs | 95 ++++++++++++- apps/labrinth/src/routes/v2/projects.rs | 125 +++++++++++++----- apps/labrinth/src/routes/v2/teams.rs | 11 +- apps/labrinth/src/routes/v2/versions.rs | 2 + apps/labrinth/src/routes/v3/disclosures.rs | 2 + apps/labrinth/src/routes/v3/images.rs | 1 + apps/labrinth/src/routes/v3/organizations.rs | 1 + apps/labrinth/src/routes/v3/projects/mod.rs | 114 +++++++++++----- .../src/routes/v3/projects/validate.rs | 11 +- apps/labrinth/src/routes/v3/teams.rs | 1 + apps/labrinth/src/routes/v3/versions.rs | 2 + apps/labrinth/tests/project.rs | 115 ++++++++++++++++ 12 files changed, 404 insertions(+), 76 deletions(-) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 91368006e16..02e6a9cca36 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -1,10 +1,15 @@ -use crate::auth::AuthenticationError; +use crate::auth::{ + AuthenticationError, filter_visible_project_ids, get_user_from_headers, +}; use crate::database::PgPool; +use crate::database::models::DBProject; use crate::database::models::DBProjectId; use crate::env::ENV; use crate::models::ids::ProjectId; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; use crate::util::cors::default_cors; -use crate::util::error::Context; +use crate::util::error::{ApiContext, Context}; use actix_cors::Cors; use actix_files::Files; use actix_web::http::{StatusCode, header}; @@ -208,14 +213,67 @@ pub async fn clear_project_redirect_cache( Ok(()) } +/// Checks visibility after alias resolution, keeping the alias cache caller-independent. +async fn resolve_visible_refs( + req: &HttpRequest, + project_refs: &[String], + pool: &PgPool, + redis: &RedisPool, + session_queue: &AuthQueue, +) -> Result>, ApiError> { + let mut targets = resolve_refs(project_refs, pool, redis).await?; + if targets.iter().all(Option::is_none) { + return Ok(targets); + } + + let target_refs = targets + .iter() + .flatten() + .map(ToString::to_string) + .collect::>(); + let projects = DBProject::get_many(&target_refs, pool, redis) + .await + .wrap_internal_err("fetching project redirect targets")?; + let user = get_user_from_headers( + req, + pool, + redis, + session_queue, + Scopes::PROJECT_READ, + ) + .await + .map(|(_, user)| user) + .ok(); + let visible_ids = filter_visible_project_ids( + projects.iter().map(|project| &project.inner).collect(), + &user, + pool, + false, + ) + .await + .wrap_api_err("checking project redirect visibility")? + .into_iter() + .map(ProjectId::from) + .collect::>(); + for target in &mut targets { + if target.is_some_and(|id| !visible_ids.contains(&id)) { + *target = None; + } + } + Ok(targets) +} + pub async fn redirect_query_refs( req: &HttpRequest, parameter_name: &str, project_refs: &[String], pool: &PgPool, redis: &RedisPool, + session_queue: &AuthQueue, ) -> Result, ApiError> { - let resolved_refs = resolve_refs(project_refs, pool, redis).await?; + let resolved_refs = + resolve_visible_refs(req, project_refs, pool, redis, session_queue) + .await?; if resolved_refs.iter().all(Option::is_none) { return Ok(None); } @@ -247,6 +305,7 @@ pub async fn redirect_query_refs( Ok(Some( HttpResponse::PermanentRedirect() .append_header((header::LOCATION, location)) + .append_header((header::CACHE_CONTROL, "private, no-store")) .finish(), )) } @@ -257,9 +316,19 @@ pub async fn redirect_query_ref( project_ref: &str, pool: &PgPool, redis: &RedisPool, + session_queue: &AuthQueue, ) -> Result, ApiError> { - let Some(target_project_id) = resolve_ref(project_ref, pool, redis).await? - else { + let Some(target_project_id) = resolve_visible_refs( + req, + &[project_ref.to_string()], + pool, + redis, + session_queue, + ) + .await? + .into_iter() + .next() + .flatten() else { return Ok(None); }; @@ -279,6 +348,7 @@ pub async fn redirect_query_ref( Ok(Some( HttpResponse::PermanentRedirect() .append_header((header::LOCATION, location)) + .append_header((header::CACHE_CONTROL, "private, no-store")) .finish(), )) } @@ -288,12 +358,22 @@ pub async fn redirect_ref( parameter_name: &str, pool: &PgPool, redis: &RedisPool, + session_queue: &AuthQueue, ) -> Result, ApiError> { let Some(project_ref) = req.match_info().get(parameter_name) else { return Ok(None); }; - let Some(target_project_id) = resolve_ref(project_ref, pool, redis).await? - else { + let Some(target_project_id) = resolve_visible_refs( + req, + &[project_ref.to_string()], + pool, + redis, + session_queue, + ) + .await? + .into_iter() + .next() + .flatten() else { return Ok(None); }; @@ -327,6 +407,7 @@ pub async fn redirect_ref( Ok(Some( HttpResponse::PermanentRedirect() .append_header((header::LOCATION, location)) + .append_header((header::CACHE_CONTROL, "private, no-store")) .finish(), )) } diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 60482355af0..84f2081d92f 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -249,6 +249,7 @@ pub async fn projects_get( &project_refs, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -308,9 +309,14 @@ pub async fn project_get( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -382,10 +388,16 @@ pub async fn project_get_check( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -433,6 +445,7 @@ pub async fn dependency_list( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -615,9 +628,14 @@ pub async fn project_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -894,6 +912,7 @@ pub async fn projects_edit( &project_refs, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -1041,9 +1060,14 @@ pub async fn project_icon_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1093,9 +1117,14 @@ pub async fn delete_project_icon( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1176,9 +1205,14 @@ pub async fn add_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1263,9 +1297,14 @@ pub async fn edit_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1324,9 +1363,14 @@ pub async fn delete_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1373,9 +1417,14 @@ pub async fn project_delete( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1421,9 +1470,14 @@ pub async fn project_follow( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1461,9 +1515,14 @@ pub async fn project_unfollow( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index fea945cecfe..d6a4a38631c 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -56,9 +56,14 @@ pub async fn team_members_get_project( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index b18c5582ff2..ad6a73c80fd 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -85,6 +85,7 @@ pub async fn version_list( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -200,6 +201,7 @@ pub async fn version_project_get( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index f845399ee2d..51e5bd206c4 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -51,6 +51,7 @@ pub async fn get_project_disclosures( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -142,6 +143,7 @@ pub async fn modify_project_disclosures( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index 0b2ff4bee55..090eb1880ec 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -71,6 +71,7 @@ pub async fn images_add( project_ref, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 21f35d8f8c6..dc3274d5cc7 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -1118,6 +1118,7 @@ pub async fn organization_projects_remove( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 46aa7aa34d2..38ff0549065 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -258,6 +258,7 @@ pub async fn projects_get_route( &project_refs, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -312,9 +313,14 @@ pub async fn project_get( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -458,9 +464,14 @@ pub async fn project_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1738,10 +1749,16 @@ pub async fn project_get_check( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -1794,6 +1811,7 @@ pub async fn dependency_list( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -1963,6 +1981,7 @@ pub async fn projects_edit_route( &project_refs, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -2367,9 +2386,14 @@ pub async fn project_icon_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -2530,9 +2554,14 @@ pub async fn delete_project_icon( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -2690,9 +2719,14 @@ pub async fn add_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -3326,9 +3360,14 @@ pub async fn project_delete( session_queue: web::Data, search_state: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -3653,9 +3692,14 @@ pub async fn project_follow( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -3765,9 +3809,14 @@ pub async fn project_unfollow( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } @@ -3869,9 +3918,14 @@ pub async fn project_get_organization( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } diff --git a/apps/labrinth/src/routes/v3/projects/validate.rs b/apps/labrinth/src/routes/v3/projects/validate.rs index c427597a94b..89f55d574e0 100644 --- a/apps/labrinth/src/routes/v3/projects/validate.rs +++ b/apps/labrinth/src/routes/v3/projects/validate.rs @@ -135,9 +135,14 @@ pub async fn validate( redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(response) = - crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) - .await? + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + ) + .await? { return Ok(response); } diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 187d5f3c08d..3ded28b5766 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -53,6 +53,7 @@ pub async fn team_members_get_project( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 8efea5ac626..abbd60e25c9 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -73,6 +73,7 @@ pub async fn version_project_get( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { @@ -1070,6 +1071,7 @@ pub async fn version_list( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), ) .await? { diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index a86def9e787..fc1da4a4126 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -113,6 +113,121 @@ async fn test_get_project() { .await; } +#[actix_rt::test] +async fn project_redirect_cache_respects_visibility() { + with_test_environment_all(None, |env| async move { + let public_id = &env.dummy.project_alpha.project_id; + let private_id = &env.dummy.project_beta.project_id; + let public_alias = "redirect-public-alias"; + let private_alias = "redirect-private-alias"; + let mut redis = env.db.redis_pool.connect().await.unwrap(); + for (alias, target) in + [(public_alias, public_id), (private_alias, private_id)] + { + let key = redis.key().entity("project_redirects:v1", alias); + redis + .set_serialized( + &key, + &Some(parse_base62(target).unwrap() as i64), + Some(300), + ) + .await + .unwrap(); + } + + for pat in [USER_USER_PAT, MOD_USER_PAT, ADMIN_USER_PAT] { + let response = env.api.get_project(private_alias, pat).await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + assert!( + response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .ends_with(private_id) + ); + assert_eq!( + response.headers().get("cache-control").unwrap(), + "private, no-store" + ); + } + + for pat in [None, ENEMY_USER_PAT, FRIEND_USER_PAT] { + let response = env.api.get_project(private_alias, pat).await; + assert_status!(&response, StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key("location")); + let response = env.api.get_projects(&[private_alias], pat).await; + assert_status!(&response, StatusCode::OK); + assert!(!response.headers().contains_key("location")); + let projects: Vec = + test::read_body_json(response).await; + assert!(projects.is_empty()); + } + + let response = env.api.get_project(public_alias, None).await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + assert!( + response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .ends_with(public_id) + ); + + for pat in [None, ENEMY_USER_PAT, USER_USER_PAT] { + let response = env + .api + .get_projects( + &[ + public_alias, + private_alias, + public_id, + "missing", + private_alias, + ], + pat, + ) + .await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let (_, query) = location.split_once('?').unwrap(); + let ids = url::form_urlencoded::parse(query.as_bytes()) + .find(|(name, _)| name == "ids") + .unwrap() + .1; + let ids: Vec = serde_json::from_str(&ids).unwrap(); + let expected_private = if pat == USER_USER_PAT { + private_id.as_str() + } else { + private_alias + }; + assert_eq!( + ids, + [ + public_id.as_str(), + expected_private, + public_id.as_str(), + "missing", + expected_private + ] + ); + assert_eq!( + response.headers().get("cache-control").unwrap(), + "private, no-store" + ); + } + }) + .await; +} + #[actix_rt::test] async fn test_add_remove_project() { // Test setup and dummy data From 453d9f92acf5a5d34bba35bb5959ef33046328db Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:06:18 +0100 Subject: [PATCH 5/8] fix 200 instead of 204 --- apps/labrinth/src/routes/v3/projects/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 38ff0549065..d9f6d430726 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -3381,7 +3381,7 @@ pub async fn project_delete( search_state, ) .await?; - Ok(HttpResponse::Ok().finish()) + Ok(HttpResponse::NoContent().finish()) } pub async fn project_delete_internal( From 73cf413b127034d6221ee90843beaf5f147bbdff Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:45:40 +0100 Subject: [PATCH 6/8] match endpoints which take canonical IDs to not resolve slug refs anymore --- apps/labrinth/src/routes/mod.rs | 62 ++++---- .../src/routes/v3/analytics_get/mod.rs | 6 - apps/labrinth/src/routes/v3/collections.rs | 2 + apps/labrinth/src/routes/v3/content/mod.rs | 10 +- apps/labrinth/src/routes/v3/organizations.rs | 1 + .../src/routes/v3/project_creation.rs | 9 +- apps/labrinth/src/routes/v3/reports.rs | 8 +- .../src/routes/v3/version_creation.rs | 5 +- apps/labrinth/src/routes/v3/versions.rs | 9 +- apps/labrinth/tests/project.rs | 140 +++++++++++++++++- 10 files changed, 178 insertions(+), 74 deletions(-) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 02e6a9cca36..8cd82f8a6cf 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -67,9 +67,7 @@ pub async fn resolve_refs( let keys = project_refs .iter() .map(|project_ref| { - redis - .key() - .entity(PROJECT_REDIRECTS_NAMESPACE, project_ref.to_lowercase()) + redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, project_ref) }) .collect::>(); let cached_targets = redis @@ -150,6 +148,7 @@ pub async fn resolve_refs( /// Rewrites body references without redirecting the HTTP request or changing unresolved values. pub async fn resolve_body_refs( mut project_refs: Vec<&mut String>, + user: &Option, pool: &PgPool, redis: &RedisPool, ) -> Result<(), ApiError> { @@ -157,7 +156,8 @@ pub async fn resolve_body_refs( .iter() .map(|value| (**value).clone()) .collect::>(); - let targets = resolve_refs(&refs, pool, redis).await?; + let targets = + resolve_visible_refs_for_user(&refs, user, pool, redis).await?; for (value, target) in project_refs.iter_mut().zip(targets) { if let Some(target) = target { **value = target.to_string(); @@ -166,25 +166,6 @@ pub async fn resolve_body_refs( Ok(()) } -/// Resolves redirect aliases accepted by typed project ID fields. -pub async fn resolve_body_project_ids( - project_ids: Vec<&mut ProjectId>, - pool: &PgPool, - redis: &RedisPool, -) -> Result<(), ApiError> { - let refs = project_ids - .iter() - .map(|id| id.to_string()) - .collect::>(); - let targets = resolve_refs(&refs, pool, redis).await?; - for (id, target) in project_ids.into_iter().zip(targets) { - if let Some(target) = target { - *id = target; - } - } - Ok(()) -} - pub async fn clear_project_redirect_cache( project_refs: &[String], redis: &RedisPool, @@ -200,9 +181,7 @@ pub async fn clear_project_redirect_cache( let keys = project_refs .iter() .map(|project_ref| { - redis - .key() - .entity(PROJECT_REDIRECTS_NAMESPACE, project_ref.to_lowercase()) + redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, project_ref) }) .collect::>(); redis @@ -220,6 +199,25 @@ async fn resolve_visible_refs( pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, +) -> Result>, ApiError> { + let user = get_user_from_headers( + req, + pool, + redis, + session_queue, + Scopes::PROJECT_READ, + ) + .await + .map(|(_, user)| user) + .ok(); + resolve_visible_refs_for_user(project_refs, &user, pool, redis).await +} + +async fn resolve_visible_refs_for_user( + project_refs: &[String], + user: &Option, + pool: &PgPool, + redis: &RedisPool, ) -> Result>, ApiError> { let mut targets = resolve_refs(project_refs, pool, redis).await?; if targets.iter().all(Option::is_none) { @@ -234,19 +232,9 @@ async fn resolve_visible_refs( let projects = DBProject::get_many(&target_refs, pool, redis) .await .wrap_internal_err("fetching project redirect targets")?; - let user = get_user_from_headers( - req, - pool, - redis, - session_queue, - Scopes::PROJECT_READ, - ) - .await - .map(|(_, user)| user) - .ok(); let visible_ids = filter_visible_project_ids( projects.iter().map(|project| &project.inner).collect(), - &user, + user, pool, false, ) diff --git a/apps/labrinth/src/routes/v3/analytics_get/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/mod.rs index 8e2c2170db5..72576c58e83 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/mod.rs @@ -199,12 +199,6 @@ pub async fn fetch_analytics( .await .wrap_auth_err("authenticating API request")?; - let mut req = req.into_inner(); - let mut ids = req.project_ids.iter_mut().collect::>(); - if let Some(metrics) = &mut req.return_metrics.project_downloads { - ids.extend(metrics.filter_by.dependent_project_id.iter_mut()); - } - crate::routes::resolve_body_project_ids(ids, &pool, &redis).await?; let full_time_range = req.time_range.end - req.time_range.start; if full_time_range < TimeDelta::zero() { return Err(ApiError::Request(eyre::eyre!( diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index dcac894d492..cc4def47944 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -93,6 +93,7 @@ pub async fn collection_create( crate::routes::resolve_body_refs( collection_create_data.projects.iter_mut().collect(), + &Some(current_user.clone()), &client, &redis, ) @@ -289,6 +290,7 @@ pub async fn collection_edit( if let Some(projects) = &mut new_collection.new_projects { crate::routes::resolve_body_refs( projects.iter_mut().collect(), + &Some(user.clone()), &pool, &redis, ) diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs index cb7496b93de..f5f2cd1e9d2 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -67,9 +67,13 @@ pub async fn resolve_content( trace: ResolveContentTrace::default(), }; let mut request = request.into_inner(); - let mut refs = vec![&mut request.project_id]; - refs.extend(request.existing_project_ids.iter_mut()); - crate::routes::resolve_body_refs(refs, &pool, &redis).await?; + crate::routes::resolve_body_refs( + vec![&mut request.project_id], + &user_option, + &pool, + &redis, + ) + .await?; let plan = if cache_public_result { resolve_content_with_cache(&mut provider, request).await } else { diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index dc3274d5cc7..4174ae73948 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -947,6 +947,7 @@ pub async fn organization_projects_add( let mut project_info = project_info.into_inner(); crate::routes::resolve_body_refs( vec![&mut project_info.project_id], + &Some(current_user.clone()), &pool, &redis, ) diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 2621d5b4ddf..f02d301f22d 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -566,14 +566,7 @@ async fn project_create_inner( &chunk.map_err(CreateError::MultipartError)?, ); } - let mut create_data: ProjectCreateData = serde_json::from_slice(&data)?; - let ids = create_data - .initial_versions - .iter_mut() - .flat_map(|version| version.dependencies.iter_mut()) - .filter_map(|dependency| dependency.project_id.as_mut()) - .collect(); - crate::routes::resolve_body_project_ids(ids, pool, redis).await?; + let create_data: ProjectCreateData = serde_json::from_slice(&data)?; create_data.validate().map_err(|err| { CreateError::InvalidInput(validation_errors_to_string(err, None)) diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index df29d77d4a6..d3ad4136e00 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -90,7 +90,7 @@ pub async fn report_create( .wrap_auth_err("authenticating API request")? .1; - let mut new_report: CreateReport = read_typed_from_payload(&mut body) + let new_report: CreateReport = read_typed_from_payload(&mut body) .await .wrap_api_err("reading request payload")?; @@ -124,12 +124,6 @@ pub async fn report_create( match new_report.item_type { ItemType::Project => { - crate::routes::resolve_body_refs( - vec![&mut new_report.item_id], - &pool, - &redis, - ) - .await?; let project_id = ProjectId( parse_base62(new_report.item_id.as_str()) .wrap_request_err("parsing reported project ID")?, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index a7439478fc7..65953332e84 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -248,10 +248,7 @@ async fn version_create_inner( data.extend_from_slice(&chunk?); } - let mut version_create_data: InitialVersionData = serde_json::from_slice(&data)?; - let mut ids = version_create_data.project_id.iter_mut().collect::>(); - ids.extend(version_create_data.dependencies.iter_mut().filter_map(|dependency| dependency.project_id.as_mut())); - crate::routes::resolve_body_project_ids(ids, pool, redis).await?; + let version_create_data: InitialVersionData = serde_json::from_slice(&data)?; initial_version_data = Some(version_create_data); let version_create_data = initial_version_data.as_ref().unwrap(); if version_create_data.project_id.is_none() { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index abbd60e25c9..82914159826 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -472,7 +472,7 @@ pub async fn version_edit_helper( info: (VersionId,), pool: web::Data, redis: web::Data, - mut new_version: EditVersion, + new_version: EditVersion, session_queue: web::Data, search_state: web::Data, ) -> Result { @@ -487,13 +487,6 @@ pub async fn version_edit_helper( .wrap_auth_err("authenticating API request")? .1; - if let Some(dependencies) = &mut new_version.dependencies { - let ids = dependencies - .iter_mut() - .filter_map(|dependency| dependency.project_id.as_mut()) - .collect(); - crate::routes::resolve_body_project_ids(ids, &pool, &redis).await?; - } new_version .validate() .map_err(|err| eyre::eyre!(err)) diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index fc1da4a4126..eb3446d017d 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -124,7 +124,7 @@ async fn project_redirect_cache_respects_visibility() { for (alias, target) in [(public_alias, public_id), (private_alias, private_id)] { - let key = redis.key().entity("project_redirects:v1", alias); + let key = redis.key().entity("project_redirects:v2", alias); redis .set_serialized( &key, @@ -135,6 +135,58 @@ async fn project_redirect_cache_respects_visibility() { .unwrap(); } + for user_id in [ + None, + Some(ENEMY_USER_ID_PARSED), + Some(USER_USER_ID_PARSED), + Some(MOD_USER_ID_PARSED), + Some(ADMIN_USER_ID_PARSED), + ] { + let user = if let Some(user_id) = user_id { + Some(labrinth::models::users::User::from( + labrinth::database::models::DBUser::get_id( + labrinth::database::models::DBUserId(user_id), + &*env.db.pool, + &env.db.redis_pool, + ) + .await + .unwrap() + .unwrap(), + )) + } else { + None + }; + let mut body_refs = [ + public_alias.to_string(), + private_alias.to_string(), + public_id.clone(), + "missing".to_string(), + private_alias.to_string(), + ]; + labrinth::routes::resolve_body_refs( + body_refs.iter_mut().collect(), + &user, + &env.db.pool, + &env.db.redis_pool, + ) + .await + .unwrap(); + let expected_private = match user_id { + None | Some(ENEMY_USER_ID_PARSED) => private_alias, + _ => private_id.as_str(), + }; + assert_eq!( + body_refs, + [ + public_id.as_str(), + expected_private, + public_id.as_str(), + "missing", + expected_private, + ] + ); + } + for pat in [USER_USER_PAT, MOD_USER_PAT, ADMIN_USER_PAT] { let response = env.api.get_project(private_alias, pat).await; assert_status!(&response, StatusCode::PERMANENT_REDIRECT); @@ -228,6 +280,92 @@ async fn project_redirect_cache_respects_visibility() { .await; } +#[actix_rt::test] +async fn project_redirect_cache_preserves_case_sensitive_ids() { + with_test_environment_all(None, |env| async move { + let project_id = &env.dummy.project_beta.project_id; + let target_id = &env.dummy.project_alpha.project_id; + let alias = project_id.to_lowercase(); + assert_ne!( + project_id, &alias, + "fixture ID must contain uppercase letters" + ); + assert_ne!(project_id, target_id); + let target = parse_base62(target_id).unwrap(); + let fixture_sql = format!( + r#"INSERT INTO project_redirects (slug, target_project_id) + VALUES ('{alias}', {target})"# + ); + sqlx::raw_sql(&fixture_sql) + .execute(&env.db.pool) + .await + .unwrap(); + + let mut redis = env.db.redis_pool.connect().await.unwrap(); + let keys = [ + redis.key().entity("project_redirects:v2", project_id), + redis.key().entity("project_redirects:v2", &alias), + ]; + for alias_first in [false, true] { + redis.delete_many(&keys).await.unwrap(); + let refs = if alias_first { + [alias.as_str(), project_id.as_str()] + } else { + [project_id.as_str(), alias.as_str()] + }; + for project_ref in refs { + let response = + env.api.get_project(project_ref, USER_USER_PAT).await; + if project_ref == alias { + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + assert!( + response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .ends_with(target_id) + ); + } else { + assert_status!(&response, StatusCode::OK); + assert!(!response.headers().contains_key("location")); + let project: CommonProject = + test::read_body_json(response).await; + assert_eq!(project.id.to_string(), *project_id); + } + } + + assert_eq!( + redis + .get_deserialized::>(&keys[0]) + .await + .unwrap(), + Some(None) + ); + assert_eq!( + redis + .get_deserialized::>(&keys[1]) + .await + .unwrap(), + Some(Some(target as i64)) + ); + + let mut body_refs = [project_id.clone(), alias.clone()]; + labrinth::routes::resolve_body_refs( + body_refs.iter_mut().collect(), + &None, + &env.db.pool, + &env.db.redis_pool, + ) + .await + .unwrap(); + assert_eq!(body_refs, [project_id.clone(), target_id.clone()]); + } + }) + .await; +} + #[actix_rt::test] async fn test_add_remove_project() { // Test setup and dummy data From 53dad4e667f3e296812a22f548ef74b9ba3c1f2c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:15:15 +0100 Subject: [PATCH 7/8] slug invalidation dfetails, auth scopes --- apps/labrinth/src/routes/mod.rs | 35 ++++--- apps/labrinth/src/routes/v2/projects.rs | 15 +++ apps/labrinth/src/routes/v2/teams.rs | 2 + apps/labrinth/src/routes/v2/versions.rs | 3 + apps/labrinth/src/routes/v3/disclosures.rs | 2 + apps/labrinth/src/routes/v3/images.rs | 1 + apps/labrinth/src/routes/v3/organizations.rs | 1 + apps/labrinth/src/routes/v3/projects/mod.rs | 28 ++++-- .../src/routes/v3/projects/validate.rs | 1 + apps/labrinth/src/routes/v3/teams.rs | 1 + apps/labrinth/src/routes/v3/versions.rs | 2 + apps/labrinth/tests/project.rs | 96 ++++++++++++++++++- 12 files changed, 164 insertions(+), 23 deletions(-) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 8cd82f8a6cf..132ed5131b1 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -35,7 +35,7 @@ mod updates; pub use self::not_found::not_found; -const PROJECT_REDIRECTS_NAMESPACE: &str = "project_redirects:v1"; +pub const PROJECT_REDIRECTS_NAMESPACE: &str = "project_redirects:v1"; const PROJECT_REDIRECT_CACHE_TTL_SECONDS: i64 = 300; pub async fn resolve_ref( @@ -199,17 +199,13 @@ async fn resolve_visible_refs( pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, + required_scopes: Scopes, ) -> Result>, ApiError> { - let user = get_user_from_headers( - req, - pool, - redis, - session_queue, - Scopes::PROJECT_READ, - ) - .await - .map(|(_, user)| user) - .ok(); + let user = + get_user_from_headers(req, pool, redis, session_queue, required_scopes) + .await + .map(|(_, user)| user) + .ok(); resolve_visible_refs_for_user(project_refs, &user, pool, redis).await } @@ -258,10 +254,17 @@ pub async fn redirect_query_refs( pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, + required_scopes: Scopes, ) -> Result, ApiError> { - let resolved_refs = - resolve_visible_refs(req, project_refs, pool, redis, session_queue) - .await?; + let resolved_refs = resolve_visible_refs( + req, + project_refs, + pool, + redis, + session_queue, + required_scopes, + ) + .await?; if resolved_refs.iter().all(Option::is_none) { return Ok(None); } @@ -305,6 +308,7 @@ pub async fn redirect_query_ref( pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, + required_scopes: Scopes, ) -> Result, ApiError> { let Some(target_project_id) = resolve_visible_refs( req, @@ -312,6 +316,7 @@ pub async fn redirect_query_ref( pool, redis, session_queue, + required_scopes, ) .await? .into_iter() @@ -347,6 +352,7 @@ pub async fn redirect_ref( pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, + required_scopes: Scopes, ) -> Result, ApiError> { let Some(project_ref) = req.match_info().get(parameter_name) else { return Ok(None); @@ -357,6 +363,7 @@ pub async fn redirect_ref( pool, redis, session_queue, + required_scopes, ) .await? .into_iter() diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 84f2081d92f..adcbf6b8e7b 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -4,6 +4,7 @@ use crate::database::{PgPool, ReadOnlyPgPool}; use crate::file_hosting::FileHost; use crate::models::disclosures::ProjectDisclosureType; use crate::models::link_platform::LinkPlatform; +use crate::models::pats::Scopes; use crate::models::projects::{ Link, MonetizationStatus, Project, ProjectStatus, Version, }; @@ -250,6 +251,7 @@ pub async fn projects_get( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -315,6 +317,7 @@ pub async fn project_get( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -396,6 +399,7 @@ pub async fn project_get_check( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -446,6 +450,7 @@ pub async fn dependency_list( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -634,6 +639,7 @@ pub async fn project_edit( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -913,6 +919,7 @@ pub async fn projects_edit( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1066,6 +1073,7 @@ pub async fn project_icon_edit( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1123,6 +1131,7 @@ pub async fn delete_project_icon( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1211,6 +1220,7 @@ pub async fn add_gallery_item( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1303,6 +1313,7 @@ pub async fn edit_gallery_item( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1369,6 +1380,7 @@ pub async fn delete_gallery_item( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1423,6 +1435,7 @@ pub async fn project_delete( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_DELETE, ) .await? { @@ -1476,6 +1489,7 @@ pub async fn project_follow( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::USER_WRITE, ) .await? { @@ -1521,6 +1535,7 @@ pub async fn project_unfollow( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::USER_WRITE, ) .await? { diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index d6a4a38631c..1378214e3a7 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -1,5 +1,6 @@ use crate::database::PgPool; use crate::models::ids::TeamId; +use crate::models::pats::Scopes; use crate::models::teams::{ OrganizationPermissions, ProjectPermissions, TeamMember, }; @@ -62,6 +63,7 @@ pub async fn team_members_get_project( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index ad6a73c80fd..08d3852eb78 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -6,6 +6,7 @@ use super::ApiError; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models; use crate::models::ids::VersionId; +use crate::models::pats::Scopes; use crate::models::projects::{ Dependency, FileType, Version, VersionStatus, VersionType, }; @@ -86,6 +87,7 @@ pub async fn version_list( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await? { @@ -202,6 +204,7 @@ pub async fn version_project_get( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await? { diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 51e5bd206c4..027fa04ef6c 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -52,6 +52,7 @@ pub async fn get_project_disclosures( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -144,6 +145,7 @@ pub async fn modify_project_disclosures( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index 090eb1880ec..797a457849f 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -72,6 +72,7 @@ pub async fn images_add( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + context.relevant_scope(), ) .await? { diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 4174ae73948..b0f0f1a8b93 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -1120,6 +1120,7 @@ pub async fn organization_projects_remove( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE, ) .await? { diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index d9f6d430726..1171e880733 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -259,6 +259,7 @@ pub async fn projects_get_route( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -319,6 +320,7 @@ pub async fn project_get( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -470,6 +472,7 @@ pub async fn project_edit( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1451,12 +1454,15 @@ pub async fn project_edit_internal( .await .wrap_internal_err("committing database transaction")?; - let mut project_refs = - vec![ProjectId::from(project_item.inner.id).to_string()]; - if let Some(slug) = &reloaded_project.inner.slug { - project_refs.push(slug.clone()); - } - crate::routes::clear_project_redirect_cache(&project_refs, &redis).await?; + let mut project_refs = + vec![ProjectId::from(project_item.inner.id).to_string()]; + if let Some(slug) = &project_item.inner.slug { + project_refs.push(slug.clone()); + } + if let Some(slug) = &new_project.slug { + project_refs.push(slug.to_lowercase()); + } + crate::routes::clear_project_redirect_cache(&project_refs, &redis).await?; if became_unsearchable { db_models::DBProject::clear_cache( @@ -1757,6 +1763,7 @@ pub async fn project_get_check( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -1812,6 +1819,7 @@ pub async fn dependency_list( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -1982,6 +1990,7 @@ pub async fn projects_edit_route( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -2392,6 +2401,7 @@ pub async fn project_icon_edit( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -2560,6 +2570,7 @@ pub async fn delete_project_icon( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -2725,6 +2736,7 @@ pub async fn add_gallery_item( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -3366,6 +3378,7 @@ pub async fn project_delete( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_DELETE, ) .await? { @@ -3698,6 +3711,7 @@ pub async fn project_follow( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::USER_WRITE, ) .await? { @@ -3815,6 +3829,7 @@ pub async fn project_unfollow( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::USER_WRITE, ) .await? { @@ -3924,6 +3939,7 @@ pub async fn project_get_organization( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ, ) .await? { diff --git a/apps/labrinth/src/routes/v3/projects/validate.rs b/apps/labrinth/src/routes/v3/projects/validate.rs index 89f55d574e0..af9e525e9e9 100644 --- a/apps/labrinth/src/routes/v3/projects/validate.rs +++ b/apps/labrinth/src/routes/v3/projects/validate.rs @@ -141,6 +141,7 @@ pub async fn validate( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 3ded28b5766..f5f7b9f9ab6 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -54,6 +54,7 @@ pub async fn team_members_get_project( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 82914159826..109705576dc 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -74,6 +74,7 @@ pub async fn version_project_get( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await? { @@ -1065,6 +1066,7 @@ pub async fn version_list( pool.as_ref(), redis.as_ref(), session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await? { diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index eb3446d017d..4bf60a1b0fc 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -26,8 +26,11 @@ use labrinth::database::models::project_item::{ PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE, ProjectQueryResult, }; use labrinth::models::ids::ProjectId; +use labrinth::models::pats::Scopes; use labrinth::models::projects::ProjectStatus; use labrinth::models::teams::ProjectPermissions; +use labrinth::routes::PROJECT_REDIRECTS_NAMESPACE; +use labrinth::test::pats::create_test_pat; use labrinth::util::actix::{MultipartSegment, MultipartSegmentData}; use serde_json::json; use sha1::Digest; @@ -124,7 +127,7 @@ async fn project_redirect_cache_respects_visibility() { for (alias, target) in [(public_alias, public_id), (private_alias, private_id)] { - let key = redis.key().entity("project_redirects:v2", alias); + let key = redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, alias); redis .set_serialized( &key, @@ -187,6 +190,45 @@ async fn project_redirect_cache_respects_visibility() { ); } + let write_pat = create_test_pat( + Scopes::PROJECT_WRITE, + USER_USER_ID_PARSED, + &env.db, + ) + .await; + let response = env + .api + .edit_project(private_id, json!({}), Some(&write_pat)) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = env + .api + .edit_project(private_alias, json!({}), Some(&write_pat)) + .await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + let response = env + .api + .edit_project_bulk(&[private_alias], json!({}), Some(&write_pat)) + .await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + let response = + env.api.get_project(private_alias, Some(&write_pat)).await; + assert_status!(&response, StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key("location")); + + let enemy_write_pat = create_test_pat( + Scopes::PROJECT_WRITE, + ENEMY_USER_ID_PARSED, + &env.db, + ) + .await; + let response = env + .api + .edit_project(private_alias, json!({}), Some(&enemy_write_pat)) + .await; + assert_status!(&response, StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key("location")); + for pat in [USER_USER_PAT, MOD_USER_PAT, ADMIN_USER_PAT] { let response = env.api.get_project(private_alias, pat).await; assert_status!(&response, StatusCode::PERMANENT_REDIRECT); @@ -280,6 +322,54 @@ async fn project_redirect_cache_respects_visibility() { .await; } +#[actix_rt::test] +async fn project_slug_edit_invalidates_redirect_cache() { + with_test_environment_all(None, |env| async move { + let target_id = &env.dummy.project_beta.project_id; + let project_id = &env.dummy.project_alpha.project_id; + let alias = "redirect-claimed-slug"; + let mut redis = env.db.redis_pool.connect().await.unwrap(); + let key = redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, alias); + redis + .set_serialized( + &key, + &Some(parse_base62(target_id).unwrap() as i64), + Some(300), + ) + .await + .unwrap(); + + let response = env.api.get_project(alias, USER_USER_PAT).await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + assert!( + response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .ends_with(target_id) + ); + + let response = env + .api + .edit_project( + project_id, + json!({ "slug": alias.to_uppercase() }), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + + let response = env.api.get_project(alias, USER_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + assert!(!response.headers().contains_key("location")); + let project: CommonProject = test::read_body_json(response).await; + assert_eq!(project.id.to_string(), *project_id); + }) + .await; +} + #[actix_rt::test] async fn project_redirect_cache_preserves_case_sensitive_ids() { with_test_environment_all(None, |env| async move { @@ -303,8 +393,8 @@ async fn project_redirect_cache_preserves_case_sensitive_ids() { let mut redis = env.db.redis_pool.connect().await.unwrap(); let keys = [ - redis.key().entity("project_redirects:v2", project_id), - redis.key().entity("project_redirects:v2", &alias), + redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, project_id), + redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, &alias), ]; for alias_first in [false, true] { redis.delete_many(&keys).await.unwrap(); From 209d7f5575b734d76ead7431718839e70827e2fa Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:46:21 +0100 Subject: [PATCH 8/8] cargo fmt --- apps/labrinth/src/routes/v3/projects/mod.rs | 18 ++--- apps/labrinth/tests/project.rs | 86 ++++++++++----------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 1171e880733..e3d17d303fc 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -1454,15 +1454,15 @@ pub async fn project_edit_internal( .await .wrap_internal_err("committing database transaction")?; - let mut project_refs = - vec![ProjectId::from(project_item.inner.id).to_string()]; - if let Some(slug) = &project_item.inner.slug { - project_refs.push(slug.clone()); - } - if let Some(slug) = &new_project.slug { - project_refs.push(slug.to_lowercase()); - } - crate::routes::clear_project_redirect_cache(&project_refs, &redis).await?; + let mut project_refs = + vec![ProjectId::from(project_item.inner.id).to_string()]; + if let Some(slug) = &project_item.inner.slug { + project_refs.push(slug.clone()); + } + if let Some(slug) = &new_project.slug { + project_refs.push(slug.to_lowercase()); + } + crate::routes::clear_project_redirect_cache(&project_refs, &redis).await?; if became_unsearchable { db_models::DBProject::clear_cache( diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 4bf60a1b0fc..978638dbd5c 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -324,50 +324,50 @@ async fn project_redirect_cache_respects_visibility() { #[actix_rt::test] async fn project_slug_edit_invalidates_redirect_cache() { - with_test_environment_all(None, |env| async move { - let target_id = &env.dummy.project_beta.project_id; - let project_id = &env.dummy.project_alpha.project_id; - let alias = "redirect-claimed-slug"; - let mut redis = env.db.redis_pool.connect().await.unwrap(); - let key = redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, alias); - redis - .set_serialized( - &key, - &Some(parse_base62(target_id).unwrap() as i64), - Some(300), - ) - .await - .unwrap(); - - let response = env.api.get_project(alias, USER_USER_PAT).await; - assert_status!(&response, StatusCode::PERMANENT_REDIRECT); - assert!( - response - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap() - .ends_with(target_id) - ); - - let response = env - .api - .edit_project( - project_id, - json!({ "slug": alias.to_uppercase() }), - USER_USER_PAT, - ) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); + with_test_environment_all(None, |env| async move { + let target_id = &env.dummy.project_beta.project_id; + let project_id = &env.dummy.project_alpha.project_id; + let alias = "redirect-claimed-slug"; + let mut redis = env.db.redis_pool.connect().await.unwrap(); + let key = redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, alias); + redis + .set_serialized( + &key, + &Some(parse_base62(target_id).unwrap() as i64), + Some(300), + ) + .await + .unwrap(); - let response = env.api.get_project(alias, USER_USER_PAT).await; - assert_status!(&response, StatusCode::OK); - assert!(!response.headers().contains_key("location")); - let project: CommonProject = test::read_body_json(response).await; - assert_eq!(project.id.to_string(), *project_id); - }) - .await; + let response = env.api.get_project(alias, USER_USER_PAT).await; + assert_status!(&response, StatusCode::PERMANENT_REDIRECT); + assert!( + response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .ends_with(target_id) + ); + + let response = env + .api + .edit_project( + project_id, + json!({ "slug": alias.to_uppercase() }), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + + let response = env.api.get_project(alias, USER_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + assert!(!response.headers().contains_key("location")); + let project: CommonProject = test::read_body_json(response).await; + assert_eq!(project.id.to_string(), *project_id); + }) + .await; } #[actix_rt::test]