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>
@@ -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
@@ -10349,6 +10349,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.106",
|
||||
"url",
|
||||
"uuid 1.18.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">•</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">•</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1138
apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue
Normal 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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
227
apps/frontend/src/components/ui/thread/ThreadView.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
34
apps/labrinth/.sqlx/query-0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea.json
generated
Normal 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"
|
||||
}
|
||||
28
apps/labrinth/.sqlx/query-2d9e36c76a1e214c53d9dc2aa3debe1d03998be169a306b63a0ca1beaa07397f.json
generated
Normal 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"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-3240e4b5abc9850b5d3c09fafcac71674941487c15be1e8ce0ebc78e7c26b34d.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
29
apps/labrinth/.sqlx/query-3473715e4ff6efb6707f73e8ddf19ef7bcbb341c7ffea3d13acd250bb20e6d07.json
generated
Normal 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"
|
||||
}
|
||||
49
apps/labrinth/.sqlx/query-3961aa17ce3219c057c398dca0ed3aaeb30a8da4721959fdee99cf649a8b29e3.json
generated
Normal 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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-3e2804a3443239104b2d8b095941fe1472402338e0f0bb323b6147d2a0cc4eca.json
generated
Normal 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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-52ef6d02f8d533fc4e4ceb141d07a2eb115dc88da24735fffeca3eb1c269ad53.json
generated
Normal 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"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-6cf1862b3c197d42f9183dcbbd3d07b7d42c37e089403961ee16be0f99958ea0.json
generated
Normal 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"
|
||||
}
|
||||
37
apps/labrinth/.sqlx/query-6f5ec5cee9fc0007d11b4707b4442917689c31af7dd9a6baea4dbde99dc1a08e.json
generated
Normal file
22
apps/labrinth/.sqlx/query-7d1f49699e242f3e002afee9bf466b6696052ac6d5ebe131b9e7242104f700af.json
generated
Normal 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"
|
||||
}
|
||||
14
apps/labrinth/.sqlx/query-9ab1f07c2968b5d445752c1480345c1fa3af3a899b232482aab9cc44b9336063.json
generated
Normal 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"
|
||||
}
|
||||
26
apps/labrinth/.sqlx/query-b1df83f4592701f8aa03f6d16bac9e2bd27ac9a87987eafd79b06f1c4ecdb659.json
generated
Normal 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"
|
||||
}
|
||||
39
apps/labrinth/.sqlx/query-b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4.json
generated
Normal 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"
|
||||
}
|
||||
14
apps/labrinth/.sqlx/query-c7c72cf1f98cbc2b647ab840bdfadf1de8aaf214b32a2aab299a0d87fd2dc453.json
generated
Normal 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"
|
||||
}
|
||||
28
apps/labrinth/.sqlx/query-cd630ba950611b387fb5b04999a061d930ff06a8a928ff1cea6a723bb37c1b75.json
generated
Normal 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"
|
||||
}
|
||||
26
apps/labrinth/.sqlx/query-cfe6c9e2abba8e9c1cd7aa799a6a95f2732f1a7611ea6f7ce49cd7e077761ebf.json
generated
Normal 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"
|
||||
}
|
||||
24
apps/labrinth/.sqlx/query-d30290c1b55d9fb0939d122a96f350233d40ad81ac2d16481a0e9b32424a999d.json
generated
Normal 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"
|
||||
}
|
||||
37
apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json
generated
Normal 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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-f6432d7a3c67e058c0e9da42f23ea29fa063b416c18dc857132127db95ff17f3.json
generated
Normal 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"
|
||||
}
|
||||
20
apps/labrinth/.sqlx/query-fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3.json
generated
Normal 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"
|
||||
}
|
||||
90
apps/labrinth/fixtures/delphi-report-2025-11-15.sql
Normal file
47
apps/labrinth/migrations/20250810155316_delphi-reports.sql
Normal 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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE delphi_reports
|
||||
ADD COLUMN status delphi_report_issue_status NOT NULL DEFAULT 'pending';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
266
apps/labrinth/src/database/models/delphi_report_item.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
)?;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
423
apps/labrinth/src/routes/internal/delphi.rs
Normal 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,
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
84
apps/labrinth/src/routes/internal/moderation/ownership.rs
Normal 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)
|
||||
}
|
||||
894
apps/labrinth/src/routes/internal/moderation/tech_review.rs
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
124
packages/api-client/src/modules/labrinth/tech-review/internal.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
14
packages/assets/icons/bug.svg
Normal 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 |
4
packages/assets/icons/chevron-down.svg
Normal 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 |
8
packages/assets/icons/file-code.svg
Normal 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 |
9
packages/assets/icons/file-image.svg
Normal 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 |
5
packages/assets/icons/folder.svg
Normal 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 |
6
packages/assets/icons/list-filter.svg
Normal 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 |
18
packages/assets/icons/shield-alert.svg
Normal 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 |
7
packages/assets/icons/shield-check.svg
Normal 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 |
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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>>
|
||||
|
||||
11
packages/moderation/src/data/tech-review-quick-replies.ts
Normal 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>>
|
||||
@@ -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'
|
||||
|
||||
6
packages/moderation/src/types/quick-reply.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||