feat: moderation locking (#5070)

* feat: base locking impl

* feat: lock logic in place in rev endpoint + fetch rev

* feat: frontend impl and finalize

* feat: auto skip if using the moderation queue page

* fix: qa issues

* fix: async state + locking fix

* fix: lint

* fix: fmt

* fix: qa issue

* fix: qa + redirect bug

* fix: lint

* feat: delete all locks endpoint for admins

* fix: dedupe

* fix: fmt

* fix: project redirect move to middleware

* fix: lint
This commit is contained in:
Calum H.
2026-01-12 17:08:30 +00:00
committed by GitHub
parent 915d8c68bf
commit b46f6d0141
21 changed files with 1644 additions and 321 deletions

View File

@@ -1,5 +1,7 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::database;
use crate::database::models::DBModerationLock;
use crate::database::redis::RedisPool;
use crate::models::ids::OrganizationId;
use crate::models::projects::{Project, ProjectStatus};
@@ -7,8 +9,9 @@ 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, get, post, web};
use actix_web::{HttpRequest, delete, get, post, web};
use ariadne::ids::{UserId, random_base62};
use chrono::{DateTime, Utc};
use ownership::get_projects_ownership;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@@ -21,6 +24,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(get_projects)
.service(get_project_meta)
.service(set_project_meta)
.service(acquire_lock)
.service(get_lock_status)
.service(release_lock)
.service(delete_all_locks)
.service(
utoipa_actix_web::scope("/tech-review")
.configure(tech_review::config),
@@ -76,6 +83,59 @@ pub enum Ownership {
},
}
/// Response for lock status check
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LockStatusResponse {
/// Whether the project is currently locked
pub locked: 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>>,
/// Whether the lock has expired (>15 minutes old)
#[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,
/// 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>>,
#[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,
}
/// Fetch all projects which are in the moderation queue.
#[utoipa::path(
responses((status = OK, body = inline(Vec<FetchedProject>)))
@@ -422,3 +482,185 @@ async fn set_project_meta(
Ok(())
}
/// Acquire or refresh a moderation lock on a project.
/// Returns success if acquired, or info about who holds the lock if blocked.
#[utoipa::path(
responses(
(status = OK, body = LockAcquireResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[post("/lock/{project_id}")]
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,
locked_by: None,
locked_at: None,
expired: None,
})),
Err(lock) => Ok(web::Json(LockAcquireResponse {
success: 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),
expired: Some(lock.expired),
})),
}
}
/// Check the lock status for a project
#[utoipa::path(
responses(
(status = OK, body = LockStatusResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[get("/lock/{project_id}")]
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> {
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;
match DBModerationLock::get_with_user(db_project_id, &pool).await? {
Some(lock) => Ok(web::Json(LockStatusResponse {
locked: true,
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),
expired: Some(lock.expired),
})),
None => Ok(web::Json(LockStatusResponse {
locked: false,
locked_by: None,
locked_at: None,
expired: None,
})),
}
}
/// Release a moderation lock on a project
#[utoipa::path(
responses(
(status = OK, body = LockReleaseResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[delete("/lock/{project_id}")]
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 }))
}
/// Delete all moderation locks (admin only)
#[utoipa::path(
responses(
(status = OK, body = DeleteAllLocksResponse),
(status = UNAUTHORIZED, description = "Not an admin")
)
)]
#[delete("/locks")]
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 }))
}

View File

@@ -6,7 +6,9 @@ use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::project_item::{DBGalleryItem, DBModCategory};
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::models::{DBTeamMember, ids as db_ids, image_item};
use crate::database::models::{
DBModerationLock, DBTeamMember, ids as db_ids, image_item,
};
use crate::database::redis::RedisPool;
use crate::database::{self, models as db_models};
use crate::file_hosting::{FileHost, FileHostPublicity};
@@ -368,6 +370,23 @@ pub async fn project_edit(
));
}
// If a moderator is completing a review (changing from Processing to another status),
// check if another moderator holds an active lock on this project
if user.role.is_mod()
&& project_item.inner.status == ProjectStatus::Processing
&& status != &ProjectStatus::Processing
&& let Some(lock) =
DBModerationLock::get_with_user(project_item.inner.id, &pool)
.await?
&& lock.moderator_id != db_ids::DBUserId::from(user.id)
&& !lock.expired
{
return Err(ApiError::CustomAuthentication(format!(
"This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.",
lock.moderator_username
)));
}
if status == &ProjectStatus::Processing {
if project_item.versions.is_empty() {
return Err(ApiError::InvalidInput(String::from(