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

@@ -1,5 +1,15 @@
<template>
<KeybindsModal ref="keybindsModal" />
<ConfirmModal
v-if="lockStatus?.locked && !lockStatus?.isOwnLock"
ref="takeOverModal"
title="Override moderation lock"
description="Are you sure you want to override?"
:has-to-type="false"
:markdown="false"
proceed-label="Take over"
@proceed="confirmTakeOverOverride"
/>
<div
tabindex="0"
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
@@ -60,7 +70,7 @@
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="lockStatus.expired" @click="retryAcquireLock">
<ButtonStyled @click="openTakeOverModal">
<button>
<LockIcon aria-hidden="true" />
Take over
@@ -384,13 +394,7 @@
</button>
</ButtonStyled>
<template
v-for="opt in stageOptions.filter(
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
)"
#[opt.id]
:key="opt.id"
>
<template v-for="opt in stageOptionsForSlots" #[opt.id] :key="opt.id">
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
{{ opt.text }}
</template>
@@ -466,6 +470,7 @@ import {
ButtonStyled,
Checkbox,
Collapsible,
ConfirmModal,
DropdownSelect,
injectNotificationManager,
injectProjectPageContext,
@@ -486,6 +491,7 @@ import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
import { useGeneratedState } from '~/composables/generated'
import { useImageUpload } from '~/composables/image-upload.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import type { LockAcquireResponse } from '~/store/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts'
import KeybindsModal from './ChecklistKeybindsModal.vue'
@@ -496,6 +502,7 @@ const { addNotification } = notifications
const debug = useDebugLogger('ModerationChecklist')
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
const props = defineProps<{
collapsed: boolean
@@ -511,6 +518,7 @@ const lockStatus = ref<{
locked: boolean
lockedBy?: { id: string; username: string; avatar_url?: string }
lockedAt?: Date
expiresAt?: Date
expired?: boolean
isOwnLock: boolean
} | null>(null)
@@ -536,13 +544,15 @@ const PREFETCH_STALE_MS = 30_000 // 30 seconds
const PREFETCH_TARGET_COUNT = 3 // Keep 3 unlocked projects ready
const PREFETCH_BATCH_SIZE = 5 // Check 5 at a time in parallel
const LOCK_EXPIRY_MINUTES = 15
function handleVisibilityChange() {
async function handleVisibilityChange() {
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
// Immediately refresh the lock when returning to the tab
// This handles cases where the heartbeat was throttled while backgrounded
moderationStore.refreshLock()
const refreshResult = await moderationStore.refreshLock()
if (!refreshResult.success) {
handleLockLost(refreshResult)
return
}
// Refresh prefetch queue when tab becomes visible (not debounced)
maintainPrefetchQueue()
}
@@ -555,7 +565,9 @@ function updateLockCountdown() {
}
const lockedAt = new Date(lockStatus.value.lockedAt)
const expiresAt = new Date(lockedAt.getTime() + LOCK_EXPIRY_MINUTES * 60 * 1000)
const expiresAt = lockStatus.value.expiresAt
? new Date(lockStatus.value.expiresAt)
: new Date(lockedAt.getTime() + 15 * 60 * 1000)
const now = new Date()
const remainingMs = expiresAt.getTime() - now.getTime()
@@ -582,12 +594,47 @@ function clearLockCountdown() {
function startLockHeartbeat() {
lockCheckInterval.value = setInterval(
async () => {
await moderationStore.refreshLock()
const result = await moderationStore.refreshLock()
if (!result.success) {
handleLockLost(result)
}
},
5 * 60 * 1000,
)
}
function handleLockLost(result: LockAcquireResponse) {
clearInterval(lockCheckInterval.value!)
lockCheckInterval.value = null
clearLockCountdown()
lockStatus.value = {
locked: result.locked_by != null,
lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
expired: result.expired,
isOwnLock: false,
}
lockError.value = false
if (result.locked_by) {
addNotification({
title: 'Lock taken over',
text: `@${result.locked_by.username} is now moderating this project.`,
type: 'warning',
})
updateLockCountdown()
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
} else {
addNotification({
title: 'Moderation lock lost',
text: 'Your lock on this project has expired. Acquire the lock again to continue.',
type: 'warning',
})
}
}
function handleLockAcquired() {
lockStatus.value = { locked: false, isOwnLock: true }
lockError.value = false
@@ -713,28 +760,36 @@ async function handleExit() {
emit('exit')
}
async function retryAcquireLock() {
function openTakeOverModal() {
takeOverModal.value?.show()
}
async function confirmTakeOverOverride() {
const projectId = projectV2.value?.id
if (!projectId) {
console.warn('[retryAcquireLock] No project ID available')
console.warn('[confirmTakeOverOverride] No project ID available')
return
}
const result = await moderationStore.acquireLock(projectId)
const result = await moderationStore.overrideLock(projectId)
if (result.success) {
addNotification({
title: 'Moderation lock overridden',
text: 'You are now moderating this project.',
type: 'success',
})
handleLockAcquired()
} else if (result.locked_by) {
// Still locked by another moderator, update status
lockStatus.value = {
locked: true,
lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
expired: result.expired,
isOwnLock: false,
}
lockError.value = false
// Restart countdown timer
updateLockCountdown()
if (!lockCountdownInterval.value) {
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
@@ -764,25 +819,21 @@ async function batchCheckLocksWithMetadata(
projectIds: string[],
): Promise<Map<string, LockCheckResult>> {
const results = new Map<string, LockCheckResult>()
const currentUserId = (auth.value?.user as { id?: string } | null)?.id
// Check locks and fetch minimal project data in parallel
const checks = await Promise.allSettled(
projectIds.map(async (id) => {
// Parallel: check lock AND fetch project metadata
const [lockStatus, projectData] = await Promise.all([
const [lockResponse, projectData] = await Promise.all([
moderationStore.checkLock(id),
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
])
// Check if lock is by the current user (own lock = can acquire)
const isOwnLock = lockStatus.locked_by?.id === currentUserId
return {
id,
locked: lockStatus.locked,
expired: lockStatus.expired,
isOwnLock,
locked: lockResponse.locked,
expired: lockResponse.expired,
isOwnLock: lockResponse.is_own_lock,
slug: (projectData as { slug?: string })?.slug,
projectType: (projectData as { project_type?: string })?.project_type,
}
@@ -1187,6 +1238,7 @@ watch(currentStage, () => {
onMounted(async () => {
window.addEventListener('keydown', handleKeybinds)
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('left')
@@ -1220,6 +1272,7 @@ onMounted(async () => {
locked: true,
lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
expired: result.expired,
isOwnLock: false,
}
@@ -1233,7 +1286,25 @@ onMounted(async () => {
}
})
function handleBeforeUnload() {
const projectId = projectV2.value?.id
if (!projectId || !lockStatus.value?.isOwnLock) return
const config = useRuntimeConfig()
const base = config.public.apiBaseUrl.replace(/\/v\d\/?$/, '/_internal/')
const token = (auth as unknown as { value?: { token?: string } }).value?.token
if (!token) return
// sendBeacon is POST-only and cannot set Authorization. The internal POST /release endpoint
// accepts the same token as text/plain (matches useBaseFetch's Authorization value).
void navigator.sendBeacon(
`${base}moderation/lock/${projectId}/release`,
new Blob([token], { type: 'text/plain' }),
)
}
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('keydown', handleKeybinds)
document.removeEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('right')
@@ -1243,6 +1314,12 @@ onUnmounted(() => {
}
clearLockCountdown()
// Release lock if we own it (navigation away without explicit exit)
const projectId = projectV2.value?.id
if (projectId && lockStatus.value?.isOwnLock) {
moderationStore.releaseLock(projectId)
}
// Clear prefetch state to prevent memory leaks
prefetchQueue.value = []
isPrefetching.value = false
@@ -1891,9 +1968,10 @@ async function sendMessage(status: ProjectStatus) {
const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed')
moderationStore.releaseLock(projectId).catch((err) => {
console.warn('Failed to release lock:', err)
})
await Promise.race([
moderationStore.releaseLock(projectId),
new Promise((r) => setTimeout(r, 2000)),
])
// Set both states together - hasNextProject MUST be set before done
// to avoid the race condition where done=true renders with hasNextProject=false
@@ -2021,9 +2099,10 @@ async function skipCurrentProject() {
return
}
moderationStore.releaseLock(projectId).catch((err) => {
console.warn('Failed to release lock:', err)
})
await Promise.race([
moderationStore.releaseLock(projectId),
new Promise((r) => setTimeout(r, 2000)),
])
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
@@ -2089,6 +2168,12 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
return options
})
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string }
const stageOptionsForSlots = computed(() =>
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),
)
</script>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
import { createPinia, defineStore } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface ModerationQueue {
items: string[]
@@ -17,15 +17,19 @@ export interface LockedByUser {
export interface LockStatusResponse {
locked: boolean
is_own_lock: boolean
locked_by?: LockedByUser
locked_at?: string
expires_at?: string
expired?: boolean
}
export interface LockAcquireResponse {
success: boolean
is_own_lock: boolean
locked_by?: LockedByUser
locked_at?: string
expires_at?: string
expired?: boolean
}
@@ -42,71 +46,68 @@ function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue
}
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export const useModerationStore = defineStore(
'moderation',
() => {
const currentQueue = ref<ModerationQueue>(createEmptyQueue())
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
const isQueueMode = ref(false)
export const useModerationStore = defineStore('moderation', {
state: () => ({
currentQueue: createEmptyQueue(),
currentLock: null as { projectId: string; lockedAt: Date } | null,
isQueueMode: false,
}),
const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0)
const progress = computed(() => {
if (currentQueue.value.total === 0) return 0
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
})
getters: {
queueLength: (state) => state.currentQueue.items.length,
hasItems: (state) => state.currentQueue.items.length > 0,
progress: (state) => {
if (state.currentQueue.total === 0) return 0
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total
},
},
actions: {
setQueue(projectIDs: string[]) {
this.isQueueMode = true
this.currentQueue = {
function setQueue(projectIDs: string[]) {
isQueueMode.value = true
currentQueue.value = {
items: [...projectIDs],
total: projectIDs.length,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
},
}
setSingleProject(projectId: string) {
this.isQueueMode = false
this.currentQueue = {
function setSingleProject(projectId: string) {
isQueueMode.value = false
currentQueue.value = {
items: [projectId],
total: 1,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
},
}
completeCurrentProject(projectId: string, status: 'completed' | 'skipped' = 'completed') {
function completeCurrentProject(
projectId: string,
status: 'completed' | 'skipped' = 'completed',
) {
if (status === 'completed') {
this.currentQueue.completed++
currentQueue.value.completed++
} else {
this.currentQueue.skipped++
currentQueue.value.skipped++
}
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId)
this.currentQueue.lastUpdated = new Date()
currentQueue.value.items = currentQueue.value.items.filter((id: string) => id !== projectId)
currentQueue.value.lastUpdated = new Date()
return this.currentQueue.items.length > 0
},
return currentQueue.value.items.length > 0
}
getCurrentProjectId(): string | null {
return this.currentQueue.items[0] || null
},
function getCurrentProjectId(): string | null {
return currentQueue.value.items[0] || null
}
resetQueue() {
this.isQueueMode = false
this.currentQueue = createEmptyQueue()
},
function resetQueue() {
isQueueMode.value = false
currentQueue.value = createEmptyQueue()
}
async acquireLock(projectId: string): Promise<LockAcquireResponse> {
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'POST',
@@ -114,35 +115,57 @@ export const useModerationStore = defineStore('moderation', {
})) as LockAcquireResponse
if (response.success) {
this.currentLock = { projectId, lockedAt: new Date() }
currentLock.value = { projectId, lockedAt: new Date() }
} else if (currentLock.value?.projectId === projectId) {
// We were outbid or our lock expired — clear stale state
currentLock.value = null
}
return response
} catch (error) {
console.error('Failed to acquire moderation lock:', error)
// Return a failed response so the UI can handle it gracefully
return { success: false }
return { success: false, is_own_lock: false }
}
},
}
async releaseLock(projectId: string): Promise<boolean> {
async function overrideLock(projectId: string): Promise<LockAcquireResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}/override`, {
method: 'POST',
internal: true,
})) as LockAcquireResponse
if (response.success) {
currentLock.value = { projectId, lockedAt: new Date() }
} else if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response
} catch (error) {
console.error('Failed to override moderation lock:', error)
return { success: false, is_own_lock: false }
}
}
async function releaseLock(projectId: string): Promise<boolean> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'DELETE',
internal: true,
})) as { success: boolean }
if (this.currentLock?.projectId === projectId) {
this.currentLock = null
if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response.success
} catch {
return false
}
},
}
async checkLock(projectId: string): Promise<LockStatusResponse> {
async function checkLock(projectId: string): Promise<LockStatusResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'GET',
@@ -152,34 +175,58 @@ export const useModerationStore = defineStore('moderation', {
} catch (error) {
console.error('Failed to check moderation lock:', error)
// Return unlocked status on error so moderation can proceed
return { locked: false }
return { locked: false, is_own_lock: false }
}
},
}
async refreshLock(): Promise<boolean> {
if (!this.currentLock) return false
async function refreshLock(): Promise<LockAcquireResponse> {
if (!currentLock.value) return { success: false, is_own_lock: false }
try {
const response = await this.acquireLock(this.currentLock.projectId)
return response.success
const response = await acquireLock(currentLock.value.projectId)
// acquireLock already clears currentLock on failure
return response
} catch (error) {
console.error('Failed to refresh moderation lock:', error)
return false
currentLock.value = null
return { success: false, is_own_lock: false }
}
},
},
}
persist: {
key: 'moderation-store',
serializer: {
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value)
if (parsed.currentQueue?.lastUpdated) {
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated)
}
return parsed
return {
currentQueue,
currentLock,
isQueueMode,
queueLength,
hasItems,
progress,
setQueue,
setSingleProject,
completeCurrentProject,
getCurrentProjectId,
resetQueue,
acquireLock,
overrideLock,
releaseLock,
checkLock,
refreshLock,
}
},
{
persist: {
key: 'moderation-store',
// Only persist queue state — currentLock is always revalidated on mount
paths: ['currentQueue', 'isQueueMode'],
serializer: {
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value)
if (parsed.currentQueue?.lastUpdated) {
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated)
}
return parsed
},
},
},
},
})
)

View File

@@ -29,8 +29,7 @@
"low",
"medium",
"high",
"severe",
"malware"
"severe"
]
}
}
@@ -46,7 +45,7 @@
false,
true,
false,
true
false
]
},
"hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17"

View File

@@ -44,8 +44,7 @@
"low",
"medium",
"high",
"severe",
"malware"
"severe"
]
}
}
@@ -80,7 +79,7 @@
true,
false,
false,
true,
false,
null
]
},

View 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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -25,8 +25,7 @@
"low",
"medium",
"high",
"severe",
"malware"
"severe"
]
}
}

View File

@@ -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"
}

View File

@@ -22,8 +22,7 @@
"low",
"medium",
"high",
"severe",
"malware"
"severe"
]
}
}

View File

@@ -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;

View File

@@ -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())
}

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 {

View File

@@ -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"

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -288,7 +288,8 @@ defineExpose({
const mouseX = ref(0)
const mouseY = ref(0)
const stackZBase = computed(() => stackDepth.value * 10)
const MODAL_STACK_BASE_Z = 100
const stackZBase = computed(() => MODAL_STACK_BASE_Z + stackDepth.value * 10)
const stackOverlayZ = computed(() => stackZBase.value + 19)
const stackTauriZ = computed(() => stackZBase.value + 20)
const stackContainerZ = computed(() => stackZBase.value + 21)