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:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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