1
0

Technical review queue (#4775)

* chore: fix typo in status message

* feat(labrinth): overhaul malware scanner report storage and routes

* chore: address some review comments

* feat: add Delphi to Docker Compose `with-delphi` profile

* chore: fix unused import Clippy lint

* feat(labrinth/delphi): use PAT token authorization with project read scopes

* chore: expose file IDs in version queries

* fix: accept null decompiled source payloads from Delphi

* tweak(labrinth): expose base62 file IDs more consistently for Delphi

* feat(labrinth/delphi): support new Delphi report severity field

* chore(labrinth): run `cargo sqlx prepare` to fix Docker build errors

* tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types

* chore: run `cargo sqlx prepare`

* chore: fix typo on frontend generated state file message

* feat: update to use new Delphi issue schema

* wip: tech review endpoints

* wip: add ToSchema for dependent types

* wip: report issues return

* wip

* wip: returning more data

* wip

* Fix up db query

* Delphi configuration to talk to Labrinth

* Get Delphi working with Labrinth

* Add Delphi dummy fixture

* Better Delphi logging

* Improve utoipa for tech review routes

* Add more sorting options for tech review queue

* Oops join

* New routes for fetching issues and reports

* Fix which kind of ID is returned in tech review endpoints

* Deduplicate tech review report rows

* Reduce info sent for projects

* Fetch more thread info

* Address PR comments

* fix ci

* fix postgres version mismatch

* fix version creation

* Implement routes

* fix up tech review

* Allow adding a moderation comment to Delphi rejections

* fix up rebase

* exclude rejected projects from tech review

* add status change msg to tech review thread

* cargo sqlx prepare

* also ignore withheld projects

* More filtering on issue search

* wip: report routes

* Fix up for build

* cargo sqlx prepare

* fix thread message privacy

* New tech review search route

* submit route

* details have statuses now

* add default to drid status

* dedup issue details

* fix sqlx query on empty files

* fixes

* Dedupe issue detail statuses and message on entering tech rev

* Fix qa issues

* Fix qa issues

* fix review comments

* typos

* fix ci

* feat: tech review frontend (#4781)

* chore: fix typo in status message

* feat(labrinth): overhaul malware scanner report storage and routes

* chore: address some review comments

* feat: add Delphi to Docker Compose `with-delphi` profile

* chore: fix unused import Clippy lint

* feat(labrinth/delphi): use PAT token authorization with project read scopes

* chore: expose file IDs in version queries

* fix: accept null decompiled source payloads from Delphi

* tweak(labrinth): expose base62 file IDs more consistently for Delphi

* feat(labrinth/delphi): support new Delphi report severity field

* chore(labrinth): run `cargo sqlx prepare` to fix Docker build errors

* tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types

* chore: run `cargo sqlx prepare`

* chore: fix typo on frontend generated state file message

* feat: update to use new Delphi issue schema

* wip: tech review endpoints

* wip: add ToSchema for dependent types

* wip: report issues return

* wip

* wip: returning more data

* wip

* Fix up db query

* Delphi configuration to talk to Labrinth

* Get Delphi working with Labrinth

* Add Delphi dummy fixture

* Better Delphi logging

* Improve utoipa for tech review routes

* Add more sorting options for tech review queue

* Oops join

* New routes for fetching issues and reports

* Fix which kind of ID is returned in tech review endpoints

* Deduplicate tech review report rows

* Reduce info sent for projects

* Fetch more thread info

* Address PR comments

* fix ci

* fix ci

* fix postgres version mismatch

* fix version creation

* Implement routes

* feat: batch scan alert

* feat: layout

* feat: introduce surface variables

* fix: theme selector

* feat: rough draft of tech review card

* feat: tab switcher

* feat: batch scan btn

* feat: api-client module for tech review

* draft: impl

* feat: auto icons

* fix: layout issues

* feat: fixes to code blocks + flag labels

* feat: temp remove mock data

* fix: search sort types

* fix: intl & lint

* chore: re-enable mock data

* fix: flag badges + auto open first issue in file tab

* feat: update for new routes

* fix: more qa issues

* feat: lazy load sources

* fix: re-enable auth middleware

* feat: impl threads

* fix: lint & severity

* feat: download btn + switch to using NavTabs with new local mode option

* feat: re-add toplevel btns

* feat: reports page consistency

* fix: consistency on project queue

* fix: icons + sizing

* fix: colors and gaps

* fix: impl endpoints

* feat: load all flags on file tab

* feat: thread generics changes

* feat: more qa

* feat: fix collapse

* fix: qa

* feat: msg modal

* fix: ISO import

* feat: qa fixes

* fix: empty state basic

* fix: collapsible region

* fix: collapse thread by default

* feat: rough draft of new process/flow

* fix labrinth build

* fix thread message privacy

* New tech review search route

* feat: qa fixes

* feat: QA changes

* fix: verdict on detail not whole issue

* fix: lint + intl

* fix: lint

* fix: thread message for tech rev verdict

* feat: use anim frames

* fix: exports + typecheck

* polish: qa changes

* feat: qa

* feat: qa polish

* feat: fix malic modal

* fix: lint

* fix: qa + lint

* fix: pagination

* fix: lint

* fix: qa

* intl extract

* fix ci

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: aecsocket <aecsocket@tutanota.com>

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Calum H. <contact@cal.engineer>
This commit is contained in:
aecsocket
2025-12-20 11:43:04 +00:00
committed by GitHub
parent 1e9e13aebb
commit 39f2b0ecb6
109 changed files with 6281 additions and 2017 deletions

View File

@@ -56,7 +56,7 @@ Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse
### Postgres
Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance.
Use `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "SELECT 1"` to access the PostgreSQL instance, replacing the `SELECT 1` with your query.
# Guidelines

1
Cargo.lock generated
View File

@@ -10349,6 +10349,7 @@ dependencies = [
"quote",
"regex",
"syn 2.0.106",
"url",
"uuid 1.18.1",
]

View File

@@ -154,7 +154,7 @@ export default defineNuxtConfig({
(state.errors ?? []).length === 0
) {
console.log(
'Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.',
'Tags already recently generated. Delete apps/frontend/src/generated/state.json to force regeneration.',
)
return
}

View File

@@ -1,23 +1,58 @@
<template>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="[mode === 'navigation' ? 'card-shadow' : undefined]"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-button-textSelected': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<template v-if="mode === 'navigation'">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="link.href"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span class="text-nowrap text-contrast">{{ link.label }}</span>
</NuxtLink>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="link.href"
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
@click="emit('tabClick', index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
</div>
</template>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
@@ -27,7 +62,8 @@
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
opacity:
sliderLeft === 4 && sliderLeft === sliderRight ? 0 : currentActiveIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
@@ -35,7 +71,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const route = useNativeRoute()
@@ -43,13 +80,26 @@ interface Tab {
label: string
href: string
shown?: boolean
icon?: string
icon?: Component
subpages?: string[]
}
const props = defineProps<{
links: Tab[]
query?: string
const props = withDefaults(
defineProps<{
links: Tab[]
query?: string
mode?: 'navigation' | 'local'
activeIndex?: number
}>(),
{
mode: 'navigation',
query: undefined,
activeIndex: undefined,
},
)
const emit = defineEmits<{
tabClick: [index: number, tab: Tab]
}>()
const scrollContainer = ref<HTMLElement | null>(null)
@@ -58,7 +108,7 @@ const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const currentActiveIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
@@ -74,30 +124,36 @@ const tabLinkElements = ref()
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
if (props.mode === 'local' && props.activeIndex !== undefined) {
index = Math.min(props.activeIndex, filteredLinks.value.length - 1)
} else {
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i
break
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation()
currentActiveIndex.value = index
if (currentActiveIndex.value !== -1) {
nextTick(() => startAnimation())
} else {
sliderLeft.value = 0
sliderRight.value = 0
@@ -105,7 +161,12 @@ function pickLink() {
}
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value]?.$el
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const el =
props.mode === 'navigation'
? tabLinkElements.value[currentActiveIndex.value]?.$el
: tabLinkElements.value[currentActiveIndex.value]
if (!el || !el.offsetParent) return
@@ -156,7 +217,29 @@ onMounted(() => {
watch(
() => [route.path, route.query],
() => pickLink(),
() => {
if (props.mode === 'navigation') {
pickLink()
}
},
)
watch(
() => props.activeIndex,
() => {
if (props.mode === 'local') {
pickLink()
}
},
)
watch(
() => props.links,
() => {
// Re-trigger animation when links change
pickLink()
},
{ deep: true },
)
</script>

View File

@@ -207,6 +207,8 @@ import {
financialMessages,
formFieldLabels,
formFieldPlaceholders,
getBlockchainColor,
getBlockchainIcon,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
@@ -218,12 +220,6 @@ import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { useGeneratedState } from '@/composables/generated'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import {
getBlockchainColor,
getBlockchainIcon,
getCurrencyColor,
getCurrencyIcon,
} from '@/utils/finance-icons.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()

View File

@@ -0,0 +1,39 @@
<template>
<div
class="flex flex-col gap-4 rounded-2xl border-[1px] border-solid border-blue bg-highlight-blue p-4"
>
<div class="flex flex-row justify-between">
<div class="flex flex-col text-contrast">
<span class="text-xl font-semibold">Batch scan in progress</span>
<span>{{ progress?.complete }} of {{ progress?.total }} projects completed</span>
</div>
<ButtonStyled circular color="blue" type="outlined">
<button class="!px-4" @click="emit('cancel-scan')">Cancel scan</button>
</ButtonStyled>
</div>
<div class="w-full rounded-full bg-highlight-blue">
<div
class="h-3 rounded-[inherit] bg-blue"
:style="`width: ${((progress?.complete ?? 0) / (progress?.total ?? 1)) * 100}%`"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from '@modrinth/ui'
import { defineProps } from 'vue'
export interface BatchScanProgress {
total: number
complete: number
}
defineProps<{
progress?: BatchScanProgress
}>()
const emit = defineEmits<{
(e: 'cancel-scan'): void
}>()
</script>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
export type UnsafeFile = {
file: Labrinth.TechReview.Internal.FileReport & { version_id: string }
projectName: string
projectId: string
userId: string
username: string
}
const props = defineProps<{
unsafeFiles: UnsafeFile[]
}>()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const versionDataCache = ref<
Map<
string,
{
files: Map<string, string>
loading: boolean
error?: string
}
>
>(new Map())
async function fetchVersionHashes(versionIds: string[]) {
const uniqueIds = [...new Set(versionIds)]
for (const versionId of uniqueIds) {
if (versionDataCache.value.has(versionId)) continue
versionDataCache.value.set(versionId, { files: new Map(), loading: true })
try {
// TODO: switch to api-client once truman's vers stuff is merged
const version = (await useBaseFetch(`version/${versionId}`)) as {
files: Array<{
filename: string
file_name?: string
hashes: { sha512: string; sha1: string }
}>
}
const filesMap = new Map<string, string>()
for (const file of version.files) {
const name = file.file_name ?? file.filename
filesMap.set(name, file.hashes.sha512)
}
versionDataCache.value.set(versionId, { files: filesMap, loading: false })
} catch (error) {
console.error(`Failed to fetch version ${versionId}:`, error)
versionDataCache.value.set(versionId, {
files: new Map(),
loading: false,
error: 'Failed',
})
}
}
}
function getFileHash(versionId: string, fileName: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileName)
}
function isHashLoading(versionId: string): boolean {
return versionDataCache.value.get(versionId)?.loading ?? false
}
function show() {
const versionIds = props.unsafeFiles.map((f) => f.file.version_id)
fetchVersionHashes(versionIds)
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
async function copy(text: string) {
await navigator.clipboard.writeText(text)
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modalRef"
header="Malicious file(s) summary"
:close-on-click-outside="false"
:close-on-esc="false"
:closable="false"
>
<div class="markdown-body inset-0">
<div v-if="unsafeFiles.length > 0" class="mb-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-tertiary text-sm font-medium">Project:</span>
<CopyCode :text="unsafeFiles[0].projectName" />
<CopyCode :text="unsafeFiles[0].projectId" />
</div>
<div class="flex items-center gap-2">
<span class="text-tertiary text-sm font-medium">User:</span>
<CopyCode :text="unsafeFiles[0].username" />
<CopyCode :text="unsafeFiles[0].userId" />
</div>
</div>
<table v-if="unsafeFiles.length > 0" class="w-full text-sm">
<thead>
<tr class="text-tertiary text-left text-xs font-medium">
<th class="pb-2">Hash</th>
<th class="pb-2">Version ID</th>
<th class="pb-2">File Name</th>
<th class="pb-2">CDN Link</th>
</tr>
</thead>
<tbody>
<tr v-for="item in unsafeFiles" :key="item.file.file_id">
<td class="py-1 pr-2">
<LoaderCircleIcon
v-if="isHashLoading(item.file.version_id)"
class="size-4 animate-spin text-secondary"
/>
<ButtonStyled
v-else-if="getFileHash(item.file.version_id, item.file.file_name)"
size="small"
type="standard"
>
<button @click="copy(getFileHash(item.file.version_id, item.file.file_name)!)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
</ButtonStyled>
<span v-else class="text-tertiary italic">N/A</span>
</td>
<td class="py-1 pr-2">
<CopyCode :text="item.file.version_id" />
</td>
<td class="py-1 pr-2">
<CopyCode :text="item.file.file_name" />
</td>
<td class="py-1">
<ButtonStyled size="small" type="standard">
<button @click="copy(item.file.download_url)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
</ButtonStyled>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-sm italic text-secondary">No files currently marked as malicious.</p>
<div class="flex justify-end">
<ButtonStyled>
<button @click="hide">
<XIcon class="size-4" />
Close
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>

View File

@@ -1,182 +0,0 @@
<template>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
'Unknown primary file'
}}
</span>
</div>
</div>
</div>
</div>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from '@modrinth/assets'
import type { ExtendedDelphiReport } from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import dayjs from 'dayjs'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
report: ExtendedDelphiReport
}>()
const formatRelativeTime = useRelativeTime()
const isPending = computed(() => props.report.status === 'pending')
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: 'success',
title: 'Tech review link copied',
text: 'The link to this tech review has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: 'success',
title: 'Version ID copied',
text: 'The ID of this version has been copied to your clipboard.',
})
})
},
},
]
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/version/${props.report.version.id}`
})
</script>
<style lang="scss" scoped></style>

View File

@@ -1,143 +1,127 @@
<template>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(', ')
}}</span>
<span class="sm:hidden">{{
props.queueEntry.project.project_types.map(formatProjectType).join(', ')
}}</span>
</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
<div class="shadow-card rounded-2xl border border-surface-5 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
/>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
{{ queueEntry.project.name }}
</NuxtLink>
<div
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<component
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
aria-hidden="true"
class="h-4 w-4"
/>
<span class="text-sm font-medium text-secondary">
{{
queueEntry.project.project_types.map((t) => formatProjectType(t, true)).join(', ')
}}
</span>
</div>
<div
v-if="queueEntry.project.requested_status"
class="flex items-center gap-2 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="status" />
</div>
</div>
<div v-if="queueEntry.owner" class="flex items-center gap-1">
<Avatar
:src="queueEntry.owner.user.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/user/${queueEntry.owner.user.username}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.owner.user.username }}
</NuxtLink>
</div>
<div v-else-if="queueEntry.org" class="flex items-center gap-1">
<Avatar
:src="queueEntry.org.icon_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/organization/${queueEntry.org.slug}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.org.name }}
</NuxtLink>
</div>
</div>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex items-center gap-3">
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
class="text-base text-secondary"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
'text-orange': daysInQueue > 2 && daysInQueue <= 4,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace('Submitted ', '')
}}</span>
{{ formattedDate }}
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<ButtonStyled circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon class="size-5" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, EllipsisVerticalIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
BoxIcon,
BracesIcon,
EyeIcon,
GlassesIcon,
PackageOpenIcon,
PaintbrushIcon,
PlugIcon,
ScaleIcon,
} from '@modrinth/assets'
import { Avatar, Badge, ButtonStyled, useRelativeTime } from '@modrinth/ui'
Avatar,
Badge,
ButtonStyled,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
@@ -145,6 +129,7 @@ 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()
@@ -170,6 +155,49 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate())
})
const formattedDate = computed(() => {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
try {
return formatRelativeTime(dayjs(date).toISOString())
} catch {
return 'Unknown'
}
})
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
title: 'Project link copied',
text: 'The link to this project has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.queueEntry.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
},
]
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id)
navigateTo({
@@ -183,18 +211,4 @@ function openProjectForReview() {
},
})
}
function getSubmittedTime(): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`
} catch {
return 'Unknown'
}
}
</script>

View File

