1
0

feat: moderation locking (#5070)

* feat: base locking impl

* feat: lock logic in place in rev endpoint + fetch rev

* feat: frontend impl and finalize

* feat: auto skip if using the moderation queue page

* fix: qa issues

* fix: async state + locking fix

* fix: lint

* fix: fmt

* fix: qa issue

* fix: qa + redirect bug

* fix: lint

* feat: delete all locks endpoint for admins

* fix: dedupe

* fix: fmt

* fix: project redirect move to middleware

* fix: lint
This commit is contained in:
Calum H.
2026-01-12 17:08:30 +00:00
committed by GitHub
parent 915d8c68bf
commit b46f6d0141
21 changed files with 1644 additions and 321 deletions

View File

@@ -311,7 +311,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-01-01', compatibilityDate: '2025-01-01',
telemetry: false, telemetry: false,
experimental: { experimental: {
asyncContext: true, asyncContext: isProduction(),
}, },
}) })

View File

@@ -127,11 +127,9 @@ import dayjs from 'dayjs'
import { computed } from 'vue' import { computed } from 'vue'
import type { ModerationProject } from '~/helpers/moderation' import type { ModerationProject } from '~/helpers/moderation'
import { useModerationStore } from '~/store/moderation.ts'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const moderationStore = useModerationStore()
const baseId = useId() const baseId = useId()
@@ -139,6 +137,10 @@ const props = defineProps<{
queueEntry: ModerationProject queueEntry: ModerationProject
}>() }>()
const emit = defineEmits<{
startFromProject: [projectId: string]
}>()
function getDaysQueued(date: Date): number { function getDaysQueued(date: Date): number {
const now = new Date() const now = new Date()
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
@@ -201,16 +203,6 @@ const quickActions: OverflowMenuOption[] = [
] ]
function openProjectForReview() { function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id) emit('startFromProject', props.queueEntry.project.id)
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: props.queueEntry.project.slug || props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
})
} }
</script> </script>

View File

