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> <template>
<KeybindsModal ref="keybindsModal" /> <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 <div
tabindex="0" 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" 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" 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"> <div class="flex items-center gap-2">
<ButtonStyled v-if="lockStatus.expired" @click="retryAcquireLock"> <ButtonStyled @click="openTakeOverModal">
<button> <button>
<LockIcon aria-hidden="true" /> <LockIcon aria-hidden="true" />
Take over Take over
@@ -384,13 +394,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<template <template v-for="opt in stageOptionsForSlots" #[opt.id] :key="opt.id">
v-for="opt in stageOptions.filter(
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
)"
#[opt.id]
:key="opt.id"
>
<component :is="opt.icon" v-if="opt.icon" class="mr-2" /> <component :is="opt.icon" v-if="opt.icon" class="mr-2" />
{{ opt.text }} {{ opt.text }}
</template> </template>
@@ -466,6 +470,7 @@ import {
ButtonStyled, ButtonStyled,
Checkbox, Checkbox,
Collapsible, Collapsible,
ConfirmModal,
DropdownSelect, DropdownSelect,
injectNotificationManager, injectNotificationManager,
injectProjectPageContext, injectProjectPageContext,
@@ -486,6 +491,7 @@ import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
import { useGeneratedState } from '~/composables/generated' import { useGeneratedState } from '~/composables/generated'
import { useImageUpload } from '~/composables/image-upload.ts' import { useImageUpload } from '~/composables/image-upload.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js' import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import type { LockAcquireResponse } from '~/store/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts' import { useModerationStore } from '~/store/moderation.ts'
import KeybindsModal from './ChecklistKeybindsModal.vue' import KeybindsModal from './ChecklistKeybindsModal.vue'
@@ -496,6 +502,7 @@ const { addNotification } = notifications
const debug = useDebugLogger('ModerationChecklist') const debug = useDebugLogger('ModerationChecklist')
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>() const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
const props = defineProps<{ const props = defineProps<{
collapsed: boolean collapsed: boolean
@@ -511,6 +518,7 @@ const lockStatus = ref<{
locked: boolean locked: boolean
lockedBy?: { id: string; username: string; avatar_url?: string } lockedBy?: { id: string; username: string; avatar_url?: string }
lockedAt?: Date lockedAt?: Date
expiresAt?: Date
expired?: boolean expired?: boolean
isOwnLock: boolean isOwnLock: boolean
} | null>(null) } | 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_TARGET_COUNT = 3 // Keep 3 unlocked projects ready
const PREFETCH_BATCH_SIZE = 5 // Check 5 at a time in parallel const PREFETCH_BATCH_SIZE = 5 // Check 5 at a time in parallel
const LOCK_EXPIRY_MINUTES = 15 async function handleVisibilityChange() {
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) { if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
// Immediately refresh the lock when returning to the tab // Immediately refresh the lock when returning to the tab
// This handles cases where the heartbeat was throttled while backgrounded // 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) // Refresh prefetch queue when tab becomes visible (not debounced)
maintainPrefetchQueue() maintainPrefetchQueue()
} }
@@ -555,7 +565,9 @@ function updateLockCountdown() {
} }
const lockedAt = new Date(lockStatus.value.lockedAt) 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 now = new Date()
const remainingMs = expiresAt.getTime() - now.getTime() const remainingMs = expiresAt.getTime() - now.getTime()
@@ -582,12 +594,47 @@ function clearLockCountdown() {
function startLockHeartbeat() { function startLockHeartbeat() {
lockCheckInterval.value = setInterval( lockCheckInterval.value = setInterval(
async () => { async () => {
await moderationStore.refreshLock() const result = await moderationStore.refreshLock()
if (!result.success) {
handleLockLost(result)
}
}, },
5 * 60 * 1000, 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() { function handleLockAcquired() {
lockStatus.value = { locked: false, isOwnLock: true } lockStatus.value = { locked: false, isOwnLock: true }
lockError.value = false lockError.value = false
@@ -713,28 +760,36 @@ async function handleExit() {
emit('exit') emit('exit')
} }
async function retryAcquireLock() { function openTakeOverModal() {
takeOverModal.value?.show()
}
async function confirmTakeOverOverride() {
const projectId = projectV2.value?.id const projectId = projectV2.value?.id
if (!projectId) { if (!projectId) {
console.warn('[retryAcquireLock] No project ID available') console.warn('[confirmTakeOverOverride] No project ID available')
return return
} }
const result = await moderationStore.acquireLock(projectId) const result = await moderationStore.overrideLock(projectId)
if (result.success) { if (result.success) {
addNotification({
title: 'Moderation lock overridden',
text: 'You are now moderating this project.',
type: 'success',
})
handleLockAcquired() handleLockAcquired()
} else if (result.locked_by) { } else if (result.locked_by) {
// Still locked by another moderator, update status
lockStatus.value = { lockStatus.value = {
locked: true, locked: true,
lockedBy: result.locked_by, lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined, lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
expired: result.expired, expired: result.expired,
isOwnLock: false, isOwnLock: false,
} }
lockError.value = false lockError.value = false
// Restart countdown timer
updateLockCountdown() updateLockCountdown()
if (!lockCountdownInterval.value) { if (!lockCountdownInterval.value) {
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000) lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
@@ -764,25 +819,21 @@ async function batchCheckLocksWithMetadata(
projectIds: string[], projectIds: string[],
): Promise<Map<string, LockCheckResult>> { ): Promise<Map<string, LockCheckResult>> {
const results = new 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 // Check locks and fetch minimal project data in parallel
const checks = await Promise.allSettled( const checks = await Promise.allSettled(
projectIds.map(async (id) => { projectIds.map(async (id) => {
// Parallel: check lock AND fetch project metadata // Parallel: check lock AND fetch project metadata
const [lockStatus, projectData] = await Promise.all([ const [lockResponse, projectData] = await Promise.all([
moderationStore.checkLock(id), moderationStore.checkLock(id),
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null), 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 { return {
id, id,
locked: lockStatus.locked, locked: lockResponse.locked,
expired: lockStatus.expired, expired: lockResponse.expired,
isOwnLock, isOwnLock: lockResponse.is_own_lock,
slug: (projectData as { slug?: string })?.slug, slug: (projectData as { slug?: string })?.slug,
projectType: (projectData as { project_type?: string })?.project_type, projectType: (projectData as { project_type?: string })?.project_type,
} }
@@ -1187,6 +1238,7 @@ watch(currentStage, () => {
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', handleKeybinds) window.addEventListener('keydown', handleKeybinds)
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('left') notifications.setNotificationLocation('left')
@@ -1220,6 +1272,7 @@ onMounted(async () => {
locked: true, locked: true,
lockedBy: result.locked_by, lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined, lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
expired: result.expired, expired: result.expired,
isOwnLock: false, 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(() => { onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('keydown', handleKeybinds) window.removeEventListener('keydown', handleKeybinds)
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('right') notifications.setNotificationLocation('right')
@@ -1243,6 +1314,12 @@ onUnmounted(() => {
} }
clearLockCountdown() 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 // Clear prefetch state to prevent memory leaks
prefetchQueue.value = [] prefetchQueue.value = []
isPrefetching.value = false isPrefetching.value = false
@@ -1891,9 +1968,10 @@ async function sendMessage(status: ProjectStatus) {
const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed') const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed')
moderationStore.releaseLock(projectId).catch((err) => { await Promise.race([
console.warn('Failed to release lock:', err) moderationStore.releaseLock(projectId),
}) new Promise((r) => setTimeout(r, 2000)),
])
// Set both states together - hasNextProject MUST be set before done // Set both states together - hasNextProject MUST be set before done
// to avoid the race condition where done=true renders with hasNextProject=false // to avoid the race condition where done=true renders with hasNextProject=false
@@ -2021,9 +2099,10 @@ async function skipCurrentProject() {
return return
} }
moderationStore.releaseLock(projectId).catch((err) => { await Promise.race([
console.warn('Failed to release lock:', err) moderationStore.releaseLock(projectId),
}) new Promise((r) => setTimeout(r, 2000)),
])
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped') hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
@@ -2089,6 +2168,12 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
return options 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

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

View File

@@ -44,8 +44,7 @@
"low", "low",
"medium", "medium",
"high", "high",
"severe", "severe"
"malware"
] ]
} }
} }
@@ -80,7 +79,7 @@
true, true,
false, false,
false, false,
true, false,
null 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", "low",
"medium", "medium",
"high", "high",
"severe", "severe"
"malware"
] ]
} }
} }

View File

@@ -1,12 +1,14 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Left": [] "Left": [
"Int8"
]
}, },
"nullable": [] "nullable": []
}, },
"hash": "e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425" "hash": "c433faf330a283367b974a1b78f9a1df80d6a21ce57f107f40a761c07a064a25"
} }

View File

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

View File

@@ -8,7 +8,10 @@ pub use checks::{
filter_visible_projects, filter_visible_projects,
}; };
use serde::{Deserialize, Serialize}; 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::file_hosting::FileHostingError;
use crate::models::error::ApiError; use crate::models::error::ApiError;

View File

@@ -1,10 +1,10 @@
use crate::database::PgPool; use crate::database::PgPool;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::database::models::{DBProjectId, DBUserId}; use crate::database::models::{DBProjectId, DBUserId};
const LOCK_EXPIRY_MINUTES: i64 = 15; pub const LOCK_EXPIRY_MINUTES: i64 = 15;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBModerationLock { pub struct DBModerationLock {
@@ -20,69 +20,102 @@ pub struct ModerationLockWithUser {
pub moderator_username: String, pub moderator_username: String,
pub moderator_avatar_url: Option<String>, pub moderator_avatar_url: Option<String>,
pub locked_at: DateTime<Utc>, pub locked_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub expired: bool, pub expired: bool,
} }
impl DBModerationLock { impl DBModerationLock {
/// Check if a lock is expired (older than 15 minutes) /// Try to acquire or refresh a lock for a project atomically.
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.
/// Returns Ok(Ok(())) if lock acquired/refreshed, Ok(Err(lock)) if blocked by another moderator. /// Returns Ok(Ok(())) if lock acquired/refreshed, Ok(Err(lock)) if blocked by another moderator.
pub async fn acquire( pub async fn acquire(
project_id: DBProjectId, project_id: DBProjectId,
moderator_id: DBUserId, moderator_id: DBUserId,
pool: &PgPool, pool: &PgPool,
) -> Result<Result<(), ModerationLockWithUser>, sqlx::Error> { ) -> Result<Result<(), ModerationLockWithUser>, sqlx::Error> {
// First check if there's an existing lock // Atomic upsert that always returns the post-operation row. When the lock is held by
let existing = Self::get_with_user(project_id, pool).await?; // 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
if let Some(lock) = existing { // current holder. We cannot rely on a bare `DO UPDATE ... WHERE` because:
// Same moderator - refresh the lock // * `WHERE` that evaluates false suppresses the update *and* `RETURNING`, and
if lock.moderator_id == moderator_id { // * data-modifying CTEs share a snapshot with the enclosing SELECT, so a plain
sqlx::query!( // `SELECT ... FROM moderation_locks` in the same statement cannot see a row
"UPDATE moderation_locks SET locked_at = NOW() WHERE project_id = $1", // inserted by the CTE above it.
project_id as DBProjectId let row = sqlx::query!(
) r#"
.execute(pool) WITH upsert AS (
.await?; INSERT INTO moderation_locks (project_id, moderator_id, locked_at)
return Ok(Ok(())); VALUES ($1, $2, NOW())
} ON CONFLICT (project_id) DO UPDATE SET
moderator_id = CASE
// Different moderator but lock expired - take over WHEN moderation_locks.moderator_id = EXCLUDED.moderator_id
if lock.expired { OR moderation_locks.locked_at < NOW() - ($3::bigint * INTERVAL '1 minute')
sqlx::query!( THEN EXCLUDED.moderator_id
"UPDATE moderation_locks SET moderator_id = $1, locked_at = NOW() WHERE project_id = $2", ELSE moderation_locks.moderator_id
moderator_id as DBUserId, END,
project_id as DBProjectId locked_at = CASE
) WHEN moderation_locks.moderator_id = EXCLUDED.moderator_id
.execute(pool) OR moderation_locks.locked_at < NOW() - ($3::bigint * INTERVAL '1 minute')
.await?; THEN EXCLUDED.locked_at
return Ok(Ok(())); ELSE moderation_locks.locked_at
} END
RETURNING moderator_id, locked_at
// Different moderator, not expired - blocked )
return Ok(Err(lock)); SELECT
} upsert.moderator_id,
upsert.locked_at,
// No existing lock - create new one u.username AS moderator_username,
sqlx::query!( u.avatar_url AS moderator_avatar_url
"INSERT INTO moderation_locks (project_id, moderator_id, locked_at) FROM upsert
VALUES ($1, $2, NOW()) INNER JOIN users u ON u.id = upsert.moderator_id
ON CONFLICT (project_id) DO UPDATE "#,
SET moderator_id = EXCLUDED.moderator_id, locked_at = EXCLUDED.locked_at",
project_id as DBProjectId, 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) .execute(pool)
.await?; .await?;
Ok(Ok(())) Ok(())
} }
/// Get lock status for a project, including moderator username /// Get lock status for a project, including moderator username
@@ -109,9 +142,8 @@ impl DBModerationLock {
Ok(row.map(|r| { Ok(row.map(|r| {
let locked_at: DateTime<Utc> = r.locked_at; let locked_at: DateTime<Utc> = r.locked_at;
let expired = let expires_at = locked_at + Duration::minutes(LOCK_EXPIRY_MINUTES);
Utc::now().signed_duration_since(locked_at).num_minutes() let expired = Utc::now() >= expires_at;
>= LOCK_EXPIRY_MINUTES;
ModerationLockWithUser { ModerationLockWithUser {
project_id: DBProjectId(r.project_id), project_id: DBProjectId(r.project_id),
@@ -119,6 +151,7 @@ impl DBModerationLock {
moderator_username: r.moderator_username, moderator_username: r.moderator_username,
moderator_avatar_url: r.moderator_avatar_url, moderator_avatar_url: r.moderator_avatar_url,
locked_at, locked_at,
expires_at,
expired, expired,
} }
})) }))
@@ -144,10 +177,11 @@ impl DBModerationLock {
/// Clean up expired locks (can be called periodically) /// Clean up expired locks (can be called periodically)
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64, sqlx::Error> { pub async fn cleanup_expired(pool: &PgPool) -> Result<u64, sqlx::Error> {
let result = sqlx::query!( let result = sqlx::query!(
"DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'" "DELETE FROM moderation_locks WHERE locked_at < NOW() - ($1::bigint * INTERVAL '1 minute')",
) LOCK_EXPIRY_MINUTES,
.execute(pool) )
.await?; .execute(pool)
.await?;
Ok(result.rows_affected()) 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::moderation::{ApprovalType, IdentifiedFile, MissingMetadata};
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::util::error::Context; 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 actix_web::{HttpRequest, delete, get, post, web};
use ariadne::ids::{UserId, random_base62}; use ariadne::ids::{UserId, random_base62};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -25,8 +28,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.service(get_project_meta) .service(get_project_meta)
.service(set_project_meta) .service(set_project_meta)
.service(acquire_lock) .service(acquire_lock)
.service(override_lock)
.service(get_lock_status) .service(get_lock_status)
.service(release_lock) .service(release_lock)
.service(release_lock_beacon)
.service(delete_all_locks) .service(delete_all_locks)
.service( .service(
utoipa_actix_web::scope("/tech-review") utoipa_actix_web::scope("/tech-review")
@@ -88,13 +93,18 @@ pub enum Ownership {
pub struct LockStatusResponse { pub struct LockStatusResponse {
/// Whether the project is currently locked /// Whether the project is currently locked
pub locked: bool, pub locked: bool,
/// Whether the requesting user holds the lock
pub is_own_lock: bool,
/// Information about who holds the lock (if locked) /// Information about who holds the lock (if locked)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub locked_by: Option<LockedByUser>, pub locked_by: Option<LockedByUser>,
/// When the lock was acquired /// When the lock was acquired
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub locked_at: Option<DateTime<Utc>>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub expired: Option<bool>, pub expired: Option<bool>,
} }
@@ -115,11 +125,16 @@ pub struct LockedByUser {
pub struct LockAcquireResponse { pub struct LockAcquireResponse {
/// Whether lock was successfully acquired /// Whether lock was successfully acquired
pub success: bool, 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 /// If blocked, info about who holds the lock
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub locked_by: Option<LockedByUser>, pub locked_by: Option<LockedByUser>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub locked_at: Option<DateTime<Utc>>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub expired: Option<bool>, pub expired: Option<bool>,
} }
@@ -520,23 +535,72 @@ async fn acquire_lock(
match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? { match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? {
Ok(()) => Ok(web::Json(LockAcquireResponse { Ok(()) => Ok(web::Json(LockAcquireResponse {
success: true, success: true,
is_own_lock: true,
locked_by: None, locked_by: None,
locked_at: None, locked_at: None,
expires_at: None,
expired: None, expired: None,
})), })),
Err(lock) => Ok(web::Json(LockAcquireResponse { Err(lock) => Ok(web::Json(LockAcquireResponse {
success: false, success: false,
is_own_lock: false,
locked_by: Some(LockedByUser { locked_by: Some(LockedByUser {
id: UserId::from(lock.moderator_id).to_string(), id: UserId::from(lock.moderator_id).to_string(),
username: lock.moderator_username, username: lock.moderator_username,
avatar_url: lock.moderator_avatar_url, avatar_url: lock.moderator_avatar_url,
}), }),
locked_at: Some(lock.locked_at), locked_at: Some(lock.locked_at),
expires_at: Some(lock.expires_at),
expired: Some(lock.expired), 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 /// Check the lock status for a project
#[utoipa::path( #[utoipa::path(
responses( responses(
@@ -552,7 +616,7 @@ async fn get_lock_status(
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
path: web::Path<(String,)>, path: web::Path<(String,)>,
) -> Result<web::Json<LockStatusResponse>, ApiError> { ) -> Result<web::Json<LockStatusResponse>, ApiError> {
check_is_moderator_from_headers( let user = check_is_moderator_from_headers(
&req, &req,
&**pool, &**pool,
&redis, &redis,
@@ -568,22 +632,30 @@ async fn get_lock_status(
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
let db_project_id = project.inner.id; 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? { match DBModerationLock::get_with_user(db_project_id, &pool).await? {
Some(lock) => Ok(web::Json(LockStatusResponse { Some(lock) => {
locked: true, let is_own_lock = lock.moderator_id == db_user_id;
locked_by: Some(LockedByUser { Ok(web::Json(LockStatusResponse {
id: UserId::from(lock.moderator_id).to_string(), locked: true,
username: lock.moderator_username, is_own_lock,
avatar_url: lock.moderator_avatar_url, locked_by: Some(LockedByUser {
}), id: UserId::from(lock.moderator_id).to_string(),
locked_at: Some(lock.locked_at), username: lock.moderator_username,
expired: Some(lock.expired), 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 { None => Ok(web::Json(LockStatusResponse {
locked: false, locked: false,
is_own_lock: false,
locked_by: None, locked_by: None,
locked_at: None, locked_at: None,
expires_at: None,
expired: None, expired: None,
})), })),
} }
@@ -630,6 +702,77 @@ async fn release_lock(
Ok(web::Json(LockReleaseResponse { success: released })) 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) /// Delete all moderation locks (admin only)
#[utoipa::path( #[utoipa::path(
responses( 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), // If a moderator (non-admin) is completing a review (changing from Processing to another
// check if another moderator holds an active lock on this project // status), they must hold an active non-expired lock on this project.
if user.role.is_mod() if user.role.is_mod()
&& !user.role.is_admin()
&& project_item.inner.status == ProjectStatus::Processing && project_item.inner.status == ProjectStatus::Processing
&& 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!( let lock =
"This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.", DBModerationLock::get_with_user(project_item.inner.id, &pool)
lock.moderator_username .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 { if status == &ProjectStatus::Processing {

View File

@@ -22,7 +22,7 @@ use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use serde_json::json; use serde_json::json;
use crate::test::database::MOD_USER_PAT; use crate::test::database::ADMIN_USER_PAT;
use super::{ use super::{
ApiV2, ApiV2,
@@ -95,10 +95,10 @@ impl ApiProject for ApiV2 {
let resp = self.create_project(creation_data, pat).await; let resp = self.create_project(creation_data, pat).await;
assert_status!(&resp, StatusCode::OK); 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() let req = TestRequest::patch()
.uri(&format!("/v2/project/{slug}")) .uri(&format!("/v2/project/{slug}"))
.append_pat(MOD_USER_PAT) .append_pat(ADMIN_USER_PAT)
.set_json(json!( .set_json(json!(
{ {
"status": "approved" "status": "approved"

View File

@@ -23,7 +23,7 @@ use crate::test::{
models::{CommonItemType, CommonProject, CommonVersion}, models::{CommonItemType, CommonProject, CommonVersion},
request_data::{ImageData, ProjectCreationRequestData}, request_data::{ImageData, ProjectCreationRequestData},
}, },
database::MOD_USER_PAT, database::ADMIN_USER_PAT,
dummy_data::TestFile, dummy_data::TestFile,
}; };
@@ -49,10 +49,11 @@ impl ApiProject for ApiV3 {
let resp = self.create_project(creation_data, pat).await; let resp = self.create_project(creation_data, pat).await;
assert_status!(&resp, StatusCode::OK); 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() let req = TestRequest::patch()
.uri(&format!("/v3/project/{slug}")) .uri(&format!("/v3/project/{slug}"))
.append_pat(MOD_USER_PAT) .append_pat(ADMIN_USER_PAT)
.set_json(json!( .set_json(json!(
{ {
"status": "approved" "status": "approved"

File diff suppressed because it is too large Load Diff

View File

@@ -288,7 +288,8 @@ defineExpose({
const mouseX = ref(0) const mouseX = ref(0)
const mouseY = 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 stackOverlayZ = computed(() => stackZBase.value + 19)
const stackTauriZ = computed(() => stackZBase.value + 20) const stackTauriZ = computed(() => stackZBase.value + 20)
const stackContainerZ = computed(() => stackZBase.value + 21) const stackContainerZ = computed(() => stackZBase.value + 21)