@@ -1,176 +1,287 @@
<template>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
<div class="overflow-hidden rounded-2xl">
<div class="bg-bg-raised p-4">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
<span class="text-secondary">Reported for</span>
<span class="font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden text-secondary sm:inline">By</span>
<span class="text-secondary sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
target="_blank"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<hr class="my-4 rounded-xl border-solid text-divider" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || 'Unknown User' }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || 'Unknown Version'
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="whitespace-nowrap text-sm text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
<CollapsibleRegion ref="collapsibleRegion" class="my-4">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
<div class="my-4 h-px bg-surface-5" />
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="4rem"
:class="[
'flex-shrink-0 border border-surface-5 bg-surface-4 !shadow-none',
report.item_type !== 'user' && 'rounded-2xl',
]"
/>
<div v-if="report.item_type === 'user'" class="flex flex-col gap-1.5">
<NuxtLink
:to="`/user/${report.user?.username}`"
target="_blank"
class="text-base font-semibold text-contrast hover:underline"
>
{{ report.user?.username || 'Unknown User' }}
</NuxtLink>
<span
v-if="report.user?.created"
v-tooltip="formatExactDate(report.user.created)"
class="cursor-help text-sm text-secondary"
>
Joined {{ formatRelativeTime(report.user.created) }}
</span>
</div>
<div v-else class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="reportItemUrl"
target="_blank"
class="text-base font-semibold text-contrast hover:underline"
>
{{ reportItemTitle }}
</NuxtLink>
<div
v-if="report.project?.project_type"
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<component
:is="getProjectTypeIcon(report.project.project_type as any)"
aria-hidden="true"
class="h-4 w-4"
/>
<span class="text-sm font-medium text-secondary">
{{ formatProjectType(report.project.project_type, true) }}
</span>
</div>
<span
v-if="report.item_type === 'version' && report.version"
class="text-sm text-secondary"
>
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }}
</span>
</div>
<div v-if="report.target" class="flex items-center gap-1">
<Avatar
:src="report.target.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/${report.target.type}/${report.target.slug}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ report.target.name }}
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
<CollapsibleRegion
v-model:collapsed="isThreadCollapsed"
:expand-text="expandText"
collapse-text="Collapse thread"
>
<div class="bg-surface-2 p-4 pt-2">
<ThreadView
v-if="report.thread"
ref="reportThread"
:thread="report.thread"
:quick-replies="reportQuickReplies"
:quick-reply-context="report"
:closed="reportClosed"
@update-thread="updateThread"
>
<template #closedActions>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2">
<button class="w-full gap-2 sm:w-auto" @click="reopenReport()">
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template #additionalActions="{ hasReply }">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="hasReply" color="red">
<button class="w-full gap-2 sm:w-auto" @click="closeReport(true)">
<CheckCircleIcon class="size-4" />
Reply and close
</button>
</ButtonStyled>
<ButtonStyled v-else color="red">
<button class="w-full gap-2 sm:w-auto" @click="closeReport()">
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</template>
</ThreadView>
</div>
</CollapsibleRegion>
</div>
</template>
<script setup lang="ts">
import {
CheckCircleIcon,
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from '@modrinth/assets'
import {
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from '@modrinth/moderation'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import type { OverflowMenuOption } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
CollapsibleRegion,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
import ReportThread from '../thread/ReportThread.vue'
import { isStaff } from '~/helpers/users.js'
import ThreadView from '../thread/ThreadView.vue'
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const props = defineProps<{
report: ExtendedReport
}>()
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null)
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null)
const reportThread = ref<{
setReplyContent: (content: string) => void
sendReply: (privateMessage?: boolean) => Promise<void>
} | null>(null)
const isThreadCollapsed = ref(true)
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || props.report.closed
})
const remainingMessageCount = computed(() => {
if (!props.report.thread?.messages) return 0
return Math.max(0, props.report.thread.messages.length - 1)
})
const expandText = computed(() => {
if (remainingMessageCount.value === 0) return 'Expand'
if (remainingMessageCount.value === 1) return 'Show 1 more message'
return `Show ${remainingMessageCount.value} more messages`
})
async function closeReport(reply = false) {
if (reply && reportThread.value) {
await reportThread.value.sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
})
updateThread(props.report.thread)
didCloseReport.value = true
} catch (err: any) {
addNotification({
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: false,
},
})
updateThread(props.report.thread)
didCloseReport.value = false
} catch (err: any) {
addNotification({
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const formatRelativeTime = useRelativeTime()
function formatExactDate(date: string): string {
return dayjs(date).format('MMMM D, YYYY [at] h:mm A')
}
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread)
@@ -206,34 +317,6 @@ const quickActions: OverflowMenuOption[] = [
},
]
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true
if (typeof reply.shouldShow === 'function') {
return reply.shouldShow(props.report)
}
return reply.shouldShow
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
)
})
async function handleQuickReply(reply: ReportQuickReply) {
const message =
typeof reply.message === 'function' ? await reply.message(props.report) : reply.message
collapsibleRegion.value?.setCollapsed(false)
await nextTick()
reportThread.value?.setReplyContent(message)
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case 'project':
@@ -265,11 +348,6 @@ const reportItemUrl = computed(() => {
}
})
const formattedItemType = computed(() => {
const itemType = props.report.item_type
return itemType.charAt(0).toUpperCase() + itemType.slice(1)
})
const formattedReportType = computed(() => {
const reportType = props.report.report_type
@@ -278,5 +356,3 @@ const formattedReportType = computed(() => {
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
})
</script>
<style lang="scss" scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -68,26 +68,18 @@
import {
DownloadIcon,
EditIcon,
FileArchiveIcon,
FileIcon,
FolderOpenIcon,
MoreHorizontalIcon,
PackageOpenIcon,
RightArrowIcon,
TrashIcon,
} from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { ButtonStyled, getFileExtensionIcon } from '@modrinth/ui'
import { computed, ref, shallowRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useRoute, useRouter } from 'vue-router'
import {
UiServersIconsCodeFileIcon,
UiServersIconsCogFolderIcon,
UiServersIconsEarthIcon,
UiServersIconsImageFileIcon,
UiServersIconsTextFileIcon,
} from '#components'
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
@@ -116,36 +108,7 @@ const emit = defineEmits<{
const isDragOver = ref(false)
const isDragging = ref(false)
const codeExtensions = Object.freeze([
'json',
'json5',
'jsonc',
'java',
'kt',
'kts',
'sh',
'bat',
'ps1',
'yml',
'yaml',
'toml',
'js',
'ts',
'py',
'rb',
'php',
'html',
'css',
'cpp',
'c',
'h',
'rs',
'go',
])
const textExtensions = Object.freeze(['txt', 'md', 'log', 'cfg', 'conf', 'properties', 'ini', 'sk'])
const imageExtensions = Object.freeze(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'])
const supportedArchiveExtensions = Object.freeze(['zip'])
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const route = shallowRef(useRoute())
@@ -199,12 +162,7 @@ const iconComponent = computed(() => {
return FolderOpenIcon
}
const ext = fileExtension.value
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon
return FileIcon
return getFileExtensionIcon(fileExtension.value)
})
const subText = computed(() => {

View File

@@ -1,232 +1,22 @@
<template>
<svg
v-if="loader === 'Fabric'"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" />
<path
fill="none"
stroke="currentColor"
stroke-width="23"
d="m820 761-85.6-87.6c-4.6-4.7-10.4-9.6-25.9 1-19.9 13.6-8.4 21.9-5.2 25.4 8.2 9 84.1 89 97.2 104 2.5 2.8-20.3-22.5-6.5-39.7 5.4-7 18-12 26-3 6.5 7.3 10.7 18-3.4 29.7-24.7 20.4-102 82.4-127 103-12.5 10.3-28.5 2.3-35.8-6-7.5-8.9-30.6-34.6-51.3-58.2-5.5-6.3-4.1-19.6 2.3-25 35-30.3 91.9-73.8 111.9-90.8"
transform="matrix(.08671 0 0 .0867 -49.8 -56)"
/>
</svg>
<svg
v-else-if="loader === 'Quilt'"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="2"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<defs>
<path
id="quilt"
fill="none"
stroke="currentColor"
stroke-width="65.6"
d="M442.5 233.9c0-6.4-5.2-11.6-11.6-11.6h-197c-6.4 0-11.6 5.2-11.6 11.6v197c0 6.4 5.2 11.6 11.6 11.6h197c6.4 0 11.6-5.2 11.6-11.7v-197Z"
></path>
</defs>
<path fill="none" d="M0 0h24v24H0z"></path>
<use
xlink:href="#quilt"
stroke-width="65.6"
transform="matrix(.03053 0 0 .03046 -3.2 -3.2)"
></use>
<use xlink:href="#quilt" stroke-width="65.6" transform="matrix(.03053 0 0 .03046 -3.2 7)"></use>
<use
xlink:href="#quilt"
stroke-width="65.6"
transform="matrix(.03053 0 0 .03046 6.9 -3.2)"
></use>
<path
fill="none"
stroke="currentColor"
stroke-width="70.4"
d="M442.5 234.8c0-7-5.6-12.5-12.5-12.5H234.7c-6.8 0-12.4 5.6-12.4 12.5V430c0 6.9 5.6 12.5 12.4 12.5H430c6.9 0 12.5-5.6 12.5-12.5V234.8Z"
transform="rotate(45 3.5 24) scale(.02843 .02835)"
></path>
</svg>
<svg
v-else-if="loader === 'Forge'"
ml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z"></path>
<path
fill="none"
stroke="currentColor"
stroke-width="2"
d="M2 7.5h8v-2h12v2s-7 3.4-7 6 3.1 3.1 3.1 3.1l.9 3.9H5l1-4.1s3.8.1 4-2.9c.2-2.7-6.5-.7-8-6Z"
></path>
</svg>
<svg
v-else-if="loader === 'NeoForge'"
enable-background="new 0 0 24 24"
version="1.1"
viewBox="0 0 24 24"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="m12 19.2v2m0-2v2" />
<path
d="m8.4 1.3c0.5 1.5 0.7 3 0.1 4.6-0.2 0.5-0.9 1.5-1.6 1.5m8.7-6.1c-0.5 1.5-0.7 3-0.1 4.6 0.2 0.6 0.9 1.5 1.6 1.5"
/>
<path d="m3.6 15.8h-1.7m18.5 0h1.7" />
<path d="m3.2 12.1h-1.7m19.3 0h1.8" />
<path d="m8.1 12.7v1.6m7.8-1.6v1.6" />
<path d="m10.8 18h1.2m0 1.2-1.2-1.2m2.4 0h-1.2m0 1.2 1.2-1.2" />
<path
d="m4 9.7c-0.5 1.2-0.8 2.4-0.8 3.7 0 3.1 2.9 6.3 5.3 8.2 0.9 0.7 2.2 1.1 3.4 1.1m0.1-17.8c-1.1 0-2.1 0.2-3.2 0.7m11.2 4.1c0.5 1.2 0.8 2.4 0.8 3.7 0 3.1-2.9 6.3-5.3 8.2-0.9 0.7-2.2 1.1-3.4 1.1m-0.1-17.8c1.1 0 2.1 0.2 3.2 0.7"
/>
<path
d="m4 9.7c-0.2-1.8-0.3-3.7 0.5-5.5s2.2-2.6 3.9-3m11.6 8.5c0.2-1.9 0.3-3.7-0.5-5.5s-2.2-2.6-3.9-3"
/>
<path d="m12 21.2-2.4 0.4m2.4-0.4 2.4 0.4" />
</g>
</svg>
<svg
v-else-if="loader === 'Paper'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" />
<path fill="none" stroke="currentColor" stroke-width="2" d="m12 18 6 2 3-17L2 14l6 2" />
<path stroke="currentColor" stroke-width="2" d="m9 21-1-5 4 2-3 3Z" />
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
</svg>
<svg
v-else-if="loader === 'Spigot'"
viewBox="0 0 332 284"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
fill: none;
fill-rule: nonzero;
stroke-width: 24px;
"
stroke="currentColor"
>
<path
d="M147.5,27l27,-15l27.5,15l66.5,0l0,33.5l-73,-0.912l0,45.5l26,-0.088l0,31.5l-12.5,0l0,15.5l16,21.5l35,0l0,-21.5l35.5,0l0,21.5l24.5,0l0,55.5l-24.5,0l0,17l-35.5,0l0,-27l-35,0l-55.5,14.5l-67.5,-14.5l-15,14.5l18,12.5l-3,24.5l-41.5,1.5l-48.5,-19.5l6,-19l24.5,-4.5l16,-41l79,-36l-7,-15.5l0,-31.5l23.5,0l0,-45.5l-73.5,0l0,-32.5l67,0Z"
/>
</svg>
<svg
v-else-if="loader === 'Bukkit'"
viewBox="0 0 292 319"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
stroke="currentColor"
>
<g transform="matrix(1,0,0,1,0,-5)">
<path
d="M12,109.5L12,155L34.5,224L57.5,224L57.5,271L81,294L160,294L160,172L259.087,172L265,155L265,109.5M12,109.5L12,64L34.5,64L34.5,41L81,17L195.5,17L241,41L241,64L265,64L265,109.5M12,109.5L81,109.5L81,132L195.5,132L195.5,109.5L265,109.5M264.087,204L264.087,244M207.5,272L207.5,312M250,272L250,312L280,312L280,272L250,272ZM192.5,204L192.5,244L222.5,244L222.5,204L192.5,204Z"
style="fill: none; fill-rule: nonzero; stroke-width: 24px"
/>
</g>
</svg>
<svg
v-else-if="loader === 'Purpur'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<defs>
<path
id="purpur"
fill="none"
stroke="currentColor"
stroke-width="1.68"
d="m264 41.95 8-4v8l-8 4v-8Z"
></path>
</defs>
<path fill="none" d="M0 0h24v24H0z"></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="m264 29.95-8 4 8 4.42 8-4.42-8-4Z"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="m272 38.37-8 4.42-8-4.42"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="m260 31.95 8 4.21V45"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="M260 45v-8.84l8-4.21"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<use
xlink:href="#purpur"
stroke-width="1.68"
transform="matrix(1.125 0 0 1.2569 -285 -40.78)"
></use>
<use
xlink:href="#purpur"
stroke-width="1.68"
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
></use>
</svg>
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
clip-rule="evenodd"
></path>
</svg>
<div v-if="loaderData?.icon" v-html="loaderData.icon" />
<LoaderIcon v-else />
</template>
<script setup lang="ts">
import { LoaderIcon } from '@modrinth/assets'
import type { Loaders } from '@modrinth/utils'
import { computed } from 'vue'
defineProps<{
loader: Loaders
import { useGeneratedState } from '~/composables/generated'
const props = defineProps<{
loader: string
}>()
const tags = useGeneratedState()
// Find the loader by name (case-insensitive comparison)
const loaderData = computed(() =>
tags.value.loaders.find((l) => l.name.toLowerCase() === props.loader.toLowerCase()),
)
</script>

View File

@@ -217,6 +217,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'send-to-review-reply',
action: () => {
sendReply('processing', true)
},
hoverFilled: true,
disabled: project.status === 'processing',
},
]
: [
{
@@ -228,6 +236,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'send-to-review',
action: () => {
setStatus('processing')
},
hoverFilled: true,
disabled: project.status === 'processing',
},
]
"
>
@@ -240,6 +256,14 @@
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
Send to review with reply
</template>
<template #send-to-review>
<ScaleIcon aria-hidden="true" />
Send to review
</template>
</OverflowMenu>
</div>
</template>

View File

@@ -1,286 +0,0 @@
<template>
<div>
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div
v-if="sortedMessages.length > 0"
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
>
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="reportClosed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="reopenReport()"
>
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template v-else>
<div class="mt-4">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply(true)"
>
<ScaleIcon class="size-4" />
<span class="hidden sm:inline">Add private note</span>
<span class="sm:hidden">Private note</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport(true)"
>
<CheckCircleIcon class="size-4" />
<span class="hidden sm:inline">Close with reply</span>
<span class="sm:hidden">Close & reply</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport()"
>
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, ReplyIcon, ScaleIcon, SendIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, injectNotificationManager, MarkdownEditor } from '@modrinth/ui'
import type { Report, Thread, ThreadMessage as TypeThreadMessage, User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isStaff } from '~/helpers/users.js'
import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
thread: Thread
reporter: User
report: Report
}>()
defineExpose({
setReplyContent,
})
const auth = await useAuth()
const emit = defineEmits<{
updateThread: [thread: Thread]
}>()
const flags = useFeatureFlags()
const members = computed(() => {
const membersMap: Record<string, User> = {
[props.reporter.id]: props.reporter,
}
for (const member of props.thread.members) {
membersMap[member.id] = member
}
return membersMap
})
const replyBody = ref('')
function setReplyContent(content: string) {
replyBody.value = content
}
const sortedMessages = computed(() => {
const messages: TypeThreadMessage[] = [
{
id: null,
author_id: props.reporter.id,
body: {
type: 'text',
body: props.report.body || 'Report opened.',
private: false,
replying_to: null,
associated_images: [],
},
created: props.report.created,
hide_identity: false,
},
]
if (props.thread) {
messages.push(
...[...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
),
)
}
return messages
})
async function updateThreadLocal() {
const threadId = props.report.thread_id
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread
emit('updateThread', thread)
} catch (error) {
console.error('Failed to update thread:', error)
}
}
}
const imageIDs = ref<string[]>([])
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: 'thread_message' })
imageIDs.value.push(response.id)
imageIDs.value = imageIDs.value.slice(-10)
return response.url
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: 'text',
body: replyBody.value,
private: privateMessage,
},
}
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
}
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body,
})
replyBody.value = ''
await updateThreadLocal()
} catch (err: any) {
addNotification({
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || (props.report && props.report.closed)
})
async function closeReport(reply = false) {
if (reply) {
await sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
})
await updateThreadLocal()
didCloseReport.value = true
} catch (err: any) {
addNotification({
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: false,
},
})
await updateThreadLocal()
} catch (err: any) {
addNotification({
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>

View File

@@ -4,7 +4,7 @@
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: message.body.private,
private: isPrivateMessage,
}"
>
<template v-if="members[message.author_id]">
@@ -23,7 +23,7 @@
</AutoLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<LockIcon
v-if="message.body.private"
v-if="isPrivateMessage"
v-tooltip="'Only visible to moderators'"
class="private-icon"
/>
@@ -40,13 +40,30 @@
v-tooltip="'Reporter'"
class="reporter-icon"
/>
<span
v-if="message.preview"
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
>
Preview
</span>
</span>
</template>
<template v-else>
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
<div
class="message__icon backed-svg circle moderation-color"
:class="{
raised: raised,
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
message.body.type,
),
}"
>
<ScaleIcon />
</div>
<span class="message__author moderation-color">
<span
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
class="message__author moderation-color"
>
Moderator
<ScaleIcon v-tooltip="'Moderator'" />
</span>
@@ -69,6 +86,17 @@
</template>
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
<span v-else-if="message.body.type === 'thread_reopen'">reopened the thread.</span>
<span v-else-if="message.body.type === 'tech_review'">
completed technical review and marked project as
<Badge :type="message.body.verdict" />.
</span>
<span v-else-if="message.body.type === 'tech_review_entered'">
The project has entered the technical review queue.
</span>
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
The project has left the technical review queue as all files pending review were deleted by
the user.
</span>
</div>
<span class="message__date">
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
@@ -160,6 +188,15 @@ const formattedMessage = computed(() => {
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
const isPrivateMessage = computed(() => {
return (
props.message.body.private ||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
props.message.body.type,
)
)
})
async function deleteMessage() {
await useBaseFetch(`message/${props.message.id}`, {
method: 'DELETE',
@@ -333,4 +370,8 @@ a:active + .message__author a,
.private {
color: var(--color-icon);
}
.system-message-icon {
--size: 2rem !important;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div>
<div v-if="flags.developerMode" class="mt-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="flex flex-col space-y-4 rounded-xl p-3 sm:p-4">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<div v-else class="flex flex-col items-center justify-center space-y-3 py-12">
<MessageIcon class="size-12 text-secondary" />
<p class="text-lg text-secondary">No messages yet</p>
</div>
<template v-if="closed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<slot name="closedActions" />
</template>
<template v-else>
<div>
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand">
<button :disabled="!replyBody" class="w-full gap-2 sm:w-auto" @click="sendReply()">
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<button :disabled="!replyBody" class="w-full gap-2 sm:w-auto" @click="sendReply()">
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)">
<button :disabled="!replyBody" class="w-full sm:w-auto" @click="sendReply(true)">
Add note
</button>
</ButtonStyled>
<ButtonStyled v-if="visibleQuickReplies.length > 0">
<OverflowMenu :options="visibleQuickReplies">
Quick Reply
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<slot name="additionalActions" :has-reply="!!replyBody" />
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts" generic="T">
import { MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
import type { QuickReply } from '@modrinth/moderation'
import {
ButtonStyled,
CopyCode,
injectNotificationManager,
MarkdownEditor,
OverflowMenu,
type OverflowMenuOption,
} from '@modrinth/ui'
import type { Thread, User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isStaff } from '~/helpers/users.js'
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
const replies = props.quickReplies
const context = props.quickReplyContext
if (!replies || !context) return []
return replies
.filter((reply) => {
if (reply.shouldShow === undefined) return true
return reply.shouldShow(context)
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply, context),
}) as OverflowMenuOption,
)
})
const props = defineProps<{
thread: Thread
quickReplies?: ReadonlyArray<QuickReply<T>>
quickReplyContext?: T
closed?: boolean
}>()
async function handleQuickReply(reply: QuickReply<T>, context: T) {
const message = typeof reply.message === 'function' ? await reply.message(context) : reply.message
await nextTick()
setReplyContent(message)
}
defineExpose({
setReplyContent,
getReplyContent,
sendReply,
})
const auth = await useAuth()
const emit = defineEmits<{
updateThread: [thread: Thread]
}>()
const flags = useFeatureFlags()
const members = computed(() => {
const membersMap: Record<string, User> = {}
for (const member of props.thread.members) {
membersMap[member.id] = member
}
return membersMap
})
const replyBody = ref('')
function setReplyContent(content: string) {
replyBody.value = content
}
function getReplyContent(): string {
return replyBody.value
}
const sortedMessages = computed(() => {
if (!props.thread) return []
return [...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
)
})
async function updateThreadLocal() {
const threadId = props.thread.id
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread
emit('updateThread', thread)
} catch (error) {
console.error('Failed to update thread:', error)
}
}
}
const imageIDs = ref<string[]>([])
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: 'thread_message' })
imageIDs.value.push(response.id)
imageIDs.value = imageIDs.value.slice(-10)
return response.url
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: 'text',
body: replyBody.value,
private: privateMessage,
},
}
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
}
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body,
})
replyBody.value = ''
await updateThreadLocal()
} catch (err: any) {
addNotification({
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>

View File

@@ -464,6 +464,11 @@
color: 'orange',
link: '/moderation/',
},
{
id: 'tech-review',
color: 'orange',
link: '/moderation/technical-review',
},
{
id: 'review-reports',
color: 'orange',
@@ -511,6 +516,9 @@
<template #review-projects>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProjects) }}
</template>
<template #tech-review>
<ShieldAlertIcon aria-hidden="true" /> {{ formatMessage(messages.techReview) }}
</template>
<template #review-reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
</template>
@@ -942,6 +950,7 @@ import {
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldAlertIcon,
SunIcon,
TwitterIcon,
UserIcon,
@@ -1197,11 +1206,15 @@ const messages = defineMessages({
},
reviewProjects: {
id: 'layout.action.review-projects',
defaultMessage: 'Review projects',
defaultMessage: 'Project review',
},
techReview: {
id: 'layout.action.tech-review',
defaultMessage: 'Tech review',
},
reports: {
id: 'layout.action.reports',
defaultMessage: 'Reports',
defaultMessage: 'Review reports',
},
lookupByEmail: {
id: 'layout.action.lookup-by-email',

View File

@@ -1281,10 +1281,13 @@
"message": "New project"
},
"layout.action.reports": {
"message": "Reports"
"message": "Review reports"
},
"layout.action.review-projects": {
"message": "Review projects"
"message": "Project review"
},
"layout.action.tech-review": {
"message": "Tech review"
},
"layout.avatar.alt": {
"message": "Your avatar"
@@ -1523,9 +1526,6 @@
"moderation.sort.by": {
"message": "Sort by"
},
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"muralpay.account-type.checking": {
"message": "Checking"
},

View File

@@ -17,6 +17,7 @@
</template>
<script setup lang="ts">
import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { Chips } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
@@ -46,9 +47,13 @@ const messages = defineMessages({
})
const moderationLinks = [
{ label: formatMessage(messages.projectsTitle), href: '/moderation' },
{ label: formatMessage(messages.technicalReviewTitle), href: '/moderation/technical-review' },
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports' },
{ label: formatMessage(messages.projectsTitle), href: '/moderation', icon: FolderIcon },
{
label: formatMessage(messages.technicalReviewTitle),
href: '/moderation/technical-review',
icon: ShieldCheckIcon,
},
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports', icon: ReportIcon },
]
const mobileNavOptions = [

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
@@ -24,34 +24,41 @@
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
:options="filterTypes"
:placeholder="formatMessage(messages.filterBy)"
@select="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
>
</span>
</template>
</Combobox>
<DropdownSelect
v-slot="{ selected }"
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
:options="sortTypes"
:placeholder="formatMessage(messages.sortBy)"
@select="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
@@ -59,7 +66,7 @@
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<ScaleIcon class="flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
@@ -72,7 +79,7 @@
<ConfettiExplosion v-if="visible" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div class="flex flex-col gap-4">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<ModerationQueueCard
v-for="item in paginatedProjects"
@@ -91,14 +98,14 @@
</template>
<script setup lang="ts">
import {
FilterIcon,
ListFilterIcon,
ScaleIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
XIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, DropdownSelect, Pagination } from '@modrinth/ui'
import { Button, ButtonStyled, Combobox, type ComboboxOption, Pagination } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import Fuse from 'fuse.js'
import ConfettiExplosion from 'vue-confetti-explosion'
@@ -215,18 +222,21 @@ watch(
)
const currentFilterType = ref('All projects')
const filterTypes: readonly string[] = readonly([
'All projects',
'Modpacks',
'Mods',
'Resource Packs',
'Data Packs',
'Plugins',
'Shaders',
])
const filterTypes: ComboboxOption<string>[] = [
{ value: 'All projects', label: 'All projects' },
{ value: 'Modpacks', label: 'Modpacks' },
{ value: 'Mods', label: 'Mods' },
{ value: 'Resource Packs', label: 'Resource Packs' },
{ value: 'Data Packs', label: 'Data Packs' },
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
]
const currentSortType = ref('Oldest')
const sortTypes: readonly string[] = readonly(['Oldest', 'Newest'])
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const currentPage = ref(1)
const itemsPerPage = 15

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
@@ -22,34 +22,41 @@
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
:options="filterTypes"
:placeholder="formatMessage(messages.filterBy)"
@select="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredReports.length }})</span
>
</span>
</template>
</Combobox>
<DropdownSelect
v-slot="{ selected }"
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
:options="sortTypes"
:placeholder="formatMessage(messages.sortBy)"
@select="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
</div>
</div>
@@ -57,7 +64,7 @@
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div class="flex flex-col gap-4">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
</div>
@@ -69,9 +76,9 @@
</template>
<script setup lang="ts">
import { FilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import { ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import type { ExtendedReport } from '@modrinth/moderation'
import { Button, DropdownSelect, Pagination } from '@modrinth/ui'
import { Button, Combobox, type ComboboxOption, Pagination } from '@modrinth/ui'
import type { Report } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import Fuse from 'fuse.js'
@@ -169,10 +176,17 @@ watch(
)
const currentFilterType = ref('All')
const filterTypes: readonly string[] = readonly(['All', 'Unread', 'Read'])
const filterTypes: ComboboxOption<string>[] = [
{ value: 'All', label: 'All' },
{ value: 'Unread', label: 'Unread' },
{ value: 'Read', label: 'Read' },
]
const currentSortType = ref('Oldest')
const sortTypes: readonly string[] = readonly(['Oldest', 'Newest'])
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const currentPage = ref(1)
const itemsPerPage = 15

View File

@@ -1,387 +0,0 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<DelphiReportCard
v-for="report in paginatedReports"
:key="report.version.id"
:report="report"
/>
<div
v-if="!paginatedReports || paginatedReports.length === 0"
class="universal-card h-24 animate-pulse"
></div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { FilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import type { ExtendedDelphiReport, OwnershipTarget } from '@modrinth/moderation'
import { Button, DropdownSelect, Pagination } from '@modrinth/ui'
import type { DelphiReport, Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useLocalStorage } from '@vueuse/core'
import Fuse from 'fuse.js'
import DelphiReportCard from '~/components/ui/moderation/ModerationDelphiReportCard.vue'
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const messages = defineMessages({
searchPlaceholder: {
id: 'moderation.technical.search.placeholder',
defaultMessage: 'Search tech reviews...',
},
filterBy: {
id: 'moderation.filter.by',
defaultMessage: 'Filter by',
},
sortBy: {
id: 'moderation.sort.by',
defaultMessage: 'Sort by',
},
})
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
return (await useBaseFetch(`project/${projectId}`)) as Project
}
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
return (await useBaseFetch(`version/${versionId}`)) as Version
}
const mockDelphiReports: DelphiReport[] = [
{
project: await getProjectQuicklyForMock('7MoE34WK'),
version: await getVersionQuicklyForMock('cTkKLWgA'),
trace_type: 'url_usage',
file_path: 'me/decce/gnetum/ASMEventHandlerHelper.java',
priority_score: 29,
status: 'pending',
detected_at: '2025-04-01T12:00:00Z',
} as DelphiReport,
{
project: await getProjectQuicklyForMock('7MoE34WK'),
version: await getVersionQuicklyForMock('cTkKLWgA'),
trace_type: 'url_usage',
file_path: 'me/decce/gnetum/SomeOtherFile.java',
priority_score: 48,
status: 'rejected',
detected_at: '2025-03-02T12:00:00Z',
} as DelphiReport,
{
project: await getProjectQuicklyForMock('7MoE34WK'),
version: await getVersionQuicklyForMock('cTkKLWgA'),
trace_type: 'url_usage',
file_path: 'me/decce/gnetum/YetAnotherFile.java',
priority_score: 15,
status: 'approved',
detected_at: '2025-02-03T12:00:00Z',
} as DelphiReport,
]
const { data: allReports } = await useAsyncData('moderation-tech-reviews', async () => {
// TODO: replace with actual API call
const delphiReports = mockDelphiReports
if (delphiReports.length === 0) {
return []
}
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))]
const orgIds = [
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
]
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
])
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean)
const orgTeamsData: TeamMember[][] =
orgTeamIds.length > 0
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: []
const teamMap = new Map<string, TeamMember[]>()
const orgMap = new Map<string, Organization>()
teamsData.forEach((team) => {
let teamId = null
for (const member of team) {
teamId = member.team_id
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team)
break
}
}
})
orgTeamsData.forEach((team) => {
let teamId = null
for (const member of team) {
teamId = member.team_id
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team)
break
}
}
})
orgsData.forEach((org: Organization) => {
orgMap.set(org.id, org)
})
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
let target: OwnershipTarget | undefined
const project = report.project
if (project) {
let owner: TeamMember | null = null
let org: Organization | null = null
if (project.team) {
const teamMembers = teamMap.get(project.team)
if (teamMembers) {
owner = teamMembers.find((member) => member.role === 'Owner') || null
}
}
if (project.organization) {
org = orgMap.get(project.organization) || null
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: 'organization',
slug: org.slug,
}
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: 'user',
slug: owner.user.username,
}
}
}
return {
...report,
target,
}
})
extendedReports.sort((a, b) => b.priority_score - a.priority_score)
return extendedReports
})
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
},
)
const currentFilterType = useLocalStorage('moderation-tech-reviews-filter-type', () => 'Pending')
const filterTypes: readonly string[] = readonly(['All', 'Pending', 'Approved', 'Rejected'])
const currentSortType = useLocalStorage('moderation-tech-reviews-sort-type', () => 'Priority')
const sortTypes: readonly string[] = readonly(['Priority', 'Oldest', 'Newest'])
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage))
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null
return new Fuse(allReports.value, {
keys: [
{
name: 'version.id',
weight: 3,
},
{
name: 'version.version_number',
weight: 3,
},
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 3,
},
{
name: 'version.files.filename',
weight: 2,
},
{
name: 'trace_type',
weight: 2,
},
{
name: 'content',
weight: 0.5,
},
'file_path',
'project.id',
'target.name',
'target.slug',
],
includeScore: true,
threshold: 0.4,
})
})
const filteredReports = computed(() => {
if (!allReports.value) return []
let filtered
if (query.value && fuse.value) {
const results = fuse.value.search(query.value)
filtered = results.map((result) => result.item)
} else {
filtered = [...allReports.value]
}
if (currentFilterType.value === 'Pending') {
filtered = filtered.filter((report) => report.status === 'pending')
} else if (currentFilterType.value === 'Approved') {
filtered = filtered.filter((report) => report.status === 'approved')
} else if (currentFilterType.value === 'Rejected') {
filtered = filtered.filter((report) => report.status === 'rejected')
}
if (currentSortType.value === 'Priority') {
filtered.sort((a, b) => b.priority_score - a.priority_score)
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime()
const dateB = new Date(b.detected_at).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime()
const dateB = new Date(b.detected_at).getTime()
return dateB - dateA
})
}
return filtered
})
const paginatedReports = computed(() => {
if (!filteredReports.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredReports.value.slice(start, end)
})
function updateSearchResults() {
currentPage.value = 1
}
function goToPage(page: number) {
currentPage.value = page
}
</script>

View File

@@ -1,3 +1,572 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
injectModrinthClient,
Pagination,
} from '@modrinth/ui'
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
import { defineMessages, useVIntl } from '@vintl/vintl'
import Fuse from 'fuse.js'
import MaliciousSummaryModal, {
type UnsafeFile,
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
const client = injectModrinthClient()
const queryClient = useQueryClient()
const currentPage = ref(1)
const API_PAGE_SIZE = 50
const UI_PAGE_SIZE = 4
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'
type CachedSource = {
source: string
timestamp: number
}
function getCachedSource(detailId: string): string | null {
try {
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
if (!cached) return null
const data: CachedSource = JSON.parse(cached)
const now = Date.now()
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
return null
}
return data.source
} catch {
return null
}
}
function setCachedSource(detailId: string, source: string): void {
try {
const data: CachedSource = {
source,
timestamp: Date.now(),
}
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
} catch (error) {
console.error('Failed to cache source:', error)
}
}
function clearExpiredCache(): void {
try {
const now = Date.now()
const keys = Object.keys(localStorage)
for (const key of keys) {
if (key.startsWith(CACHE_KEY_PREFIX)) {
const cached = localStorage.getItem(key)
if (cached) {
const data: CachedSource = JSON.parse(cached)
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(key)
}
}
}
}
} catch (error) {
console.error('Failed to clear expired cache:', error)
}
}
clearExpiredCache()
const loadingIssues = ref<Set<string>>(new Set())
const decompiledSources = ref<Map<string, string>>(new Map())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.value.has(issueId)) return
loadingIssues.value.add(issueId)
try {
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
for (const detail of issueData.details) {
if (detail.decompiled_source) {
decompiledSources.value.set(detail.id, detail.decompiled_source)
setCachedSource(detail.id, detail.decompiled_source)
}
}
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
loadingIssues.value.delete(issueId)
}
}
function tryLoadCachedSourcesForFile(reportId: string): void {
for (const review of reviewItems.value) {
const report = review.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (!decompiledSources.value.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.value.set(detail.id, cached)
}
}
}
}
return
}
}
}
function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)
for (const review of reviewItems.value) {
const report = review.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
const hasUncached = issue.details.some((d) => !decompiledSources.value.has(d.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
return
}
}
}
const messages = defineMessages({
searchPlaceholder: {
id: 'moderation.search.placeholder',
defaultMessage: 'Search...',
},
filterBy: {
id: 'moderation.filter.by',
defaultMessage: 'Filter by',
},
sortBy: {
id: 'moderation.sort.by',
defaultMessage: 'Sort by',
},
})
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
})
goToPage(1)
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
},
)
const currentFilterType = ref('All issues')
const filterTypes = computed<ComboboxOption<string>[]>(() => {
const base: ComboboxOption<string>[] = [{ value: 'All issues', label: 'All issues' }]
if (!reviewItems.value) return base
const issueTypes = new Set(
reviewItems.value
.flatMap((review) => review.reports)
.flatMap((report) => report.issues)
.map((issue) => issue.issue_type),
)
const sortedTypes = Array.from(issueTypes).sort()
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
})
const currentSortType = ref('Severe first')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
{ value: 'Severe first', label: 'Severe first' },
{ value: 'Severe last', label: 'Severe last' },
]
const fuse = computed(() => {
if (!reviewItems.value || reviewItems.value.length === 0) return null
return new Fuse(reviewItems.value, {
keys: [
{ name: 'project.title', weight: 4 },
{ name: 'project.slug', weight: 3 },
{ name: 'reports.file_name', weight: 2 },
{ name: 'reports.issues.issue_type', weight: 3 },
{ name: 'project_owner.name', weight: 2 },
],
includeScore: true,
threshold: 0.4,
})
})
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
if (!reviewItems.value) return []
return query.value && searchResults.value ? searchResults.value : [...reviewItems.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All issues') return baseFiltered.value
const type = currentFilterType.value
return baseFiltered.value.filter((review) => {
return review.reports.some((report: Labrinth.TechReview.Internal.FileReport) =>
report.issues.some(
(issue: Labrinth.TechReview.Internal.FileIssue) => issue.issue_type === type,
),
)
})
})
const filteredItems = computed(() => typeFiltered.value)
const filteredIssuesCount = computed(() => {
return filteredItems.value.reduce((total, review) => {
if (currentFilterType.value === 'All issues') {
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
} else {
return (
total +
review.reports.reduce((sum, report) => {
return (
sum +
report.issues.filter((issue) => issue.issue_type === currentFilterType.value).length
)
}, 0)
)
}
}, 0)
})
const totalPages = computed(() => Math.ceil((filteredItems.value?.length || 0) / UI_PAGE_SIZE))
const paginatedItems = computed(() => {
if (!filteredItems.value) return []
const start = (currentPage.value - 1) * UI_PAGE_SIZE
const end = start + UI_PAGE_SIZE
return filteredItems.value.slice(start, end)
})
function goToPage(page: number, top = false) {
currentPage.value = page
if (top && window) {
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth',
})
}
}
function toApiSort(label: string): Labrinth.TechReview.Internal.SearchProjectsSort {
switch (label) {
case 'Oldest':
return 'created_asc'
case 'Newest':
return 'created_desc'
case 'Severe first':
return 'severity_desc'
case 'Severe last':
return 'severity_asc'
default:
return 'severity_desc'
}
}
const {
data: infiniteData,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ['tech-reviews', currentSortType],
queryFn: async ({ pageParam = 0 }) => {
return await client.labrinth.tech_review_internal.searchProjects({
limit: API_PAGE_SIZE,
page: pageParam,
sort_by: toApiSort(currentSortType.value),
})
},
getNextPageParam: (lastPage, allPages) => {
// If we got a full page, there's probably more
return lastPage.project_reports.length >= API_PAGE_SIZE ? allPages.length : undefined
},
initialPageParam: 0,
})
watch(
[() => infiniteData.value, hasNextPage],
() => {
if (hasNextPage.value && !isFetchingNextPage.value) {
fetchNextPage()
}
},
{ immediate: true },
)
const mergedSearchResponse = computed(() => {
if (!infiniteData.value?.pages?.length) return null
return infiniteData.value.pages.reduce(
(merged, page) => ({
project_reports: [...merged.project_reports, ...page.project_reports],
projects: { ...merged.projects, ...page.projects },
threads: { ...merged.threads, ...page.threads },
ownership: { ...merged.ownership, ...page.ownership },
}),
{
project_reports: [] as Labrinth.TechReview.Internal.ProjectReport[],
projects: {} as Record<string, Labrinth.TechReview.Internal.ProjectModerationInfo>,
threads: {} as Record<string, Labrinth.TechReview.Internal.Thread>,
ownership: {} as Record<string, Labrinth.TechReview.Internal.Ownership>,
},
)
})
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
id: string
version_id: string
}
const reviewItems = computed(() => {
if (!mergedSearchResponse.value?.project_reports?.length) {
return []
}
const response = mergedSearchResponse.value
return response.project_reports
.map((projectReport) => {
const project = response.projects[projectReport.project_id]
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
if (!thread) return null
const reports: FlattenedFileReport[] = projectReport.versions.flatMap((version) =>
version.files.map((file) => ({
...file,
id: file.report_id,
version_id: version.version_id,
})),
)
return {
project,
project_owner: response.ownership[projectReport.project_id],
thread,
reports,
}
})
.filter(
(
item,
): item is {
project: Labrinth.TechReview.Internal.ProjectModerationInfo
project_owner: Labrinth.TechReview.Internal.Ownership
thread: Labrinth.TechReview.Internal.Thread
reports: FlattenedFileReport[]
} => item !== null,
)
})
function handleMarkComplete(projectId: string) {
queryClient.setQueryData(
['tech-reviews', currentSortType],
(
oldData:
| {
pages: Labrinth.TechReview.Internal.SearchResponse[]
pageParams: number[]
}
| undefined,
) => {
if (!oldData) return oldData
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
project_reports: page.project_reports.filter((pr) => pr.project_id !== projectId),
projects: Object.fromEntries(
Object.entries(page.projects).filter(([id]) => id !== projectId),
),
ownership: Object.fromEntries(
Object.entries(page.ownership).filter(([id]) => id !== projectId),
),
})),
}
},
)
}
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
const currentUnsafeFiles = ref<UnsafeFile[]>([])
function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
currentUnsafeFiles.value = unsafeFiles
maliciousSummaryModalRef.value?.show()
}
watch(currentSortType, () => {
goToPage(1)
})
// TODO: Reimpl when backend is available
// const batchScanProgressInformation = computed<BatchScanProgress | undefined>(() => {
// return {
// total: 58,
// complete: 20,
// }
// })
</script>
<template>
<p>Not yet implemented.</p>
<div class="flex flex-col gap-4">
<!-- TODO: Reimpl when backend is available -->
<!-- <BatchScanProgressAlert
v-if="batchScanProgressInformation"
:progress="batchScanProgressInformation"
/> -->
<div class="flex flex-col justify-between gap-2 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="!h-10"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:options="filterTypes"
:placeholder="formatMessage(messages.filterBy)"
searchable
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredIssuesCount }})</span
>
</span>
</template>
</Combobox>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[175px]"
:options="sortTypes"
:placeholder="formatMessage(messages.sortBy)"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
<!-- <ButtonStyled color="orange">
<button class="!h-10"><ShieldAlertIcon /> Batch scan</button>
</ButtonStyled> -->
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col gap-4">
<div v-if="isLoading || isFetchingNextPage" class="universal-card h-24 animate-pulse"></div>
<div
v-else-if="paginatedItems.length === 0"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
No projects in queue.
</div>
<div v-for="(item, idx) in paginatedItems" :key="item.project.id ?? idx">
<ModerationTechRevCard
:item="item"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"
@load-file-sources="handleLoadFileSources"
@mark-complete="handleMarkComplete"
@show-malicious-summary="handleShowMaliciousSummary"
/>
</div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination
:page="currentPage"
:count="totalPages"
@switch-page="(num) => goToPage(num, true)"
/>
</div>
<MaliciousSummaryModal ref="maliciousSummaryModalRef" :unsafe-files="currentUnsafeFiles" />
</div>
</template>