@@ -3,7 +3,7 @@
<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"
:class="collapsed ? '!w-fit' : ''" :class="{ '!w-fit': collapsed, locked: lockStatus?.locked && !lockStatus?.isOwnLock }"
> >
<div class="flex grow-0 items-center gap-2"> <div class="flex grow-0 items-center gap-2">
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast"> <h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
@@ -25,7 +25,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background"> <ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button v-tooltip="`Exit moderation`" @click="emit('exit')"> <button v-tooltip="`Exit moderation`" @click="handleExit">
<XIcon /> <XIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -38,6 +38,85 @@
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed"> <Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
<div class="my-4 h-[1px] w-full bg-divider" /> <div class="my-4 h-[1px] w-full bg-divider" />
<div v-if="lockStatus?.locked && !lockStatus?.isOwnLock" class="flex flex-1 flex-col">
<div class="flex flex-1 flex-col items-center justify-center gap-4 py-8 text-center">
<LockIcon class="size-8 text-orange" />
<span class="text-secondary">
This project
{{ lockStatus.expired ? 'was being' : 'is currently being' }}
moderated<template v-if="lockStatus.lockedBy?.username"> by</template>
</span>
<span v-if="lockStatus.lockedBy?.username" class="inline-flex items-center gap-1">
<Avatar :src="lockStatus.lockedBy?.avatar_url" size="2rem" circle />
<strong class="text-contrast">@{{ lockStatus.lockedBy.username }}</strong>
</span>
<span v-if="lockTimeRemaining && !lockStatus.expired" class="text-secondary">
Lock expires in {{ lockTimeRemaining }}
</span>
</div>
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="lockStatus.expired" @click="retryAcquireLock">
<button>
<LockIcon aria-hidden="true" />
Take over
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
color="brand"
@click="skipToNextProject"
>
<button>
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else-if="alreadyReviewed" class="flex flex-1 flex-col">
<div class="flex flex-1 flex-col items-center justify-center gap-4 py-8 text-center">
<CheckIcon class="size-8 text-green" />
<span class="text-secondary"> This project was already moderated. </span>
</div>
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled @click="reviewAnyway">
<button>
<ScaleIcon aria-hidden="true" />
Review anyway
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
color="brand"
@click="skipToNextProject"
>
<button>
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<template v-else>
<div class="flex-1"> <div class="flex-1">
<div v-if="done"> <div v-if="done">
<p> <p>
@@ -157,7 +236,10 @@
</div> </div>
<!-- Multi-select chips actions group --> <!-- Multi-select chips actions group -->
<div v-if="multiSelectActions.length > 0" class="multi-select-actions-group space-y-3"> <div
v-if="multiSelectActions.length > 0"
class="multi-select-actions-group space-y-3"
>
<template v-for="action in multiSelectActions" :key="getActionKey(action)"> <template v-for="action in multiSelectActions" :key="getActionKey(action)">
<div> <div>
<div class="mb-2 font-semibold">{{ action.label }}</div> <div class="mb-2 font-semibold">{{ action.label }}</div>
@@ -252,11 +334,11 @@
<button @click="endChecklist(undefined)"> <button @click="endChecklist(undefined)">
<template v-if="hasNextProject"> <template v-if="hasNextProject">
<RightArrowIcon aria-hidden="true" /> <RightArrowIcon aria-hidden="true" />
Next Project ({{ moderationStore.queueLength }} left) Next project ({{ moderationStore.queueLength }} left)
</template> </template>
<template v-else> <template v-else>
<CheckIcon aria-hidden="true" /> <CheckIcon aria-hidden="true" />
All Done! All done!
</template> </template>
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -330,6 +412,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</Collapsible> </Collapsible>
</div> </div>
</template> </template>
@@ -344,6 +427,7 @@ import {
KeyboardIcon, KeyboardIcon,
LeftArrowIcon, LeftArrowIcon,
ListBulletedIcon, ListBulletedIcon,
LockIcon,
RightArrowIcon, RightArrowIcon,
ScaleIcon, ScaleIcon,
ToggleLeftIcon, ToggleLeftIcon,
@@ -377,6 +461,7 @@ import {
type ToggleAction, type ToggleAction,
} from '@modrinth/moderation' } from '@modrinth/moderation'
import { import {
Avatar,
ButtonStyled, ButtonStyled,
Checkbox, Checkbox,
Collapsible, Collapsible,
@@ -386,6 +471,7 @@ import {
MarkdownEditor, MarkdownEditor,
OverflowMenu, OverflowMenu,
type OverflowMenuOption, type OverflowMenuOption,
useDebugLogger,
} from '@modrinth/ui' } from '@modrinth/ui'
import { import {
type ModerationJudgements, type ModerationJudgements,
@@ -402,6 +488,7 @@ import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
const notifications = injectNotificationManager() const notifications = injectNotificationManager()
const { addNotification } = notifications const { addNotification } = notifications
const debug = useDebugLogger('ModerationChecklist')
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>() const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
@@ -413,6 +500,121 @@ const { projectV2, projectV3 } = injectProjectPageContext()
const moderationStore = useModerationStore() const moderationStore = useModerationStore()
const lockStatus = ref<{
locked: boolean
lockedBy?: { id: string; username: string; avatar_url?: string }
lockedAt?: Date
expired?: boolean
isOwnLock: boolean
} | null>(null)
const lockError = ref(false)
const lockCheckInterval = ref<ReturnType<typeof setInterval> | null>(null)
const lockCountdownInterval = ref<ReturnType<typeof setInterval> | null>(null)
const lockTimeRemaining = ref<string | null>(null)
const alreadyReviewed = ref(false)
// Prefetched next project data for instant navigation
const prefetchedNextProject = ref<{
projectId: string
skippedCount: number
skippedIds: string[]
} | null>(null)
const isPrefetching = ref(false)
const LOCK_EXPIRY_MINUTES = 15
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()
}
}
function updateLockCountdown() {
if (!lockStatus.value?.lockedAt || lockStatus.value?.isOwnLock) {
lockTimeRemaining.value = null
return
}
const lockedAt = new Date(lockStatus.value.lockedAt)
const expiresAt = new Date(lockedAt.getTime() + LOCK_EXPIRY_MINUTES * 60 * 1000)
const now = new Date()
const remainingMs = expiresAt.getTime() - now.getTime()
if (remainingMs <= 0) {
lockTimeRemaining.value = null
lockStatus.value.expired = true
clearLockCountdown()
return
}
const minutes = Math.floor(remainingMs / 60000)
const seconds = Math.floor((remainingMs % 60000) / 1000)
lockTimeRemaining.value = `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function clearLockCountdown() {
if (lockCountdownInterval.value) {
clearInterval(lockCountdownInterval.value)
lockCountdownInterval.value = null
}
lockTimeRemaining.value = null
}
function startLockHeartbeat() {
lockCheckInterval.value = setInterval(
async () => {
await moderationStore.refreshLock()
},
5 * 60 * 1000,
)
}
function handleLockAcquired() {
lockStatus.value = { locked: false, isOwnLock: true }
lockError.value = false
initializeAllStages()
clearLockCountdown()
startLockHeartbeat()
prefetchNextProject()
}
function handleLockUnavailable() {
lockError.value = true
lockStatus.value = { locked: false, isOwnLock: false }
initializeAllStages()
clearLockCountdown()
addNotification({
title: 'Lock unavailable',
text: 'Could not acquire moderation lock. Others may also be moderating this project.',
type: 'warning',
})
}
function navigateToNextUnlockedProject(): boolean {
if (!prefetchedNextProject.value) return false
const { projectId, skippedCount, skippedIds } = prefetchedNextProject.value
skippedIds.forEach((id) => moderationStore.completeCurrentProject(id, 'skipped'))
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
prefetchedNextProject.value = null
navigateTo({
name: 'type-id',
params: { type: 'project', id: projectId },
state: { showChecklist: true },
})
return true
}
const variables = computed(() => { const variables = computed(() => {
return { return {
...flattenStaticVariables(), ...flattenStaticVariables(),
@@ -442,6 +644,204 @@ const emit = defineEmits<{
toggleCollapsed: [] toggleCollapsed: []
}>() }>()
async function handleExit() {
// Release if we own the lock, or if there was an error checking (we might still own it)
if (lockStatus.value?.isOwnLock || lockError.value) {
const released = await moderationStore.releaseLock(projectV2.value.id)
if (!released && lockStatus.value?.isOwnLock) {
console.warn('Failed to release moderation lock for project:', projectV2.value.id)
}
}
emit('exit')
}
async function retryAcquireLock() {
const result = await moderationStore.acquireLock(projectV2.value.id)
if (result.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,
expired: result.expired,
isOwnLock: false,
}
lockError.value = false
// Restart countdown timer
updateLockCountdown()
if (!lockCountdownInterval.value) {
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
}
} else {
handleLockUnavailable()
}
}
function reviewAnyway() {
alreadyReviewed.value = false
initializeAllStages()
// Start prefetching the next project in the background
prefetchNextProject()
}
// Prefetch the next unlocked project in the background
async function prefetchNextProject() {
if (isPrefetching.value || !moderationStore.isQueueMode || moderationStore.queueLength <= 1) {
return
}
isPrefetching.value = true
prefetchedNextProject.value = null
const skippedIds: string[] = []
let attempts = 0
// Get queue items excluding current project
const queueItems = [...moderationStore.currentQueue.items]
const currentIndex = queueItems.indexOf(projectV2.value.id)
const remainingItems =
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
for (const nextId of remainingItems) {
if (attempts >= MAX_SKIP_ATTEMPTS) break
attempts++
try {
const lockStatusResult = await moderationStore.checkLock(nextId)
if (!lockStatusResult.locked || lockStatusResult.expired) {
// Found an unlocked project
prefetchedNextProject.value = {
projectId: nextId,
skippedCount: skippedIds.length,
skippedIds,
}
isPrefetching.value = false
return
}
// Project is locked, add to skipped list
skippedIds.push(nextId)
} catch {
// On error, assume unlocked and let navigation handle it
prefetchedNextProject.value = {
projectId: nextId,
skippedCount: skippedIds.length,
skippedIds,
}
isPrefetching.value = false
return
}
}
// No unlocked projects found
isPrefetching.value = false
}
const MAX_SKIP_ATTEMPTS = 10
async function skipToNextProject() {
// Skip the current project
const currentProjectId = projectV2.value.id
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
debug('[skipToNextProject] Queue before complete:', [...moderationStore.currentQueue.items])
moderationStore.completeCurrentProject(currentProjectId, 'skipped')
debug('[skipToNextProject] Queue after complete:', [...moderationStore.currentQueue.items])
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
// Use prefetched data if available
if (navigateToNextUnlockedProject()) {
debug('[skipToNextProject] Used prefetch, returning')
return
}
debug('[skipToNextProject] No prefetch, entering fallback loop')
// Fallback: find the next unlocked project (with a limit to prevent excessive API calls)
let skippedCount = 0
let attempts = 0
while (moderationStore.hasItems && attempts < MAX_SKIP_ATTEMPTS) {
const nextId = moderationStore.getCurrentProjectId()
debug('[skipToNextProject] Loop iteration. nextId:', nextId, 'attempts:', attempts)
if (!nextId) break
attempts++
try {
const lockStatusResult = await moderationStore.checkLock(nextId)
debug('[skipToNextProject] Lock check for', nextId, ':', lockStatusResult)
if (!lockStatusResult.locked || lockStatusResult.expired) {
// Found an unlocked project
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: nextId,
},
state: {
showChecklist: true,
},
})
return
}
// Project is locked, skip it and try the next one
moderationStore.completeCurrentProject(nextId, 'skipped')
skippedCount++
} catch {
// On error, just try to navigate to the project
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: nextId,
},
state: {
showChecklist: true,
},
})
return
}
}
// No unlocked projects found (or hit attempt limit)
debug(
'[skipToNextProject] Loop exited. skippedCount:',
skippedCount,
'attempts:',
attempts,
'hasItems:',
moderationStore.hasItems,
)
if (skippedCount > 0 || attempts >= MAX_SKIP_ATTEMPTS) {
debug('[skipToNextProject] Showing "All projects locked" notification')
addNotification({
title: 'All projects locked',
text:
attempts >= MAX_SKIP_ATTEMPTS
? 'Many projects are currently locked. Try again later.'
: 'All remaining projects are currently being moderated by others.',
type: 'warning',
})
}
debug('[skipToNextProject] Emitting exit')
emit('exit')
}
function resetProgress() { function resetProgress() {
currentStage.value = findFirstValidStage() currentStage.value = findFirstValidStage()
actionStates.value = {} actionStates.value = {}
@@ -642,15 +1042,63 @@ function handleKeybinds(event: KeyboardEvent) {
) )
} }
onMounted(() => { onMounted(async () => {
window.addEventListener('keydown', handleKeybinds) window.addEventListener('keydown', handleKeybinds)
initializeAllStages() document.addEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('left') notifications.setNotificationLocation('left')
// Check if project has already been reviewed (not in processing status)
if (projectV2.value.status !== 'processing') {
alreadyReviewed.value = true
return
}
// Try to acquire lock
const result = await moderationStore.acquireLock(projectV2.value.id)
if (result.success) {
handleLockAcquired()
} else if (result.locked_by) {
// Actually locked by another moderator
// In queue mode with more projects - auto-skip to next project
if (moderationStore.isQueueMode && moderationStore.queueLength > 1) {
addNotification({
title: 'Project locked',
text: `Skipped project locked by @${result.locked_by.username}.`,
type: 'info',
})
// skipToNextProject already calls completeCurrentProject
await skipToNextProject()
return
}
// Single project mode or last in queue - show locked UI
lockStatus.value = {
locked: true,
lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expired: result.expired,
isOwnLock: false,
}
lockError.value = false
// Start countdown timer
updateLockCountdown()
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
} else {
handleLockUnavailable()
}
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeybinds) window.removeEventListener('keydown', handleKeybinds)
document.removeEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('right') notifications.setNotificationLocation('right')
if (lockCheckInterval.value) {
clearInterval(lockCheckInterval.value)
}
clearLockCountdown()
}) })
function initializeAllStages() { function initializeAllStages() {
@@ -1284,10 +1732,10 @@ async function sendMessage(status: ProjectStatus) {
done.value = true done.value = true
hasNextProject.value = await moderationStore.completeCurrentProject( // Release the lock after successful submission
projectV2.value.id, await moderationStore.releaseLock(projectV2.value.id)
'completed',
) hasNextProject.value = moderationStore.completeCurrentProject(projectV2.value.id, 'completed')
} catch (error) { } catch (error) {
console.error('Error submitting moderation:', error) console.error('Error submitting moderation:', error)
addNotification({ addNotification({
@@ -1325,21 +1773,86 @@ async function endChecklist(status?: string) {
}) })
} }
} else { } else {
// Use prefetched data if available for instant navigation
if (!navigateToNextUnlockedProject()) {
// Fallback: find next unlocked project with lock checking
let foundUnlocked = false
let skippedCount = 0
let attempts = 0
while (moderationStore.hasItems && attempts < MAX_SKIP_ATTEMPTS) {
attempts++
const nextId = moderationStore.getCurrentProjectId()
if (!nextId) break
try {
const lockStatus = await moderationStore.checkLock(nextId)
if (!lockStatus.locked || lockStatus.expired) {
// Found an unlocked project
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
navigateTo({ navigateTo({
name: 'type-id', name: 'type-id',
params: { params: {
type: 'project', type: 'project',
id: moderationStore.getCurrentProjectId(), id: nextId,
}, },
state: { state: {
showChecklist: true, showChecklist: true,
}, },
}) })
foundUnlocked = true
break
}
// Project is locked, skip it
moderationStore.completeCurrentProject(nextId, 'skipped')
skippedCount++
} catch {
// On error, try to navigate anyway
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: nextId,
},
state: {
showChecklist: true,
},
})
foundUnlocked = true
break
}
}
// If no unlocked projects found, go back to moderation queue
if (!foundUnlocked) {
if (skippedCount > 0) {
addNotification({
title: 'All projects locked',
text: 'All remaining projects are currently being moderated by others.',
type: 'warning',
})
}
await navigateTo({
name: 'moderation',
})
}
}
} }
} }
async function skipCurrentProject() { async function skipCurrentProject() {
hasNextProject.value = await moderationStore.completeCurrentProject(projectV2.value.id, 'skipped') // Release the lock before skipping
await moderationStore.releaseLock(projectV2.value.id)
hasNextProject.value = moderationStore.completeCurrentProject(projectV2.value.id, 'skipped')
await endChecklist('skipped') await endChecklist('skipped')
} }
@@ -1411,6 +1924,20 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
transition: none !important; transition: none !important;
} }
&.locked {
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%,
100% {
border-color: var(--color-orange);
}
50% {
border-color: color-mix(in srgb, var(--color-orange) 40%, transparent);
}
}
.button-actions-group, .button-actions-group,
.toggle-actions-group, .toggle-actions-group,
.dropdown-actions-group, .dropdown-actions-group,

View File

@@ -0,0 +1,56 @@
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
// All valid project type URL segments
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
export default defineNuxtRouteMiddleware(async (to) => {
// Only handle project routes
if (!to.params.id || !PROJECT_TYPES.includes(to.params.type as string)) {
return
}
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
try {
const project = await client.labrinth.projects_v2.get(to.params.id as string)
if (!project) {
return
}
// Determine the correct URL type
const correctType = getProjectTypeForUrlShorthand(
project.project_type,
project.loaders,
tags.value,
)
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
const pathParts = to.path.split('/')
pathParts.splice(0, 3) // Remove '', type, and id
const remainder = pathParts.filter((x) => x).join('/')
// Build the canonical path
const canonicalPath = `/${correctType}/${project.slug}${remainder ? `/${remainder}` : ''}`
// Only redirect if the path actually changed
if (to.path !== canonicalPath) {
return navigateTo(
{
path: canonicalPath,
query: to.query,
hash: to.hash,
},
{
redirectCode: 301,
replace: true,
},
)
}
} catch {
// Let the page handle 404s and other errors
}
})

View File

@@ -1019,8 +1019,12 @@ import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { useModerationStore } from '~/store/moderation.ts' import { useModerationStore } from '~/store/moderation.ts'
import { reportProject } from '~/utils/report-helpers.ts' import { reportProject } from '~/utils/report-helpers.ts'
definePageMeta({
key: (route) => route.fullPath,
})
const data = useNuxtApp() const data = useNuxtApp()
const route = useNativeRoute() const route = useRoute()
const config = useRuntimeConfig() const config = useRuntimeConfig()
const moderationStore = useModerationStore() const moderationStore = useModerationStore()
const notifications = injectNotificationManager() const notifications = injectNotificationManager()
@@ -1539,8 +1543,6 @@ try {
), ),
]) ])
await updateProjectRoute()
versions = shallowRef(toRaw(versions)) versions = shallowRef(toRaw(versions))
versionsV3 = shallowRef(toRaw(versionsV3)) versionsV3 = shallowRef(toRaw(versionsV3))
versions.value = (versions.value ?? []).map((v) => ({ versions.value = (versions.value ?? []).map((v) => ({
@@ -1621,22 +1623,6 @@ if (!project.value) {
}) })
} }
if (
project.value.project_type !== route.params.type ||
(route.params.id !== project.value.slug && !flags.value.disablePrettyProjectUrlRedirects)
) {
let path = route.fullPath.split('/')
path.splice(0, 3)
path = path.filter((x) => x)
await navigateTo(
`/${project.value.project_type}/${project.value.slug}${
path.length > 0 ? `/${path.join('/')}` : ''
}`,
{ redirectCode: 301, replace: true },
)
}
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start // Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name // The rest of the members should be sorted by role, then by name
const members = computed(() => { const members = computed(() => {

View File

@@ -89,6 +89,7 @@
:queue-entry="item" :queue-entry="item"
:owner="item.owner" :owner="item.owner"
:org="item.org" :org="item.org"
@start-from-project="startFromProject"
/> />
</div> </div>
@@ -112,6 +113,7 @@ import {
Combobox, Combobox,
type ComboboxOption, type ComboboxOption,
defineMessages, defineMessages,
injectNotificationManager,
Pagination, Pagination,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -123,6 +125,7 @@ import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation
import { useModerationStore } from '~/store/moderation.ts' import { useModerationStore } from '~/store/moderation.ts'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const moderationStore = useModerationStore() const moderationStore = useModerationStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -342,13 +345,105 @@ function goToPage(page: number) {
currentPage.value = page currentPage.value = page
} }
function moderateAllInFilter() { async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id)) let skippedCount = 0
while (moderationStore.hasItems) {
const currentId = moderationStore.getCurrentProjectId()
if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
moderationStore.completeCurrentProject(currentId, 'skipped')
continue
}
try {
const lockStatus = await moderationStore.checkLock(currentId)
if (!lockStatus.locked || lockStatus.expired) {
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
return project
}
// Project is locked, skip it
moderationStore.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return project
}
}
return null
}
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
moderationStore.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
type: 'warning',
})
return
}
navigateTo({ navigateTo({
name: 'type-id', name: 'type-id',
params: { params: {
type: 'project', type: 'project',
id: moderationStore.getCurrentProjectId(), id: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
}
async function startFromProject(projectId: string) {
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
moderationStore.setSingleProject(projectId)
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
moderationStore.setQueue(projectIds)
}
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
type: 'warning',
})
return
}
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: targetProject.project.slug,
}, },
state: { state: {
showChecklist: true, showChecklist: true,

View File

@@ -6,7 +6,7 @@ const CACHE_MAX_AGE = 60 * 10 // 10 minutes
export default defineCachedEventHandler( export default defineCachedEventHandler(
async (event) => { async (event) => {
const client = useServerModrinthClient(event) const client = useServerModrinthClient({ event })
const response = await client.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', { const response = await client.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', {
api: 'labrinth', api: 'labrinth',

View File

@@ -1,4 +1,4 @@
import { type NuxtClientConfig, NuxtModrinthClient } from '@modrinth/api-client' import { AuthFeature, type NuxtClientConfig, NuxtModrinthClient } from '@modrinth/api-client'
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> { async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
@@ -11,14 +11,30 @@ async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
} }
} }
export function useServerModrinthClient(event: H3Event): NuxtModrinthClient { export interface ServerModrinthClientOptions {
const config = useRuntimeConfig(event) event?: H3Event
authToken?: string
}
export function useServerModrinthClient(options?: ServerModrinthClientOptions): NuxtModrinthClient {
const config = useRuntimeConfig(options?.event)
const apiBaseUrl = (config.apiBaseUrl || config.public.apiBaseUrl).replace('/v2/', '/') const apiBaseUrl = (config.apiBaseUrl || config.public.apiBaseUrl).replace('/v2/', '/')
const features = []
if (options?.authToken) {
features.push(
new AuthFeature({
token: options.authToken,
tokenPrefix: '',
}),
)
}
const clientConfig: NuxtClientConfig = { const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: apiBaseUrl, labrinthBaseUrl: apiBaseUrl,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore, rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [], features,
} }
return new NuxtModrinthClient(clientConfig) return new NuxtModrinthClient(clientConfig)

View File

@@ -9,6 +9,26 @@ export interface ModerationQueue {
lastUpdated: Date lastUpdated: Date
} }
export interface LockedByUser {
id: string
username: string
avatar_url?: string
}
export interface LockStatusResponse {
locked: boolean
locked_by?: LockedByUser
locked_at?: string
expired?: boolean
}
export interface LockAcquireResponse {
success: boolean
locked_by?: LockedByUser
locked_at?: string
expired?: boolean
}
const EMPTY_QUEUE: Partial<ModerationQueue> = { const EMPTY_QUEUE: Partial<ModerationQueue> = {
items: [], items: [],
@@ -28,6 +48,8 @@ pinia.use(piniaPluginPersistedstate)
export const useModerationStore = defineStore('moderation', { export const useModerationStore = defineStore('moderation', {
state: () => ({ state: () => ({
currentQueue: createEmptyQueue(), currentQueue: createEmptyQueue(),
currentLock: null as { projectId: string; lockedAt: Date } | null,
isQueueMode: false,
}), }),
getters: { getters: {
@@ -41,6 +63,7 @@ export const useModerationStore = defineStore('moderation', {
actions: { actions: {
setQueue(projectIDs: string[]) { setQueue(projectIDs: string[]) {
this.isQueueMode = true
this.currentQueue = { this.currentQueue = {
items: [...projectIDs], items: [...projectIDs],
total: projectIDs.length, total: projectIDs.length,
@@ -51,6 +74,7 @@ export const useModerationStore = defineStore('moderation', {
}, },
setSingleProject(projectId: string) { setSingleProject(projectId: string) {
this.isQueueMode = false
this.currentQueue = { this.currentQueue = {
items: [projectId], items: [projectId],
total: 1, total: 1,
@@ -78,8 +102,71 @@ export const useModerationStore = defineStore('moderation', {
}, },
resetQueue() { resetQueue() {
this.isQueueMode = false
this.currentQueue = createEmptyQueue() this.currentQueue = createEmptyQueue()
}, },
async acquireLock(projectId: string): Promise<LockAcquireResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'POST',
internal: true,
})) as LockAcquireResponse
if (response.success) {
this.currentLock = { projectId, lockedAt: new Date() }
}
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 }
}
},
async 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
}
return response.success
} catch {
return false
}
},
async checkLock(projectId: string): Promise<LockStatusResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'GET',
internal: true,
})) as LockStatusResponse
return response
} catch (error) {
console.error('Failed to check moderation lock:', error)
// Return unlocked status on error so moderation can proceed
return { locked: false }
}
},
async refreshLock(): Promise<boolean> {
if (!this.currentLock) return false
try {
const response = await this.acquireLock(this.currentLock.projectId)
return response.success
} catch (error) {
console.error('Failed to refresh moderation lock:', error)
return false
}
},
}, },
persist: { persist: {

View File

@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT\n\t\t\t\tml.project_id,\n\t\t\t\tml.moderator_id,\n\t\t\t\tu.username as moderator_username,\n\t\t\t\tu.avatar_url as moderator_avatar_url,\n\t\t\t\tml.locked_at\n\t\t\tFROM moderation_locks ml\n\t\t\tINNER JOIN users u ON u.id = ml.moderator_id\n\t\t\tWHERE ml.project_id = $1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "moderator_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "moderator_username",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "moderator_avatar_url",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
false
]
},
"hash": "15ce2cf3154ba3358461b375504ca2de7f5b8b742c0196e71ab2139174fcc12f"
}

View File

@@ -0,0 +1,14 @@
{
"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

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM moderation_locks WHERE project_id = $1 AND moderator_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM moderation_locks",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "60ee89bff8241dd00a1aa33d072cb8b78a9b5d935097391247b0c14fa25f1118"
}

View File

@@ -0,0 +1,15 @@
{
"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

@@ -0,0 +1,15 @@
{
"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

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425"
}

View File

@@ -0,0 +1,8 @@
CREATE TABLE moderation_locks (
project_id BIGINT PRIMARY KEY REFERENCES mods(id) ON DELETE CASCADE,
moderator_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
locked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX idx_moderation_locks_moderator ON moderation_locks(moderator_id);
CREATE INDEX idx_moderation_locks_locked_at ON moderation_locks(locked_at);

View File

@@ -11,6 +11,7 @@ pub mod ids;
pub mod image_item; pub mod image_item;
pub mod legacy_loader_fields; pub mod legacy_loader_fields;
pub mod loader_fields; pub mod loader_fields;
pub mod moderation_lock_item;
pub mod notification_item; pub mod notification_item;
pub mod notifications_deliveries_item; pub mod notifications_deliveries_item;
pub mod notifications_template_item; pub mod notifications_template_item;
@@ -53,6 +54,8 @@ pub use thread_item::{DBThread, DBThreadMessage};
pub use user_item::DBUser; pub use user_item::DBUser;
pub use version_item::DBVersion; pub use version_item::DBVersion;
pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DatabaseError { pub enum DatabaseError {
#[error("Error while interacting with the database: {0}")] #[error("Error while interacting with the database: {0}")]

View File

@@ -0,0 +1,163 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::database::models::{DBProjectId, DBUserId};
const LOCK_EXPIRY_MINUTES: i64 = 15;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBModerationLock {
pub project_id: DBProjectId,
pub moderator_id: DBUserId,
pub locked_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModerationLockWithUser {
pub project_id: DBProjectId,
pub moderator_id: DBUserId,
pub moderator_username: String,
pub moderator_avatar_url: Option<String>,
pub locked_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.
/// 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",
project_id as DBProjectId,
moderator_id as DBUserId
)
.execute(pool)
.await?;
Ok(Ok(()))
}
/// Get lock status for a project, including moderator username
pub async fn get_with_user(
project_id: DBProjectId,
pool: &PgPool,
) -> Result<Option<ModerationLockWithUser>, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT
ml.project_id,
ml.moderator_id,
u.username as moderator_username,
u.avatar_url as moderator_avatar_url,
ml.locked_at
FROM moderation_locks ml
INNER JOIN users u ON u.id = ml.moderator_id
WHERE ml.project_id = $1
"#,
project_id as DBProjectId
)
.fetch_optional(pool)
.await?;
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;
ModerationLockWithUser {
project_id: DBProjectId(r.project_id),
moderator_id: DBUserId(r.moderator_id),
moderator_username: r.moderator_username,
moderator_avatar_url: r.moderator_avatar_url,
locked_at,
expired,
}
}))
}
/// Release a lock (only if held by the specified moderator)
pub async fn release(
project_id: DBProjectId,
moderator_id: DBUserId,
pool: &PgPool,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
"DELETE FROM moderation_locks WHERE project_id = $1 AND moderator_id = $2",
project_id as DBProjectId,
moderator_id as DBUserId
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// 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?;
Ok(result.rows_affected())
}
/// Delete all moderation locks (admin only)
pub async fn delete_all(pool: &PgPool) -> Result<u64, sqlx::Error> {
let result = sqlx::query!("DELETE FROM moderation_locks")
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}

View File

@@ -1,5 +1,7 @@
use super::ApiError; use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::database; use crate::database;
use crate::database::models::DBModerationLock;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::ids::OrganizationId; use crate::models::ids::OrganizationId;
use crate::models::projects::{Project, ProjectStatus}; use crate::models::projects::{Project, ProjectStatus};
@@ -7,8 +9,9 @@ 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, models::pats::Scopes};
use actix_web::{HttpRequest, 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 ownership::get_projects_ownership; use ownership::get_projects_ownership;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
@@ -21,6 +24,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(get_projects) cfg.service(get_projects)
.service(get_project_meta) .service(get_project_meta)
.service(set_project_meta) .service(set_project_meta)
.service(acquire_lock)
.service(get_lock_status)
.service(release_lock)
.service(delete_all_locks)
.service( .service(
utoipa_actix_web::scope("/tech-review") utoipa_actix_web::scope("/tech-review")
.configure(tech_review::config), .configure(tech_review::config),
@@ -76,6 +83,59 @@ pub enum Ownership {
}, },
} }
/// Response for lock status check
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LockStatusResponse {
/// Whether the project is currently locked
pub locked: 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)
#[serde(skip_serializing_if = "Option::is_none")]
pub expired: Option<bool>,
}
/// Information about the moderator holding the lock
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LockedByUser {
/// User ID (base62 encoded)
pub id: String,
/// Username
pub username: String,
/// Avatar URL
pub avatar_url: Option<String>,
}
/// Response for successful lock acquisition
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LockAcquireResponse {
/// Whether lock was successfully acquired
pub success: 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>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expired: Option<bool>,
}
/// Response for lock release
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LockReleaseResponse {
pub success: bool,
}
/// Response for deleting all locks
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DeleteAllLocksResponse {
pub deleted_count: u64,
}
/// Fetch all projects which are in the moderation queue. /// Fetch all projects which are in the moderation queue.
#[utoipa::path( #[utoipa::path(
responses((status = OK, body = inline(Vec<FetchedProject>))) responses((status = OK, body = inline(Vec<FetchedProject>)))
@@ -422,3 +482,185 @@ async fn set_project_meta(
Ok(()) Ok(())
} }
/// Acquire or refresh a moderation lock on a project.
/// Returns success if acquired, or info about who holds the lock if blocked.
#[utoipa::path(
responses(
(status = OK, body = LockAcquireResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[post("/lock/{project_id}")]
async fn acquire_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);
match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? {
Ok(()) => Ok(web::Json(LockAcquireResponse {
success: true,
locked_by: None,
locked_at: None,
expired: None,
})),
Err(lock) => Ok(web::Json(LockAcquireResponse {
success: 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),
expired: Some(lock.expired),
})),
}
}
/// Check the lock status for a project
#[utoipa::path(
responses(
(status = OK, body = LockStatusResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[get("/lock/{project_id}")]
async fn get_lock_status(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(String,)>,
) -> Result<web::Json<LockStatusResponse>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.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;
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),
})),
None => Ok(web::Json(LockStatusResponse {
locked: false,
locked_by: None,
locked_at: None,
expired: None,
})),
}
}
/// Release a moderation lock on a project
#[utoipa::path(
responses(
(status = OK, body = LockReleaseResponse),
(status = NOT_FOUND, description = "Project not found")
)
)]
#[delete("/lock/{project_id}")]
async fn release_lock(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(String,)>,
) -> Result<web::Json<LockReleaseResponse>, 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);
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(
(status = OK, body = DeleteAllLocksResponse),
(status = UNAUTHORIZED, description = "Not an admin")
)
)]
#[delete("/locks")]
async fn delete_all_locks(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<DeleteAllLocksResponse>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You must be an admin to delete all locks".to_string(),
));
}
let deleted_count = DBModerationLock::delete_all(&pool).await?;
Ok(web::Json(DeleteAllLocksResponse { deleted_count }))
}

View File

@@ -6,7 +6,9 @@ use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::project_item::{DBGalleryItem, DBModCategory}; use crate::database::models::project_item::{DBGalleryItem, DBModCategory};
use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::models::{DBTeamMember, ids as db_ids, image_item}; use crate::database::models::{
DBModerationLock, DBTeamMember, ids as db_ids, image_item,
};
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::database::{self, models as db_models}; use crate::database::{self, models as db_models};
use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::file_hosting::{FileHost, FileHostPublicity};
@@ -368,6 +370,23 @@ pub async fn project_edit(
)); ));
} }
// 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 user.role.is_mod()
&& 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
)));
}
if status == &ProjectStatus::Processing { if status == &ProjectStatus::Processing {
if project_item.versions.is_empty() { if project_item.versions.is_empty() {
return Err(ApiError::InvalidInput(String::from( return Err(ApiError::InvalidInput(String::from(