mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
* chore(labrinth): bump to redis 1.4.1 * feat(labrinth): redis cluster * chore: cleanup * feat(labrinth): cache locking * fix(labrinth): clippy * chore(labrinth): cleanup env, remove postcard support * chore(ci): fix test env for labrinth * chore(labrinth): bump all key versions * chore(labrinth): improve redis key identities handling * chore(labrinth): simplify deadline handling * chore(labrinth): remove unused lease tracking * chore(labrinth): remove distributed cache locking for now * chore(labrinth): improve redis backend init error * feat(labrinth): expose redis read replica strategy * chore(ci): remove other connection mode tests * chore: split xredis crate * feat(xredis): primaries routing * chore: tombi fmt * chore: clippy * chore: update query cache
1630 lines
56 KiB
Rust
1630 lines
56 KiB
Rust
use super::ApiError;
|
|
use crate::auth::get_user_from_headers;
|
|
use crate::database;
|
|
use crate::database::PgPool;
|
|
use crate::database::models::DBModerationLock;
|
|
use crate::database::models::moderation_external_item;
|
|
use crate::models::ids::{OrganizationId, ProjectId};
|
|
use crate::models::projects::{ProjectStatus, VersionStatus};
|
|
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, get_user_from_bearer_token},
|
|
models::pats::Scopes,
|
|
};
|
|
use actix_web::{HttpRequest, delete, get, post, web};
|
|
use ariadne::ids::{UserId, random_base62};
|
|
use chrono::{DateTime, Utc};
|
|
use eyre::eyre;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use xredis::RedisPool;
|
|
|
|
pub mod external_license;
|
|
mod ownership;
|
|
pub mod tech_review;
|
|
|
|
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
|
cfg.service(get_projects)
|
|
.service(get_project_ids)
|
|
.service(get_project_meta)
|
|
.service(set_project_meta)
|
|
.service(acquire_lock)
|
|
.service(override_lock)
|
|
.service(get_lock_status)
|
|
.service(release_lock)
|
|
.service(release_lock_beacon)
|
|
.service(delete_all_locks)
|
|
.service(web::scope("/tech-review").configure(tech_review::config))
|
|
.service(
|
|
web::scope("/external-license").configure(external_license::config),
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
/// Whether to filter by modpacks that have external dependencies.
|
|
#[serde(default)]
|
|
pub has_external_dependencies: Option<bool>,
|
|
/// Whether to exclude projects with pending technical review issues.
|
|
#[serde(default)]
|
|
pub exclude_technical_review: bool,
|
|
/// Text query to search against project and owner fields.
|
|
#[serde(default)]
|
|
pub query: Option<String>,
|
|
/// Project type to filter by. Use `none` for projects without a type.
|
|
#[serde(default)]
|
|
pub project_type: Option<String>,
|
|
/// Sort order for the moderation queue.
|
|
#[serde(default)]
|
|
pub sort: Option<ModerationProjectsSort>,
|
|
}
|
|
|
|
fn default_count() -> u16 {
|
|
100
|
|
}
|
|
|
|
const MAX_PROJECTS_PER_PAGE: u16 = 200;
|
|
|
|
#[derive(
|
|
Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema, Default,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ModerationProjectsSort {
|
|
#[default]
|
|
Oldest,
|
|
Newest,
|
|
MostExternalDeps,
|
|
LeastExternalDeps,
|
|
}
|
|
|
|
impl ModerationProjectsSort {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Oldest => "oldest",
|
|
Self::Newest => "newest",
|
|
Self::MostExternalDeps => "most_external_deps",
|
|
Self::LeastExternalDeps => "least_external_deps",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ModerationProjectsResponse {
|
|
pub total: i64,
|
|
pub projects: Vec<ModerationQueueProject>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ModerationProjectIdsResponse {
|
|
pub ids: Vec<ProjectId>,
|
|
}
|
|
|
|
/// Lightweight project information for the moderation queue index.
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ModerationQueueProject {
|
|
pub id: ProjectId,
|
|
pub slug: Option<String>,
|
|
pub name: String,
|
|
pub summary: String,
|
|
pub icon_url: Option<String>,
|
|
pub status: ProjectStatus,
|
|
pub requested_status: Option<ProjectStatus>,
|
|
pub queued: Option<DateTime<Utc>>,
|
|
pub published: DateTime<Utc>,
|
|
pub updated: DateTime<Utc>,
|
|
pub project_types: Vec<String>,
|
|
/// Who owns the project.
|
|
pub ownership: Ownership,
|
|
/// How many external file dependencies the project has.
|
|
pub external_dependencies_count: i64,
|
|
}
|
|
|
|
/// Fetched information on who owns a project.
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, Clone)]
|
|
#[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>,
|
|
},
|
|
}
|
|
|
|
/// Response for lock status check
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct LockStatusResponse {
|
|
/// Whether the project is currently locked
|
|
pub locked: bool,
|
|
/// Whether the requesting user holds the lock
|
|
pub is_own_lock: bool,
|
|
/// Information about who holds the lock (if locked)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub locked_by: Option<LockedByUser>,
|
|
/// When the lock was acquired
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub locked_at: Option<DateTime<Utc>>,
|
|
/// When the lock expires
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
/// Whether the lock has expired
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expired: Option<bool>,
|
|
}
|
|
|
|
/// Information about the moderator holding the lock
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct LockedByUser {
|
|
/// User ID (base62 encoded)
|
|
pub id: String,
|
|
/// Username
|
|
pub username: String,
|
|
/// Avatar URL
|
|
pub avatar_url: Option<String>,
|
|
}
|
|
|
|
/// Response for successful lock acquisition
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct LockAcquireResponse {
|
|
/// Whether lock was successfully acquired
|
|
pub success: bool,
|
|
/// Whether the requesting user holds the lock (true when success is true)
|
|
pub is_own_lock: bool,
|
|
/// If blocked, info about who holds the lock
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub locked_by: Option<LockedByUser>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub locked_at: Option<DateTime<Utc>>,
|
|
/// When the lock expires (present whether acquired or blocked)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expired: Option<bool>,
|
|
}
|
|
|
|
/// Response for lock release
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct LockReleaseResponse {
|
|
pub success: bool,
|
|
}
|
|
|
|
/// Response for deleting all locks
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct DeleteAllLocksResponse {
|
|
pub deleted_count: u64,
|
|
}
|
|
|
|
/// List projects in the moderation queue.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
params(
|
|
("count" = Option<u16>, Query),
|
|
("offset" = Option<u32>, Query),
|
|
("has_external_dependencies" = Option<bool>, Query),
|
|
("exclude_technical_review" = bool, Query),
|
|
("query" = Option<String>, Query),
|
|
("project_type" = Option<String>, Query),
|
|
("sort" = Option<ModerationProjectsSort>, Query)
|
|
),
|
|
responses((status = OK, body = ModerationProjectsResponse))
|
|
)]
|
|
#[get("/projects")]
|
|
pub async fn get_projects(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
request_opts: web::Query<ProjectsRequestOptions>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
) -> Result<web::Json<ModerationProjectsResponse>, 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<ModerationProjectsResponse>, ApiError> {
|
|
check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_READ,
|
|
)
|
|
.await?;
|
|
|
|
let request_opts = request_opts.into_inner();
|
|
let query = normalize_optional_string(request_opts.query.as_deref());
|
|
let project_type =
|
|
normalize_optional_string(request_opts.project_type.as_deref());
|
|
let sort = request_opts.sort.unwrap_or_default().as_str();
|
|
let listed_version_statuses = listed_version_statuses();
|
|
let count = request_opts.count.min(MAX_PROJECTS_PER_PAGE) as i64;
|
|
let offset = request_opts.offset as i64;
|
|
let exclude_technical_review = request_opts.exclude_technical_review;
|
|
let needs_filtered_queue = query.is_some()
|
|
|| project_type.is_some()
|
|
|| request_opts.has_external_dependencies.is_some()
|
|
|| matches!(sort, "most_external_deps" | "least_external_deps");
|
|
|
|
let (total, projects) = if needs_filtered_queue {
|
|
let project_rows = sqlx::query!(
|
|
r#"
|
|
WITH moderation_projects AS (
|
|
SELECT
|
|
m.id,
|
|
m.slug,
|
|
m.name,
|
|
m.summary,
|
|
m.description,
|
|
m.queued,
|
|
m.published,
|
|
m.organization_id,
|
|
m.team_id,
|
|
m.components
|
|
FROM mods m
|
|
WHERE
|
|
m.status = $1
|
|
AND (
|
|
$9::boolean = false
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM delphi_issue_details_with_statuses didws
|
|
WHERE didws.project_id = m.id
|
|
AND didws.status = 'pending'
|
|
)
|
|
)
|
|
),
|
|
external_dependencies AS (
|
|
SELECT
|
|
v.mod_id,
|
|
COUNT(*) AS external_dependencies_count
|
|
FROM versions v
|
|
INNER JOIN moderation_projects mp ON mp.id = v.mod_id
|
|
INNER JOIN dependencies d ON d.dependent_id = v.id
|
|
WHERE d.dependency_file_name IS NOT NULL
|
|
GROUP BY v.mod_id
|
|
),
|
|
version_project_types AS (
|
|
SELECT
|
|
v.mod_id,
|
|
ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types
|
|
FROM versions v
|
|
INNER JOIN moderation_projects mp ON mp.id = v.mod_id
|
|
INNER JOIN loaders_versions lv ON v.id = lv.version_id
|
|
INNER JOIN loaders l ON lv.loader_id = l.id
|
|
INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id
|
|
INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id
|
|
WHERE v.status = ANY($2)
|
|
GROUP BY v.mod_id
|
|
),
|
|
queue_projects AS (
|
|
SELECT
|
|
mp.id,
|
|
mp.slug,
|
|
mp.name,
|
|
mp.summary,
|
|
mp.description,
|
|
mp.queued,
|
|
mp.published,
|
|
search_organization.name AS organization_name,
|
|
search_owner.username AS owner_name,
|
|
CASE
|
|
WHEN mp.components ? 'minecraft_server'
|
|
THEN ARRAY_APPEND(
|
|
ARRAY_REMOVE(
|
|
COALESCE(vpt.project_types::text[], ARRAY[]::text[]),
|
|
'modpack'
|
|
),
|
|
'minecraft_java_server'
|
|
)
|
|
ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])
|
|
END AS project_types,
|
|
COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count
|
|
FROM moderation_projects mp
|
|
LEFT JOIN organizations search_organization
|
|
ON search_organization.id = mp.organization_id
|
|
AND $3::text IS NOT NULL
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
u.username
|
|
FROM team_members tm
|
|
INNER JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = mp.team_id
|
|
AND tm.is_owner
|
|
ORDER BY tm.ordering ASC
|
|
LIMIT 1
|
|
) search_owner ON $3::text IS NOT NULL
|
|
AND mp.organization_id IS NULL
|
|
LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id
|
|
LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id
|
|
),
|
|
filtered_projects AS (
|
|
SELECT
|
|
id,
|
|
queued,
|
|
published,
|
|
project_types,
|
|
external_dependencies_count
|
|
FROM queue_projects
|
|
WHERE
|
|
(
|
|
$3::text IS NULL
|
|
OR name ILIKE '%' || $3 || '%'
|
|
OR slug ILIKE '%' || $3 || '%'
|
|
OR summary ILIKE '%' || $3 || '%'
|
|
OR description ILIKE '%' || $3 || '%'
|
|
OR owner_name ILIKE '%' || $3 || '%'
|
|
OR organization_name ILIKE '%' || $3 || '%'
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM UNNEST(project_types) AS searched_project_type(project_type)
|
|
WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'
|
|
)
|
|
)
|
|
AND (
|
|
$4::text IS NULL
|
|
OR ($4 = 'none' AND CARDINALITY(project_types) = 0)
|
|
OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])
|
|
OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND
|
|
project_types[1] = $4
|
|
)
|
|
)
|
|
AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)
|
|
),
|
|
total AS (
|
|
SELECT COUNT(*) AS total_count FROM filtered_projects
|
|
),
|
|
page_ids AS (
|
|
SELECT
|
|
id,
|
|
queued,
|
|
published,
|
|
project_types,
|
|
external_dependencies_count
|
|
FROM filtered_projects
|
|
ORDER BY
|
|
CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC,
|
|
CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC,
|
|
CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,
|
|
CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,
|
|
id ASC
|
|
OFFSET $7
|
|
LIMIT $6
|
|
),
|
|
page_projects AS (
|
|
SELECT
|
|
m.id,
|
|
m.slug,
|
|
m.name,
|
|
m.summary,
|
|
m.icon_url,
|
|
m.status,
|
|
m.requested_status,
|
|
m.queued,
|
|
m.published,
|
|
m.updated,
|
|
m.organization_id,
|
|
m.team_id,
|
|
o.name AS organization_name,
|
|
o.icon_url AS organization_icon_url,
|
|
owner.user_id AS owner_id,
|
|
owner.username AS owner_name,
|
|
owner.avatar_url AS owner_icon_url,
|
|
page_ids.project_types,
|
|
page_ids.external_dependencies_count
|
|
FROM page_ids
|
|
INNER JOIN mods m ON m.id = page_ids.id
|
|
LEFT JOIN organizations o ON o.id = m.organization_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
tm.user_id,
|
|
u.username,
|
|
u.avatar_url
|
|
FROM team_members tm
|
|
INNER JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = m.team_id
|
|
AND tm.is_owner
|
|
ORDER BY tm.ordering ASC
|
|
LIMIT 1
|
|
) owner ON m.organization_id IS NULL
|
|
)
|
|
SELECT
|
|
total.total_count AS "total_count!",
|
|
page_projects.id AS "id?",
|
|
page_projects.slug AS "slug?",
|
|
page_projects.name AS "name?",
|
|
page_projects.summary AS "summary?",
|
|
page_projects.icon_url AS "icon_url?",
|
|
page_projects.status AS "status?",
|
|
page_projects.requested_status AS "requested_status?",
|
|
page_projects.queued AS "queued?",
|
|
page_projects.published AS "published?",
|
|
page_projects.updated AS "updated?",
|
|
page_projects.organization_id AS "organization_id?",
|
|
page_projects.organization_name AS "organization_name?",
|
|
page_projects.organization_icon_url AS "organization_icon_url?",
|
|
page_projects.owner_id AS "owner_id?",
|
|
page_projects.owner_name AS "owner_name?",
|
|
page_projects.owner_icon_url AS "owner_icon_url?",
|
|
page_projects.project_types AS "project_types?: Vec<String>",
|
|
page_projects.external_dependencies_count AS "external_dependencies_count?"
|
|
FROM total
|
|
LEFT JOIN page_projects ON true
|
|
ORDER BY
|
|
CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC,
|
|
CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC,
|
|
CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,
|
|
CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,
|
|
page_projects.id ASC
|
|
"#,
|
|
ProjectStatus::Processing.as_str(),
|
|
&listed_version_statuses,
|
|
query,
|
|
project_type,
|
|
request_opts.has_external_dependencies,
|
|
count,
|
|
offset,
|
|
sort,
|
|
exclude_technical_review,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await
|
|
.wrap_internal_err("failed to fetch filtered projects awaiting review")?;
|
|
|
|
let total =
|
|
project_rows.first().map(|row| row.total_count).unwrap_or(0);
|
|
let mut projects = Vec::new();
|
|
for row in project_rows {
|
|
if let Some(project) = row_to_queue_project(
|
|
row.id,
|
|
row.slug,
|
|
row.name,
|
|
row.summary,
|
|
row.icon_url,
|
|
row.status,
|
|
row.requested_status,
|
|
row.queued,
|
|
row.published,
|
|
row.updated,
|
|
row.organization_id,
|
|
row.organization_name,
|
|
row.organization_icon_url,
|
|
row.owner_id,
|
|
row.owner_name,
|
|
row.owner_icon_url,
|
|
row.project_types,
|
|
row.external_dependencies_count,
|
|
)? {
|
|
projects.push(project);
|
|
}
|
|
}
|
|
|
|
(total, projects)
|
|
} else {
|
|
let project_rows = sqlx::query!(
|
|
r#"
|
|
WITH filtered_projects AS (
|
|
SELECT
|
|
m.id,
|
|
m.queued,
|
|
m.published
|
|
FROM mods m
|
|
WHERE
|
|
m.status = $1
|
|
AND (
|
|
$6::boolean = false
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM delphi_issue_details_with_statuses didws
|
|
WHERE didws.project_id = m.id
|
|
AND didws.status = 'pending'
|
|
)
|
|
)
|
|
),
|
|
total AS (
|
|
SELECT COUNT(*) AS total_count FROM filtered_projects
|
|
),
|
|
page_ids AS (
|
|
SELECT
|
|
id,
|
|
queued,
|
|
published
|
|
FROM filtered_projects
|
|
ORDER BY
|
|
CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,
|
|
CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,
|
|
id ASC
|
|
OFFSET $4
|
|
LIMIT $3
|
|
),
|
|
page_project_types AS (
|
|
SELECT
|
|
v.mod_id,
|
|
ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types
|
|
FROM versions v
|
|
INNER JOIN page_ids page ON page.id = v.mod_id
|
|
INNER JOIN loaders_versions lv ON v.id = lv.version_id
|
|
INNER JOIN loaders l ON lv.loader_id = l.id
|
|
INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id
|
|
INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id
|
|
WHERE v.status = ANY($2)
|
|
GROUP BY v.mod_id
|
|
),
|
|
page_external_dependencies AS (
|
|
SELECT
|
|
v.mod_id,
|
|
COUNT(*) AS external_dependencies_count
|
|
FROM versions v
|
|
INNER JOIN page_ids page ON page.id = v.mod_id
|
|
INNER JOIN dependencies d ON d.dependent_id = v.id
|
|
WHERE d.dependency_file_name IS NOT NULL
|
|
GROUP BY v.mod_id
|
|
),
|
|
page_projects AS (
|
|
SELECT
|
|
m.id,
|
|
m.slug,
|
|
m.name,
|
|
m.summary,
|
|
m.icon_url,
|
|
m.status,
|
|
m.requested_status,
|
|
m.queued,
|
|
m.published,
|
|
m.updated,
|
|
m.organization_id,
|
|
o.name AS organization_name,
|
|
o.icon_url AS organization_icon_url,
|
|
owner.user_id AS owner_id,
|
|
owner.username AS owner_name,
|
|
owner.avatar_url AS owner_icon_url,
|
|
CASE
|
|
WHEN m.components ? 'minecraft_server'
|
|
THEN ARRAY_APPEND(
|
|
ARRAY_REMOVE(
|
|
COALESCE(ppt.project_types::text[], ARRAY[]::text[]),
|
|
'modpack'
|
|
),
|
|
'minecraft_java_server'
|
|
)
|
|
ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[])
|
|
END AS project_types,
|
|
COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count
|
|
FROM page_ids page
|
|
INNER JOIN mods m ON m.id = page.id
|
|
LEFT JOIN organizations o ON o.id = m.organization_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
tm.user_id,
|
|
u.username,
|
|
u.avatar_url
|
|
FROM team_members tm
|
|
INNER JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = m.team_id
|
|
AND tm.is_owner
|
|
ORDER BY tm.ordering ASC
|
|
LIMIT 1
|
|
) owner ON m.organization_id IS NULL
|
|
LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id
|
|
LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id
|
|
)
|
|
SELECT
|
|
total.total_count AS "total_count!",
|
|
page_projects.id AS "id?",
|
|
page_projects.slug AS "slug?",
|
|
page_projects.name AS "name?",
|
|
page_projects.summary AS "summary?",
|
|
page_projects.icon_url AS "icon_url?",
|
|
page_projects.status AS "status?",
|
|
page_projects.requested_status AS "requested_status?",
|
|
page_projects.queued AS "queued?",
|
|
page_projects.published AS "published?",
|
|
page_projects.updated AS "updated?",
|
|
page_projects.organization_id AS "organization_id?",
|
|
page_projects.organization_name AS "organization_name?",
|
|
page_projects.organization_icon_url AS "organization_icon_url?",
|
|
page_projects.owner_id AS "owner_id?",
|
|
page_projects.owner_name AS "owner_name?",
|
|
page_projects.owner_icon_url AS "owner_icon_url?",
|
|
page_projects.project_types AS "project_types?: Vec<String>",
|
|
page_projects.external_dependencies_count AS "external_dependencies_count?"
|
|
FROM total
|
|
LEFT JOIN page_projects ON true
|
|
ORDER BY
|
|
CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,
|
|
CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,
|
|
page_projects.id ASC
|
|
"#,
|
|
ProjectStatus::Processing.as_str(),
|
|
&listed_version_statuses,
|
|
count,
|
|
offset,
|
|
sort,
|
|
exclude_technical_review,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await
|
|
.wrap_internal_err("failed to fetch projects awaiting review")?;
|
|
|
|
let total =
|
|
project_rows.first().map(|row| row.total_count).unwrap_or(0);
|
|
let mut projects = Vec::new();
|
|
for row in project_rows {
|
|
if let Some(project) = row_to_queue_project(
|
|
row.id,
|
|
row.slug,
|
|
row.name,
|
|
row.summary,
|
|
row.icon_url,
|
|
row.status,
|
|
row.requested_status,
|
|
row.queued,
|
|
row.published,
|
|
row.updated,
|
|
row.organization_id,
|
|
row.organization_name,
|
|
row.organization_icon_url,
|
|
row.owner_id,
|
|
row.owner_name,
|
|
row.owner_icon_url,
|
|
row.project_types,
|
|
row.external_dependencies_count,
|
|
)? {
|
|
projects.push(project);
|
|
}
|
|
}
|
|
|
|
(total, projects)
|
|
};
|
|
|
|
Ok(web::Json(ModerationProjectsResponse { total, projects }))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
params(
|
|
("has_external_dependencies" = Option<bool>, Query),
|
|
("exclude_technical_review" = bool, Query),
|
|
("query" = Option<String>, Query),
|
|
("project_type" = Option<String>, Query),
|
|
("sort" = Option<ModerationProjectsSort>, Query)
|
|
),
|
|
responses((status = OK, body = ModerationProjectIdsResponse))
|
|
)]
|
|
#[get("/projects/ids")]
|
|
pub async fn get_project_ids(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
request_opts: web::Query<ProjectsRequestOptions>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
) -> Result<web::Json<ModerationProjectIdsResponse>, ApiError> {
|
|
check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_READ,
|
|
)
|
|
.await?;
|
|
|
|
let request_opts = request_opts.into_inner();
|
|
let query = normalize_optional_string(request_opts.query.as_deref());
|
|
let project_type =
|
|
normalize_optional_string(request_opts.project_type.as_deref());
|
|
let sort = request_opts.sort.unwrap_or_default().as_str();
|
|
let listed_version_statuses = listed_version_statuses();
|
|
let exclude_technical_review = request_opts.exclude_technical_review;
|
|
let needs_filtered_queue = query.is_some()
|
|
|| project_type.is_some()
|
|
|| request_opts.has_external_dependencies.is_some()
|
|
|| matches!(sort, "most_external_deps" | "least_external_deps");
|
|
|
|
let ids = if needs_filtered_queue {
|
|
let project_rows = sqlx::query!(
|
|
r#"
|
|
WITH moderation_projects AS (
|
|
SELECT
|
|
m.id,
|
|
m.slug,
|
|
m.name,
|
|
m.summary,
|
|
m.description,
|
|
m.queued,
|
|
m.published,
|
|
m.organization_id,
|
|
m.team_id,
|
|
m.components
|
|
FROM mods m
|
|
WHERE
|
|
m.status = $1
|
|
AND (
|
|
$7::boolean = false
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM delphi_issue_details_with_statuses didws
|
|
WHERE didws.project_id = m.id
|
|
AND didws.status = 'pending'
|
|
)
|
|
)
|
|
),
|
|
external_dependencies AS (
|
|
SELECT
|
|
v.mod_id,
|
|
COUNT(*) AS external_dependencies_count
|
|
FROM versions v
|
|
INNER JOIN moderation_projects mp ON mp.id = v.mod_id
|
|
INNER JOIN dependencies d ON d.dependent_id = v.id
|
|
WHERE d.dependency_file_name IS NOT NULL
|
|
GROUP BY v.mod_id
|
|
),
|
|
version_project_types AS (
|
|
SELECT
|
|
v.mod_id,
|
|
ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types
|
|
FROM versions v
|
|
INNER JOIN moderation_projects mp ON mp.id = v.mod_id
|
|
INNER JOIN loaders_versions lv ON v.id = lv.version_id
|
|
INNER JOIN loaders l ON lv.loader_id = l.id
|
|
INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id
|
|
INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id
|
|
WHERE v.status = ANY($2)
|
|
GROUP BY v.mod_id
|
|
),
|
|
queue_projects AS (
|
|
SELECT
|
|
mp.id,
|
|
mp.slug,
|
|
mp.name,
|
|
mp.summary,
|
|
mp.description,
|
|
mp.queued,
|
|
mp.published,
|
|
search_organization.name AS organization_name,
|
|
search_owner.username AS owner_name,
|
|
CASE
|
|
WHEN mp.components ? 'minecraft_server'
|
|
THEN ARRAY_APPEND(
|
|
ARRAY_REMOVE(
|
|
COALESCE(vpt.project_types::text[], ARRAY[]::text[]),
|
|
'modpack'
|
|
),
|
|
'minecraft_java_server'
|
|
)
|
|
ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])
|
|
END AS project_types,
|
|
COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count
|
|
FROM moderation_projects mp
|
|
LEFT JOIN organizations search_organization
|
|
ON search_organization.id = mp.organization_id
|
|
AND $3::text IS NOT NULL
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
u.username
|
|
FROM team_members tm
|
|
INNER JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = mp.team_id
|
|
AND tm.is_owner
|
|
ORDER BY tm.ordering ASC
|
|
LIMIT 1
|
|
) search_owner ON $3::text IS NOT NULL
|
|
AND mp.organization_id IS NULL
|
|
LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id
|
|
LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id
|
|
)
|
|
SELECT id
|
|
FROM queue_projects
|
|
WHERE
|
|
(
|
|
$3::text IS NULL
|
|
OR name ILIKE '%' || $3 || '%'
|
|
OR slug ILIKE '%' || $3 || '%'
|
|
OR summary ILIKE '%' || $3 || '%'
|
|
OR description ILIKE '%' || $3 || '%'
|
|
OR owner_name ILIKE '%' || $3 || '%'
|
|
OR organization_name ILIKE '%' || $3 || '%'
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM UNNEST(project_types) AS searched_project_type(project_type)
|
|
WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'
|
|
)
|
|
)
|
|
AND (
|
|
$4::text IS NULL
|
|
OR ($4 = 'none' AND CARDINALITY(project_types) = 0)
|
|
OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])
|
|
OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4)
|
|
)
|
|
AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)
|
|
ORDER BY
|
|
CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC,
|
|
CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC,
|
|
CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,
|
|
CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,
|
|
id ASC
|
|
"#,
|
|
ProjectStatus::Processing.as_str(),
|
|
&listed_version_statuses,
|
|
query,
|
|
project_type,
|
|
request_opts.has_external_dependencies,
|
|
sort,
|
|
exclude_technical_review,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await
|
|
.wrap_internal_err("failed to fetch filtered project ids awaiting review")?;
|
|
|
|
project_rows
|
|
.into_iter()
|
|
.map(|row| ProjectId::from(database::models::DBProjectId(row.id)))
|
|
.collect()
|
|
} else {
|
|
let project_rows = sqlx::query!(
|
|
r#"
|
|
SELECT id
|
|
FROM mods
|
|
WHERE
|
|
status = $1
|
|
AND (
|
|
$3::boolean = false
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM delphi_issue_details_with_statuses didws
|
|
WHERE didws.project_id = mods.id
|
|
AND didws.status = 'pending'
|
|
)
|
|
)
|
|
ORDER BY
|
|
CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,
|
|
CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,
|
|
id ASC
|
|
"#,
|
|
ProjectStatus::Processing.as_str(),
|
|
sort,
|
|
exclude_technical_review,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await
|
|
.wrap_internal_err("failed to fetch project ids awaiting review")?;
|
|
|
|
project_rows
|
|
.into_iter()
|
|
.map(|row| ProjectId::from(database::models::DBProjectId(row.id)))
|
|
.collect()
|
|
};
|
|
|
|
Ok(web::Json(ModerationProjectIdsResponse { ids }))
|
|
}
|
|
|
|
fn row_to_queue_project(
|
|
id: Option<i64>,
|
|
slug: Option<String>,
|
|
name: Option<String>,
|
|
summary: Option<String>,
|
|
icon_url: Option<String>,
|
|
status: Option<String>,
|
|
requested_status: Option<String>,
|
|
queued: Option<DateTime<Utc>>,
|
|
published: Option<DateTime<Utc>>,
|
|
updated: Option<DateTime<Utc>>,
|
|
organization_id: Option<i64>,
|
|
organization_name: Option<String>,
|
|
organization_icon_url: Option<String>,
|
|
owner_id: Option<i64>,
|
|
owner_name: Option<String>,
|
|
owner_icon_url: Option<String>,
|
|
project_types: Option<Vec<String>>,
|
|
external_dependencies_count: Option<i64>,
|
|
) -> Result<Option<ModerationQueueProject>, ApiError> {
|
|
let Some(id) = id else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let project_id = ProjectId::from(database::models::DBProjectId(id));
|
|
let name = name.wrap_internal_err_with(|| {
|
|
eyre!("project {project_id} is missing `name` in moderation queue row")
|
|
})?;
|
|
let summary = summary.wrap_internal_err_with(|| {
|
|
eyre!(
|
|
"project {project_id} is missing `summary` in moderation queue row"
|
|
)
|
|
})?;
|
|
let status = status.wrap_internal_err_with(|| {
|
|
eyre!(
|
|
"project {project_id} is missing `status` in moderation queue row"
|
|
)
|
|
})?;
|
|
let published = published.wrap_internal_err_with(|| {
|
|
eyre!(
|
|
"project {project_id} is missing `published` in moderation queue row"
|
|
)
|
|
})?;
|
|
let updated = updated.wrap_internal_err_with(|| {
|
|
eyre!(
|
|
"project {project_id} is missing `updated` in moderation queue row"
|
|
)
|
|
})?;
|
|
let ownership = row_to_ownership(
|
|
project_id,
|
|
organization_id,
|
|
organization_name,
|
|
organization_icon_url,
|
|
owner_id,
|
|
owner_name,
|
|
owner_icon_url,
|
|
)?;
|
|
|
|
Ok(Some(ModerationQueueProject {
|
|
id: project_id,
|
|
slug,
|
|
name,
|
|
summary,
|
|
icon_url,
|
|
status: ProjectStatus::from_string(&status),
|
|
requested_status: requested_status
|
|
.as_deref()
|
|
.map(ProjectStatus::from_string),
|
|
queued,
|
|
published,
|
|
updated,
|
|
project_types: project_types.unwrap_or_default(),
|
|
ownership,
|
|
external_dependencies_count: external_dependencies_count.unwrap_or(0),
|
|
}))
|
|
}
|
|
|
|
fn normalize_optional_string(value: Option<&str>) -> Option<&str> {
|
|
value.map(str::trim).filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn listed_version_statuses() -> Vec<String> {
|
|
VersionStatus::iterator()
|
|
.filter(|status| status.is_listed())
|
|
.map(|status| status.as_str().to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn row_to_ownership(
|
|
project_id: ProjectId,
|
|
organization_id: Option<i64>,
|
|
organization_name: Option<String>,
|
|
organization_icon_url: Option<String>,
|
|
owner_id: Option<i64>,
|
|
owner_name: Option<String>,
|
|
owner_icon_url: Option<String>,
|
|
) -> Result<Ownership, ApiError> {
|
|
if let Some(organization_id) = organization_id {
|
|
let organization_name =
|
|
organization_name.wrap_internal_err_with(|| {
|
|
eyre!(
|
|
"project {project_id} is owned by organization {} without a valid name",
|
|
OrganizationId::from(database::models::DBOrganizationId(
|
|
organization_id
|
|
))
|
|
)
|
|
})?;
|
|
|
|
return Ok(Ownership::Organization {
|
|
id: OrganizationId::from(database::models::DBOrganizationId(
|
|
organization_id,
|
|
)),
|
|
name: organization_name,
|
|
icon_url: organization_icon_url,
|
|
});
|
|
}
|
|
|
|
let owner_id = owner_id.wrap_internal_err_with(|| {
|
|
eyre!("project {project_id} is owned by a team without a valid owner")
|
|
})?;
|
|
let owner_name = owner_name.wrap_internal_err_with(|| {
|
|
eyre!(
|
|
"project {project_id} is owned by a team owner without a valid name"
|
|
)
|
|
})?;
|
|
|
|
Ok(Ownership::User {
|
|
id: UserId::from(database::models::DBUserId(owner_id)),
|
|
name: owner_name,
|
|
icon_url: owner_icon_url,
|
|
})
|
|
}
|
|
|
|
/// Get project moderation metadata.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses((status = OK, body = MissingMetadata))
|
|
)]
|
|
#[get("/project/{id}")]
|
|
pub async fn get_project_meta(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
info: web::Path<(String,)>,
|
|
) -> Result<web::Json<MissingMetadata>, ApiError> {
|
|
check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_READ,
|
|
)
|
|
.await?;
|
|
|
|
let project_id = info.into_inner().0;
|
|
let project =
|
|
database::models::DBProject::get(&project_id, &**pool, &redis).await?;
|
|
|
|
if let Some(project) = project {
|
|
let rows = sqlx::query!(
|
|
"
|
|
SELECT
|
|
f.metadata, v.id version_id
|
|
FROM versions v
|
|
INNER JOIN files f ON f.version_id = v.id
|
|
WHERE v.mod_id = $1
|
|
",
|
|
project.inner.id.0
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await?;
|
|
|
|
let mut merged = MissingMetadata {
|
|
identified: HashMap::new(),
|
|
flame_files: HashMap::new(),
|
|
unknown_files: HashMap::new(),
|
|
};
|
|
|
|
let mut check_hashes = Vec::new();
|
|
let mut check_flames = Vec::new();
|
|
|
|
for row in rows {
|
|
if let Some(metadata) = row
|
|
.metadata
|
|
.and_then(|x| serde_json::from_value::<MissingMetadata>(x).ok())
|
|
{
|
|
merged.identified.extend(metadata.identified);
|
|
merged.flame_files.extend(metadata.flame_files);
|
|
merged.unknown_files.extend(metadata.unknown_files);
|
|
|
|
check_hashes.extend(merged.flame_files.keys().cloned());
|
|
check_hashes.extend(merged.unknown_files.keys().cloned());
|
|
check_flames
|
|
.extend(merged.flame_files.values().map(|x| x.id as i32));
|
|
}
|
|
}
|
|
|
|
let rows = sqlx::query!(
|
|
"
|
|
SELECT encode(mef.sha1, 'escape') sha1, mel.status status
|
|
FROM moderation_external_files mef
|
|
INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id
|
|
WHERE mef.sha1 = ANY($1)
|
|
",
|
|
&check_hashes
|
|
.iter()
|
|
.map(|x| x.as_bytes().to_vec())
|
|
.collect::<Vec<_>>()
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await?;
|
|
|
|
for row in rows {
|
|
if let Some(sha1) = row.sha1 {
|
|
if let Some(val) = merged.flame_files.remove(&sha1) {
|
|
merged.identified.insert(
|
|
sha1,
|
|
IdentifiedFile {
|
|
file_name: val.file_name,
|
|
status: ApprovalType::from_string(&row.status)
|
|
.unwrap_or(ApprovalType::Unidentified),
|
|
},
|
|
);
|
|
} else if let Some(val) = merged.unknown_files.remove(&sha1) {
|
|
merged.identified.insert(
|
|
sha1,
|
|
IdentifiedFile {
|
|
file_name: val,
|
|
status: ApprovalType::from_string(&row.status)
|
|
.unwrap_or(ApprovalType::Unidentified),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let rows = sqlx::query!(
|
|
"
|
|
SELECT mel.id, mel.flame_project_id, mel.status status
|
|
FROM moderation_external_licenses mel
|
|
WHERE mel.flame_project_id = ANY($1)
|
|
",
|
|
&check_flames,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await?;
|
|
|
|
for row in rows {
|
|
if let Some(sha1) = merged
|
|
.flame_files
|
|
.iter()
|
|
.find(|x| Some(x.1.id as i32) == row.flame_project_id)
|
|
.map(|x| x.0.clone())
|
|
&& let Some(val) = merged.flame_files.remove(&sha1)
|
|
{
|
|
merged.identified.insert(
|
|
sha1,
|
|
IdentifiedFile {
|
|
file_name: val.file_name.clone(),
|
|
status: ApprovalType::from_string(&row.status)
|
|
.unwrap_or(ApprovalType::Unidentified),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(web::Json(merged))
|
|
} else {
|
|
Err(ApiError::NotFound)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum Judgement {
|
|
Flame {
|
|
id: i32,
|
|
status: ApprovalType,
|
|
link: String,
|
|
title: String,
|
|
},
|
|
Unknown {
|
|
status: ApprovalType,
|
|
proof: Option<String>,
|
|
link: Option<String>,
|
|
title: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Update project moderation judgements.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses((status = NO_CONTENT))
|
|
)]
|
|
#[post("/project")]
|
|
pub async fn set_project_meta(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
judgements: web::Json<HashMap<String, Judgement>>,
|
|
) -> Result<(), ApiError> {
|
|
let user = check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_READ,
|
|
)
|
|
.await?;
|
|
|
|
let mut transaction = pool.begin().await?;
|
|
|
|
let mut licenses = Vec::new();
|
|
let mut file_hashes = Vec::new();
|
|
let mut file_filenames = Vec::new();
|
|
let mut file_license_ids = Vec::new();
|
|
|
|
for (hash, judgement) in judgements.0 {
|
|
let id = random_base62(8);
|
|
|
|
let (title, status, link, proof, flame_id) = match judgement {
|
|
Judgement::Flame {
|
|
id,
|
|
status,
|
|
link,
|
|
title,
|
|
} => (
|
|
Some(title),
|
|
status,
|
|
Some(link),
|
|
Some("See Flame page/license for permission".to_string()),
|
|
Some(id),
|
|
),
|
|
Judgement::Unknown {
|
|
status,
|
|
proof,
|
|
link,
|
|
title,
|
|
} => (title, status, link, proof, None),
|
|
};
|
|
|
|
licenses.push(moderation_external_item::ExternalLicense {
|
|
id: id as i64,
|
|
title,
|
|
status: status.as_str().to_string(),
|
|
link,
|
|
proof,
|
|
flame_project_id: flame_id,
|
|
});
|
|
file_hashes.push(hash);
|
|
file_filenames.push(None);
|
|
file_license_ids.push(id as i64);
|
|
}
|
|
|
|
let user_id = database::models::ids::DBUserId::from(user.id);
|
|
|
|
moderation_external_item::ExternalLicense::insert_many(
|
|
&mut transaction,
|
|
&licenses,
|
|
user_id,
|
|
)
|
|
.await?;
|
|
|
|
moderation_external_item::ExternalLicense::insert_files(
|
|
&mut transaction,
|
|
&file_hashes
|
|
.iter()
|
|
.map(|x| x.as_bytes().to_vec())
|
|
.collect::<Vec<_>>(),
|
|
&file_filenames,
|
|
&file_license_ids,
|
|
user_id,
|
|
)
|
|
.await?;
|
|
|
|
transaction.commit().await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Acquire a moderation lock.
|
|
/// Returns success if acquired, or info about who holds the lock if blocked.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses(
|
|
(status = OK, body = LockAcquireResponse),
|
|
(status = NOT_FOUND, description = "Project not found")
|
|
)
|
|
)]
|
|
#[post("/lock/{project_id}")]
|
|
pub async fn acquire_lock(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
path: web::Path<(String,)>,
|
|
) -> Result<web::Json<LockAcquireResponse>, ApiError> {
|
|
let user = check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_WRITE,
|
|
)
|
|
.await?;
|
|
|
|
let project_id_str = path.into_inner().0;
|
|
let project =
|
|
database::models::DBProject::get(&project_id_str, &**pool, &redis)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let db_project_id = project.inner.id;
|
|
let db_user_id = database::models::DBUserId::from(user.id);
|
|
|
|
match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? {
|
|
Ok(()) => Ok(web::Json(LockAcquireResponse {
|
|
success: true,
|
|
is_own_lock: true,
|
|
locked_by: None,
|
|
locked_at: None,
|
|
expires_at: None,
|
|
expired: None,
|
|
})),
|
|
Err(lock) => Ok(web::Json(LockAcquireResponse {
|
|
success: false,
|
|
is_own_lock: false,
|
|
locked_by: Some(LockedByUser {
|
|
id: UserId::from(lock.moderator_id).to_string(),
|
|
username: lock.moderator_username,
|
|
avatar_url: lock.moderator_avatar_url,
|
|
}),
|
|
locked_at: Some(lock.locked_at),
|
|
expires_at: Some(lock.expires_at),
|
|
expired: Some(lock.expired),
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// Override a moderation lock.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses(
|
|
(status = OK, body = LockAcquireResponse),
|
|
(status = NOT_FOUND, description = "Project not found")
|
|
)
|
|
)]
|
|
#[post("/lock/{project_id}/override")]
|
|
pub async fn override_lock(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
path: web::Path<(String,)>,
|
|
) -> Result<web::Json<LockAcquireResponse>, ApiError> {
|
|
let user = check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_WRITE,
|
|
)
|
|
.await?;
|
|
|
|
let project_id_str = path.into_inner().0;
|
|
let project =
|
|
database::models::DBProject::get(&project_id_str, &**pool, &redis)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let db_project_id = project.inner.id;
|
|
let db_user_id = database::models::DBUserId::from(user.id);
|
|
|
|
DBModerationLock::force_acquire(db_project_id, db_user_id, &pool).await?;
|
|
|
|
Ok(web::Json(LockAcquireResponse {
|
|
success: true,
|
|
is_own_lock: true,
|
|
locked_by: None,
|
|
locked_at: None,
|
|
expires_at: None,
|
|
expired: None,
|
|
}))
|
|
}
|
|
|
|
/// Get moderation lock status.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses(
|
|
(status = OK, body = LockStatusResponse),
|
|
(status = NOT_FOUND, description = "Project not found")
|
|
)
|
|
)]
|
|
#[get("/lock/{project_id}")]
|
|
pub async fn get_lock_status(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
path: web::Path<(String,)>,
|
|
) -> Result<web::Json<LockStatusResponse>, ApiError> {
|
|
let user = check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_READ,
|
|
)
|
|
.await?;
|
|
|
|
let project_id_str = path.into_inner().0;
|
|
let project =
|
|
database::models::DBProject::get(&project_id_str, &**pool, &redis)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let db_project_id = project.inner.id;
|
|
let db_user_id = database::models::DBUserId::from(user.id);
|
|
|
|
match DBModerationLock::get_with_user(db_project_id, &pool).await? {
|
|
Some(lock) => {
|
|
let is_own_lock = lock.moderator_id == db_user_id;
|
|
Ok(web::Json(LockStatusResponse {
|
|
locked: true,
|
|
is_own_lock,
|
|
locked_by: Some(LockedByUser {
|
|
id: UserId::from(lock.moderator_id).to_string(),
|
|
username: lock.moderator_username,
|
|
avatar_url: lock.moderator_avatar_url,
|
|
}),
|
|
locked_at: Some(lock.locked_at),
|
|
expires_at: Some(lock.expires_at),
|
|
expired: Some(lock.expired),
|
|
}))
|
|
}
|
|
None => Ok(web::Json(LockStatusResponse {
|
|
locked: false,
|
|
is_own_lock: false,
|
|
locked_by: None,
|
|
locked_at: None,
|
|
expires_at: None,
|
|
expired: None,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// Release a moderation lock.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses(
|
|
(status = OK, body = LockReleaseResponse),
|
|
(status = NOT_FOUND, description = "Project not found")
|
|
)
|
|
)]
|
|
#[delete("/lock/{project_id}")]
|
|
pub async fn release_lock(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
path: web::Path<(String,)>,
|
|
) -> Result<web::Json<LockReleaseResponse>, ApiError> {
|
|
let user = check_is_moderator_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_WRITE,
|
|
)
|
|
.await?;
|
|
|
|
let project_id_str = path.into_inner().0;
|
|
let project =
|
|
database::models::DBProject::get(&project_id_str, &**pool, &redis)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let db_project_id = project.inner.id;
|
|
let db_user_id = database::models::DBUserId::from(user.id);
|
|
|
|
let released =
|
|
DBModerationLock::release(db_project_id, db_user_id, &pool).await?;
|
|
|
|
let _ = DBModerationLock::cleanup_expired(&pool).await;
|
|
|
|
Ok(web::Json(LockReleaseResponse { success: released }))
|
|
}
|
|
|
|
/// Release a moderation lock by beacon.
|
|
///
|
|
/// For use with `navigator.sendBeacon`, which cannot set `Authorization` or send `DELETE`.
|
|
/// The body must be `text/plain` containing the same token value as the `Authorization` header
|
|
/// (optional `Bearer ` prefix). This avoids a CORS preflight compared to `application/json`.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
request_body(
|
|
content = String,
|
|
description = "Token value (same as Authorization header)",
|
|
content_type = "text/plain"
|
|
),
|
|
responses(
|
|
(status = OK, body = LockReleaseResponse),
|
|
(status = NOT_FOUND, description = "Project not found")
|
|
)
|
|
)]
|
|
#[post("/lock/{project_id}/release")]
|
|
pub async fn release_lock_beacon(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
path: web::Path<(String,)>,
|
|
body: String,
|
|
) -> Result<web::Json<LockReleaseResponse>, ApiError> {
|
|
let token = body.trim();
|
|
if token.is_empty() {
|
|
return Err(ApiError::InvalidInput(
|
|
"missing token in request body".to_string(),
|
|
));
|
|
}
|
|
let token = token.strip_prefix("Bearer ").unwrap_or(token).trim();
|
|
|
|
let (scopes, user) = get_user_from_bearer_token(
|
|
&req,
|
|
Some(token),
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
false,
|
|
)
|
|
.await?;
|
|
|
|
if !scopes.contains(Scopes::PROJECT_WRITE) {
|
|
return Err(ApiError::CustomAuthentication(
|
|
"token is missing required scopes".to_string(),
|
|
));
|
|
}
|
|
if !user.role.is_mod() {
|
|
return Err(ApiError::CustomAuthentication(
|
|
"only moderators may release moderation locks".to_string(),
|
|
));
|
|
}
|
|
|
|
let project_id_str = path.into_inner().0;
|
|
let project =
|
|
database::models::DBProject::get(&project_id_str, &**pool, &redis)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let db_project_id = project.inner.id;
|
|
let db_user_id = database::models::DBUserId::from(user.id);
|
|
|
|
let released =
|
|
DBModerationLock::release(db_project_id, db_user_id, &pool).await?;
|
|
|
|
let _ = DBModerationLock::cleanup_expired(&pool).await;
|
|
|
|
Ok(web::Json(LockReleaseResponse { success: released }))
|
|
}
|
|
|
|
/// Delete all moderation locks.
|
|
#[utoipa::path(
|
|
context_path = "/moderation",
|
|
tag = "moderation",
|
|
responses(
|
|
(status = OK, body = DeleteAllLocksResponse),
|
|
(status = UNAUTHORIZED, description = "Not an admin")
|
|
)
|
|
)]
|
|
#[delete("/locks")]
|
|
pub async fn delete_all_locks(
|
|
req: HttpRequest,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
) -> Result<web::Json<DeleteAllLocksResponse>, ApiError> {
|
|
let user = get_user_from_headers(
|
|
&req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::PROJECT_WRITE,
|
|
)
|
|
.await?
|
|
.1;
|
|
|
|
if !user.role.is_admin() {
|
|
return Err(ApiError::CustomAuthentication(
|
|
"You must be an admin to delete all locks".to_string(),
|
|
));
|
|
}
|
|
|
|
let deleted_count = DBModerationLock::delete_all(&pool).await?;
|
|
|
|
Ok(web::Json(DeleteAllLocksResponse { deleted_count }))
|
|
}
|