View File

@@ -510,9 +510,7 @@
class="universal-card recessed !mb-0 flex items-center justify-between"
>
<div class="flex gap-2">
<CardIcon v-if="method.type === 'card'" class="h-8 w-8" />
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="h-8 w-8" />
<PayPalIcon v-else-if="method.type === 'paypal'" class="h-8 w-8" />
<component :is="getPaymentMethodIcon(method.type)" class="h-8 w-8" />
<div class="flex flex-col">
<div class="flex items-center gap-2">
<div class="font-bold text-contrast">
@@ -599,14 +597,11 @@
<script setup>
import {
ArrowBigUpDashIcon,
CardIcon,
CheckCircleIcon,
CurrencyIcon,
EditIcon,
HistoryIcon,
ModrinthPlusIcon,
MoreVerticalIcon,
PayPalIcon,
PlusIcon,
RightArrowIcon,
SpinnerIcon,
@@ -622,6 +617,7 @@ import {
commonMessages,
ConfirmModal,
CopyCode,
getPaymentMethodIcon,
injectNotificationManager,
OverflowMenu,
PurchaseModal,

View File

@@ -6,7 +6,7 @@ import {
PayPalColorIcon,
VenmoColorIcon,
} from '@modrinth/assets'
import { createContext, paymentMethodMessages, useDebugLogger } from '@modrinth/ui'
import { createContext, getCurrencyIcon, paymentMethodMessages, useDebugLogger } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl'
import { type Component, computed, type ComputedRef, type Ref, ref } from 'vue'

View File

@@ -1,34 +0,0 @@
import { PolygonIcon, USDCColorIcon } from '@modrinth/assets'
import type { Component } from 'vue'
export function getCurrencyIcon(currency: string): Component | null {
const lower = currency.toLocaleLowerCase()
if (lower.includes('usdc')) return USDCColorIcon
return null
}
export function getCurrencyColor(currency: string): string {
const lower = currency.toLowerCase()
if (lower.includes('usdc')) return 'text-blue'
return 'text-contrast'
}
export function getBlockchainIcon(blockchain: string): Component | null {
const lower = blockchain.toLowerCase()
if (lower.includes('polygon')) return PolygonIcon
return null
}
export function getBlockchainColor(blockchain: string): string {
const lower = blockchain.toLowerCase()
if (lower.includes('polygon')) return 'text-purple'
return 'text-contrast'
}

View File

@@ -129,7 +129,7 @@ PYRO_API_KEY=none
BREX_API_URL=https://platform.brexapis.com/v2/
BREX_API_KEY=none
DELPHI_URL=none
DELPHI_URL=http://labrinth-delphi:59999
DELPHI_SLACK_WEBHOOK=none
AVALARA_1099_API_URL=https://www.track1099.com/api

View File

@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n version_id AS \"version_id: crate::database::models::DBVersionId\",\n versions.mod_id AS \"project_id: crate::database::models::DBProjectId\",\n files.url AS \"url\"\n FROM files INNER JOIN versions ON files.version_id = versions.id\n WHERE files.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: crate::database::models::DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "project_id: crate::database::models::DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "url",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(\n SELECT 1 FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = $1 AND didws.status = 'pending'\n ) AS \"pending_issue_details_exist!\",\n t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pending_issue_details_exist!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null,
false
]
},
"hash": "2d9e36c76a1e214c53d9dc2aa3debe1d03998be169a306b63a0ca1beaa07397f"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n FROM delphi_report_issues dri\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON v.mod_id = m.id\n WHERE dri.id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "3240e4b5abc9850b5d3c09fafcac71674941487c15be1e8ce0ebc78e7c26b34d"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"query": "\n INSERT INTO delphi_report_issues (report_id, issue_type)\n VALUES ($1, $2)\n RETURNING id\n ",
"describe": {
"columns": [
{
@@ -11,14 +11,13 @@
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
"Text"
]
},
"nullable": [
false
]
},
"hash": "ccb0315ff52ea4402f53508334a7288fc9f8e77ffd7bce665441ff682384cbf9"
"hash": "33f26ce7e262d7c5707d05fe926390683636bbde53a51ee61fa18ef49cea8c3a"
}

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n WHERE m.id = $2\n RETURNING\n t.id AS \"thread_id: DBThreadId\",\n (SELECT status FROM mods WHERE id = m.id) AS \"old_status!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "old_status!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": [
false,
null
]
},
"hash": "3473715e4ff6efb6707f73e8ddf19ef7bcbb341c7ffea3d13acd250bb20e6d07"
}

