You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
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:
@@ -311,7 +311,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-01-01',
|
||||
telemetry: false,
|
||||
experimental: {
|
||||
asyncContext: true,
|
||||
asyncContext: isProduction(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -127,11 +127,9 @@ import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ModerationProject } from '~/helpers/moderation'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const moderationStore = useModerationStore()
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
@@ -139,6 +137,10 @@ const props = defineProps<{
|
||||
queueEntry: ModerationProject
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
startFromProject: [projectId: string]
|
||||
}>()
|
||||
|
||||
function getDaysQueued(date: Date): number {
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
@@ -201,16 +203,6 @@ const quickActions: OverflowMenuOption[] = [
|
||||
]
|
||||
|
||||
function openProjectForReview() {
|
||||
moderationStore.setSingleProject(props.queueEntry.project.id)
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: {
|
||||
type: 'project',
|
||||
id: props.queueEntry.project.slug || props.queueEntry.project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
})
|
||||
emit('startFromProject', props.queueEntry.project.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
56
apps/frontend/src/middleware/project-canonicalize.global.ts
Normal file
56
apps/frontend/src/middleware/project-canonicalize.global.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -1019,8 +1019,12 @@ import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
import { reportProject } from '~/utils/report-helpers.ts'
|
||||
|
||||
definePageMeta({
|
||||
key: (route) => route.fullPath,
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useNativeRoute()
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const moderationStore = useModerationStore()
|
||||
const notifications = injectNotificationManager()
|
||||
@@ -1539,8 +1543,6 @@ try {
|
||||
),
|
||||
])
|
||||
|
||||
await updateProjectRoute()
|
||||
|
||||
versions = shallowRef(toRaw(versions))
|
||||
versionsV3 = shallowRef(toRaw(versionsV3))
|
||||
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
|
||||
// The rest of the members should be sorted by role, then by name
|
||||
const members = computed(() => {
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
:queue-entry="item"
|
||||
:owner="item.owner"
|
||||
:org="item.org"
|
||||
@start-from-project="startFromProject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -123,6 +125,7 @@ import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const moderationStore = useModerationStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -342,13 +345,105 @@ function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function moderateAllInFilter() {
|
||||
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id))
|
||||
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
||||
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({
|
||||
name: 'type-id',
|
||||
params: {
|
||||
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: {
|
||||
showChecklist: true,
|
||||
|
||||
@@ -6,7 +6,7 @@ const CACHE_MAX_AGE = 60 * 10 // 10 minutes
|
||||
|
||||
export default defineCachedEventHandler(
|
||||
async (event) => {
|
||||
const client = useServerModrinthClient(event)
|
||||
const client = useServerModrinthClient({ event })
|
||||
|
||||
const response = await client.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', {
|
||||
api: 'labrinth',
|
||||
|
||||
@@ -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'
|
||||
|
||||
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
|
||||
@@ -11,14 +11,30 @@ async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerModrinthClient(event: H3Event): NuxtModrinthClient {
|
||||
const config = useRuntimeConfig(event)
|
||||
export interface ServerModrinthClientOptions {
|
||||
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 features = []
|
||||
|
||||
if (options?.authToken) {
|
||||
features.push(
|
||||
new AuthFeature({
|
||||
token: options.authToken,
|
||||
tokenPrefix: '',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const clientConfig: NuxtClientConfig = {
|
||||
labrinthBaseUrl: apiBaseUrl,
|
||||
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
|
||||
features: [],
|
||||
features,
|
||||
}
|
||||
|
||||
return new NuxtModrinthClient(clientConfig)
|
||||
|
||||
@@ -9,6 +9,26 @@ export interface ModerationQueue {
|
||||
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> = {
|
||||
items: [],
|
||||
|
||||
@@ -28,6 +48,8 @@ pinia.use(piniaPluginPersistedstate)
|
||||
export const useModerationStore = defineStore('moderation', {
|
||||
state: () => ({
|
||||
currentQueue: createEmptyQueue(),
|
||||
currentLock: null as { projectId: string; lockedAt: Date } | null,
|
||||
isQueueMode: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -41,6 +63,7 @@ export const useModerationStore = defineStore('moderation', {
|
||||
|
||||
actions: {
|
||||
setQueue(projectIDs: string[]) {
|
||||
this.isQueueMode = true
|
||||
this.currentQueue = {
|
||||
items: [...projectIDs],
|
||||
total: projectIDs.length,
|
||||
@@ -51,6 +74,7 @@ export const useModerationStore = defineStore('moderation', {
|
||||
},
|
||||
|
||||
setSingleProject(projectId: string) {
|
||||
this.isQueueMode = false
|
||||
this.currentQueue = {
|
||||
items: [projectId],
|
||||
total: 1,
|
||||
@@ -78,8 +102,71 @@ export const useModerationStore = defineStore('moderation', {
|
||||
},
|
||||
|
||||
resetQueue() {
|
||||
this.isQueueMode = false
|
||||
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: {
|
||||
|
||||
46
apps/labrinth/.sqlx/query-15ce2cf3154ba3358461b375504ca2de7f5b8b742c0196e71ab2139174fcc12f.json
generated
Normal file
46
apps/labrinth/.sqlx/query-15ce2cf3154ba3358461b375504ca2de7f5b8b742c0196e71ab2139174fcc12f.json
generated
Normal 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"
|
||||
}
|
||||
14
apps/labrinth/.sqlx/query-420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c.json
generated
Normal file
14
apps/labrinth/.sqlx/query-420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c.json
generated
Normal 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"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5.json
generated
Normal file
15
apps/labrinth/.sqlx/query-47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5.json
generated
Normal 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"
|
||||
}
|
||||
12
apps/labrinth/.sqlx/query-60ee89bff8241dd00a1aa33d072cb8b78a9b5d935097391247b0c14fa25f1118.json
generated
Normal file
12
apps/labrinth/.sqlx/query-60ee89bff8241dd00a1aa33d072cb8b78a9b5d935097391247b0c14fa25f1118.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM moderation_locks",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "60ee89bff8241dd00a1aa33d072cb8b78a9b5d935097391247b0c14fa25f1118"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1.json
generated
Normal file
15
apps/labrinth/.sqlx/query-72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1.json
generated
Normal 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"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a.json
generated
Normal file
15
apps/labrinth/.sqlx/query-834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a.json
generated
Normal 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"
|
||||
}
|
||||
12
apps/labrinth/.sqlx/query-e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425.json
generated
Normal file
12
apps/labrinth/.sqlx/query-e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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);
|
||||
@@ -11,6 +11,7 @@ pub mod ids;
|
||||
pub mod image_item;
|
||||
pub mod legacy_loader_fields;
|
||||
pub mod loader_fields;
|
||||
pub mod moderation_lock_item;
|
||||
pub mod notification_item;
|
||||
pub mod notifications_deliveries_item;
|
||||
pub mod notifications_template_item;
|
||||
@@ -53,6 +54,8 @@ pub use thread_item::{DBThread, DBThreadMessage};
|
||||
pub use user_item::DBUser;
|
||||
pub use version_item::DBVersion;
|
||||
|
||||
pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("Error while interacting with the database: {0}")]
|
||||
|
||||
163
apps/labrinth/src/database/models/moderation_lock_item.rs
Normal file
163
apps/labrinth/src/database/models/moderation_lock_item.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::ApiError;
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database;
|
||||
use crate::database::models::DBModerationLock;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::OrganizationId;
|
||||
use crate::models::projects::{Project, ProjectStatus};
|
||||
@@ -7,8 +9,9 @@ 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 actix_web::{HttpRequest, get, post, web};
|
||||
use actix_web::{HttpRequest, delete, get, post, web};
|
||||
use ariadne::ids::{UserId, random_base62};
|
||||
use chrono::{DateTime, Utc};
|
||||
use ownership::get_projects_ownership;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -21,6 +24,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(get_projects)
|
||||
.service(get_project_meta)
|
||||
.service(set_project_meta)
|
||||
.service(acquire_lock)
|
||||
.service(get_lock_status)
|
||||
.service(release_lock)
|
||||
.service(delete_all_locks)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/tech-review")
|
||||
.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.
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(Vec<FetchedProject>)))
|
||||
@@ -422,3 +482,185 @@ async fn set_project_meta(
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -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::project_item::{DBGalleryItem, DBModCategory};
|
||||
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::{self, models as db_models};
|
||||
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 project_item.versions.is_empty() {
|
||||
return Err(ApiError::InvalidInput(String::from(
|
||||
|
||||
Reference in New Issue
Block a user