diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 672161b9..78a7489f 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -350,7 +350,8 @@ pub fn utoipa_app_config( cfg: &mut utoipa_actix_web::service_config::ServiceConfig, _labrinth_config: LabrinthConfig, ) { - cfg.configure(routes::v3::utoipa_config); + cfg.configure(routes::v3::utoipa_config) + .configure(routes::internal::utoipa_config); } // This is so that env vars not used immediately don't panic at runtime diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 12cb6b2e..f70d806a 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use validator::Validate; /// A project returned from the API -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct Project { /// The ID of the project, encoded as a base62 string. pub id: ProjectId, @@ -370,7 +370,7 @@ impl Project { // }) // } } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct GalleryItem { pub url: String, pub raw_url: String, @@ -381,7 +381,7 @@ pub struct GalleryItem { pub ordering: i64, } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct ModeratorMessage { pub message: String, pub body: Option, @@ -389,14 +389,23 @@ pub struct ModeratorMessage { pub const DEFAULT_LICENSE_ID: &str = "LicenseRef-All-Rights-Reserved"; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct License { pub id: String, pub name: String, pub url: Option, } -#[derive(Serialize, Deserialize, Validate, Clone, Eq, PartialEq)] +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Validate, + Eq, + PartialEq, + utoipa::ToSchema, +)] pub struct Link { pub platform: String, pub donation: bool, @@ -425,7 +434,9 @@ impl From for Link { /// Processing - Project is not displayed on search, and not accessible by URL (Temporary state, project under review) /// Scheduled - Project is scheduled to be released in the future /// Private - Project is approved, but is not viewable to the public -#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[derive( + Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema, +)] #[serde(rename_all = "lowercase")] pub enum ProjectStatus { Approved, @@ -564,7 +575,9 @@ impl ProjectStatus { } } -#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)] +#[derive( + Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq, utoipa::ToSchema, +)] #[serde(rename_all = "kebab-case")] pub enum MonetizationStatus { ForceDemonetized, @@ -599,7 +612,9 @@ impl MonetizationStatus { /// Represents the status of the manual review of the migration of side types of this /// project to the new environment field. -#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)] +#[derive( + Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq, utoipa::ToSchema, +)] #[serde(rename_all = "kebab-case")] pub enum SideTypesMigrationReviewStatus { /// The project has been reviewed to use the new environment side types appropriately. diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 58945604..cb17fe84 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -773,20 +773,20 @@ impl AutomatedModerationQueue { } } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct MissingMetadata { pub identified: HashMap, pub flame_files: HashMap, pub unknown_files: HashMap, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct IdentifiedFile { pub file_name: String, pub status: ApprovalType, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct MissingMetadataFlame { pub title: String, pub file_name: String, @@ -794,7 +794,9 @@ pub struct MissingMetadataFlame { pub id: u32, } -#[derive(Deserialize, Serialize, Copy, Clone, PartialEq, Eq, Debug)] +#[derive( + Deserialize, Serialize, Copy, Clone, PartialEq, Eq, Debug, utoipa::ToSchema, +)] #[serde(rename_all = "kebab-case")] pub enum ApprovalType { Yes, diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 8cca28c2..f15da09f 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -25,7 +25,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(session::config) .configure(flows::config) .configure(pats::config) - .configure(moderation::config) .configure(billing::config) .configure(gdpr::config) .configure(gotenberg::config) @@ -36,3 +35,13 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(mural::config), ); } + +pub fn utoipa_config( + cfg: &mut utoipa_actix_web::service_config::ServiceConfig, +) { + cfg.service( + utoipa_actix_web::scope("/_internal/moderation") + .wrap(default_cors()) + .configure(moderation::config), + ); +} diff --git a/apps/labrinth/src/routes/internal/moderation.rs b/apps/labrinth/src/routes/internal/moderation.rs index 049ce727..97318382 100644 --- a/apps/labrinth/src/routes/internal/moderation.rs +++ b/apps/labrinth/src/routes/internal/moderation.rs @@ -1,26 +1,32 @@ use super::ApiError; use crate::database; +use crate::database::models::{DBOrganization, DBTeamId, DBTeamMember, DBUser}; use crate::database::redis::RedisPool; -use crate::models::projects::ProjectStatus; +use crate::models::ids::{OrganizationId, TeamId}; +use crate::models::projects::{Project, ProjectStatus}; use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata}; use crate::queue::session::AuthQueue; +use crate::util::error::Context; use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; -use actix_web::{HttpRequest, HttpResponse, web}; -use ariadne::ids::random_base62; -use serde::Deserialize; +use actix_web::{HttpRequest, get, post, web}; +use ariadne::ids::{UserId, random_base62}; +use eyre::eyre; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("moderation/projects", web::get().to(get_projects)); - cfg.route("moderation/project/{id}", web::get().to(get_project_meta)); - cfg.route("moderation/project", web::post().to(set_project_meta)); +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(get_projects) + .service(get_project_meta) + .service(set_project_meta); } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ProjectsRequestOptions { + /// How many projects to fetch. #[serde(default = "default_count")] pub count: u16, + /// How many projects to skip. #[serde(default)] pub offset: u32, } @@ -29,13 +35,63 @@ fn default_count() -> u16 { 100 } -pub async fn get_projects( +/// Project with extra information fetched from the database, to avoid having +/// clients make more round trips. +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct FetchedProject { + /// Project info. + #[serde(flatten)] + pub project: Project, + /// Who owns the project. + pub ownership: Ownership, +} + +/// Fetched information on who owns a project. +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Ownership { + /// Project is owned by a team, and this is the team owner. + User { + /// ID of the team owner. + id: UserId, + /// Name of the team owner. + name: String, + /// URL of the team owner's icon. + icon_url: Option, + }, + /// Project is owned by an organization. + Organization { + /// ID of the organization. + id: OrganizationId, + /// Name of the organization. + name: String, + /// URL of the organization's icon. + icon_url: Option, + }, +} + +/// Fetch all projects which are in the moderation queue. +#[utoipa::path( + responses((status = OK, body = inline(Vec))) +)] +#[get("/projects")] +async fn get_projects( req: HttpRequest, pool: web::Data, redis: web::Data, request_opts: web::Query, session_queue: web::Data, -) -> Result { +) -> Result>, ApiError> { + get_projects_internal(req, pool, redis, request_opts, session_queue).await +} + +pub async fn get_projects_internal( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + request_opts: web::Query, + session_queue: web::Data, +) -> Result>, ApiError> { check_is_moderator_from_headers( &req, &**pool, @@ -62,25 +118,100 @@ pub async fn get_projects( .fetch(&**pool) .map_ok(|m| database::models::DBProjectId(m.id)) .try_collect::>() - .await?; + .await + .wrap_internal_err("failed to fetch projects awaiting review")?; - let projects: Vec<_> = + let projects = database::DBProject::get_many_ids(&project_ids, &**pool, &redis) - .await? + .await + .wrap_internal_err("failed to fetch projects")? .into_iter() .map(crate::models::projects::Project::from) - .collect(); + .collect::>(); - Ok(HttpResponse::Ok().json(projects)) + let team_ids = projects + .iter() + .map(|project| project.team_id) + .map(DBTeamId::from) + .collect::>(); + let org_ids = projects + .iter() + .filter_map(|project| project.organization) + .collect::>(); + + let team_members = + DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis) + .await + .wrap_internal_err("failed to fetch team members")?; + let users = DBUser::get_many_ids( + &team_members + .iter() + .map(|member| member.user_id) + .collect::>(), + &**pool, + &redis, + ) + .await + .wrap_internal_err("failed to fetch user data of team members")?; + let orgs = DBOrganization::get_many(&org_ids, &**pool, &redis) + .await + .wrap_internal_err("failed to fetch organizations")?; + + let map_project = |project: Project| -> Result { + let project_id = project.id; + let ownership = if let Some(org_id) = project.organization { + let org = orgs + .iter() + .find(|org| OrganizationId::from(org.id) == org_id) + .wrap_internal_err_with(|| { + eyre!( + "project {project_id} is owned by an invalid organization {org_id}" + ) + })?; + + Ownership::Organization { + id: OrganizationId::from(org.id), + name: org.name.clone(), + icon_url: org.icon_url.clone(), + } + } else { + let team_id = project.team_id; + let team_owner = team_members.iter().find(|member| TeamId::from(member.team_id) == team_id && member.is_owner) + .wrap_internal_err_with(|| eyre!("project {project_id} is owned by a team {team_id} which has no valid owner"))?; + let team_owner_id = team_owner.user_id; + let user = users.iter().find(|user| user.id == team_owner_id) + .wrap_internal_err_with(|| eyre!("project {project_id} is owned by a team {team_id} which has owner {} which does not exist", UserId::from(team_owner_id)))?; + + Ownership::User { + id: UserId::from(user.id), + name: user.username.clone(), + icon_url: user.avatar_url.clone(), + } + }; + + Ok(FetchedProject { ownership, project }) + }; + + let projects = projects + .into_iter() + .map(map_project) + .collect::, _>>()?; + + Ok(web::Json(projects)) } -pub async fn get_project_meta( +/// Fetch moderation metadata for a specific project. +#[utoipa::path( + responses((status = OK, body = inline(Vec))) +)] +#[get("/project/{id}")] +async fn get_project_meta( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, info: web::Path<(String,)>, -) -> Result { +) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, @@ -202,13 +333,13 @@ pub async fn get_project_meta( } } - Ok(HttpResponse::Ok().json(merged)) + Ok(web::Json(merged)) } else { Err(ApiError::NotFound) } } -#[derive(Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Judgement { Flame { @@ -225,13 +356,16 @@ pub enum Judgement { }, } -pub async fn set_project_meta( +/// Update moderation judgements for projects in the review queue. +#[utoipa::path] +#[post("/project")] +async fn set_project_meta( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, judgements: web::Json>, -) -> Result { +) -> Result<(), ApiError> { check_is_moderator_from_headers( &req, &**pool, @@ -302,11 +436,11 @@ pub async fn set_project_meta( sqlx::query( " - INSERT INTO moderation_external_files (sha1, external_license_id) - SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) - ON CONFLICT (sha1) - DO NOTHING - ", + INSERT INTO moderation_external_files (sha1, external_license_id) + SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) + ON CONFLICT (sha1) + DO NOTHING + ", ) .bind(&file_hashes[..]) .bind(&ids[..]) @@ -315,5 +449,5 @@ pub async fn set_project_meta( transaction.commit().await?; - Ok(HttpResponse::NoContent().finish()) + Ok(()) } diff --git a/apps/labrinth/src/routes/v2/moderation.rs b/apps/labrinth/src/routes/v2/moderation.rs index ff721e6c..f80970d1 100644 --- a/apps/labrinth/src/routes/v2/moderation.rs +++ b/apps/labrinth/src/routes/v2/moderation.rs @@ -30,7 +30,7 @@ pub async fn get_projects( count: web::Query, session_queue: web::Data, ) -> Result { - let response = internal::moderation::get_projects( + let response = internal::moderation::get_projects_internal( req, pool.clone(), redis.clone(), @@ -41,6 +41,7 @@ pub async fn get_projects( session_queue, ) .await + .map(|resp| HttpResponse::Ok().json(resp)) .or_else(v2_reroute::flatten_404_error)?; // Convert to V2 projects