View File

@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n project_id AS \"project_id: DBProjectId\",\n project_thread_id AS \"project_thread_id: DBThreadId\",\n report AS \"report!: sqlx::types::Json<FileReport>\"\n FROM (\n SELECT DISTINCT ON (dr.id)\n dr.id AS report_id,\n dr.created AS report_created,\n dr.severity AS report_severity,\n m.id AS project_id,\n t.id AS project_thread_id,\n\n to_jsonb(dr)\n || jsonb_build_object(\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT json_agg(\n jsonb_build_object(\n 'id', drid.id,\n 'issue_id', drid.issue_id,\n 'key', drid.key,\n 'file_path', drid.file_path,\n -- ignore `decompiled_source`\n 'data', drid.data,\n 'severity', drid.severity\n )\n )\n FROM delphi_report_issue_details drid\n WHERE drid.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE dri.report_id = dr.id\n )\n ) AS report\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN threads t ON t.mod_id = m.id\n\n -- filtering\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n WHERE\n -- project type\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND dr.status = $5\n ) t\n\n -- sorting\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN t.report_created ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN t.report_created ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN t.report_severity ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN t.report_severity ELSE 'low'::delphi_severity END DESC\n\n -- pagination\n LIMIT $1\n OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "project_thread_id: DBThreadId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "report!: sqlx::types::Json<FileReport>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Int4Array",
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
]
},
"nullable": [
false,
false,
null
]
},
"hash": "3961aa17ce3219c057c398dca0ed3aaeb30a8da4721959fdee99cf649a8b29e3"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT json_agg(to_jsonb(drid))\n FROM delphi_report_issue_details drid\n WHERE drid.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "data!: sqlx::types::Json<FileReport>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "3e2804a3443239104b2d8b095941fe1472402338e0f0bb323b6147d2a0cc4eca"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n didws.id AS \"issue_detail_id!\"\n FROM mods m\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.issue_id = dri.id\n WHERE\n m.id = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "issue_detail_id!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
true
]
},
"hash": "52ef6d02f8d533fc4e4ceb141d07a2eb115dc88da24735fffeca3eb1c269ad53"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON v.mod_id = m.id\n WHERE dr.id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "6cf1862b3c197d42f9183dcbbd3d07b7d42c37e089403961ee16be0f99958ea0"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT json_agg(to_jsonb(drid))\n FROM delphi_report_issue_details drid\n WHERE drid.issue_id = dri.id\n )\n ) AS \"data!: sqlx::types::Json<FileIssue>\"\n FROM delphi_report_issues dri\n WHERE dri.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "data!: sqlx::types::Json<FileIssue>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "7d1f49699e242f3e002afee9bf466b6696052ac6d5ebe131b9e7242104f700af"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM delphi_report_issue_details drid\n WHERE issue_id IN (\n SELECT dri.id\n FROM mods m\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n WHERE m.id = $1 AND dri.issue_type = '__dummy'\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "9ab1f07c2968b5d445752c1480345c1fa3af3a899b232482aab9cc44b9336063"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE delphi_report_issues\n SET status = $1\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
},
"Int8"
]
},
"nullable": []
},
"hash": "b1df83f4592701f8aa03f6d16bac9e2bd27ac9a87987eafd79b06f1c4ecdb659"
}

View File

@@ -0,0 +1,39 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Text",
"Jsonb",
{
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM delphi_report_issue_details WHERE issue_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c7c72cf1f98cbc2b647ab840bdfadf1de8aaf214b32a2aab299a0d87fd2dc453"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n f.url,\n COUNT(dr.id) AS \"report_count!\"\n FROM files f\n LEFT JOIN delphi_reports dr ON dr.file_id = f.id\n WHERE f.id = $1\n GROUP BY f.url\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "url",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "report_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
null
]
},
"hash": "cd630ba950611b387fb5b04999a061d930ff06a8a928ff1cea6a723bb37c1b75"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT\n didws.project_id,\n didws.key,\n $1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.id = $2\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
},
"Int8"
]
},
"nullable": []
},
"hash": "cfe6c9e2abba8e9c1cd7aa799a6a95f2732f1a7611ea6f7ce49cd7e077761ebf"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued\n FROM mods m\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n\n WHERE\n m.status = $1\n AND didws.status IS NULL\n\n GROUP BY m.id\n ) t\n\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "d30290c1b55d9fb0939d122a96f350233d40ad81ac2d16481a0e9b32424a999d"
}

View File

