fix: moderation locking fixes (#5843)

* fix: moderation locking fixes

* fix: lint

* wip: override always available

* fix: newmodal base z

* fix: cargo fmt
This commit is contained in:
Calum H.
2026-04-18 19:55:33 +01:00
committed by GitHub
parent 3a44def301
commit 2236dd8ade
19 changed files with 1630 additions and 251 deletions

View File

@@ -9,7 +9,10 @@ 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 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};
@@ -25,8 +28,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.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(
utoipa_actix_web::scope("/tech-review")
@@ -88,13 +93,18 @@ pub enum Ownership {
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>>,
/// Whether the lock has expired (>15 minutes old)
/// 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>,
}
@@ -115,11 +125,16 @@ pub struct LockedByUser {
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>,
}
@@ -520,23 +535,72 @@ async fn acquire_lock(
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),
})),
}
}
/// Force-acquire a moderation lock on a project (moderator override).
#[utoipa::path(
responses(
(status = OK, body = LockAcquireResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[post("/lock/{project_id}/override")]
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,
}))
}
/// Check the lock status for a project
#[utoipa::path(
responses(
@@ -552,7 +616,7 @@ async fn get_lock_status(
session_queue: web::Data<AuthQueue>,
path: web::Path<(String,)>,
) -> Result<web::Json<LockStatusResponse>, ApiError> {
check_is_moderator_from_headers(
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
@@ -568,22 +632,30 @@ async fn get_lock_status(
.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) => 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),
})),
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,
})),
}
@@ -630,6 +702,77 @@ async fn release_lock(
Ok(web::Json(LockReleaseResponse { success: released }))
}
/// Release a moderation lock using credentials in the request body.
///
/// 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(
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")]
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 (admin only)
#[utoipa::path(
responses(

View File

@@ -424,21 +424,28 @@ pub async fn project_edit_internal(
));
}
// 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 a moderator (non-admin) is completing a review (changing from Processing to another
// status), they must hold an active non-expired lock on this project.
if user.role.is_mod()
&& !user.role.is_admin()
&& 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
)));
let lock =
DBModerationLock::get_with_user(project_item.inner.id, &pool)
.await?;
let owns = lock.as_ref().is_some_and(|l| {
l.moderator_id == db_ids::DBUserId::from(user.id) && !l.expired
});
if !owns {
return Err(ApiError::CustomAuthentication(match lock {
Some(l) => format!(
"This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.",
l.moderator_username
),
None => "You must hold an active moderation lock to complete this review. Open the project in the moderation checklist to acquire one.".to_string(),
}));
}
}
if status == &ProjectStatus::Processing {