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:
@@ -29,8 +29,7 @@
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"severe",
|
||||
"malware"
|
||||
"severe"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -46,7 +45,7 @@
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17"
|
||||
|
||||
@@ -44,8 +44,7 @@
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"severe",
|
||||
"malware"
|
||||
"severe"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -80,7 +79,7 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
|
||||
42
apps/labrinth/.sqlx/query-3c4b6b837f44183633327fef511efa2009d55ae6cd3f3d9312cce4912ad51558.json
generated
Normal file
42
apps/labrinth/.sqlx/query-3c4b6b837f44183633327fef511efa2009d55ae6cd3f3d9312cce4912ad51558.json
generated
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH upsert AS (\n INSERT INTO moderation_locks (project_id, moderator_id, locked_at)\n VALUES ($1, $2, NOW())\n ON CONFLICT (project_id) DO UPDATE SET\n moderator_id = CASE\n WHEN moderation_locks.moderator_id = EXCLUDED.moderator_id\n OR moderation_locks.locked_at < NOW() - ($3::bigint * INTERVAL '1 minute')\n THEN EXCLUDED.moderator_id\n ELSE moderation_locks.moderator_id\n END,\n locked_at = CASE\n WHEN moderation_locks.moderator_id = EXCLUDED.moderator_id\n OR moderation_locks.locked_at < NOW() - ($3::bigint * INTERVAL '1 minute')\n THEN EXCLUDED.locked_at\n ELSE moderation_locks.locked_at\n END\n RETURNING moderator_id, locked_at\n )\n SELECT\n upsert.moderator_id,\n upsert.locked_at,\n u.username AS moderator_username,\n u.avatar_url AS moderator_avatar_url\n FROM upsert\n INNER JOIN users u ON u.id = upsert.moderator_id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "moderator_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "locked_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "moderator_username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "moderator_avatar_url",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3c4b6b837f44183633327fef511efa2009d55ae6cd3f3d9312cce4912ad51558"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE moderation_locks SET locked_at = NOW() WHERE project_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE moderation_locks SET moderator_id = $1, locked_at = NOW() WHERE project_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO moderation_locks (project_id, moderator_id, locked_at)\n\t\t\tVALUES ($1, $2, NOW())\n\t\t\tON CONFLICT (project_id) DO UPDATE\n\t\t\tSET moderator_id = EXCLUDED.moderator_id, locked_at = EXCLUDED.locked_at",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a"
|
||||
}
|
||||
@@ -25,8 +25,7 @@
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"severe",
|
||||
"malware"
|
||||
"severe"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'",
|
||||
"query": "DELETE FROM moderation_locks WHERE locked_at < NOW() - ($1::bigint * INTERVAL '1 minute')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425"
|
||||
"hash": "c433faf330a283367b974a1b78f9a1df80d6a21ce57f107f40a761c07a064a25"
|
||||
}
|
||||
@@ -22,8 +22,7 @@
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"severe",
|
||||
"malware"
|
||||
"severe"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ pub use checks::{
|
||||
filter_visible_projects,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use validate::{check_is_moderator_from_headers, get_user_from_headers};
|
||||
pub use validate::{
|
||||
check_is_moderator_from_headers, get_user_from_bearer_token,
|
||||
get_user_from_headers,
|
||||
};
|
||||
|
||||
use crate::file_hosting::FileHostingError;
|
||||
use crate::models::error::ApiError;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::database::PgPool;
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::models::{DBProjectId, DBUserId};
|
||||
|
||||
const LOCK_EXPIRY_MINUTES: i64 = 15;
|
||||
pub const LOCK_EXPIRY_MINUTES: i64 = 15;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DBModerationLock {
|
||||
@@ -20,69 +20,102 @@ pub struct ModerationLockWithUser {
|
||||
pub moderator_username: String,
|
||||
pub moderator_avatar_url: Option<String>,
|
||||
pub locked_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub expired: bool,
|
||||
}
|
||||
|
||||
impl DBModerationLock {
|
||||
/// Check if a lock is expired (older than 15 minutes)
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now()
|
||||
.signed_duration_since(self.locked_at)
|
||||
.num_minutes()
|
||||
>= LOCK_EXPIRY_MINUTES
|
||||
}
|
||||
|
||||
/// Try to acquire or refresh a lock for a project.
|
||||
/// Try to acquire or refresh a lock for a project atomically.
|
||||
/// Returns Ok(Ok(())) if lock acquired/refreshed, Ok(Err(lock)) if blocked by another moderator.
|
||||
pub async fn acquire(
|
||||
project_id: DBProjectId,
|
||||
moderator_id: DBUserId,
|
||||
pool: &PgPool,
|
||||
) -> Result<Result<(), ModerationLockWithUser>, sqlx::Error> {
|
||||
// First check if there's an existing lock
|
||||
let existing = Self::get_with_user(project_id, pool).await?;
|
||||
|
||||
if let Some(lock) = existing {
|
||||
// Same moderator - refresh the lock
|
||||
if lock.moderator_id == moderator_id {
|
||||
sqlx::query!(
|
||||
"UPDATE moderation_locks SET locked_at = NOW() WHERE project_id = $1",
|
||||
project_id as DBProjectId
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
return Ok(Ok(()));
|
||||
}
|
||||
|
||||
// Different moderator but lock expired - take over
|
||||
if lock.expired {
|
||||
sqlx::query!(
|
||||
"UPDATE moderation_locks SET moderator_id = $1, locked_at = NOW() WHERE project_id = $2",
|
||||
moderator_id as DBUserId,
|
||||
project_id as DBProjectId
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
return Ok(Ok(()));
|
||||
}
|
||||
|
||||
// Different moderator, not expired - blocked
|
||||
return Ok(Err(lock));
|
||||
}
|
||||
|
||||
// No existing lock - create new one
|
||||
sqlx::query!(
|
||||
"INSERT INTO moderation_locks (project_id, moderator_id, locked_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE
|
||||
SET moderator_id = EXCLUDED.moderator_id, locked_at = EXCLUDED.locked_at",
|
||||
// Atomic upsert that always returns the post-operation row. When the lock is held by
|
||||
// another moderator and is still valid, the CASE branches write the existing values
|
||||
// back (a harmless self-update), so `RETURNING` always yields a row describing the
|
||||
// current holder. We cannot rely on a bare `DO UPDATE ... WHERE` because:
|
||||
// * `WHERE` that evaluates false suppresses the update *and* `RETURNING`, and
|
||||
// * data-modifying CTEs share a snapshot with the enclosing SELECT, so a plain
|
||||
// `SELECT ... FROM moderation_locks` in the same statement cannot see a row
|
||||
// inserted by the CTE above it.
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
WITH upsert AS (
|
||||
INSERT INTO moderation_locks (project_id, moderator_id, locked_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE SET
|
||||
moderator_id = CASE
|
||||
WHEN moderation_locks.moderator_id = EXCLUDED.moderator_id
|
||||
OR moderation_locks.locked_at < NOW() - ($3::bigint * INTERVAL '1 minute')
|
||||
THEN EXCLUDED.moderator_id
|
||||
ELSE moderation_locks.moderator_id
|
||||
END,
|
||||
locked_at = CASE
|
||||
WHEN moderation_locks.moderator_id = EXCLUDED.moderator_id
|
||||
OR moderation_locks.locked_at < NOW() - ($3::bigint * INTERVAL '1 minute')
|
||||
THEN EXCLUDED.locked_at
|
||||
ELSE moderation_locks.locked_at
|
||||
END
|
||||
RETURNING moderator_id, locked_at
|
||||
)
|
||||
SELECT
|
||||
upsert.moderator_id,
|
||||
upsert.locked_at,
|
||||
u.username AS moderator_username,
|
||||
u.avatar_url AS moderator_avatar_url
|
||||
FROM upsert
|
||||
INNER JOIN users u ON u.id = upsert.moderator_id
|
||||
"#,
|
||||
project_id as DBProjectId,
|
||||
moderator_id as DBUserId
|
||||
moderator_id as DBUserId,
|
||||
LOCK_EXPIRY_MINUTES,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let locked_at: DateTime<Utc> = row.locked_at;
|
||||
let expires_at = locked_at + Duration::minutes(LOCK_EXPIRY_MINUTES);
|
||||
let expired = Utc::now() >= expires_at;
|
||||
|
||||
if row.moderator_id == moderator_id.0 {
|
||||
Ok(Ok(()))
|
||||
} else {
|
||||
Ok(Err(ModerationLockWithUser {
|
||||
project_id,
|
||||
moderator_id: DBUserId(row.moderator_id),
|
||||
moderator_username: row.moderator_username,
|
||||
moderator_avatar_url: row.moderator_avatar_url,
|
||||
locked_at,
|
||||
expires_at,
|
||||
expired,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reassign the lock to `moderator_id`, even when another moderator holds an active lock.
|
||||
/// Used only after explicit client confirmation (override flow).
|
||||
pub async fn force_acquire(
|
||||
project_id: DBProjectId,
|
||||
moderator_id: DBUserId,
|
||||
pool: &PgPool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO moderation_locks (project_id, moderator_id, locked_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE SET
|
||||
moderator_id = EXCLUDED.moderator_id,
|
||||
locked_at = EXCLUDED.locked_at
|
||||
"#,
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(moderator_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(Ok(()))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get lock status for a project, including moderator username
|
||||
@@ -109,9 +142,8 @@ impl DBModerationLock {
|
||||
|
||||
Ok(row.map(|r| {
|
||||
let locked_at: DateTime<Utc> = r.locked_at;
|
||||
let expired =
|
||||
Utc::now().signed_duration_since(locked_at).num_minutes()
|
||||
>= LOCK_EXPIRY_MINUTES;
|
||||
let expires_at = locked_at + Duration::minutes(LOCK_EXPIRY_MINUTES);
|
||||
let expired = Utc::now() >= expires_at;
|
||||
|
||||
ModerationLockWithUser {
|
||||
project_id: DBProjectId(r.project_id),
|
||||
@@ -119,6 +151,7 @@ impl DBModerationLock {
|
||||
moderator_username: r.moderator_username,
|
||||
moderator_avatar_url: r.moderator_avatar_url,
|
||||
locked_at,
|
||||
expires_at,
|
||||
expired,
|
||||
}
|
||||
}))
|
||||
@@ -144,10 +177,11 @@ impl DBModerationLock {
|
||||
/// Clean up expired locks (can be called periodically)
|
||||
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'"
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
"DELETE FROM moderation_locks WHERE locked_at < NOW() - ($1::bigint * INTERVAL '1 minute')",
|
||||
LOCK_EXPIRY_MINUTES,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,7 +22,7 @@ use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test::database::MOD_USER_PAT;
|
||||
use crate::test::database::ADMIN_USER_PAT;
|
||||
|
||||
use super::{
|
||||
ApiV2,
|
||||
@@ -95,10 +95,10 @@ impl ApiProject for ApiV2 {
|
||||
let resp = self.create_project(creation_data, pat).await;
|
||||
assert_status!(&resp, StatusCode::OK);
|
||||
|
||||
// Approve as a moderator.
|
||||
// Approve as admin so fixture setup is not blocked by the moderation-lock guard.
|
||||
let req = TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{slug}"))
|
||||
.append_pat(MOD_USER_PAT)
|
||||
.append_pat(ADMIN_USER_PAT)
|
||||
.set_json(json!(
|
||||
{
|
||||
"status": "approved"
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::test::{
|
||||
models::{CommonItemType, CommonProject, CommonVersion},
|
||||
request_data::{ImageData, ProjectCreationRequestData},
|
||||
},
|
||||
database::MOD_USER_PAT,
|
||||
database::ADMIN_USER_PAT,
|
||||
dummy_data::TestFile,
|
||||
};
|
||||
|
||||
@@ -49,10 +49,11 @@ impl ApiProject for ApiV3 {
|
||||
let resp = self.create_project(creation_data, pat).await;
|
||||
assert_status!(&resp, StatusCode::OK);
|
||||
|
||||
// Approve as a moderator.
|
||||
// Approve as admin so fixture setup is not blocked by the moderation-lock guard
|
||||
// (non-admin moderators must hold a lock to move a project out of processing).
|
||||
let req = TestRequest::patch()
|
||||
.uri(&format!("/v3/project/{slug}"))
|
||||
.append_pat(MOD_USER_PAT)
|
||||
.append_pat(ADMIN_USER_PAT)
|
||||
.set_json(json!(
|
||||
{
|
||||
"status": "approved"
|
||||
|
||||
1062
apps/labrinth/tests/moderation_lock.rs
Normal file
1062
apps/labrinth/tests/moderation_lock.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user