@@ -0,0 +1,37 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_reports (file_id, delphi_version, artifact_url, severity)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (file_id, delphi_version) DO UPDATE SET\n delphi_version = $2, artifact_url = $3, created = CURRENT_TIMESTAMP, severity = $4\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int4",
"Varchar",
{
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n WHERE m.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "f6432d7a3c67e058c0e9da42f23ea29fa063b416c18dc857132127db95ff17f3"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(delphi_version) FROM delphi_reports",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,47 @@
CREATE TYPE delphi_severity AS ENUM ('low', 'medium', 'high', 'severe');
CREATE TYPE delphi_report_issue_status AS ENUM ('pending', 'safe', 'unsafe');
-- A Delphi analysis report for a project version
CREATE TABLE delphi_reports (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
file_id BIGINT REFERENCES files (id)
ON DELETE SET NULL
ON UPDATE CASCADE,
delphi_version INTEGER NOT NULL,
artifact_url VARCHAR(2048) NOT NULL,
created TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
severity DELPHI_SEVERITY NOT NULL,
UNIQUE (file_id, delphi_version)
);
CREATE INDEX delphi_version ON delphi_reports (delphi_version);
-- An issue found in a Delphi report. Every issue belongs to a report,
-- and a report can have zero, one, or more issues attached to it
CREATE TABLE delphi_report_issues (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
report_id BIGINT NOT NULL REFERENCES delphi_reports (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
issue_type TEXT NOT NULL,
status DELPHI_REPORT_ISSUE_STATUS NOT NULL,
UNIQUE (report_id, issue_type)
);
CREATE INDEX delphi_report_issue_by_status_and_type ON delphi_report_issues (status, issue_type);
-- The details of a Delphi report issue, which contain data about a
-- Java class affected by it. Every Delphi report issue details object
-- belongs to a specific issue, and an issue can have zero, one, or
-- more details attached to it. (Some issues may be artifact-wide,
-- or otherwise not really specific to any particular class.)
CREATE TABLE delphi_report_issue_details (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
issue_id BIGINT NOT NULL REFERENCES delphi_report_issues (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
key TEXT NOT NULL,
file_path TEXT NOT NULL,
decompiled_source TEXT,
data JSONB NOT NULL,
severity DELPHI_SEVERITY NOT NULL
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE delphi_reports
ADD COLUMN status delphi_report_issue_status NOT NULL DEFAULT 'pending';

View File

@@ -0,0 +1,8 @@
ALTER TABLE delphi_reports
DROP COLUMN status;
ALTER TABLE delphi_report_issues
DROP COLUMN status;
ALTER TABLE delphi_report_issue_details
ADD COLUMN status DELPHI_REPORT_ISSUE_STATUS NOT NULL DEFAULT 'pending';

View File

@@ -0,0 +1,26 @@
ALTER TABLE delphi_report_issue_details
DROP COLUMN status;
CREATE TABLE delphi_issue_detail_verdicts (
project_id BIGINT REFERENCES mods(id)
ON DELETE SET NULL
ON UPDATE CASCADE,
detail_key TEXT NOT NULL,
verdict delphi_report_issue_status NOT NULL,
PRIMARY KEY (project_id, detail_key)
);
CREATE VIEW delphi_issue_details_with_statuses AS
SELECT
drid.*,
m.id AS project_id,
COALESCE(didv.verdict, 'pending') AS status
FROM delphi_report_issue_details drid
INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
INNER JOIN files f ON f.id = dr.file_id
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN mods m ON m.id = v.mod_id
LEFT JOIN delphi_issue_detail_verdicts didv
ON m.id = didv.project_id
AND drid.key = didv.detail_key;

View File

@@ -113,7 +113,15 @@ impl AuthenticationError {
}
#[derive(
Serialize, Deserialize, Default, Eq, PartialEq, Clone, Copy, Debug,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum AuthProvider {

View File

@@ -0,0 +1,266 @@
use std::{
collections::HashMap,
fmt::{self, Display, Formatter},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
use crate::database::models::{
DBFileId, DBProjectId, DatabaseError, DelphiReportId,
DelphiReportIssueDetailsId, DelphiReportIssueId,
};
/// A Delphi malware analysis report for a project version file.
///
/// Malware analysis reports usually belong to a specific project file,
/// but they can get orphaned if the versions they belong to are deleted.
/// Thus, deleting versions does not delete these reports.
#[derive(Serialize)]
pub struct DBDelphiReport {
pub id: DelphiReportId,
pub file_id: Option<DBFileId>,
/// A sequential, monotonically increasing version number for the
/// Delphi version that generated this report.
pub delphi_version: i32,
pub artifact_url: String,
pub created: DateTime<Utc>,
pub severity: DelphiSeverity,
}
impl DBDelphiReport {
pub async fn upsert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DelphiReportId, DatabaseError> {
Ok(DelphiReportId(sqlx::query_scalar!(
"
INSERT INTO delphi_reports (file_id, delphi_version, artifact_url, severity)
VALUES ($1, $2, $3, $4)
ON CONFLICT (file_id, delphi_version) DO UPDATE SET
delphi_version = $2, artifact_url = $3, created = CURRENT_TIMESTAMP, severity = $4
RETURNING id
",
self.file_id as Option<DBFileId>,
self.delphi_version,
self.artifact_url,
self.severity as DelphiSeverity,
)
.fetch_one(&mut **transaction)
.await?))
}
}
/// A severity level reported by Delphi.
#[derive(
Deserialize,
Serialize,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
sqlx::Type,
utoipa::ToSchema,
)]
// The canonical serialized form of this enum is the snake_case representation.
// We add `alias`es so we can deserialize it from how Delphi sends it,
// which follows the Java conventions of `SCREAMING_SNAKE_CASE`.
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "delphi_severity", rename_all = "snake_case")]
pub enum DelphiSeverity {
#[serde(alias = "LOW")]
Low,
#[serde(alias = "MEDIUM")]
Medium,
#[serde(alias = "HIGH")]
High,
#[serde(alias = "SEVERE")]
Severe,
}
/// An issue found in a Delphi report. Every issue belongs to a report,
/// and a report can have zero, one, or more issues attached to it.
#[derive(Deserialize, Serialize)]
pub struct DBDelphiReportIssue {
pub id: DelphiReportIssueId,
pub report_id: DelphiReportId,
pub issue_type: String,
}
/// A status a Delphi report issue can have.
#[derive(
Deserialize,
Serialize,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
sqlx::Type,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "delphi_report_issue_status", rename_all = "snake_case")]
pub enum DelphiStatus {
/// The issue is pending review by the moderation team.
Pending,
/// The issue has been rejected (i.e., reviewed as a false positive).
/// The affected artifact has thus been verified to be clean, other issues
/// with it notwithstanding.
Safe,
/// The issue has been approved (i.e., reviewed as a valid, true positive).
/// The affected artifact has thus been verified to be potentially malicious.
Unsafe,
}
impl Display for DelphiStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
self.serialize(f)
}
}
/// What verdict a moderator can give to a project flagged for technical review.
#[derive(
Deserialize,
Serialize,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
sqlx::Type,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum DelphiVerdict {
/// The issue has been rejected (i.e., reviewed as a false positive).
/// The affected artifact has thus been verified to be clean, other issues
/// with it notwithstanding.
Safe,
/// The issue has been approved (i.e., reviewed as a valid, true positive).
/// The affected artifact has thus been verified to be potentially malicious.
Unsafe,
}
/// An order in which Delphi report issues can be sorted during queries.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum DelphiReportListOrder {
CreatedAsc,
CreatedDesc,
PendingStatusFirst,
SeverityAsc,
SeverityDesc,
}
impl Display for DelphiReportListOrder {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
self.serialize(f)
}
}
/// A result returned from a Delphi report issue query, slightly
/// denormalized with related entity information for ease of
/// consumption by clients.
#[derive(Serialize)]
pub struct DelphiReportIssueResult {
pub issue: DBDelphiReportIssue,
pub report: DBDelphiReport,
pub details: Vec<ReportIssueDetail>,
pub project_id: Option<DBProjectId>,
pub project_published: Option<DateTime<Utc>>,
}
impl DBDelphiReportIssue {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DelphiReportIssueId, DatabaseError> {
Ok(DelphiReportIssueId(
sqlx::query_scalar!(
"
INSERT INTO delphi_report_issues (report_id, issue_type)
VALUES ($1, $2)
RETURNING id
",
self.report_id as DelphiReportId,
self.issue_type,
)
.fetch_one(&mut **transaction)
.await?,
))
}
}
/// The details of a Delphi report issue, which contain data about a
/// Java class affected by it. Every Delphi report issue details object
/// belongs to a specific issue, and an issue can have zero, one, or
/// more details attached to it. (Some issues may be artifact-wide,
/// or otherwise not really specific to any particular class.)
#[derive(
Debug, Clone, Deserialize, Serialize, utoipa::ToSchema, sqlx::FromRow,
)]
pub struct ReportIssueDetail {
/// ID of this issue detail.
pub id: DelphiReportIssueDetailsId,
/// ID of the issue this detail belongs to.
pub issue_id: DelphiReportIssueId,
/// Opaque identifier for where this issue detail is located, relative to
/// the file scanned.
///
/// This acts as a stable identifier for an issue detail, even across
/// different versions of the same file.
pub key: String,
/// Name of the Java class path in which this issue was found.
pub file_path: String,
/// Decompiled, pretty-printed source of the Java class.
pub decompiled_source: Option<String>,
/// Extra detail-specific info for this detail.
#[sqlx(json)]
pub data: HashMap<String, serde_json::Value>,
/// How important is this issue, as flagged by Delphi?
pub severity: DelphiSeverity,
/// Has this issue detail been marked as safe or unsafe?
pub status: DelphiStatus,
}
impl ReportIssueDetail {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DelphiReportIssueDetailsId, DatabaseError> {
Ok(DelphiReportIssueDetailsId(sqlx::query_scalar!(
"
INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
",
self.issue_id as DelphiReportIssueId,
self.key,
self.file_path,
self.decompiled_source,
sqlx::types::Json(&self.data) as Json<&HashMap<String, serde_json::Value>>,
self.severity as DelphiSeverity,
)
.fetch_one(&mut **transaction)
.await?))
}
pub async fn remove_all_by_issue_id(
issue_id: DelphiReportIssueId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<u64, DatabaseError> {
Ok(sqlx::query!(
"DELETE FROM delphi_report_issue_details WHERE issue_id = $1",
issue_id as DelphiReportIssueId,
)
.execute(&mut **transaction)
.await?
.rows_affected())
}
}

View File

@@ -94,7 +94,7 @@ macro_rules! generate_bulk_ids {
macro_rules! impl_db_id_interface {
($id_struct:ident, $db_id_struct:ident, $(, generator: $generator_function:ident @ $db_table:expr, $(bulk_generator: $bulk_generator_function:ident,)?)?) => {
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash, utoipa::ToSchema)]
#[sqlx(transparent)]
pub struct $db_id_struct(pub i64);
@@ -140,8 +140,8 @@ macro_rules! db_id_interface {
};
}
macro_rules! short_id_type {
($name:ident) => {
macro_rules! id_type {
($name:ident as $type:ty) => {
#[derive(
Copy,
Clone,
@@ -152,9 +152,10 @@ macro_rules! short_id_type {
Eq,
PartialEq,
Hash,
utoipa::ToSchema,
)]
#[sqlx(transparent)]
pub struct $name(pub i32);
pub struct $name(pub $type);
};
}
@@ -268,14 +269,17 @@ db_id_interface!(
generator: generate_affiliate_code_id @ "affiliate_codes",
);
short_id_type!(CategoryId);
short_id_type!(GameId);
short_id_type!(LinkPlatformId);
short_id_type!(LoaderFieldEnumId);
short_id_type!(LoaderFieldEnumValueId);
short_id_type!(LoaderFieldId);
short_id_type!(LoaderId);
short_id_type!(NotificationActionId);
short_id_type!(ProjectTypeId);
short_id_type!(ReportTypeId);
short_id_type!(StatusId);
id_type!(CategoryId as i32);
id_type!(GameId as i32);
id_type!(LinkPlatformId as i32);
id_type!(LoaderFieldEnumId as i32);
id_type!(LoaderFieldEnumValueId as i32);
id_type!(LoaderFieldId as i32);
id_type!(LoaderId as i32);
id_type!(NotificationActionId as i32);
id_type!(ProjectTypeId as i32);
id_type!(ReportTypeId as i32);
id_type!(StatusId as i32);
id_type!(DelphiReportId as i64);
id_type!(DelphiReportIssueId as i64);
id_type!(DelphiReportIssueDetailsId as i64);

View File

@@ -4,6 +4,7 @@ pub mod affiliate_code_item;
pub mod categories;
pub mod charge_item;
pub mod collection_item;
pub mod delphi_report_item;
pub mod flow_item;
pub mod friend_item;
pub mod ids;

View File

@@ -11,7 +11,7 @@ pub struct ThreadBuilder {
pub report_id: Option<DBReportId>,
}
#[derive(Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DBThread {
pub id: DBThreadId,
@@ -30,7 +30,7 @@ pub struct ThreadMessageBuilder {
pub hide_identity: bool,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DBThreadMessage {
pub id: DBThreadMessageId,
pub thread_id: DBThreadId,

View File

@@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{
};
use crate::database::redis::RedisPool;
use crate::models::projects::{FileType, VersionStatus};
use crate::routes::internal::delphi::DelphiRunParameters;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
use futures::TryStreamExt;
@@ -164,6 +165,17 @@ impl VersionFileBuilder {
.await?;
}
if let Err(err) = crate::routes::internal::delphi::run(
&mut **transaction,
DelphiRunParameters {
file_id: file_id.into(),
},
)
.await
{
tracing::error!("Error submitting new file to Delphi: {err}");
}
Ok(file_id)
}
}

View File

@@ -27,7 +27,9 @@ impl FileHost for MockHost {
file_publicity: FileHostPublicity,
file_bytes: Bytes,
) -> Result<UploadFileData, FileHostingError> {
let path = get_file_path(file_name, file_publicity);
let file_name = urlencoding::decode(file_name)
.map_err(|_| FileHostingError::InvalidFilename)?;
let path = get_file_path(&file_name, file_publicity);
std::fs::create_dir_all(
path.parent().ok_or(FileHostingError::InvalidFilename)?,
)?;

View File

@@ -21,6 +21,7 @@ use std::sync::Arc;
use tracing::{Instrument, error, info, info_span};
use tracing_actix_web::TracingLogger;
use utoipa::OpenApi;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::AppExt;
use utoipa_swagger_ui::SwaggerUi;
@@ -262,9 +263,23 @@ async fn main() -> std::io::Result<()> {
}
#[derive(utoipa::OpenApi)]
#[openapi(info(title = "Labrinth"))]
#[openapi(info(title = "Labrinth"), modifiers(&SecurityAddon))]
struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"bearer_auth",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new(
"authorization",
))),
);
}
}
fn log_error(err: &actix_web::Error) {
if err.as_response_error().status_code().is_client_error() {
tracing::debug!(

View File

@@ -94,6 +94,32 @@ impl From<crate::models::v3::threads::MessageBody> for LegacyMessageBody {
new_status,
old_status,
},
crate::models::v3::threads::MessageBody::TechReview { verdict } => {
LegacyMessageBody::Text {
body: format!(
"(legacy) Reviewed technical report and gave verdict {verdict:?}"
),
private: true,
replying_to: None,
associated_images: Vec::new(),
}
}
crate::models::v3::threads::MessageBody::TechReviewEntered => {
LegacyMessageBody::Text {
body: "(legacy) Entered technical review".into(),
private: true,
replying_to: None,
associated_images: Vec::new(),
}
}
crate::models::v3::threads::MessageBody::TechReviewExitFileDeleted => {
LegacyMessageBody::Text {
body: "(legacy) Exited technical review because file was deleted".into(),
private: true,
replying_to: None,
associated_images: Vec::new(),
}
}
crate::models::v3::threads::MessageBody::ThreadClosure => {
LegacyMessageBody::ThreadClosure
}

View File

@@ -5,7 +5,7 @@ use crate::database::models::loader_fields::VersionField;
use crate::database::models::project_item::{LinkUrl, ProjectQueryResult};
use crate::database::models::version_item::VersionQueryResult;
use crate::models::ids::{
OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
};
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
@@ -731,6 +731,7 @@ impl From<VersionQueryResult> for Version {
.files
.into_iter()
.map(|f| VersionFile {
id: Some(FileId(f.id.0 as u64)),
url: f.url,
filename: f.filename,
hashes: f.hashes,
@@ -855,6 +856,10 @@ impl VersionStatus {
/// A single project file, with a url for the file and the file's hash
#[derive(Serialize, Deserialize, Clone)]
pub struct VersionFile {
/// The ID of the file. Every file has an ID once created, but it
/// is not known until it indeed has been created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<FileId>,
/// A map of hashes of the file. The key is the hashing algorithm
/// and the value is the string version of the hash.
pub hashes: std::collections::HashMap<String, String>,

View File

@@ -1,3 +1,4 @@
use crate::database::models::delphi_report_item::DelphiVerdict;
use crate::models::ids::{
ImageId, ProjectId, ReportId, ThreadId, ThreadMessageId,
};
@@ -7,7 +8,7 @@ use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Thread {
pub id: ThreadId,
#[serde(rename = "type")]
@@ -18,7 +19,7 @@ pub struct Thread {
pub members: Vec<User>,
}
#[derive(Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ThreadMessage {
pub id: ThreadMessageId,
pub author_id: Option<UserId>,
@@ -27,7 +28,7 @@ pub struct ThreadMessage {
pub hide_identity: bool,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageBody {
Text {
@@ -42,6 +43,11 @@ pub enum MessageBody {
new_status: ProjectStatus,
old_status: ProjectStatus,
},
TechReview {
verdict: DelphiVerdict,
},
TechReviewEntered,
TechReviewExitFileDeleted,
ThreadClosure,
ThreadReopen,
Deleted {
@@ -50,7 +56,23 @@ pub enum MessageBody {
},
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)]
impl MessageBody {
pub fn is_private(&self) -> bool {
match self {
Self::Text { private, .. } | Self::Deleted { private } => *private,
Self::TechReview { .. }
| Self::TechReviewEntered
| Self::TechReviewExitFileDeleted => true,
Self::StatusChange { .. }
| Self::ThreadClosure
| Self::ThreadReopen => false,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ThreadType {
Report,
@@ -100,16 +122,7 @@ impl Thread {
messages: data
.messages
.into_iter()
.filter(|x| {
if let MessageBody::Text { private, .. } = x.body {
!private || user.role.is_mod()
} else if let MessageBody::Deleted { private, .. } = x.body
{
!private || user.role.is_mod()
} else {
true
}
})
.filter(|x| user.role.is_mod() || !x.body.is_private())
.map(|x| ThreadMessage::from(x, user))
.collect(),
members: users,

View File

@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
pub const DELETED_USER: UserId = UserId(127155982985829);
bitflags::bitflags! {
#[derive(Copy, Clone, Debug)]
#[derive(Debug, Clone, Copy)]
pub struct Badges: u64 {
const MIDAS = 1 << 0;
const EARLY_MODPACK_ADOPTER = 1 << 1;
@@ -21,6 +21,23 @@ bitflags::bitflags! {
}
}
impl utoipa::PartialSchema for Badges {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
u64::schema()
}
}
impl utoipa::ToSchema for Badges {
fn schemas(
schemas: &mut Vec<(
String,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
)>,
) {
u64::schemas(schemas);
}
}
bitflags_serde_impl!(Badges, u64);
impl Default for Badges {
@@ -29,7 +46,7 @@ impl Default for Badges {
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct User {
pub id: UserId,
pub username: String,
@@ -52,7 +69,7 @@ pub struct User {
pub github_id: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserPayoutData {
pub paypal_address: Option<String>,
pub paypal_country: Option<String>,
@@ -137,7 +154,9 @@ impl User {
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
#[derive(
Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Developer,

View File

@@ -1,12 +1,9 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::models::analytics::Download;
use crate::models::ids::ProjectId;
use crate::models::pats::Scopes;
use crate::models::threads::MessageBody;
use crate::queue::analytics::AnalyticsQueue;
use crate::queue::moderation::AUTOMOD_ID;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchConfig;
@@ -17,17 +14,14 @@ use modrinth_maxmind::MaxMind;
use serde::Deserialize;
use sqlx::PgPool;
use std::collections::HashMap;
use std::fmt::Write;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tracing::info;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("admin")
.service(count_download)
.service(force_reindex)
.service(delphi_result_ingest),
.service(force_reindex),
);
}
@@ -163,98 +157,3 @@ pub async fn force_reindex(
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
Ok(HttpResponse::NoContent().finish())
}
#[derive(Deserialize)]
pub struct DelphiIngest {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
pub version_id: crate::models::ids::VersionId,
pub issues: HashMap<String, HashMap<String, String>>,
}
#[post("/_delphi", guard = "admin_key_guard")]
pub async fn delphi_result_ingest(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
body: web::Json<DelphiIngest>,
) -> Result<HttpResponse, ApiError> {
if body.issues.is_empty() {
info!("No issues found for file {}", body.url);
return Ok(HttpResponse::NoContent().finish());
}
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let project = crate::database::models::DBProject::get_id(
body.project_id.into(),
&**pool,
&redis,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Project {} does not exist",
body.project_id
))
})?;
let mut header = format!("Suspicious traces found at {}", body.url);
for (issue, trace) in &body.issues {
for (path, code) in trace {
write!(
&mut header,
"\n issue {issue} found at file {path}: \n ```\n{code}\n```"
)
.unwrap();
}
}
crate::util::webhook::send_slack_project_webhook(
body.project_id,
&pool,
&redis,
webhook_url,
Some(header),
)
.await
.ok();
let mut thread_header = format!(
"Suspicious traces found at [version {}](https://modrinth.com/project/{}/version/{})",
body.version_id, body.project_id, body.version_id
);
for (issue, trace) in &body.issues {
for path in trace.keys() {
write!(
&mut thread_header,
"\n\n- issue {issue} found at file {path}"
)
.unwrap();
}
if trace.is_empty() {
write!(&mut thread_header, "\n\n- issue {issue} found").unwrap();
}
}
let mut transaction = pool.begin().await?;
ThreadMessageBuilder {
author_id: Some(crate::database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: thread_header,
private: true,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().finish())
}

View File

@@ -0,0 +1,423 @@
use std::{collections::HashMap, fmt::Write, sync::LazyLock, time::Instant};
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use sqlx::PgPool;
use tokio::sync::Mutex;
use tracing::info;
use crate::{
auth::check_is_moderator_from_headers,
database::{
models::{
DBFileId, DBProjectId, DBThreadId, DelphiReportId,
DelphiReportIssueDetailsId, DelphiReportIssueId,
delphi_report_item::{
DBDelphiReport, DBDelphiReportIssue, DelphiSeverity,
DelphiStatus, ReportIssueDetail,
},
thread_item::ThreadMessageBuilder,
},
redis::RedisPool,
},
models::{
ids::{ProjectId, VersionId},
pats::Scopes,
threads::MessageBody,
},
queue::session::AuthQueue,
routes::ApiError,
util::{error::Context, guards::admin_key_guard},
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("delphi")
.service(ingest_report)
.service(_run)
.service(version)
.service(issue_type_schema),
);
}
static DELPHI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.default_headers({
HeaderMap::from_iter([(
USER_AGENT,
HeaderValue::from_static(concat!(
"Labrinth/",
env!("COMPILATION_DATE")
)),
)])
})
.build()
.unwrap()
});
#[derive(Deserialize)]
struct DelphiReportIssueDetails {
pub file: String,
pub key: String,
pub data: HashMap<String, serde_json::Value>,
pub severity: DelphiSeverity,
}
#[derive(Deserialize)]
struct DelphiReport {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
#[serde(rename = "version_id")]
pub version_id: crate::models::ids::VersionId,
pub file_id: crate::models::ids::FileId,
/// A sequential, monotonically increasing version number for the
/// Delphi version that generated this report.
pub delphi_version: i32,
pub issues: HashMap<String, Vec<DelphiReportIssueDetails>>,
pub severity: DelphiSeverity,
/// Map of [`DelphiReportIssueDetails::file`] to the decompiled Java source
/// code.
pub decompiled_sources: HashMap<String, Option<String>>,
}
impl DelphiReport {
async fn send_to_slack(
&self,
pool: &PgPool,
redis: &RedisPool,
) -> Result<(), ApiError> {
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let mut message_header =
format!("⚠️ Suspicious traces found at {}", self.url);
for (issue, trace) in &self.issues {
for DelphiReportIssueDetails { file, .. } in trace {
let decompiled_source =
self.decompiled_sources.get(file).and_then(|o| o.as_ref());
write!(
&mut message_header,
"\n issue {issue} found at class `{file}`:\n```\n{}\n```",
decompiled_source.as_ref().map_or(
"No decompiled source available",
|decompiled_source| &**decompiled_source
)
)
.ok();
}
}
crate::util::webhook::send_slack_project_webhook(
self.project_id,
pool,
redis,
webhook_url,
Some(message_header),
)
.await
}
}
#[derive(Deserialize)]
pub struct DelphiRunParameters {
pub file_id: crate::models::ids::FileId,
}
#[post("ingest", guard = "admin_key_guard")]
async fn ingest_report(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
web::Json(report): web::Json<serde_json::Value>,
) -> Result<(), ApiError> {
// treat this as an internal error, since it's not a bad request from the
// client's side - it's *our* fault for handling the Delphi schema wrong
// this could happen if Delphi updates and Labrinth doesn't
let report = serde_json::from_value::<DelphiReport>(report.clone())
.wrap_internal_err_with(|| {
eyre!(
"Delphi sent a response which does not match our schema\n\n{}",
serde_json::to_string_pretty(&report).unwrap()
)
})?;
ingest_report_deserialized(pool, redis, report).await
}
#[tracing::instrument(
level = "info",
skip_all,
fields(
%report.url,
%report.file_id,
%report.project_id,
%report.version_id,
)
)]
async fn ingest_report_deserialized(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
report: DelphiReport,
) -> Result<(), ApiError> {
if report.issues.is_empty() {
info!("No issues found for file");
return Ok(());
}
report.send_to_slack(&pool, &redis).await.ok();
let mut transaction = pool.begin().await?;
let report_id = DBDelphiReport {
id: DelphiReportId(0), // This will be set by the database
file_id: Some(DBFileId(report.file_id.0 as i64)),
delphi_version: report.delphi_version,
artifact_url: report.url.clone(),
created: DateTime::<Utc>::MIN_UTC, // This will be set by the database
severity: report.severity,
}
.upsert(&mut transaction)
.await?;
info!(
num_issues = %report.issues.len(),
"Delphi found issues in file",
);
let record = sqlx::query!(
r#"
SELECT
EXISTS(
SELECT 1 FROM delphi_issue_details_with_statuses didws
WHERE didws.project_id = $1 AND didws.status = 'pending'
) AS "pending_issue_details_exist!",
t.id AS "thread_id: DBThreadId"
FROM mods m
INNER JOIN threads t ON t.mod_id = $1
"#,
DBProjectId::from(report.project_id) as _,
)
.fetch_one(&mut *transaction)
.await
.wrap_internal_err("failed to check if pending issue details exist")?;
if record.pending_issue_details_exist {
info!(
"File's project already has pending issue details, is not entering tech review queue"
);
} else {
info!("File's project is entering tech review queue");
ThreadMessageBuilder {
author_id: None,
body: MessageBody::TechReviewEntered,
thread_id: record.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await
.wrap_internal_err("failed to add entering tech review message")?;
}
// TODO: Currently, the way we determine if an issue is in tech review or not
// is if it has any issue details which are pending.
// If you mark all issue details are safe or not safe - even if you don't
// submit the final report - the project will be taken out of tech review
// queue, and into moderation queue.
//
// This is undesirable, but we can't rework the database schema to fix it
// right now. As a hack, we add a dummy report issue which blocks the
// project from exiting the tech review queue.
{
let dummy_issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
report_id,
issue_type: "__dummy".into(),
}
.insert(&mut transaction)
.await?;
ReportIssueDetail {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id: dummy_issue_id,
key: "".into(),
file_path: "".into(),
decompiled_source: None,
data: HashMap::new(),
severity: DelphiSeverity::Low,
status: DelphiStatus::Pending,
}
.insert(&mut transaction)
.await?;
}
for (issue_type, issue_details) in report.issues {
let issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
report_id,
issue_type,
}
.insert(&mut transaction)
.await?;
// This is required to handle the case where the same Delphi version is re-run on the same file
ReportIssueDetail::remove_all_by_issue_id(issue_id, &mut transaction)
.await?;
for issue_detail in issue_details {
let decompiled_source =
report.decompiled_sources.get(&issue_detail.file);
ReportIssueDetail {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id,
key: issue_detail.key,
file_path: issue_detail.file,
decompiled_source: decompiled_source.cloned().flatten(),
data: issue_detail.data,
severity: issue_detail.severity,
status: DelphiStatus::Pending,
}
.insert(&mut transaction)
.await?;
}
}
transaction.commit().await?;
Ok(())
}
pub async fn run(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
run_parameters: DelphiRunParameters,
) -> Result<HttpResponse, ApiError> {
let file_data = sqlx::query!(
r#"
SELECT
version_id AS "version_id: crate::database::models::DBVersionId",
versions.mod_id AS "project_id: crate::database::models::DBProjectId",
files.url AS "url"
FROM files INNER JOIN versions ON files.version_id = versions.id
WHERE files.id = $1
"#,
run_parameters.file_id.0 as i64
)
.fetch_one(exec)
.await?;
tracing::debug!(
"Running Delphi for project {}, version {}, file {}",
file_data.project_id.0,
file_data.version_id.0,
run_parameters.file_id.0
);
DELPHI_CLIENT
.post(dotenvy::var("DELPHI_URL")?)
.json(&serde_json::json!({
"url": file_data.url,
"project_id": ProjectId(file_data.project_id.0 as u64),
"version_id": VersionId(file_data.version_id.0 as u64),
"file_id": run_parameters.file_id,
}))
.send()
.await
.and_then(|res| res.error_for_status())
.map_err(ApiError::Delphi)?;
Ok(HttpResponse::NoContent().finish())
}
#[post("run")]
async fn _run(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
run_parameters: web::Query<DelphiRunParameters>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
run(&**pool, run_parameters.into_inner()).await
}
#[get("version")]
async fn version(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
Ok(HttpResponse::Ok().json(
sqlx::query_scalar!("SELECT MAX(delphi_version) FROM delphi_reports")
.fetch_one(&**pool)
.await?,
))
}
#[get("issue_type/schema")]
async fn issue_type_schema(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
// This route is expected to be called often by the frontend, and Delphi is not necessarily
// built to scale beyond malware analysis, so cache the result of its quasi-constant-valued
// schema route to alleviate the load on it
static CACHED_ISSUE_TYPE_SCHEMA: Mutex<
Option<(serde_json::Map<String, serde_json::Value>, Instant)>,
> = Mutex::const_new(None);
match &mut *CACHED_ISSUE_TYPE_SCHEMA.lock().await {
Some((schema, last_fetch)) if last_fetch.elapsed().as_secs() < 60 => {
Ok(HttpResponse::Ok().json(schema))
}
cache_entry => Ok(HttpResponse::Ok().json(
&cache_entry
.insert((
DELPHI_CLIENT
.get(format!("{}/schema", dotenvy::var("DELPHI_URL")?))
.send()
.await
.and_then(|res| res.error_for_status())
.map_err(ApiError::Delphi)?
.json::<serde_json::Map<String, serde_json::Value>>()
.await
.map_err(ApiError::Delphi)?,
Instant::now(),
))
.0,
)),
}
}

View File

@@ -1,6 +1,7 @@
pub(crate) mod admin;
pub mod affiliate;
pub mod billing;
pub mod delphi;
pub mod external_notifications;
pub mod flows;
pub mod gdpr;
@@ -31,7 +32,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(statuses::config)
.configure(medal::config)
.configure(external_notifications::config)
.configure(mural::config),
.configure(mural::config)
.configure(delphi::config),
);
}

View File

@@ -1,8 +1,7 @@
use super::ApiError;
use crate::database;
use crate::database::models::{DBOrganization, DBTeamId, DBTeamMember, DBUser};
use crate::database::redis::RedisPool;
use crate::models::ids::{OrganizationId, TeamId};
use crate::models::ids::OrganizationId;
use crate::models::projects::{Project, ProjectStatus};
use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata};
use crate::queue::session::AuthQueue;
@@ -10,15 +9,22 @@ use crate::util::error::Context;
use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes};
use actix_web::{HttpRequest, get, post, web};
use ariadne::ids::{UserId, random_base62};
use eyre::eyre;
use ownership::get_projects_ownership;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
mod ownership;
mod tech_review;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(get_projects)
.service(get_project_meta)
.service(set_project_meta);
.service(set_project_meta)
.service(
utoipa_actix_web::scope("/tech-review")
.configure(tech_review::config),
);
}
#[derive(Deserialize, utoipa::ToSchema)]
@@ -47,7 +53,7 @@ pub struct FetchedProject {
}
/// Fetched information on who owns a project.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, Clone)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Ownership {
/// Project is owned by a team, and this is the team owner.
@@ -105,8 +111,24 @@ pub async fn get_projects_internal(
let project_ids = sqlx::query!(
"
SELECT id FROM mods
WHERE status = $1
SELECT id
FROM (
SELECT DISTINCT ON (m.id)
m.id,
m.queued
FROM mods m
-- exclude projects in tech review queue
LEFT JOIN delphi_issue_details_with_statuses didws
ON didws.project_id = m.id AND didws.status = 'pending'
WHERE
m.status = $1
AND didws.status IS NULL
GROUP BY m.id
) t
ORDER BY queued ASC
OFFSET $3
LIMIT $2
@@ -129,73 +151,20 @@ pub async fn get_projects_internal(
.map(crate::models::projects::Project::from)
.collect::<Vec<_>>();
let team_ids = projects
.iter()
.map(|project| project.team_id)
.map(DBTeamId::from)
.collect::<Vec<_>>();
let org_ids = projects
.iter()
.filter_map(|project| project.organization)
.collect::<Vec<_>>();
let team_members =
DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch team members")?;
let users = DBUser::get_many_ids(
&team_members
.iter()
.map(|member| member.user_id)
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await
.wrap_internal_err("failed to fetch user data of team members")?;
let orgs = DBOrganization::get_many(&org_ids, &**pool, &redis)
let ownerships = get_projects_ownership(&projects, &pool, &redis)
.await
.wrap_internal_err("failed to fetch organizations")?;
.wrap_internal_err("failed to fetch project ownerships")?;
let map_project = |project: Project| -> Result<FetchedProject, ApiError> {
let project_id = project.id;
let ownership = if let Some(org_id) = project.organization {
let org = orgs
.iter()
.find(|org| OrganizationId::from(org.id) == org_id)
.wrap_internal_err_with(|| {
eyre!(
"project {project_id} is owned by an invalid organization {org_id}"
)
})?;
Ownership::Organization {
id: OrganizationId::from(org.id),
name: org.name.clone(),
icon_url: org.icon_url.clone(),
}
} else {
let team_id = project.team_id;
let team_owner = team_members.iter().find(|member| TeamId::from(member.team_id) == team_id && member.is_owner)
.wrap_internal_err_with(|| eyre!("project {project_id} is owned by a team {team_id} which has no valid owner"))?;
let team_owner_id = team_owner.user_id;
let user = users.iter().find(|user| user.id == team_owner_id)
.wrap_internal_err_with(|| eyre!("project {project_id} is owned by a team {team_id} which has owner {} which does not exist", UserId::from(team_owner_id)))?;
Ownership::User {
id: UserId::from(user.id),
name: user.username.clone(),
icon_url: user.avatar_url.clone(),
}
let map_project =
|(project, ownership): (Project, Ownership)| -> FetchedProject {
FetchedProject { ownership, project }
};
Ok(FetchedProject { ownership, project })
};
let projects = projects
.into_iter()
.zip(ownerships)
.map(map_project)
.collect::<Result<Vec<_>, _>>()?;
.collect::<Vec<_>>();
Ok(web::Json(projects))
}

View File

@@ -0,0 +1,84 @@
use crate::database::models::{DBOrganization, DBTeamId, DBTeamMember, DBUser};
use crate::database::redis::RedisPool;
use crate::models::ids::OrganizationId;
use crate::routes::internal::moderation::Ownership;
use crate::util::error::Context;
use ariadne::ids::UserId;
use eyre::eyre;
use sqlx::PgPool;
/// Fetches ownership information for multiple projects efficiently
pub async fn get_projects_ownership(
projects: &[crate::models::projects::Project],
pool: &PgPool,
redis: &RedisPool,
) -> Result<Vec<Ownership>, crate::routes::ApiError> {
let team_ids = projects
.iter()
.map(|project| project.team_id)
.map(DBTeamId::from)
.collect::<Vec<_>>();
let org_ids = projects
.iter()
.filter_map(|project| project.organization)
.collect::<Vec<_>>();
let team_members =
DBTeamMember::get_from_team_full_many(&team_ids, pool, redis)
.await
.wrap_internal_err("failed to fetch team members")?;
let users = DBUser::get_many_ids(
&team_members
.iter()
.map(|member| member.user_id)
.collect::<Vec<_>>(),
pool,
redis,
)
.await
.wrap_internal_err("failed to fetch user data of team members")?;
let orgs = DBOrganization::get_many(&org_ids, pool, redis)
.await
.wrap_internal_err("failed to fetch organizations")?;
let mut ownerships = Vec::with_capacity(projects.len());
for project in projects {
let project_id = project.id;
let ownership = if let Some(org_id) = project.organization {
let org = orgs
.iter()
.find(|org| OrganizationId::from(org.id) == org_id)
.wrap_internal_err_with(|| {
eyre!(
"project {project_id} is owned by an invalid organization {org_id}"
)
})?;
Ownership::Organization {
id: OrganizationId::from(org.id),
name: org.name.clone(),
icon_url: org.icon_url.clone(),
}
} else {
let team_id = project.team_id;
let team_owner = team_members.iter().find(|member| {
crate::models::ids::TeamId::from(member.team_id) == team_id && member.is_owner
})
.wrap_internal_err_with(|| eyre!("project {project_id} is owned by a team {team_id} which has no valid owner"))?;
let team_owner_id = team_owner.user_id;
let user = users.iter().find(|user| user.id == team_owner_id)
.wrap_internal_err_with(|| eyre!("project {project_id} is owned by a team {team_id} which has owner {} which does not exist", UserId::from(team_owner_id)))?;
Ownership::User {
id: ariadne::ids::UserId::from(user.id),
name: user.username.clone(),
icon_url: user.avatar_url.clone(),
}
};
ownerships.push(ownership);
}
Ok(ownerships)
}

View File

@@ -0,0 +1,894 @@
use std::{collections::HashMap, fmt};
use actix_web::{HttpRequest, get, patch, post, put, web};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tokio_stream::StreamExt;
use super::ownership::get_projects_ownership;
use crate::{
auth::check_is_moderator_from_headers,
database::{
DBProject,
models::{
DBFileId, DBProjectId, DBThread, DBThreadId, DBUser,
DelphiReportId, DelphiReportIssueDetailsId, DelphiReportIssueId,
ProjectTypeId,
delphi_report_item::{
DBDelphiReport, DelphiSeverity, DelphiStatus, DelphiVerdict,
ReportIssueDetail,
},
thread_item::ThreadMessageBuilder,
},
redis::RedisPool,
},
models::{
ids::{FileId, ProjectId, ThreadId, VersionId},
pats::Scopes,
projects::{Project, ProjectStatus},
threads::{MessageBody, Thread},
},
queue::session::AuthQueue,
routes::{ApiError, internal::moderation::Ownership},
util::error::Context,
};
use eyre::eyre;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(search_projects)
.service(get_report)
.service(get_issue)
.service(submit_report)
.service(update_issue_detail)
.service(add_report);
}
/// Arguments for searching project technical reviews.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchProjects {
#[serde(default = "default_limit")]
#[schema(default = 20)]
pub limit: u64,
#[serde(default)]
#[schema(default = 0)]
pub page: u64,
#[serde(default)]
pub filter: SearchProjectsFilter,
#[serde(default = "default_sort_by")]
pub sort_by: SearchProjectsSort,
}
fn default_limit() -> u64 {
20
}
fn default_sort_by() -> SearchProjectsSort {
SearchProjectsSort::CreatedAsc
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchProjectsFilter {
pub project_type: Vec<ProjectTypeId>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum SearchProjectsSort {
CreatedAsc,
CreatedDesc,
SeverityAsc,
SeverityDesc,
}
impl fmt::Display for SearchProjectsSort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = serde_json::to_value(*self).unwrap();
let s = s.as_str().unwrap();
write!(f, "{s}")
}
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FileReport {
/// ID of this report.
pub report_id: DelphiReportId,
/// ID of the file that was scanned.
pub file_id: FileId,
/// When the report for this file was created.
pub created: DateTime<Utc>,
/// Why this project was flagged.
pub flag_reason: FlagReason,
/// According to this report, how likely is the project malicious?
pub severity: DelphiSeverity,
/// Name of the flagged file.
pub file_name: String,
/// Size of the flagged file, in bytes.
pub file_size: i32,
/// URL to download the flagged file.
pub download_url: String,
/// What issues appeared in the file.
#[serde(default)]
pub issues: Vec<FileIssue>,
}
/// Issue raised by Delphi in a flagged file.
///
/// The issue is scoped to the JAR, not any specific class, but issues can be
/// raised because they appeared in a class - see [`FileIssueDetails`].
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FileIssue {
/// ID of the issue.
pub id: DelphiReportIssueId,
/// ID of the report this issue is a part of.
pub report_id: DelphiReportId,
/// Delphi-determined kind of issue that this is, e.g. `OBFUSCATED_NAMES`.
///
/// Labrinth does not know the full set of kinds of issues, so this is kept
/// as a string.
pub issue_type: String,
/// Details of why this issue might have been raised, such as what file it
/// was found in.
#[serde(default)]
pub details: Vec<ReportIssueDetail>,
}
/// Why a project was flagged for technical review.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum FlagReason {
/// Delphi anti-malware scanner flagged a file in the project.
Delphi,
}
/// Get info on an issue in a Delphi report.
#[utoipa::path(
security(("bearer_auth" = [])),
responses((status = OK, body = inline(FileIssue)))
)]
#[get("/issue/{issue_id}")]
async fn get_issue(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(DelphiReportIssueId,)>,
) -> Result<web::Json<FileIssue>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let (issue_id,) = path.into_inner();
let row = sqlx::query!(
r#"
SELECT
to_jsonb(dri)
|| jsonb_build_object(
-- TODO: replace with `json_array` in Postgres 16
'details', (
SELECT json_agg(to_jsonb(drid))
FROM delphi_report_issue_details drid
WHERE drid.issue_id = dri.id
)
) AS "data!: sqlx::types::Json<FileIssue>"
FROM delphi_report_issues dri
WHERE dri.id = $1
"#,
issue_id as DelphiReportIssueId,
)
.fetch_optional(&**pool)
.await
.wrap_internal_err("failed to fetch issue from database")?
.ok_or(ApiError::NotFound)?;
Ok(web::Json(row.data.0))
}
/// Get info on a specific report for a project.
#[utoipa::path(
security(("bearer_auth" = [])),
responses((status = OK, body = inline(FileReport)))
)]
#[get("/report/{id}")]
async fn get_report(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(DelphiReportId,)>,
) -> Result<web::Json<FileReport>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let (report_id,) = path.into_inner();
let row = sqlx::query!(
r#"
SELECT DISTINCT ON (dr.id)
to_jsonb(dr)
|| jsonb_build_object(
'file_id', to_base62(f.id),
'version_id', to_base62(v.id),
'project_id', to_base62(v.mod_id),
'file_name', f.filename,
'file_size', f.size,
'flag_reason', 'delphi',
'download_url', f.url,
-- TODO: replace with `json_array` in Postgres 16
'issues', (
SELECT json_agg(
to_jsonb(dri)
|| jsonb_build_object(
-- TODO: replace with `json_array` in Postgres 16
'details', (
SELECT json_agg(to_jsonb(drid))
FROM delphi_report_issue_details drid
WHERE drid.issue_id = dri.id
)
)
)
FROM delphi_report_issues dri
WHERE
dri.report_id = dr.id
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
)
) AS "data!: sqlx::types::Json<FileReport>"
FROM delphi_reports dr
INNER JOIN files f ON f.id = dr.file_id
INNER JOIN versions v ON v.id = f.version_id
WHERE dr.id = $1
"#,
report_id as DelphiReportId,
)
.fetch_optional(&**pool)
.await
.wrap_internal_err("failed to fetch report from database")?
.ok_or(ApiError::NotFound)?;
Ok(web::Json(row.data.0))
}
/// See [`search_projects`].
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchResponse {
/// List of reported projects returned, and their report data.
pub project_reports: Vec<ProjectReport>,
/// Fetched project information for projects in the returned reports.
pub projects: HashMap<ProjectId, ProjectModerationInfo>,
/// Fetched moderation threads for projects in the returned reports.
pub threads: HashMap<ThreadId, Thread>,
/// Fetched owner information for projects.
pub ownership: HashMap<ProjectId, Ownership>,
}
/// Single project's reports from a search response.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectReport {
/// ID of the project this report is for.
pub project_id: ProjectId,
/// Highest severity of any report of any file of any version under this
/// project.
pub max_severity: Option<DelphiSeverity>,
/// Reports for this project's versions.
#[serde(default)]
pub versions: Vec<VersionReport>,
}
/// Single project version's reports from a search response.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct VersionReport {
/// ID of the project version this report is for.
pub version_id: VersionId,
/// Reports for this version's files.
#[serde(default)]
pub files: Vec<FileReport>,
}
/// Limited set of project information returned by [`search_projects`].
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectModerationInfo {
/// Project ID.
pub id: ProjectId,
/// Project moderation thread ID.
pub thread_id: ThreadId,
/// Project name.
pub name: String,
/// The aggregated project typos of the versions of this project
#[serde(default)]
pub project_types: Vec<String>,
/// The URL of the icon of the project
pub icon_url: Option<String>,
}
/// Searches all projects which are awaiting technical review.
#[utoipa::path(
security(("bearer_auth" = [])),
responses((status = OK, body = inline(Vec<SearchResponse>)))
)]
#[post("/search")]
async fn search_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
search_req: web::Json<SearchProjects>,
) -> Result<web::Json<SearchResponse>, ApiError> {
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let sort_by = search_req.sort_by.to_string();
let limit = search_req.limit.max(50);
let offset = limit.saturating_mul(search_req.page);
let limit =
i64::try_from(limit).wrap_request_err("limit cannot fit into `i64`")?;
let offset = i64::try_from(offset)
.wrap_request_err("offset cannot fit into `i64`")?;
let mut project_reports = Vec::<ProjectReport>::new();
let mut project_ids = Vec::<DBProjectId>::new();
let mut thread_ids = Vec::<DBThreadId>::new();
let mut rows = sqlx::query!(
r#"
SELECT
project_id AS "project_id: DBProjectId",
project_thread_id AS "project_thread_id: DBThreadId",
report AS "report!: sqlx::types::Json<ProjectReport>"
FROM (
SELECT DISTINCT ON (m.id)
m.id AS project_id,
t.id AS project_thread_id,
MAX(dr.severity) AS severity,
MIN(dr.created) AS earliest_report_created,
MAX(dr.created) AS latest_report_created,
jsonb_build_object(
'project_id', to_base62(m.id),
'max_severity', MAX(dr.severity),
-- TODO: replace with `json_array` in Postgres 16
'versions', (
SELECT coalesce(jsonb_agg(jsonb_build_object(
'version_id', to_base62(v.id),
-- TODO: replace with `json_array` in Postgres 16
'files', (
SELECT coalesce(jsonb_agg(jsonb_build_object(
'report_id', dr.id,
'file_id', to_base62(f.id),
'created', dr.created,
'flag_reason', 'delphi',
'severity', dr.severity,
'file_name', f.filename,
'file_size', f.size,
'download_url', f.url,
-- TODO: replace with `json_array` in Postgres 16
'issues', (
SELECT coalesce(jsonb_agg(
to_jsonb(dri)
|| jsonb_build_object(
-- TODO: replace with `json_array` in Postgres 16
'details', (
SELECT coalesce(jsonb_agg(
jsonb_build_object(
'id', didws.id,
'issue_id', didws.issue_id,
'key', didws.key,
'file_path', didws.file_path,
-- ignore `decompiled_source`
'data', didws.data,
'severity', didws.severity,
'status', didws.status
)
), '[]'::jsonb)
FROM delphi_issue_details_with_statuses didws
WHERE didws.issue_id = dri.id
)
)
), '[]'::jsonb)
FROM delphi_report_issues dri
WHERE
dri.report_id = dr.id
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
)
)), '[]'::jsonb)
FROM delphi_reports dr
WHERE dr.file_id = f.id
)
)), '[]'::jsonb)
FROM versions v
INNER JOIN files f ON f.version_id = v.id
WHERE v.mod_id = m.id
)
) AS report
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
INNER JOIN versions v ON v.mod_id = m.id
INNER JOIN files f ON f.version_id = v.id
-- only return projects with at least 1 pending drid
INNER JOIN delphi_reports dr ON dr.file_id = f.id
INNER JOIN delphi_issue_details_with_statuses didws
ON didws.project_id = m.id AND didws.status = 'pending'
-- filtering
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
LEFT JOIN categories c ON c.id = mc.joining_category_id
WHERE
-- project type
(cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))
AND m.status NOT IN ('draft', 'rejected', 'withheld')
GROUP BY m.id, t.id
) t
-- sorting
ORDER BY
CASE WHEN $3 = 'created_asc' THEN t.earliest_report_created ELSE TO_TIMESTAMP(0) END ASC,
CASE WHEN $3 = 'created_desc' THEN t.latest_report_created ELSE TO_TIMESTAMP(0) END DESC,
CASE WHEN $3 = 'severity_asc' THEN t.severity ELSE 'low'::delphi_severity END ASC,
CASE WHEN $3 = 'severity_desc' THEN t.severity ELSE 'low'::delphi_severity END DESC
-- pagination
LIMIT $1
OFFSET $2
"#,
limit,
offset,
&sort_by,
&search_req
.filter
.project_type
.iter()
.map(|ty| ty.0)
.collect::<Vec<_>>(),
)
.fetch(&**pool);
while let Some(row) = rows
.next()
.await
.transpose()
.wrap_internal_err("failed to fetch reports")?
{
project_reports.push(row.report.0);
project_ids.push(row.project_id);
thread_ids.push(row.project_thread_id);
}
let projects = DBProject::get_many_ids(&project_ids, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch projects")?
.into_iter()
.map(|project| {
(ProjectId::from(project.inner.id), Project::from(project))
})
.collect::<HashMap<_, _>>();
let db_threads = DBThread::get_many(&thread_ids, &**pool)
.await
.wrap_internal_err("failed to fetch threads")?;
let thread_author_ids = db_threads
.iter()
.flat_map(|thread| {
thread
.messages
.iter()
.filter_map(|message| message.author_id)
})
.collect::<Vec<_>>();
let thread_authors =
DBUser::get_many_ids(&thread_author_ids, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch thread authors")?
.into_iter()
.map(From::from)
.collect::<Vec<_>>();
let threads = db_threads
.into_iter()
.map(|thread| {
let thread = Thread::from(thread, thread_authors.clone(), &user);
(thread.id, thread)
})
.collect::<HashMap<_, _>>();
let project_list: Vec<Project> = projects.values().cloned().collect();
let ownerships = get_projects_ownership(&project_list, &pool, &redis)
.await
.wrap_internal_err("failed to fetch project ownerships")?;
let ownership = projects
.keys()
.copied()
.zip(ownerships)
.collect::<HashMap<_, _>>();
Ok(web::Json(SearchResponse {
project_reports,
projects: projects
.into_iter()
.map(|(id, project)| {
(
id,
ProjectModerationInfo {
id,
thread_id: project.thread_id,
name: project.name,
project_types: project.project_types,
icon_url: project.icon_url,
},
)
})
.collect(),
threads,
ownership,
}))
}
/// See [`submit_report`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SubmitReport {
/// Does the moderator think this report shows that the project is safe or
/// unsafe?
pub verdict: DelphiVerdict,
/// Moderator message to send to the thread when rejecting the project.
pub message: Option<String>,
}
/// Submits a verdict for a project based on its technical reports.
///
/// Before this is called, all issues for this project's reports must have been
/// marked as either safe or unsafe. Otherwise, this will error with
/// [`ApiError::TechReviewIssuesWithNoVerdict`], providing the issue IDs which
/// are still unmarked.
#[utoipa::path(
security(("bearer_auth" = [])),
responses((status = NO_CONTENT))
)]
#[post("/submit/{project_id}")]
async fn submit_report(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
web::Json(submit_report): web::Json<SubmitReport>,
path: web::Path<(ProjectId,)>,
) -> Result<(), ApiError> {
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let (project_id,) = path.into_inner();
let project_id = DBProjectId::from(project_id);
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let pending_issue_details = sqlx::query!(
r#"
SELECT
didws.id AS "issue_detail_id!"
FROM mods m
INNER JOIN versions v ON v.mod_id = m.id
INNER JOIN files f ON f.version_id = v.id
INNER JOIN delphi_reports dr ON dr.file_id = f.id
INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id
INNER JOIN delphi_issue_details_with_statuses didws ON didws.issue_id = dri.id
WHERE
m.id = $1
AND didws.status = 'pending'
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
"#,
project_id as _,
)
.fetch_all(&mut *txn)
.await
.wrap_internal_err("failed to fetch pending issues")?;
if !pending_issue_details.is_empty() {
return Err(ApiError::TechReviewDetailsWithNoVerdict {
details: pending_issue_details
.into_iter()
.map(|record| {
DelphiReportIssueDetailsId(record.issue_detail_id)
})
.collect(),
});
}
sqlx::query!(
"
DELETE FROM delphi_report_issue_details drid
WHERE issue_id IN (
SELECT dri.id
FROM mods m
INNER JOIN versions v ON v.mod_id = m.id
INNER JOIN files f ON f.version_id = v.id
INNER JOIN delphi_reports dr ON dr.file_id = f.id
INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id
WHERE m.id = $1 AND dri.issue_type = '__dummy'
)
",
project_id as _,
)
.execute(&mut *txn)
.await
.wrap_internal_err("failed to delete dummy issue")?;
let record = sqlx::query!(
r#"
SELECT t.id AS "thread_id: DBThreadId"
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
WHERE m.id = $1
"#,
project_id as _,
)
.fetch_one(&mut *txn)
.await
.wrap_internal_err("failed to update reports")?;
if let Some(body) = submit_report.message {
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::Text {
body,
private: true,
replying_to: None,
associated_images: Vec::new(),
},
thread_id: record.thread_id,
hide_identity: user.role.is_mod(),
}
.insert(&mut txn)
.await
.wrap_internal_err("failed to add moderator message")?;
}
let verdict = submit_report.verdict;
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::TechReview { verdict },
thread_id: record.thread_id,
hide_identity: user.role.is_mod(),
}
.insert(&mut txn)
.await
.wrap_internal_err("failed to add tech review message")?;
if verdict == DelphiVerdict::Unsafe {
let record = sqlx::query!(
r#"
UPDATE mods
SET status = $1
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
WHERE m.id = $2
RETURNING
t.id AS "thread_id: DBThreadId",
(SELECT status FROM mods WHERE id = m.id) AS "old_status!"
"#,
ProjectStatus::Rejected.as_str(),
project_id as _,
)
.fetch_one(&mut *txn)
.await
.wrap_internal_err("failed to mark project as rejected")?;
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::StatusChange {
new_status: ProjectStatus::Rejected,
old_status: ProjectStatus::from_string(&record.old_status),
},
thread_id: record.thread_id,
hide_identity: user.role.is_mod(),
}
.insert(&mut txn)
.await
.wrap_internal_err("failed to add tech review message")?;
DBProject::clear_cache(project_id, None, None, &redis)
.await
.wrap_internal_err("failed to clear project cache")?;
}
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
/// See [`update_issue`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateIssue {
/// What the moderator has decided the outcome of this issue is.
pub verdict: DelphiVerdict,
}
/// Updates the state of a technical review issue detail.
///
/// This will not automatically reject the project for malware, but just flag
/// this issue with a verdict.
#[utoipa::path(
security(("bearer_auth" = [])),
responses((status = NO_CONTENT))
)]
#[patch("/issue-detail/{id}")]
async fn update_issue_detail(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
update_req: web::Json<UpdateIssue>,
path: web::Path<(DelphiReportIssueDetailsId,)>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let (issue_detail_id,) = path.into_inner();
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
let status = match update_req.verdict {
DelphiVerdict::Safe => DelphiStatus::Safe,
DelphiVerdict::Unsafe => DelphiStatus::Unsafe,
};
let results = sqlx::query!(
r#"
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
)
SELECT
didws.project_id,
didws.key,
$1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.id = $2
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
"#,
status as _,
issue_detail_id as _,
)
.execute(&mut *txn)
.await
.wrap_internal_err("failed to update issue detail")?;
if results.rows_affected() == 0 {
return Err(ApiError::Request(eyre!("issue detail does not exist")));
}
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
/// See [`add_report`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AddReport {
pub file_id: FileId,
}
/// Adds a file to the technical review queue by adding an empty report, if one
/// does not already exist for it.
#[utoipa::path]
#[put("/report")]
async fn add_report(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
web::Json(add_report): web::Json<AddReport>,
) -> Result<web::Json<DelphiReportId>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let file_id = add_report.file_id;
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let record = sqlx::query!(
r#"
SELECT
f.url,
COUNT(dr.id) AS "report_count!"
FROM files f
LEFT JOIN delphi_reports dr ON dr.file_id = f.id
WHERE f.id = $1
GROUP BY f.url
"#,
DBFileId::from(file_id) as _,
)
.fetch_one(&mut *txn)
.await
.wrap_internal_err("failed to fetch file")?;
if record.report_count > 0 {
return Err(ApiError::Request(eyre!("file already has reports")));
}
let report_id = DBDelphiReport {
id: DelphiReportId(0),
file_id: Some(file_id.into()),
delphi_version: -1, // TODO
artifact_url: record.url,
created: Utc::now(),
severity: DelphiSeverity::Low, // TODO
}
.upsert(&mut txn)
.await
.wrap_internal_err("failed to insert report")?;
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(web::Json(report_id))
}

