diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue index 4efc2d55e39..ea4a55d118a 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/mod.rs b/apps/labrinth/src/routes/mod.rs index 04d5ea5344b..132ed5131b1 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}; @@ -30,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( @@ -140,14 +145,126 @@ 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>, + user: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result<(), ApiError> { + let refs = project_refs + .iter() + .map(|value| (**value).clone()) + .collect::>(); + 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(); + } + } + Ok(()) +} + +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) + }) + .collect::>(); + redis + .delete_many(&keys) + .await + .wrap_internal_err("clearing cached project redirects")?; + + 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, + required_scopes: Scopes, +) -> Result>, ApiError> { + 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 +} + +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) { + 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 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, + required_scopes: Scopes, ) -> 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, + required_scopes, + ) + .await?; if resolved_refs.iter().all(Option::is_none) { return Ok(None); } @@ -179,6 +296,52 @@ pub async fn redirect_query_refs( Ok(Some( HttpResponse::PermanentRedirect() .append_header((header::LOCATION, location)) + .append_header((header::CACHE_CONTROL, "private, no-store")) + .finish(), + )) +} + +pub async fn redirect_query_ref( + req: &HttpRequest, + parameter_name: &str, + project_ref: &str, + pool: &PgPool, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Scopes, +) -> Result, ApiError> { + let Some(target_project_id) = resolve_visible_refs( + req, + &[project_ref.to_string()], + pool, + redis, + session_queue, + required_scopes, + ) + .await? + .into_iter() + .next() + .flatten() 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)) + .append_header((header::CACHE_CONTROL, "private, no-store")) .finish(), )) } @@ -188,12 +351,24 @@ pub async fn redirect_ref( parameter_name: &str, 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); }; - 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, + required_scopes, + ) + .await? + .into_iter() + .next() + .flatten() else { return Ok(None); }; @@ -227,6 +402,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..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, }; @@ -249,6 +250,8 @@ pub async fn projects_get( &project_refs, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -308,9 +311,15 @@ 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(), + Scopes::PROJECT_READ, + ) + .await? { return Ok(response); } @@ -382,10 +391,17 @@ 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(), + Scopes::PROJECT_READ, + ) + .await? { return Ok(response); } @@ -433,6 +449,8 @@ pub async fn dependency_list( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), + Scopes::PROJECT_READ, ) .await? { @@ -615,9 +633,15 @@ 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(), + Scopes::PROJECT_WRITE, + ) + .await? { return Ok(response); } @@ -894,6 +918,8 @@ pub async fn projects_edit( &project_refs, pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, ) .await? { @@ -1041,9 +1067,15 @@ 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(), + Scopes::PROJECT_WRITE, + ) + .await? { return Ok(response); } @@ -1093,9 +1125,15 @@ 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(), + Scopes::PROJECT_WRITE, + ) + .await? { return Ok(response); } @@ -1176,9 +1214,15 @@ 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(), + Scopes::PROJECT_WRITE, + ) + .await? { return Ok(response); } @@ -1263,9 +1307,15 @@ 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(), + Scopes::PROJECT_WRITE, + ) + .await? { return Ok(response); } @@ -1324,9 +1374,15 @@ 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(), + Scopes::PROJECT_WRITE, + ) + .await? { return Ok(response); } @@ -1373,9 +1429,15 @@ 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(), + Scopes::PROJECT_DELETE, + ) + .await? { return Ok(response); } @@ -1421,9 +1483,15 @@ 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(), + Scopes::USER_WRITE, + ) + .await? { return Ok(response); } @@ -1461,9 +1529,15 @@ 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(), + Scopes::USER_WRITE, + ) + .await? { return Ok(response); } diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index fea945cecfe..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, }; @@ -56,9 +57,15 @@ 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(), + Scopes::PROJECT_READ, + ) + .await? { return Ok(response); } diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index b18c5582ff2..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, }; @@ -85,6 +86,8 @@ pub async fn version_list( "project_id", pool.as_ref(), redis.as_ref(), + session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await? { @@ -200,6 +203,8 @@ pub async fn version_project_get( "project_id", 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/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index 2a07f6ee7e3..cc4def47944 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,14 @@ 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(), + &Some(current_user.clone()), + &client, + &redis, + ) + .await?; + let mut transaction = client.begin().await?; let collection_id: CollectionId = @@ -262,6 +270,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 +287,16 @@ 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(), + &Some(user.clone()), + &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..f5f2cd1e9d2 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -66,7 +66,14 @@ pub async fn resolve_content( user_option: &user_option, trace: ResolveContentTrace::default(), }; - let request = request.into_inner(); + let mut request = request.into_inner(); + 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/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 9923e7b36e0..027fa04ef6c 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,20 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .await? + { + return Ok(response); + } + let (string,) = info.into_inner(); let project = db_models::DBProject::get(&string, &***ro_pool, &redis) @@ -88,7 +101,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 +138,20 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, + ) + .await? + { + return Ok(response); + } + let (string,) = info.into_inner(); let body = body.into_inner(); @@ -304,5 +330,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..797a457849f 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -63,6 +63,22 @@ 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(), + session_queue.as_ref(), + context.relevant_scope(), + ) + .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..b0f0f1a8b93 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -944,6 +944,14 @@ 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], + &Some(current_user.clone()), + &pool, + &redis, + ) + .await?; let project_item = database::models::DBProject::get( &project_info.project_id, &**pool, @@ -1106,6 +1114,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE, + ) + .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..e3d17d303fc 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) @@ -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,22 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .await? + { + return Ok(response); + } + projects_get(req, ids, pool, redis, session_queue).await } @@ -291,8 +313,23 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .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 +466,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, + ) + .await? + { + return Ok(response); + } + project_edit_internal( req, info, @@ -1404,6 +1454,16 @@ 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?; + if became_unsearchable { db_models::DBProject::clear_cache( project_item.inner.id, @@ -1691,10 +1751,25 @@ 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, + session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "id", + pool.as_ref(), + redis.as_ref(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .await? + { + return Ok(response); + } + project_get_check_internal(info, pool, redis).await } @@ -1738,6 +1813,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .await? + { + return Ok(response); + } + dependency_list_internal(req, info, pool, ro_pool, redis, session_queue) .await } @@ -1893,6 +1981,22 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, + ) + .await? + { + return Ok(response); + } + projects_edit( req, ids, @@ -2291,6 +2395,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, + ) + .await? + { + return Ok(response); + } + project_icon_edit_internal( web::Query(ext), req, @@ -2447,6 +2564,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, + ) + .await? + { + return Ok(response); + } + delete_project_icon_internal( req, info, @@ -2600,6 +2730,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_WRITE, + ) + .await? + { + return Ok(response); + } + add_gallery_item_internal( web::Query(ext), req, @@ -2822,7 +2965,6 @@ pub struct GalleryEditQuery { /// Update a gallery item. #[utoipa::path( - context_path = "/project", tag = "projects", params( ("url" = String, Query), @@ -2833,7 +2975,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, @@ -3054,14 +3196,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, @@ -3230,9 +3371,30 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_DELETE, + ) + .await? + { + return Ok(response); + } + + project_delete_internal( + req, + info, + pool, + redis, + session_queue, + search_state, + ) + .await?; + Ok(HttpResponse::NoContent().finish()) } pub async fn project_delete_internal( @@ -3543,6 +3705,19 @@ 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(), + session_queue.as_ref(), + Scopes::USER_WRITE, + ) + .await? + { + return Ok(response); + } + project_follow_internal(req, info, pool, redis, session_queue).await } @@ -3648,6 +3823,19 @@ 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(), + session_queue.as_ref(), + Scopes::USER_WRITE, + ) + .await? + { + return Ok(response); + } + project_unfollow_internal(req, info, pool, redis, session_queue).await } @@ -3745,6 +3933,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ, + ) + .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..af9e525e9e9 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,20 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .await? + { + return Ok(response); + } + let user = get_user_from_headers( &req, &**pool, @@ -206,5 +219,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..f5f7b9f9ab6 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -48,6 +48,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ, + ) + .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 06007dd0b17..a77f55c4fd0 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -68,6 +68,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, + ) + .await? + { + return Ok(response); + } + let info = info.into_inner(); version_project_get_helper(req, info, pool, ro_pool, redis, session_queue) .await @@ -1054,6 +1067,19 @@ 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(), + session_queue.as_ref(), + Scopes::PROJECT_READ | Scopes::VERSION_READ, + ) + .await? + { + return Ok(response); + } + version_list_internal( req, info, 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/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index a86def9e787..978638dbd5c 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; @@ -113,6 +116,346 @@ 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_NAMESPACE, alias); + redis + .set_serialized( + &key, + &Some(parse_base62(target).unwrap() as i64), + Some(300), + ) + .await + .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, + ] + ); + } + + 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); + 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 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 { + 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_NAMESPACE, project_id), + redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, &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 diff --git a/packages/api-client/src/core/errors.ts b/packages/api-client/src/core/errors.ts index 864a7515deb..5240be65af3 100644 --- a/packages/api-client/src/core/errors.ts +++ b/packages/api-client/src/core/errors.ts @@ -95,7 +95,8 @@ export class ModrinthServerError extends ModrinthApiError { context?: string, ): ModrinthServerError { const v1Error = isModrinthErrorResponse(responseData) ? responseData : undefined - const isHtmlPage = typeof responseData === 'string' && / { - 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',