Fetch more data for moderation endpoints (#4727)

* Moderation endpoints fetch ownership data

* fix up endpoint configs

* add some docs
This commit is contained in:
aecsocket
2025-11-07 10:50:29 -08:00
committed by GitHub
parent a261598e89
commit 608ab988f0
6 changed files with 205 additions and 43 deletions

View File

@@ -350,7 +350,8 @@ pub fn utoipa_app_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig, cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
_labrinth_config: LabrinthConfig, _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 // This is so that env vars not used immediately don't panic at runtime

View File

@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use validator::Validate; use validator::Validate;
/// A project returned from the API /// A project returned from the API
#[derive(Serialize, Deserialize, Clone)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Project { pub struct Project {
/// The ID of the project, encoded as a base62 string. /// The ID of the project, encoded as a base62 string.
pub id: ProjectId, 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 struct GalleryItem {
pub url: String, pub url: String,
pub raw_url: String, pub raw_url: String,
@@ -381,7 +381,7 @@ pub struct GalleryItem {
pub ordering: i64, pub ordering: i64,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)]
pub struct ModeratorMessage { pub struct ModeratorMessage {
pub message: String, pub message: String,
pub body: Option<String>, pub body: Option<String>,
@@ -389,14 +389,23 @@ pub struct ModeratorMessage {
pub const DEFAULT_LICENSE_ID: &str = "LicenseRef-All-Rights-Reserved"; 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 struct License {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub url: Option<String>, pub url: Option<String>,
} }
#[derive(Serialize, Deserialize, Validate, Clone, Eq, PartialEq)] #[derive(
Debug,
Clone,
Serialize,
Deserialize,
Validate,
Eq,
PartialEq,
utoipa::ToSchema,
)]
pub struct Link { pub struct Link {
pub platform: String, pub platform: String,
pub donation: bool, pub donation: bool,
@@ -425,7 +434,9 @@ impl From<LinkUrl> for Link {
/// Processing - Project is not displayed on search, and not accessible by URL (Temporary state, project under review) /// 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 /// Scheduled - Project is scheduled to be released in the future
/// Private - Project is approved, but is not viewable to the public /// 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")] #[serde(rename_all = "lowercase")]
pub enum ProjectStatus { pub enum ProjectStatus {
Approved, 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")] #[serde(rename_all = "kebab-case")]
pub enum MonetizationStatus { pub enum MonetizationStatus {
ForceDemonetized, ForceDemonetized,
@@ -599,7 +612,9 @@ impl MonetizationStatus {
/// Represents the status of the manual review of the migration of side types of this /// Represents the status of the manual review of the migration of side types of this
/// project to the new environment field. /// 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")] #[serde(rename_all = "kebab-case")]
pub enum SideTypesMigrationReviewStatus { pub enum SideTypesMigrationReviewStatus {
/// The project has been reviewed to use the new environment side types appropriately. /// The project has been reviewed to use the new environment side types appropriately.

View File

@@ -773,20 +773,20 @@ impl AutomatedModerationQueue {
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MissingMetadata { pub struct MissingMetadata {
pub identified: HashMap<String, IdentifiedFile>, pub identified: HashMap<String, IdentifiedFile>,
pub flame_files: HashMap<String, MissingMetadataFlame>, pub flame_files: HashMap<String, MissingMetadataFlame>,
pub unknown_files: HashMap<String, String>, pub unknown_files: HashMap<String, String>,
} }
#[derive(Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct IdentifiedFile { pub struct IdentifiedFile {
pub file_name: String, pub file_name: String,
pub status: ApprovalType, pub status: ApprovalType,
} }
#[derive(Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MissingMetadataFlame { pub struct MissingMetadataFlame {
pub title: String, pub title: String,
pub file_name: String, pub file_name: String,
@@ -794,7 +794,9 @@ pub struct MissingMetadataFlame {
pub id: u32, 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")] #[serde(rename_all = "kebab-case")]
pub enum ApprovalType { pub enum ApprovalType {
Yes, Yes,

View File

@@ -25,7 +25,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(session::config) .configure(session::config)
.configure(flows::config) .configure(flows::config)
.configure(pats::config) .configure(pats::config)
.configure(moderation::config)
.configure(billing::config) .configure(billing::config)
.configure(gdpr::config) .configure(gdpr::config)
.configure(gotenberg::config) .configure(gotenberg::config)
@@ -36,3 +35,13 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(mural::config), .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),
);
}

View File

@@ -1,26 +1,32 @@
use super::ApiError; use super::ApiError;
use crate::database; use crate::database;
use crate::database::models::{DBOrganization, DBTeamId, DBTeamMember, DBUser};
use crate::database::redis::RedisPool; 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::moderation::{ApprovalType, IdentifiedFile, MissingMetadata};
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::util::error::Context;
use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes};
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpRequest, get, post, web};
use ariadne::ids::random_base62; use ariadne::ids::{UserId, random_base62};
use serde::Deserialize; use eyre::eyre;
use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.route("moderation/projects", web::get().to(get_projects)); cfg.service(get_projects)
cfg.route("moderation/project/{id}", web::get().to(get_project_meta)); .service(get_project_meta)
cfg.route("moderation/project", web::post().to(set_project_meta)); .service(set_project_meta);
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct ProjectsRequestOptions { pub struct ProjectsRequestOptions {
/// How many projects to fetch.
#[serde(default = "default_count")] #[serde(default = "default_count")]
pub count: u16, pub count: u16,
/// How many projects to skip.
#[serde(default)] #[serde(default)]
pub offset: u32, pub offset: u32,
} }
@@ -29,13 +35,63 @@ fn default_count() -> u16 {
100 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<String>,
},
/// 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<String>,
},
}
/// Fetch all projects which are in the moderation queue.
#[utoipa::path(
responses((status = OK, body = inline(Vec<FetchedProject>)))
)]
#[get("/projects")]
async fn get_projects(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
request_opts: web::Query<ProjectsRequestOptions>, request_opts: web::Query<ProjectsRequestOptions>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<web::Json<Vec<FetchedProject>>, ApiError> {
get_projects_internal(req, pool, redis, request_opts, session_queue).await
}
pub async fn get_projects_internal(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
request_opts: web::Query<ProjectsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<FetchedProject>>, ApiError> {
check_is_moderator_from_headers( check_is_moderator_from_headers(
&req, &req,
&**pool, &**pool,
@@ -62,25 +118,100 @@ pub async fn get_projects(
.fetch(&**pool) .fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id)) .map_ok(|m| database::models::DBProjectId(m.id))
.try_collect::<Vec<database::models::DBProjectId>>() .try_collect::<Vec<database::models::DBProjectId>>()
.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) database::DBProject::get_many_ids(&project_ids, &**pool, &redis)
.await? .await
.wrap_internal_err("failed to fetch projects")?
.into_iter() .into_iter()
.map(crate::models::projects::Project::from) .map(crate::models::projects::Project::from)
.collect(); .collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(projects)) let team_ids = projects
.iter()
.map(|project| project.team_id)
.map(DBTeamId::from)
.collect::<Vec<_>>();
let org_ids = projects
.iter()
.filter_map(|project| project.organization)
.collect::<Vec<_>>();
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::<Vec<_>>(),
&**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<FetchedProject, ApiError> {
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::<Result<Vec<_>, _>>()?;
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<Project>)))
)]
#[get("/project/{id}")]
async fn get_project_meta(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
info: web::Path<(String,)>, info: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> { ) -> Result<web::Json<MissingMetadata>, ApiError> {
check_is_moderator_from_headers( check_is_moderator_from_headers(
&req, &req,
&**pool, &**pool,
@@ -202,13 +333,13 @@ pub async fn get_project_meta(
} }
} }
Ok(HttpResponse::Ok().json(merged)) Ok(web::Json(merged))
} else { } else {
Err(ApiError::NotFound) Err(ApiError::NotFound)
} }
} }
#[derive(Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum Judgement { pub enum Judgement {
Flame { 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, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
judgements: web::Json<HashMap<String, Judgement>>, judgements: web::Json<HashMap<String, Judgement>>,
) -> Result<HttpResponse, ApiError> { ) -> Result<(), ApiError> {
check_is_moderator_from_headers( check_is_moderator_from_headers(
&req, &req,
&**pool, &**pool,
@@ -302,11 +436,11 @@ pub async fn set_project_meta(
sqlx::query( sqlx::query(
" "
INSERT INTO moderation_external_files (sha1, external_license_id) INSERT INTO moderation_external_files (sha1, external_license_id)
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
ON CONFLICT (sha1) ON CONFLICT (sha1)
DO NOTHING DO NOTHING
", ",
) )
.bind(&file_hashes[..]) .bind(&file_hashes[..])
.bind(&ids[..]) .bind(&ids[..])
@@ -315,5 +449,5 @@ pub async fn set_project_meta(
transaction.commit().await?; transaction.commit().await?;
Ok(HttpResponse::NoContent().finish()) Ok(())
} }

View File

@@ -30,7 +30,7 @@ pub async fn get_projects(
count: web::Query<ResultCount>, count: web::Query<ResultCount>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let response = internal::moderation::get_projects( let response = internal::moderation::get_projects_internal(
req, req,
pool.clone(), pool.clone(),
redis.clone(), redis.clone(),
@@ -41,6 +41,7 @@ pub async fn get_projects(
session_queue, session_queue,
) )
.await .await
.map(|resp| HttpResponse::Ok().json(resp))
.or_else(v2_reroute::flatten_404_error)?; .or_else(v2_reroute::flatten_404_error)?;
// Convert to V2 projects // Convert to V2 projects