View File

@@ -1,3 +1,4 @@
use crate::database::models::DelphiReportIssueDetailsId;
use crate::file_hosting::FileHostingError;
use crate::routes::analytics::{page_view_ingest, playtime_ingest};
use crate::util::cors::default_cors;
@@ -7,6 +8,7 @@ use actix_files::Files;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, web};
use futures::FutureExt;
use serde_json::json;
pub mod internal;
pub mod v2;
@@ -161,8 +163,14 @@ pub enum ApiError {
RateLimitError(u128, u32),
#[error("Error while interacting with payment processor: {0}")]
Stripe(#[from] stripe::StripeError),
#[error("Error while interacting with Delphi: {0}")]
Delphi(reqwest::Error),
#[error(transparent)]
Mural(#[from] Box<muralpay::ApiError>),
#[error("report still has {} issue details with no verdict", details.len())]
TechReviewDetailsWithNoVerdict {
details: Vec<DelphiReportIssueDetailsId>,
},
}
impl ApiError {
@@ -203,7 +211,11 @@ impl ApiError {
Self::Stripe(..) => "stripe_error",
Self::TaxProcessor(..) => "tax_processor_error",
Self::Slack(..) => "slack_error",
Self::Delphi(..) => "delphi_error",
Self::Mural(..) => "mural_error",
Self::TechReviewDetailsWithNoVerdict { .. } => {
"tech_review_issues_with_no_verdict"
}
},
description: match self {
Self::Internal(e) => format!("{e:#?}"),
@@ -213,6 +225,13 @@ impl ApiError {
},
details: match self {
Self::Mural(err) => serde_json::to_value(err.clone()).ok(),
Self::TechReviewDetailsWithNoVerdict { details } => {
let details = serde_json::to_value(details)
.expect("details should never fail to serialize");
Some(json!({
"issue_details": details
}))
}
_ => None,
},
}
@@ -256,7 +275,11 @@ impl actix_web::ResponseError for ApiError {
Self::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
Self::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Delphi(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Mural(..) => StatusCode::BAD_REQUEST,
Self::TechReviewDetailsWithNoVerdict { .. } => {
StatusCode::BAD_REQUEST
}
}
}

View File

@@ -413,9 +413,6 @@ async fn project_create_inner(
session_queue: &AuthQueue,
project_id: ProjectId,
) -> Result<HttpResponse, CreateError> {
// The base URL for files uploaded to S3
let cdn_url = dotenvy::var("CDN_URL")?;
// The currently logged in user
let (_, current_user) = get_user_from_headers(
&req,
@@ -651,7 +648,6 @@ async fn project_create_inner(
uploaded_files,
&mut created_version.files,
&mut created_version.dependencies,
&cdn_url,
&content_disposition,
project_id,
created_version.version_id.into(),

View File

@@ -380,7 +380,25 @@ pub async fn thread_send_message(
.await?
.1;
let string: database::models::DBThreadId = info.into_inner().0.into();
thread_send_message_internal(
&user,
info.into_inner().0,
&pool,
new_message.into_inner(),
&redis,
)
.await?;
Ok(HttpResponse::NoContent().finish())
}
pub async fn thread_send_message_internal(
user: &User,
thread_id: ThreadId,
pool: &PgPool,
new_message: NewThreadMessage,
redis: &RedisPool,
) -> Result<(), ApiError> {
let string: database::models::DBThreadId = thread_id.into();
let is_private: bool;
@@ -406,7 +424,7 @@ pub async fn thread_send_message(
if let Some(replying_to) = replying_to {
let thread_message = database::models::DBThreadMessage::get(
(*replying_to).into(),
&**pool,
pool,
)
.await?;
@@ -431,10 +449,10 @@ pub async fn thread_send_message(
));
}
let result = database::models::DBThread::get(string, &**pool).await?;
let result = database::models::DBThread::get(string, pool).await?;
if let Some(thread) = result {
if !is_authorized_thread(&thread, &user, &pool).await? {
if !is_authorized_thread(&thread, user, pool).await? {
return Err(ApiError::NotFound);
}
@@ -450,10 +468,9 @@ pub async fn thread_send_message(
.await?;
if let Some(project_id) = thread.project_id {
let project = database::models::DBProject::get_id(
project_id, &**pool, &redis,
)
.await?;
let project =
database::models::DBProject::get_id(project_id, pool, redis)
.await?;
if let Some(project) = project
&& project.inner.status != ProjectStatus::Processing
@@ -463,8 +480,8 @@ pub async fn thread_send_message(
let members =
database::models::DBTeamMember::get_from_team_full(
project.inner.team_id,
&**pool,
&redis,
pool,
redis,
)
.await?;
@@ -479,7 +496,7 @@ pub async fn thread_send_message(
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
redis,
)
.await?;
@@ -491,15 +508,14 @@ pub async fn thread_send_message(
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
redis,
)
.await?;
}
} else if let Some(report_id) = thread.report_id {
let report = database::models::report_item::DBReport::get(
report_id, &**pool,
)
.await?;
let report =
database::models::report_item::DBReport::get(report_id, pool)
.await?;
if let Some(report) = report {
if report.closed && !user.role.is_mod() {
@@ -517,7 +533,7 @@ pub async fn thread_send_message(
report_id: Some(report.id.into()),
},
}
.insert(report.reporter, &mut transaction, &redis)
.insert(report.reporter, &mut transaction, redis)
.await?;
}
}
@@ -531,7 +547,7 @@ pub async fn thread_send_message(
if let Some(db_image) = image_item::DBImage::get(
(*image_id).into(),
&mut *transaction,
&redis,
redis,
)
.await?
{
@@ -558,7 +574,7 @@ pub async fn thread_send_message(
.execute(&mut *transaction)
.await?;
image_item::DBImage::clear_cache(image.id.into(), &redis)
image_item::DBImage::clear_cache(image.id.into(), redis)
.await?;
} else {
return Err(ApiError::InvalidInput(format!(
@@ -570,7 +586,7 @@ pub async fn thread_send_message(
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
Ok(())
} else {
Err(ApiError::NotFound)
}
@@ -630,14 +646,7 @@ pub async fn message_delete(
.await?;
}
let private = if let MessageBody::Text { private, .. } = thread.body {
private
} else if let MessageBody::Deleted { private, .. } = thread.body {
private
} else {
false
};
let private = thread.body.is_private();
database::models::DBThreadMessage::remove_full(
thread.id,
private,

View File

@@ -38,7 +38,6 @@ use sha1::Digest;
use sqlx::postgres::PgPool;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tracing::error;
use validator::Validate;
fn default_requested_status() -> VersionStatus {
@@ -158,8 +157,6 @@ async fn version_create_inner(
session_queue: &AuthQueue,
moderation_queue: &AutomatedModerationQueue,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let mut initial_version_data = None;
let mut version_builder = None;
let mut selected_loaders = None;
@@ -355,7 +352,6 @@ async fn version_create_inner(
uploaded_files,
&mut version.files,
&mut version.dependencies,
&cdn_url,
&content_disposition,
version.project_id.into(),
version.version_id.into(),
@@ -451,6 +447,7 @@ async fn version_create_inner(
.files
.iter()
.map(|file| VersionFile {
id: None,
hashes: file
.hashes
.iter()
@@ -590,8 +587,6 @@ async fn upload_file_to_version_inner(
version_id: models::DBVersionId,
session_queue: &AuthQueue,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let mut initial_file_data: Option<InitialFileData> = None;
let mut file_builders: Vec<VersionFileBuilder> = Vec::new();
@@ -741,7 +736,6 @@ async fn upload_file_to_version_inner(
uploaded_files,
&mut file_builders,
&mut dependencies,
&cdn_url,
&content_disposition,
project_id,
version_id.into(),
@@ -795,7 +789,6 @@ pub async fn upload_file(
uploaded_files: &mut Vec<UploadedFile>,
version_files: &mut Vec<VersionFileBuilder>,
dependencies: &mut Vec<DependencyBuilder>,
cdn_url: &str,
content_disposition: &actix_web::http::header::ContentDisposition,
project_id: ProjectId,
version_id: VersionId,
@@ -943,13 +936,11 @@ pub async fn upload_file(
|| total_files_len == 1;
let file_path_encode = format!(
"data/{}/versions/{}/{}",
project_id,
version_id,
"data/{project_id}/versions/{version_id}/{}",
urlencoding::encode(file_name)
);
let file_path =
format!("data/{}/versions/{}/{}", project_id, version_id, &file_name);
format!("data/{project_id}/versions/{version_id}/{file_name}");
let upload_data = file_host
.upload_file(content_type, &file_path, FileHostPublicity::Public, data)
@@ -980,33 +971,9 @@ pub async fn upload_file(
return Err(CreateError::InvalidInput(msg.to_string()));
}
let url = format!("{cdn_url}/{file_path_encode}");
let client = reqwest::Client::new();
let delphi_url = dotenvy::var("DELPHI_URL")?;
match client
.post(delphi_url)
.json(&serde_json::json!({
"url": url,
"project_id": project_id,
"version_id": version_id,
}))
.send()
.await
{
Ok(res) => {
if !res.status().is_success() {
error!("Failed to upload file to Delphi: {url}");
}
}
Err(e) => {
error!("Failed to upload file to Delphi: {url}: {e}");
}
}
version_files.push(VersionFileBuilder {
filename: file_name.to_string(),
url: format!("{cdn_url}/{file_path_encode}"),
url: format!("{}/{file_path_encode}", dotenvy::var("CDN_URL")?),
hashes: vec![
models::version_item::HashBuilder {
algorithm: "sha1".to_string(),

View File

@@ -12,6 +12,7 @@ import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
import { LabrinthProjectsV3Module } from './labrinth/projects/v3'
import { LabrinthStateModule } from './labrinth/state'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -36,6 +37,7 @@ export const MODULE_REGISTRY = {
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_state: LabrinthStateModule,
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_versions_v3: LabrinthVersionsV3Module,
} as const satisfies Record<string, ModuleConstructor>

View File

@@ -3,4 +3,5 @@ export * from './collections'
export * from './projects/v2'
export * from './projects/v3'
export * from './state'
export * from './tech-review/internal'
export * from './versions/v3'

View File

@@ -0,0 +1,124 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthTechReviewInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_tech_review_internal'
}
/**
* Search for projects awaiting technical review.
*
* Returns a flat list of file reports with associated project data, ownership
* information, and moderation threads provided as lookup maps.
*
* @param params - Search parameters including pagination, filters, and sorting
* @returns Response object containing reports array and lookup maps for projects, threads, and ownership
*
* @example
* ```typescript
* const response = await client.labrinth.tech_review_internal.searchProjects({
* limit: 20,
* page: 0,
* sort_by: 'created_asc',
* filter: {
* project_type: ['mod', 'modpack']
* }
* })
* // Access reports: response.reports
* // Access project by ID: response.projects[projectId]
* ```
*/
public async searchProjects(
params: Labrinth.TechReview.Internal.SearchProjectsRequest,
): Promise<Labrinth.TechReview.Internal.SearchResponse> {
return this.client.request<Labrinth.TechReview.Internal.SearchResponse>(
'/moderation/tech-review/search',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
/**
* Get detailed information about a specific file report.
*
* @param reportId - The Delphi report ID
* @returns Full report with all issues and details
*
* @example
* ```typescript
* const report = await client.labrinth.tech_review_internal.getReport('report-123')
* console.log(report.file_name, report.issues.length)
* ```
*/
public async getReport(reportId: string): Promise<Labrinth.TechReview.Internal.FileReport> {
return this.client.request<Labrinth.TechReview.Internal.FileReport>(
`/moderation/tech-review/report/${reportId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Get detailed information about a specific issue.
*
* @param issueId - The issue ID
* @returns Issue with all its details
*
* @example
* ```typescript
* const issue = await client.labrinth.tech_review_internal.getIssue('issue-123')
* console.log(issue.issue_type, issue.status)
* ```
*/
public async getIssue(issueId: string): Promise<Labrinth.TechReview.Internal.FileIssue> {
return this.client.request<Labrinth.TechReview.Internal.FileIssue>(
`/moderation/tech-review/issue/${issueId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Update the status of a technical review issue detail.
*
* Allows moderators to mark an individual issue detail as safe (false positive) or unsafe (malicious).
*
* @param detailId - The ID of the issue detail to update
* @param data - The verdict for the detail
* @returns Promise that resolves when the update is complete
*/
public async updateIssueDetail(
detailId: string,
data: Labrinth.TechReview.Internal.UpdateIssueRequest,
): Promise<void> {
return this.client.request<void>(`/moderation/tech-review/issue-detail/${detailId}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
public async submitProject(
projectId: string,
data: Labrinth.TechReview.Internal.SubmitProjectRequest,
): Promise<void> {
return this.client.request<void>(`/moderation/tech-review/submit/${projectId}`, {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
}

View File

@@ -13,7 +13,7 @@ export namespace Labrinth {
price_id: string
interval: PriceDuration
status: SubscriptionStatus
created: string // ISO datetime string
created: string
metadata?: SubscriptionMetadata
}
@@ -40,8 +40,8 @@ export namespace Labrinth {
amount: number
currency_code: string
status: ChargeStatus
due: string // ISO datetime string
last_attempt: string | null // ISO datetime string
due: string
last_attempt: string | null
type: ChargeType
subscription_id: string | null
subscription_interval: PriceDuration | null
@@ -337,6 +337,10 @@ export namespace Labrinth {
monetization_status: v2.MonetizationStatus
side_types_migration_review_status: 'reviewed' | 'pending'
environment?: Environment[]
/**
* @deprecated Not recommended to use.
**/
[key: string]: unknown
}
@@ -719,4 +723,181 @@ export namespace Labrinth {
errors: unknown[]
}
}
export namespace TechReview {
export namespace Internal {
export type SearchProjectsRequest = {
limit?: number
page?: number
filter?: SearchProjectsFilter
sort_by?: SearchProjectsSort
}
export type SearchProjectsFilter = {
project_type?: string[]
}
export type SearchProjectsSort =
| 'created_asc'
| 'created_desc'
| 'severity_asc'
| 'severity_desc'
export type UpdateIssueRequest = {
verdict: 'safe' | 'unsafe'
}
export type SubmitProjectRequest = {
verdict: 'safe' | 'unsafe'
message?: string
}
export type SearchResponse = {
project_reports: ProjectReport[]
projects: Record<string, ProjectModerationInfo>
threads: Record<string, Thread>
ownership: Record<string, Ownership>
}
export type ProjectModerationInfo = {
id: string
thread_id: string
name: string
project_types: string[]
icon_url: string | null
} & Projects.v3.Project
export type ProjectReport = {
project_id: string
max_severity: DelphiSeverity | null
versions: VersionReport[]
}
export type VersionReport = {
version_id: string
files: FileReport[]
}
export type FileReport = {
report_id: string
file_id: string
created: string
flag_reason: FlagReason
severity: DelphiSeverity
file_name: string
file_size: number
download_url: string
issues: FileIssue[]
}
export type FileIssue = {
id: string
report_id: string
issue_type: string
details: ReportIssueDetail[]
}
export type ReportIssueDetail = {
id: string
issue_id: string
key: string
file_path: string
decompiled_source: string | null
data: Record<string, unknown>
severity: DelphiSeverity
status: DelphiReportIssueStatus
}
export type Ownership =
| {
kind: 'user'
id: string
name: string
icon_url?: string
}
| {
kind: 'organization'
id: string
name: string
icon_url?: string
}
export type DBThread = {
id: string
project_id?: string
report_id?: string
type_: ThreadType
messages: DBThreadMessage[]
members: string[]
}
export type DBThreadMessage = {
id: string
thread_id: string
author_id?: string
body: MessageBody
created: string
hide_identity: boolean
}
export type MessageBody =
| {
type: 'text'
body: string
private?: boolean
replying_to?: string
associated_images?: string[]
}
| {
type: 'status_change'
new_status: Projects.v2.ProjectStatus
old_status: Projects.v2.ProjectStatus
}
| {
type: 'thread_closure'
}
| {
type: 'thread_reopen'
}
| {
type: 'deleted'
private?: boolean
}
export type ThreadType = 'report' | 'project' | 'direct_message'
export type User = {
id: string
username: string
avatar_url: string
role: string
badges: number
created: string
bio?: string
}
export type ThreadMessage = {
id: string | null
author_id: string | null
body: MessageBody
created: string
hide_identity: boolean
}
export type Thread = {
id: string
type: ThreadType
project_id: string | null
report_id: string | null
messages: ThreadMessage[]
members: User[]
}
export type FlagReason = 'delphi'
export type DelphiSeverity = 'low' | 'medium' | 'high' | 'severe'
export type DelphiReportIssueStatus = 'pending' | 'safe' | 'unsafe'
}
}
}

View File

@@ -28,6 +28,7 @@ import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BracesIcon from './icons/braces.svg?component'
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
import _BugIcon from './icons/bug.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
@@ -35,6 +36,7 @@ import _ChartIcon from './icons/chart.svg?component'
import _CheckIcon from './icons/check.svg?component'
import _CheckCheckIcon from './icons/check-check.svg?component'
import _CheckCircleIcon from './icons/check-circle.svg?component'
import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
@@ -69,9 +71,12 @@ import _EyeIcon from './icons/eye.svg?component'
import _EyeOffIcon from './icons/eye-off.svg?component'
import _FileIcon from './icons/file.svg?component'
import _FileArchiveIcon from './icons/file-archive.svg?component'
import _FileCodeIcon from './icons/file-code.svg?component'
import _FileImageIcon from './icons/file-image.svg?component'
import _FileTextIcon from './icons/file-text.svg?component'
import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderIcon from './icons/folder.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
@@ -114,6 +119,7 @@ import _LinkIcon from './icons/link.svg?component'
import _ListIcon from './icons/list.svg?component'
import _ListBulletedIcon from './icons/list-bulleted.svg?component'
import _ListEndIcon from './icons/list-end.svg?component'
import _ListFilterIcon from './icons/list-filter.svg?component'
import _ListOrderedIcon from './icons/list-ordered.svg?component'
import _LoaderIcon from './icons/loader.svg?component'
import _LoaderCircleIcon from './icons/loader-circle.svg?component'
@@ -170,6 +176,8 @@ import _ServerPlusIcon from './icons/server-plus.svg?component'
import _SettingsIcon from './icons/settings.svg?component'
import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component'
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
import _ShieldCheckIcon from './icons/shield-check.svg?component'
import _SignalIcon from './icons/signal.svg?component'
import _SkullIcon from './icons/skull.svg?component'
import _SlashIcon from './icons/slash.svg?component'
@@ -246,6 +254,7 @@ export const BoxImportIcon = _BoxImportIcon
export const BoxIcon = _BoxIcon
export const BracesIcon = _BracesIcon
export const BrushCleaningIcon = _BrushCleaningIcon
export const BugIcon = _BugIcon
export const CalendarIcon = _CalendarIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
@@ -253,6 +262,7 @@ export const ChartIcon = _ChartIcon
export const CheckCheckIcon = _CheckCheckIcon
export const CheckCircleIcon = _CheckCircleIcon
export const CheckIcon = _CheckIcon
export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const CircleUserIcon = _CircleUserIcon
@@ -286,6 +296,8 @@ export const ExternalIcon = _ExternalIcon
export const EyeOffIcon = _EyeOffIcon
export const EyeIcon = _EyeIcon
export const FileArchiveIcon = _FileArchiveIcon
export const FileCodeIcon = _FileCodeIcon
export const FileImageIcon = _FileImageIcon
export const FileTextIcon = _FileTextIcon
export const FileIcon = _FileIcon
export const FilterXIcon = _FilterXIcon
@@ -293,6 +305,7 @@ export const FilterIcon = _FilterIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const FolderIcon = _FolderIcon
export const FolderUpIcon = _FolderUpIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
@@ -331,6 +344,7 @@ export const LightBulbIcon = _LightBulbIcon
export const LinkIcon = _LinkIcon
export const ListBulletedIcon = _ListBulletedIcon
export const ListEndIcon = _ListEndIcon
export const ListFilterIcon = _ListFilterIcon
export const ListOrderedIcon = _ListOrderedIcon
export const ListIcon = _ListIcon
export const LoaderCircleIcon = _LoaderCircleIcon
@@ -387,6 +401,8 @@ export const ServerPlusIcon = _ServerPlusIcon
export const ServerIcon = _ServerIcon
export const SettingsIcon = _SettingsIcon
export const ShareIcon = _ShareIcon
export const ShieldAlertIcon = _ShieldAlertIcon
export const ShieldCheckIcon = _ShieldCheckIcon
export const ShieldIcon = _ShieldIcon
export const SignalIcon = _SignalIcon
export const SkullIcon = _SkullIcon

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-icon lucide-bug">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 628 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code-icon lucide-file-code">
<path
d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M10 12.5 8 15l2 2.5" />
<path d="m14 12.5 2 2.5-2 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-file-image-icon lucide-file-image">
<path
d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<circle cx="10" cy="12" r="2" />
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-icon lucide-folder">
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-filter-icon lucide-list-filter">
<path d="M2 5h20" />
<path d="M6 12h12" />
<path d="M9 19h6" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-shield-alert-icon lucide-shield-alert"
>
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
/>
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-shield-check-icon lucide-shield-check">
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/api-client": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {

View File

@@ -1,4 +1,5 @@
import type { ReportQuickReply } from '../types/reports'
import type { QuickReply } from '../types/quick-reply'
import type { ExtendedReport } from '../types/reports'
export default [
{
@@ -67,4 +68,4 @@ export default [
message: async () => (await import('./messages/reports/stale.md?raw')).default,
private: false,
},
] as ReadonlyArray<ReportQuickReply>
] as ReadonlyArray<QuickReply<ExtendedReport>>

View File

@@ -0,0 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import type { QuickReply } from '../types/quick-reply'
export interface TechReviewContext {
project: Labrinth.Projects.v3.Project
project_owner: Labrinth.TechReview.Internal.Ownership
reports: Labrinth.TechReview.Internal.FileReport[]
}
export default [] as ReadonlyArray<QuickReply<TechReviewContext>>

View File

@@ -4,10 +4,15 @@ export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as reportQuickReplies } from './data/report-quick-replies'
export {
type TechReviewContext,
default as techReviewQuickReplies,
} from './data/tech-review-quick-replies'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/messages'
export * from './types/nags'
export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './utils'

View File

@@ -0,0 +1,6 @@
export interface QuickReply<T = undefined> {
label: string
message: string | ((context: T) => Promise<string> | string)
shouldShow?: (context: T) => boolean
private?: boolean
}

View File

@@ -1,4 +1,4 @@
import type { DelphiReport, Project, Report, Thread, User, Version } from '@modrinth/utils'
import type { Project, Report, Thread, User, Version } from '@modrinth/utils'
export interface OwnershipTarget {
name: string
@@ -15,14 +15,3 @@ export interface ExtendedReport extends Report {
version?: Version
target?: OwnershipTarget
}
export interface ExtendedDelphiReport extends DelphiReport {
target?: OwnershipTarget
}
export interface ReportQuickReply {
label: string
message: string | ((report: ExtendedReport) => Promise<string> | string)
shouldShow?: (report: ExtendedReport) => boolean
private?: boolean
}

View File

@@ -8,7 +8,10 @@
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="icons[type]" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
<component
:is="getSeverityIcon(type)"
:class="['h-6 w-6 flex-none', iconClasses[type]]"
/>
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
@@ -25,7 +28,7 @@
</template>
<script setup lang="ts">
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
import { getSeverityIcon } from '../../utils'
withDefaults(
defineProps<{
@@ -53,10 +56,4 @@ const iconClasses = {
warning: 'text-brand-orange',
critical: 'text-brand-red',
}
const icons = {
info: InfoIcon,
warning: IssuesIcon,
critical: XCircleIcon,
}
</script>

View File

@@ -69,6 +69,14 @@
<XIcon aria-hidden="true" /> {{ formatMessage(messages.closedLabel) }}
</template>
<!-- Technical review verdicts -->
<template v-else-if="type === 'safe'">
<ShieldCheckIcon aria-hidden="true" /> {{ formatMessage(messages.safeLabel) }}
</template>
<template v-else-if="type === 'unsafe'">
<BugIcon aria-hidden="true" /> {{ formatMessage(messages.unsafeLabel) }}
</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
</span>
@@ -78,6 +86,7 @@
import {
ArchiveIcon,
BoxIcon,
BugIcon,
CalendarIcon,
CheckIcon,
EyeOffIcon,
@@ -86,6 +95,7 @@ import {
LockIcon,
ModrinthIcon,
ScaleIcon,
ShieldCheckIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
@@ -153,6 +163,10 @@ const messages = defineMessages({
id: 'omorphia.component.badge.label.returned',
defaultMessage: 'Returned',
},
safeLabel: {
id: 'omorphia.component.badge.label.safe',
defaultMessage: 'Pass',
},
scheduledLabel: {
id: 'omorphia.component.badge.label.scheduled',
defaultMessage: 'Scheduled',
@@ -165,6 +179,10 @@ const messages = defineMessages({
id: 'omorphia.component.badge.label.unlisted',
defaultMessage: 'Unlisted',
},
unsafeLabel: {
id: 'omorphia.component.badge.label.unsafe',
defaultMessage: 'Fail',
},
withheldLabel: {
id: 'omorphia.component.badge.label.withheld',
defaultMessage: 'Withheld',
@@ -204,6 +222,7 @@ defineProps<{
&.type--rejected,
&.type--returned,
&.type--failed,
&.type--unsafe,
&.red {
--badge-color: var(--color-red);
}
@@ -220,6 +239,7 @@ defineProps<{
&.type--admin,
&.type--processed,
&.type--approved-general,
&.type--safe,
&.green {
--badge-color: var(--color-green);
}

View File

@@ -1,29 +1,27 @@
<template>
<div
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
:class="{ 'max-h-32': isCollapsed }"
>
<div class="relative overflow-hidden">
<div
class="px-4 pt-4"
:class="{
'content-disabled pb-16': isCollapsed,
'pb-4': !isCollapsed,
}"
class="collapsible-region-content"
:class="{ open: !collapsed }"
:style="{ '--collapsed-height': collapsedHeight }"
>
<slot />
<div :class="{ 'pointer-events-none select-none pb-16': collapsed }">
<slot />
</div>
</div>
<div
v-if="isCollapsed"
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
></div>
v-if="collapsed"
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent"
:class="gradientTo"
/>
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
<ButtonStyled circular type="transparent">
<button class="flex items-center gap-1 text-xs" @click="toggleCollapsed">
<ExpandIcon v-if="isCollapsed" />
<button class="flex items-center gap-1 text-xs" @click="collapsed = !collapsed">
<ExpandIcon v-if="collapsed" />
<CollapseIcon v-else />
{{ isCollapsed ? expandText : collapseText }}
{{ collapsed ? expandText : collapseText }}
</button>
</ButtonStyled>
</div>
@@ -32,67 +30,51 @@
<script setup lang="ts">
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
withDefaults(
defineProps<{
initiallyCollapsed?: boolean
expandText?: string
collapseText?: string
collapsedHeight?: string
gradientTo?: string
}>(),
{
initiallyCollapsed: true,
expandText: 'Expand',
collapseText: 'Collapse',
collapsedHeight: '8rem',
gradientTo: 'to-surface-2',
},
)
const isCollapsed = ref(props.initiallyCollapsed)
function toggleCollapsed() {
isCollapsed.value = !isCollapsed.value
}
function setCollapsed(value: boolean) {
isCollapsed.value = value
}
defineExpose({
isCollapsed,
setCollapsed,
toggleCollapsed,
})
const collapsed = defineModel<boolean>('collapsed', { default: true })
</script>
<style lang="scss" scoped>
.content-disabled {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
<style scoped>
.collapsible-region-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s linear;
}
:deep(*) {
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
:deep(button),
:deep(input),
:deep(textarea),
:deep(select),
:deep(a),
:deep([tabindex]) {
tabindex: -1 !important;
}
:deep(*:focus) {
outline: none !important;
@media (prefers-reduced-motion) {
.collapsible-region-content {
transition: none !important;
}
}
.collapsible-region-content.open {
grid-template-rows: 1fr;
}
.collapsible-region-content > div {
overflow: hidden;
min-height: var(--collapsed-height);
transition: min-height 0.3s linear;
}
.collapsible-region-content.open > div {
min-height: 0;
}
</style>

View File

@@ -4,7 +4,7 @@
ref="triggerRef"
role="button"
tabindex="0"
class="max-h-[36px] relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
class="relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
:class="[
triggerClasses,
{
@@ -129,7 +129,7 @@ import {
watch,
} from 'vue'
export interface DropdownOption<T> {
export interface ComboboxOption<T> {
value: T
label: string
icon?: Component
@@ -145,19 +145,19 @@ const DROPDOWN_VIEWPORT_MARGIN = 8
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
opt: DropdownOption<T> | { type: 'divider' },
): opt is DropdownOption<T> {
opt: ComboboxOption<T> | { type: 'divider' },
): opt is ComboboxOption<T> {
return 'value' in opt
}
function isDivider<T>(opt: DropdownOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
function isDivider<T>(opt: ComboboxOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
return opt.type === 'divider'
}
const props = withDefaults(
defineProps<{
modelValue?: T
options: (DropdownOption<T> | { type: 'divider' })[]
options: (ComboboxOption<T> | { type: 'divider' })[]
placeholder?: string
disabled?: boolean
searchable?: boolean
@@ -187,7 +187,7 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: T]
select: [option: DropdownOption<T>]
select: [option: ComboboxOption<T>]
open: []
close: []
searchInput: [query: string]
@@ -204,6 +204,7 @@ const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const rafId = ref<number | null>(null)
const dropdownStyle = ref({
top: '0px',
@@ -225,9 +226,9 @@ const triggerClasses = computed(() => {
return classes
})
const selectedOption = computed<DropdownOption<T> | undefined>(() => {
const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is DropdownOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
(opt): opt is ComboboxOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
)
})
@@ -259,7 +260,7 @@ const filteredOptions = computed(() => {
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: DropdownOption<T> & { key: string }, index: number) {
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
return [
item.class,
{
@@ -368,11 +369,13 @@ async function openDropdown() {
setInitialFocus()
focusSearchInput()
startPositionTracking()
}
function closeDropdown() {
if (!isOpen.value) return
stopPositionTracking()
isOpen.value = false
searchQuery.value = ''
focusedIndex.value = -1
@@ -391,7 +394,7 @@ function handleTriggerClick() {
}
}
function handleOptionClick(option: DropdownOption<T>, index: number) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
focusedIndex.value = index
@@ -514,6 +517,21 @@ function handleWindowResize() {
}
}
function startPositionTracking() {
function track() {
updateDropdownPosition()
rafId.value = requestAnimationFrame(track)
}
rafId.value = requestAnimationFrame(track)
}
function stopPositionTracking() {
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
}
onClickOutside(
dropdownRef,
() => {
@@ -528,6 +546,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
stopPositionTracking()
})
watch(isOpen, (value) => {

View File

@@ -13,6 +13,7 @@ export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'

Some files were not shown because too many files have changed in this diff Show More