Merge branch 'main' into boris/search-index-fix

This commit is contained in:
aecsocket
2026-07-10 13:16:51 +01:00
89 changed files with 4124 additions and 972 deletions
@@ -269,16 +269,31 @@ provideInstallationSettings({
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
if (loader === 'fabric' || loader === 'quilt') {
const result = manifest.gameVersions[0]?.loaders ?? []
debug('resolveLoaderVersions: fabric/quilt result', {
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
if (entry?.versionGroup) {
const result =
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
debug('resolveLoaderVersions: version group result', {
loader,
gameVersion,
versionGroup: entry.versionGroup,
count: result.length,
})
return result
}
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
if (placeholder) {
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
? placeholder.loaders
: []
debug('resolveLoaderVersions: placeholder result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
const result = entry?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
@@ -1,4 +1,4 @@
import { UpdatedIcon } from '@modrinth/assets'
import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
import {
defineMessages,
type PopupNotificationButton,
@@ -15,9 +15,11 @@ import {
install_job_dismiss,
install_job_list,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
@@ -31,6 +33,14 @@ const messages = defineMessages({
id: 'app.action-bar.install.retry',
defaultMessage: 'Retry',
},
copyDetails: {
id: 'app.action-bar.install.copy-details',
defaultMessage: 'Copy details',
},
copied: {
id: 'app.action-bar.install.copied-details',
defaultMessage: 'Copied',
},
dismiss: {
id: 'app.action-bar.install.dismiss',
defaultMessage: 'Dismiss',
@@ -39,22 +49,6 @@ const messages = defineMessages({
id: 'app.action-bar.install.open-instance',
defaultMessage: 'Open instance',
},
installFailed: {
id: 'app.action-bar.install.failed',
defaultMessage: 'Install failed',
},
installFailedAppClosed: {
id: 'app.action-bar.install.failed-app-closed',
defaultMessage: 'Installation failed due to app closing.',
},
installFailedNetwork: {
id: 'app.action-bar.install.failed-network',
defaultMessage: 'Installation failed due to a network error.',
},
installFailedUnknown: {
id: 'app.action-bar.install.failed-unknown',
defaultMessage: 'Installation failed due to an unknown error.',
},
unknownInstance: {
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
@@ -64,7 +58,7 @@ const messages = defineMessages({
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.install.phase.preparing_instance',
defaultMessage: 'Preparing instance',
defaultMessage: 'Queued to install',
},
resolving_pack: {
id: 'app.install.phase.resolving_pack',
@@ -139,7 +133,97 @@ const javaStepMessages = defineMessages({
},
})
const failureSummaryMessages = defineMessages({
canceled: {
id: 'app.action-bar.install.summary.canceled',
defaultMessage: 'Canceled',
},
appClosed: {
id: 'app.action-bar.install.summary.app-closing',
defaultMessage: 'Canceled due to app closing',
},
downloadFailed: {
id: 'app.action-bar.install.summary.download-failed',
defaultMessage: "Download couldn't finish",
},
modrinthUnreachable: {
id: 'app.action-bar.install.summary.modrinth-unreachable',
defaultMessage: "Couldn't reach Modrinth",
},
packDownloadFailed: {
id: 'app.action-bar.install.summary.pack-download-failed',
defaultMessage: "Couldn't download pack",
},
badModpackFile: {
id: 'app.action-bar.install.summary.bad-modpack-file',
defaultMessage: "Couldn't read modpack",
},
invalidModpack: {
id: 'app.action-bar.install.summary.invalid-modpack',
defaultMessage: 'Modpack data invalid',
},
contentDownloadFailed: {
id: 'app.action-bar.install.summary.content-download-failed',
defaultMessage: "Couldn't download files",
},
corruptDownload: {
id: 'app.action-bar.install.summary.corrupt-download',
defaultMessage: 'Downloaded file is corrupt',
},
invalidModpackFiles: {
id: 'app.action-bar.install.summary.invalid-modpack-files',
defaultMessage: 'Modpack files have invalid metadata',
},
noWritePermission: {
id: 'app.action-bar.install.summary.no-write-permission',
defaultMessage: 'No permission to write',
},
couldNotSaveFiles: {
id: 'app.action-bar.install.summary.could-not-save-files',
defaultMessage: "Couldn't save files",
},
invalidFilePath: {
id: 'app.action-bar.install.summary.invalid-file-path',
defaultMessage: 'File path is invalid',
},
instanceNotFound: {
id: 'app.action-bar.install.summary.instance-not-found',
defaultMessage: "Instance couldn't be found",
},
cleanupIncomplete: {
id: 'app.action-bar.install.summary.cleanup-incomplete',
defaultMessage: "Cleanup didn't finish",
},
javaSetupFailed: {
id: 'app.action-bar.install.summary.java-setup-failed',
defaultMessage: "Java setup couldn't finish",
},
minecraftSetupFailed: {
id: 'app.action-bar.install.summary.minecraft-setup-failed',
defaultMessage: 'Minecraft setup failed',
},
loaderSetupFailed: {
id: 'app.action-bar.install.summary.loader-setup-failed',
defaultMessage: 'Loader setup failed',
},
localDataError: {
id: 'app.action-bar.install.summary.local-data-error',
defaultMessage: "Couldn't update local data",
},
unexpectedError: {
id: 'app.action-bar.install.summary.unexpected-error',
defaultMessage: 'Something went wrong',
},
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
@@ -156,10 +240,13 @@ export async function useInstallJobNotifications(opts: {
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const copiedJobIds = ref<Set<string>>(new Set())
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
@@ -174,13 +261,7 @@ export async function useInstallJobNotifications(opts: {
function getText(job: InstallJobSnapshot): string {
if (job.status === 'failed' || job.status === 'interrupted') {
if (job.error?.code === 'interrupted') {
return formatMessage(messages.installFailedAppClosed)
}
if (job.error?.code === 'network_error') {
return formatMessage(messages.installFailedNetwork)
}
return formatMessage(messages.installFailedUnknown)
return getFailureSummary(job)
}
if (job.phase === 'preparing_java' && job.details.type === 'java') {
return formatMessage(javaStepMessages[job.details.step], {
@@ -190,6 +271,104 @@ export async function useInstallJobNotifications(opts: {
return formatMessage(phaseMessages[job.phase])
}
function getFailureSummary(job: InstallJobSnapshot): string {
const code = job.error?.code
const phase = job.error?.phase ?? job.phase
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
return formatMessage(failureSummaryMessages.appClosed)
}
if (code === 'canceled') {
return formatMessage(failureSummaryMessages.canceled)
}
if (job.rollback_error || code === 'rollback_error') {
return formatMessage(failureSummaryMessages.cleanupIncomplete)
}
if (hasPermissionError(job)) {
return formatMessage(failureSummaryMessages.noWritePermission)
}
switch (code) {
case 'network_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.downloadFailed,
)
case 'api_error':
return formatMessage(failureSummaryMessages.modrinthUnreachable)
case 'pack_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.invalidModpack,
)
case 'archive_error':
return formatMessage(failureSummaryMessages.badModpackFile)
case 'parse_error':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'content_error':
return formatMessage(failureSummaryMessages.invalidModpackFiles)
case 'hash_error':
return formatMessage(failureSummaryMessages.corruptDownload)
case 'filesystem_error':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'path_error':
return formatMessage(failureSummaryMessages.invalidFilePath)
case 'instance_error':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'java_error':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'loader_error':
case 'processor_error':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'database_error':
return formatMessage(failureSummaryMessages.localDataError)
case 'launcher_error':
case 'metadata_error':
return getFailureSummaryForPhase(phase)
default:
return getFailureSummaryForPhase(phase)
}
}
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
switch (phase) {
case 'downloading_pack_file':
return formatMessage(failureSummaryMessages.packDownloadFailed)
case 'resolving_pack':
case 'reading_pack_manifest':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'downloading_content':
return formatMessage(failureSummaryMessages.contentDownloadFailed)
case 'extracting_overrides':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'resolving_minecraft':
case 'downloading_minecraft':
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
case 'resolving_loader':
case 'running_loader_processors':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'preparing_java':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'preparing_instance':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'rolling_back':
return formatMessage(failureSummaryMessages.cleanupIncomplete)
default:
return formatMessage(failureSummaryMessages.unexpectedError)
}
}
function hasPermissionError(job: InstallJobSnapshot): boolean {
const message = job.error?.message.toLowerCase() ?? ''
return (
message.includes('permission denied') ||
message.includes('access is denied') ||
message.includes('operation not permitted')
)
}
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
if (!getEffectiveProgress(job)) return undefined
if (
@@ -235,11 +414,165 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
if (!isTerminalJob(job)) return undefined
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
{
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
}
function isCopied(job: InstallJobSnapshot): boolean {
return copiedJobIds.value.has(job.job_id)
}
function setCopied(job: InstallJobSnapshot) {
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
const existingTimeout = copiedResetTimeouts.get(job.job_id)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
}
copiedResetTimeouts.set(
job.job_id,
window.setTimeout(() => {
copiedResetTimeouts.delete(job.job_id)
if (!copiedJobIds.value.has(job.job_id)) {
return
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(job.job_id)
copiedJobIds.value = nextCopiedJobIds
opts.onChange()
}, 1_000),
)
opts.onChange()
}
async function copyJobDetails(job: InstallJobSnapshot) {
const details = await install_job_support_details(job.job_id).catch((error) => {
opts.handleError(error)
return null
})
if (!details) {
return
}
try {
await navigator.clipboard.writeText(details)
setCopied(job)
} catch (error) {
opts.handleError(error)
}
}
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
const buttons: PopupNotificationButton[] = []
if (isTerminalJob(job)) {
buttons.push({
label: formatMessage(messages.retry),
icon: UpdatedIcon,
color: 'brand',
@@ -248,8 +581,23 @@ export async function useInstallJobNotifications(opts: {
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
},
},
]
})
}
if (shouldShowCopyDetails(job)) {
const copied = isCopied(job)
buttons.push({
label: formatMessage(copied ? messages.copied : messages.copyDetails),
icon: copied ? CheckIcon : CopyIcon,
color: 'standard',
keepOpen: true,
action: async () => {
await copyJobDetails(job)
},
})
}
return buttons
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
@@ -259,13 +607,15 @@ export async function useInstallJobNotifications(opts: {
}
}
jobs.value = nextJobs
.filter((job) => visibleJobStatuses.has(job.status))
.sort(
(a, b) =>
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
getJobSortRank(a) - getJobSortRank(b) ||
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
}
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
@@ -284,7 +634,7 @@ export async function useInstallJobNotifications(opts: {
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getTerminalButtons(job),
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
@@ -382,6 +732,14 @@ export async function useInstallJobNotifications(opts: {
progressItems,
buttons,
refresh,
dispose: () => unlisten(),
dispose: () => {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
}
+36 -1
View File
@@ -85,6 +85,36 @@ export type InstallJavaStep =
| 'extracting'
| 'validating'
export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
api?: {
error: string
status?: number | null
method?: string | null
url?: string | null
route?: string | null
} | null
context?: {
operation: string
source_path?: string | null
target_path?: string | null
file_path?: string | null
entry_path?: string | null
urls?: string[]
expected_hash?: string | null
expected_size?: number | null
project_id?: string | null
version_id?: string | null
minecraft_version?: string | null
loader?: string | null
java_version?: number | null
os?: string | null
arch?: string | null
} | null
}
export interface InstallJobSnapshot {
job_id: string
instance_id?: string | null
@@ -114,7 +144,8 @@ export interface InstallJobSnapshot {
}
| { type: 'import'; launcher_type: string; instance_folder: string }
display?: { title: string; icon?: string | null } | null
error?: { code: string; message: string } | null
error?: InstallErrorView | null
rollback_error?: InstallErrorView | null
created: string
modified: string
finished?: string | null
@@ -197,6 +228,10 @@ export async function install_job_dismiss(jobId: string) {
return await invoke<void>('plugin:install|install_job_dismiss', { jobId })
}
export async function install_job_support_details(jobId: string) {
return await invoke<string>('plugin:install|install_job_support_details', { jobId })
}
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
return job.instance_id ?? job.target.instance_id ?? null
}
+7
View File
@@ -133,11 +133,18 @@ type Hooks = {
type Manifest = {
gameVersions: ManifestGameVersion[]
versionGroups?: ManifestVersionGroup[]
}
type ManifestGameVersion = {
id: string
stable: boolean
versionGroup?: string
loaders: ManifestLoaderVersion[]
}
type ManifestVersionGroup = {
id: string
loaders: ManifestLoaderVersion[]
}
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Zavřít"
},
"app.action-bar.install.failed": {
"message": "Instalace selhala"
},
"app.action-bar.install.failed-app-closed": {
"message": "Instalace selhala kvůli zavření aplikace."
},
"app.action-bar.install.failed-network": {
"message": "Instalace selhala kvůli chybě sítě."
},
"app.action-bar.install.failed-unknown": {
"message": "Instalace selhala kvůli neznámé chybě."
},
"app.action-bar.install.open-instance": {
"message": "Otevřít instanci"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Ignorieren"
},
"app.action-bar.install.failed": {
"message": "Installation fehlgeschlagen"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation aufgrund des Schließens der App fehlgeschlagen."
},
"app.action-bar.install.failed-network": {
"message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen."
},
"app.action-bar.install.open-instance": {
"message": "Instanz öffnen"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Ausblenden"
},
"app.action-bar.install.failed": {
"message": "Installation fehlgeschlagen"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation aufgrund des Schließens der App fehlgeschlagen."
},
"app.action-bar.install.failed-network": {
"message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen."
},
"app.action-bar.install.open-instance": {
"message": "Instanz öffnen"
},
+67 -13
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Hide more running instances"
},
"app.action-bar.install.copied-details": {
"message": "Copied"
},
"app.action-bar.install.copy-details": {
"message": "Copy details"
},
"app.action-bar.install.dismiss": {
"message": "Dismiss"
},
"app.action-bar.install.failed": {
"message": "Install failed"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation failed due to app closing."
},
"app.action-bar.install.failed-network": {
"message": "Installation failed due to a network error."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation failed due to an unknown error."
},
"app.action-bar.install.open-instance": {
"message": "Open instance"
},
"app.action-bar.install.retry": {
"message": "Retry"
},
"app.action-bar.install.summary.app-closing": {
"message": "Canceled due to app closing"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Couldn't read modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Canceled"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Cleanup didn't finish"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Couldn't download files"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Downloaded file is corrupt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Couldn't save files"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download couldn't finish"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instance couldn't be found"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "File path is invalid"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Modpack data invalid"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Modpack files have invalid metadata"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java setup couldn't finish"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Loader setup failed"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Couldn't update local data"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft setup failed"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Couldn't reach Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "No permission to write"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Couldn't download pack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Something went wrong"
},
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
@@ -231,7 +285,7 @@
"message": "Finalizing"
},
"app.install.phase.preparing_instance": {
"message": "Preparing instance"
"message": "Queued to install"
},
"app.install.phase.preparing_java": {
"message": "Preparing Java"
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
"app.action-bar.install.failed": {
"message": "La instalación falló"
},
"app.action-bar.install.failed-app-closed": {
"message": "La instalación falló porque la aplicación se cerró."
},
"app.action-bar.install.failed-network": {
"message": "La instalación falló debido a un error de red."
},
"app.action-bar.install.failed-unknown": {
"message": "La instalación falló debido a un error desconocido."
},
"app.action-bar.install.open-instance": {
"message": "Abrir instancia"
},
@@ -14,15 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
"app.action-bar.install.failed": {
"message": "La instalación falló"
},
"app.action-bar.install.failed-app-closed": {
"message": "La instalación falló porque la aplicación se cerró."
},
"app.action-bar.install.failed-network": {
"message": "La instalación falló debido a un error de red."
},
"app.action-bar.make-primary-instance": {
"message": "Establecer como instancia principal"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Hylkää"
},
"app.action-bar.install.failed": {
"message": "Asennus epäonnistui"
},
"app.action-bar.install.failed-app-closed": {
"message": "Asennus epäonnistui sovelluksen sulkeutumisen vuoksi."
},
"app.action-bar.install.failed-network": {
"message": "Asennus epäonnistui verkkovirheen vuoksi."
},
"app.action-bar.install.failed-unknown": {
"message": "Asennus epäonnistui tuntemattoman virheen vuoksi."
},
"app.action-bar.install.open-instance": {
"message": "Avaa instanssi"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Rejeter"
},
"app.action-bar.install.failed": {
"message": "Échec de l'installation"
},
"app.action-bar.install.failed-app-closed": {
"message": "Échec de l'installation dû à la fermeture de l'app."
},
"app.action-bar.install.failed-network": {
"message": "Échec de l'installation dû à une erreur réseau."
},
"app.action-bar.install.failed-unknown": {
"message": "Échec de l'installation dû à une erreur inconnue."
},
"app.action-bar.install.open-instance": {
"message": "Ouvrir l'instance"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Ignora"
},
"app.action-bar.install.failed": {
"message": "Installazione fallita"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installazione fallita: l'app è stata chiusa."
},
"app.action-bar.install.failed-network": {
"message": "Installazione fallita: errore di rete."
},
"app.action-bar.install.failed-unknown": {
"message": "Installazione fallita: errore sconosciuto."
},
"app.action-bar.install.open-instance": {
"message": "Apri istanza"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Odrzuć"
},
"app.action-bar.install.failed": {
"message": "Instalacja nie powiodła się"
},
"app.action-bar.install.failed-app-closed": {
"message": "Instalacja nie powiodła się z powodu zamknięcia aplikacji."
},
"app.action-bar.install.failed-network": {
"message": "Instalacja nie powiodła się z powodu problemów z połączeniem."
},
"app.action-bar.install.failed-unknown": {
"message": "Instalacja nie powiodła się z nieznanych przyczyn."
},
"app.action-bar.install.open-instance": {
"message": "Otwórz instancję"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Dispensar"
},
"app.action-bar.install.failed": {
"message": "Instalação falha"
},
"app.action-bar.install.failed-app-closed": {
"message": "A instalação falhou devido ao aplicativo ter fechado."
},
"app.action-bar.install.failed-network": {
"message": "A instalação falhou devido a um erro na rede."
},
"app.action-bar.install.failed-unknown": {
"message": "A instalação falhou devido a um erro desconhecido."
},
"app.action-bar.install.open-instance": {
"message": "Abrir instância"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Закрыть"
},
"app.action-bar.install.failed": {
"message": "Установка не удалась"
},
"app.action-bar.install.failed-app-closed": {
"message": "Установка не удалась из-за закрытия приложения."
},
"app.action-bar.install.failed-network": {
"message": "Установка не удалась из-за сетевой ошибки."
},
"app.action-bar.install.failed-unknown": {
"message": "Установка не удалась из-за неизвестной ошибки."
},
"app.action-bar.install.open-instance": {
"message": "Открыть сборку"
},
@@ -1,10 +1,4 @@
{
"app.action-bar.install.failed": {
"message": "Neuspešna instalacija"
},
"app.action-bar.install.failed-app-closed": {
"message": "Neuspešna instalacija zbog zatvaranja aplikacije."
},
"app.auth-servers.unreachable.body": {
"message": "Minecraft serveri za autentifikaciju su možda trenutno nedostupni. Molimo vas da proverite vašu internet vezu i pokušajte ponovo kasnije."
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Avvisa"
},
"app.action-bar.install.failed": {
"message": "Installationen misslyckades"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installationen misslyckades för att appen stängdes."
},
"app.action-bar.install.failed-network": {
"message": "Installationen misslyckades av ett nätverksfel."
},
"app.action-bar.install.failed-unknown": {
"message": "Installationen misslyckades med ett okänt fel."
},
"app.action-bar.install.open-instance": {
"message": "Öppna instans"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Reddet"
},
"app.action-bar.install.failed": {
"message": "Yükleme yapılamadı"
},
"app.action-bar.install.failed-app-closed": {
"message": "Yükleme uygulama kapandığından yapılamadı."
},
"app.action-bar.install.failed-network": {
"message": "Yükleme ağ hatasından dolayı yapılamadı."
},
"app.action-bar.install.failed-unknown": {
"message": "Yükleme bilinmeyen bir hatadan yapılamadı."
},
"app.action-bar.install.open-instance": {
"message": "Kurulumu aç"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "忽略"
},
"app.action-bar.install.failed": {
"message": "安装失败"
},
"app.action-bar.install.failed-app-closed": {
"message": "由于应用关闭,安装失败。"
},
"app.action-bar.install.failed-network": {
"message": "由于网络错误,安装失败。"
},
"app.action-bar.install.failed-unknown": {
"message": "由于未知错误,安装失败。"
},
"app.action-bar.install.open-instance": {
"message": "打开实例"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "忽略"
},
"app.action-bar.install.failed": {
"message": "無法安裝"
},
"app.action-bar.install.failed-app-closed": {
"message": "由於應用程式關閉,無法安裝。"
},
"app.action-bar.install.failed-network": {
"message": "由於網路錯誤,無法安裝。"
},
"app.action-bar.install.failed-unknown": {
"message": "由於未知錯誤,無法安裝。"
},
"app.action-bar.install.open-instance": {
"message": "開啟實例"
},
+1
View File
@@ -157,6 +157,7 @@ fn main() {
"install_job_retry",
"install_job_cancel",
"install_job_dismiss",
"install_job_support_details",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
+6
View File
@@ -25,6 +25,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
install_job_retry,
install_job_cancel,
install_job_dismiss,
install_job_support_details,
])
.build()
}
@@ -169,3 +170,8 @@ pub async fn install_job_cancel(job_id: Uuid) -> Result<InstallJobSnapshot> {
pub async fn install_job_dismiss(job_id: Uuid) -> Result<()> {
Ok(theseus::install::dismiss_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_support_details(job_id: Uuid) -> Result<String> {
Ok(theseus::install::job_support_details(job_id).await?)
}
@@ -126,6 +126,9 @@
@download="emit('download')"
/>
</div>
<div v-else-if="showNoCompatibleVersions" class="pl-1 text-base text-primary" role="status">
{{ noCompatibleVersionsDescription }}
</div>
</template>
<script setup lang="ts">
@@ -174,6 +177,7 @@ const props = withDefaults(
versions?: Labrinth.Versions.v3.Version[]
dependencyDownloadFiles?: DownloadableFile[]
downloadDataLoaded?: boolean
versionsLoaded?: boolean
downloadReason?: CdnDownloadReason
initialGameVersion?: string | null
initialPlatform?: string | null
@@ -185,6 +189,7 @@ const props = withDefaults(
versions: () => [],
dependencyDownloadFiles: () => [],
downloadDataLoaded: false,
versionsLoaded: false,
downloadReason: 'standalone',
initialGameVersion: null,
initialPlatform: null,
@@ -409,6 +414,25 @@ const compatibleVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
return [defaultSelectedVersion.value, ...suggestedPreReleaseVersions.value]
})
const showNoCompatibleVersions = computed(() => {
return (
props.versionsLoaded &&
compatibleVersions.value.length === 0 &&
!!currentGameVersion.value &&
!!currentPlatform.value
)
})
const noCompatibleVersionsDescription = computed(() => {
const gameVersion = currentGameVersion.value
if (!gameVersion || !currentPlatform.value) return ''
return formatMessage(messages.noVersionsAvailable, {
gameVersion,
platform: currentPlatformText.value,
})
})
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
return (
compatibleVersions.value.find(
@@ -636,6 +660,10 @@ const messages = defineMessages({
id: 'project.download.no-game-versions-found',
defaultMessage: 'No game versions found',
},
noVersionsAvailable: {
id: 'project.download.no-versions-available',
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
},
platformUnsupportedTooltip: {
id: 'project.download.platform-unsupported-tooltip',
defaultMessage: '{title} does not support {platform} for {gameVersion}',
@@ -20,6 +20,7 @@
:versions="versions"
:dependency-download-files="dependencyDownloadFiles"
:download-data-loaded="downloadRowsLoaded"
:versions-loaded="versionsLoaded"
:download-reason="downloadReason"
:initial-game-version="initialGameVersion"
:initial-platform="initialPlatform"
@@ -255,6 +256,9 @@ const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
const versions = computed<Labrinth.Versions.v3.Version[]>(() =>
normalizeVersionsForDownload(versionsV3.value ?? []),
)
const versionsLoaded = computed(
() => versionsEnabled.value && !versionsV3Loading.value && Array.isArray(versionsV3.value),
)
const initialGameVersion = computed(() => {
const version = route.query.version
@@ -55,7 +55,18 @@
{{ formatTraceCount(trace.local_trace_count) }}
</p>
</div>
<Badge :type="trace.verdict" />
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Badge :type="trace.verdict" />
<ButtonStyled color="red">
<button
:disabled="removingTraceKeys.has(trace.detail_key)"
@click="removeGlobalTrace(trace)"
>
<TrashIcon aria-hidden="true" />
Remove
</button>
</ButtonStyled>
</div>
</div>
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
@@ -97,12 +108,13 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
} from '@modrinth/ui'
@@ -110,6 +122,7 @@ import {
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const query = ref('')
const activeQuery = ref<string | null>(null)
const isLoading = ref(false)
@@ -119,6 +132,7 @@ const itemsPerPage = 20
const localTracePreviewLimit = 10
const total = ref(0)
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
const removingTraceKeys = reactive<Set<string>>(new Set())
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
const pageStart = computed(() =>
@@ -176,5 +190,37 @@ async function switchPage(page: number) {
await loadTraces()
}
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
if (removingTraceKeys.has(trace.detail_key)) return
removingTraceKeys.add(trace.detail_key)
try {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: trace.detail_key, verdict: 'pending' },
])
addNotification({
type: 'success',
title: 'Global trace removed',
text: 'The global verdict for this trace key has been removed.',
})
if (traces.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
await loadTraces()
} catch (error) {
console.error('Failed to remove global trace', error)
addNotification({
type: 'error',
title: 'Failed to remove global trace',
text: 'An error occurred while removing the global trace verdict.',
})
} finally {
removingTraceKeys.delete(trace.detail_key)
}
}
onMounted(loadTraces)
</script>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BanIcon,
BugIcon,
CheckCheckIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
@@ -14,6 +16,7 @@ import {
EyeOffIcon,
LoaderCircleIcon,
ScaleIcon,
ShieldAlertIcon,
ShieldCheckIcon,
SpinnerIcon,
TimerIcon,
@@ -207,15 +210,31 @@ async function updateIssueDetails(
})
}
async function updateGlobalIssueDetail(
detailKey: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: detailKey, verdict },
])
}
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
type DetailDecision = 'safe' | 'malware'
type DetailDecision = 'safe' | 'malware' | 'pending'
type DetailDecisionScope = 'local' | 'global'
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
const updatingDetails = reactive<Set<string>>(new Set())
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
return verdict === 'safe' ? 'safe' : 'malware'
function verdictToDecision(
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
): DetailDecision {
if (verdict === 'safe') return 'safe'
if (verdict === 'unsafe') return 'malware'
return 'pending'
}
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
@@ -225,6 +244,7 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
function applyDecisionToRelatedDetails(
detailIds: string[],
decision: DetailDecision,
scope: DetailDecisionScope,
): { otherMatchedCount: number } {
const allDetails = getAllDetails()
const selectedDetailIds = new Set(detailIds)
@@ -242,12 +262,14 @@ function applyDecisionToRelatedDetails(
if (matchingDetails.length === 0) {
detailDecisions.set(detailId, decision)
detailDecisionScopes.set(detailId, scope)
updatedDetailIds.add(detailId)
continue
}
for (const matchingDetail of matchingDetails) {
detailDecisions.set(matchingDetail.id, decision)
detailDecisionScopes.set(matchingDetail.id, scope)
updatedDetailIds.add(matchingDetail.id)
}
}
@@ -258,6 +280,98 @@ function applyDecisionToRelatedDetails(
}
}
function statusMatchesDecision(
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
decision: DetailDecision,
): boolean {
if (status === 'safe') return decision === 'safe'
if (status === 'unsafe') return decision === 'malware'
return false
}
function isDetailActionSelected(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: DetailDecision,
scope: DetailDecisionScope,
): boolean {
const localDecision = detailDecisions.get(detail.id)
const localScope = detailDecisionScopes.get(detail.id)
if (localDecision && localScope) {
if (localDecision === 'pending') {
if (localScope === 'local') {
if (scope === 'local') return false
return statusMatchesDecision(detail.global_status, decision)
}
if (scope === 'global') return false
return statusMatchesDecision(detail.local_status, decision)
}
return localDecision === decision && localScope === scope
}
if (scope === 'global') {
return statusMatchesDecision(detail.global_status, decision)
}
if (detail.global_status) {
return false
}
return statusMatchesDecision(detail.local_status, decision)
}
function decisionToVerdict(
decision: Exclude<DetailDecision, 'pending'>,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return decision === 'safe' ? 'safe' : 'unsafe'
}
function getToggledDetailVerdict(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
}
function getDetailActionTooltip(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): string {
const action = decision === 'safe' ? 'pass' : 'fail'
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
return 'Global verdict unavailable for generated trace keys'
}
if (isDetailActionSelected(detail, decision, scope)) {
return `Unset ${scopeLabel.toLowerCase()} ${action}`
}
return `${scopeLabel} ${action}`
}
function updateLocalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
) {
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
}
function updateGlobalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
) {
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
}
function canUpdateGlobalDetail(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
}
function getFileHighestSeverity(
file: FlattenedFileReport,
): Labrinth.TechReview.Internal.DelphiSeverity {
@@ -495,6 +609,18 @@ const remainingUnmarkedCount = computed(() => {
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
})
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
return jarGroup.classes.flatMap((classItem) => classItem.flags)
}
function getJarMarkedCount(jarGroup: JarGroup): number {
return getMarkedFlagsCount(getJarFlags(jarGroup))
}
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
return getJarFlags(jarGroup).length - getJarMarkedCount(jarGroup)
}
const isBatchUpdating = ref(false)
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
@@ -518,7 +644,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
@@ -548,7 +674,54 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
}
}
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
async function batchMarkRemainingInJar(jarGroup: JarGroup, verdict: 'safe' | 'unsafe') {
if (isBatchUpdating.value) return
const detailIds = getJarFlags(jarGroup)
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
.map((flag) => flag.detail.id)
if (detailIds.length === 0) return
isBatchUpdating.value = true
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
title: `Marked ${detailIds.length} traces as ${verdict}`,
text: `All remaining traces in this JAR have been marked as ${
verdict === 'safe' ? 'false positives' : 'malicious'
}.`,
})
if (selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
emit('refetch')
} catch (error) {
console.error('Failed to batch update JAR traces:', error)
addNotification({
type: 'error',
title: 'Batch update failed',
text: 'An error occurred while updating JAR traces.',
})
} finally {
isBatchUpdating.value = false
}
}
async function updateDetailStatus(
detailId: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
outer: for (const report of props.item.reports) {
for (const issue of report.issues) {
@@ -568,10 +741,11 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detailId],
verdictToDecision(verdict),
'local',
)
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
if (priorDecision === 'pending') {
if (verdict !== 'pending' && priorDecision === 'pending') {
for (const classGroup of groupedByClass.value) {
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
@@ -582,7 +756,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
// Jump back to Files tab when all flags in the current file are marked
if (selectedFile.value) {
if (verdict !== 'pending' && selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
@@ -595,7 +769,13 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
: ''
if (verdict === 'safe') {
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Local trace verdict unset',
text: `The project-local verdict has been removed.${otherText}`,
})
} else if (verdict === 'safe') {
addNotification({
type: 'success',
title: 'Issue marked as pass',
@@ -622,6 +802,82 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
}
async function updateGlobalDetailStatus(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
if (!canUpdateGlobalDetail(detail)) {
addNotification({
type: 'error',
title: 'Global update unavailable',
text: 'Generated trace keys cannot be marked globally.',
})
return
}
updatingGlobalDetailKeys.add(detail.key)
try {
await updateGlobalIssueDetail(detail.key, verdict)
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detail.id],
verdictToDecision(verdict),
'global',
)
if (verdict !== 'pending') {
for (const classGroup of groupedByClass.value) {
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.key)
}
}
}
if (verdict !== 'pending' && selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
const otherText =
otherMatchedCount > 0
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
: ''
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Global trace verdict unset',
text: `The global verdict for this trace key has been removed.${otherText}`,
})
} else {
addNotification({
type: 'success',
title:
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
text:
verdict === 'safe'
? `This trace key has been marked as a global false positive.${otherText}`
: `This trace key has been globally flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update global detail status:', error)
addNotification({
type: 'error',
title: 'Failed to update global trace',
text: 'An error occurred while updating the global trace status.',
})
} finally {
updatingGlobalDetailKeys.delete(detail.key)
}
}
const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
@@ -1352,26 +1608,49 @@ function copyId() {
v-if="jarGroup.segments.length > 0"
class="border-b border-solid border-surface-1 px-4 py-3"
>
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</template>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</template>
</div>
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
<ButtonStyled color="brand" size="small">
<button
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
>
<CheckCircleIcon class="size-4" />
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</button>
</ButtonStyled>
<ButtonStyled color="red" size="small">
<button
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
>
<TriangleAlertIcon class="size-4" />
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -1470,38 +1749,94 @@ function copyId() {
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
<div class="detail-verdict-action-groups">
<div
class="detail-verdict-buttons"
role="group"
aria-label="Trace verdict actions"
>
<button
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'global')"
class="detail-verdict-button detail-verdict-button--safe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'safe',
'global',
),
}"
aria-label="Global pass"
:disabled="
!canUpdateGlobalDetail(flag.detail) ||
updatingGlobalDetailKeys.has(flag.detail.key) ||
updatingDetails.has(flag.detail.id)
"
@click="updateGlobalDetailAction(flag.detail, 'safe')"
>
Pass
<CheckCheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'local')"
class="detail-verdict-button detail-verdict-button--safe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'safe',
'local',
),
}"
aria-label="Local pass"
:disabled="
updatingDetails.has(flag.detail.id) ||
updatingGlobalDetailKeys.has(flag.detail.key)
"
@click="updateLocalDetailAction(flag.detail, 'safe')"
>
Fail
<CheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<button
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'local')"
class="detail-verdict-button detail-verdict-button--unsafe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'malware',
'local',
),
}"
aria-label="Local fail"
:disabled="
updatingDetails.has(flag.detail.id) ||
updatingGlobalDetailKeys.has(flag.detail.key)
"
@click="updateLocalDetailAction(flag.detail, 'malware')"
>
<BanIcon aria-hidden="true" />
</button>
<button
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'global')"
class="detail-verdict-button detail-verdict-button--unsafe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'malware',
'global',
),
}"
aria-label="Global fail"
:disabled="
!canUpdateGlobalDetail(flag.detail) ||
updatingGlobalDetailKeys.has(flag.detail.key) ||
updatingDetails.has(flag.detail.id)
"
@click="updateGlobalDetailAction(flag.detail, 'malware')"
>
<ShieldAlertIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
@@ -1609,4 +1944,90 @@ pre {
.fade-leave-to {
opacity: 0;
}
.detail-verdict-action-groups {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-end: 0.5rem;
}
.detail-verdict-buttons {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
}
.detail-verdict-button {
display: flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border: 0;
border-left: 1px solid var(--surface-5);
background: transparent;
padding: 0;
cursor: pointer;
transition:
background-color 0.15s ease-in-out,
filter 0.15s ease-in-out;
}
.detail-verdict-button:first-child {
border-left: 0;
border-start-start-radius: calc(var(--radius-md) - 1px);
border-end-start-radius: calc(var(--radius-md) - 1px);
}
.detail-verdict-button:last-child {
border-start-end-radius: calc(var(--radius-md) - 1px);
border-end-end-radius: calc(var(--radius-md) - 1px);
}
.detail-verdict-button:hover,
.detail-verdict-button:focus-visible {
background: var(--surface-4);
}
.detail-verdict-button--selected {
background: var(--color-green-bg);
box-shadow: inset 0 0 0 1px var(--color-green);
}
.detail-verdict-button--selected:hover,
.detail-verdict-button--selected:focus-visible {
background: var(--color-green-bg);
}
.detail-verdict-button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--color-brand);
}
.detail-verdict-button--selected:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-green);
}
.detail-verdict-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.detail-verdict-button svg {
width: 1rem;
height: 1rem;
}
.detail-verdict-button--safe {
color: var(--color-green);
}
.detail-verdict-button--unsafe {
color: var(--color-red);
}
</style>
@@ -54,15 +54,21 @@
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,
),
'system-message-icon': [
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(message.body.type),
}"
>
<ScaleIcon />
</div>
<span
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
v-if="
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
message.body.type,
)
"
class="message__author moderation-color"
>
Moderator
@@ -100,6 +106,9 @@
<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_exited'">
The project has left the technical review queue as all pending traces have been resolved.
</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.
@@ -214,9 +223,12 @@ 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,
)
[
'tech_review',
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(props.message.body.type)
)
})
@@ -3362,6 +3362,9 @@
"project.download.no-game-versions-found": {
"message": "No game versions found"
},
"project.download.no-versions-available": {
"message": "No versions available for {gameVersion} and {platform}."
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} does not support {platform} for {gameVersion}"
},
File diff suppressed because one or more lines are too long
@@ -33,7 +33,15 @@
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
</p>
</div>
<Badge :type="trace.verdict" />
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Badge :type="trace.verdict" />
<ButtonStyled color="red">
<button :disabled="isRemoving" @click="removeGlobalTrace">
<TrashIcon aria-hidden="true" />
Remove
</button>
</ButtonStyled>
</div>
</div>
<div
@@ -63,13 +71,22 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftIcon, HashIcon } from '@modrinth/assets'
import { Badge, ButtonStyled, EmptyState, injectModrinthClient, Pagination } from '@modrinth/ui'
import { ArrowLeftIcon, HashIcon, TrashIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
} from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const detailKey = computed(() => {
const key = route.params.key
@@ -80,6 +97,7 @@ useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
const localTracePageSize = 20
const isLoading = ref(false)
const isRemoving = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const pageStartCursors = ref<(string | null)[]>([null])
@@ -140,6 +158,34 @@ async function switchPage(page: number) {
await loadPage(page)
}
async function removeGlobalTrace() {
if (isRemoving.value) return
isRemoving.value = true
try {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: detailKey.value, verdict: 'pending' },
])
addNotification({
type: 'success',
title: 'Global trace removed',
text: 'The global verdict for this trace key has been removed.',
})
await router.push('/moderation/global-traces')
} catch (error) {
console.error('Failed to remove global detail trace', error)
addNotification({
type: 'error',
title: 'Failed to remove global trace',
text: 'An error occurred while removing the global trace verdict.',
})
} finally {
isRemoving.value = false
}
}
watch(
detailKey,
() => {
+4
View File
@@ -534,3 +534,7 @@ Xandr.com, 2398, DIRECT
#Rubicon
rubiconproject.com, 24584, DIRECT, 0bfd66d529a55807
rubiconproject.com, 24586, DIRECT, 0bfd66d529a55807
copper6.com, 916020, Reseller
openx.com, 563905670, RESELLER, 6a698e2ec38604c6
xandr.com, 16546, RESELLER
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = $1\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = $1\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "last_tech_review_message_type",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "080e2f0068c0c1c248dcfacf39798aefed70d9a61610fc7f4f1c8f291706fa4a"
}
@@ -1,28 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\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\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.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 ",
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\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\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'local_status', didws.local_status,\n 'global_status', didws.global_status,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.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": [
{
@@ -18,5 +18,5 @@
null
]
},
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
"hash": "48dfc2f2bcf8917f110b7b2b142167f1525cdea2ace9e6165e5c5f596cbd9fb3"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id AS \"thread_id: DBThreadId\"\n FROM threads\n WHERE mod_id = $1\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict::delphi_report_issue_status\n FROM incoming\n ORDER BY detail_key, ord DESC\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray"
]
},
"nullable": []
},
"hash": "6402f359bbf733a9a2173f23c2ea222611fde49f692406123145dc6acd3dcd6a"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n ),\n latest AS (\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict\n FROM incoming\n ORDER BY detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_global_detail_verdicts dgdv\n USING latest\n WHERE\n dgdv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT\n detail_key,\n verdict::delphi_report_issue_status\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray"
]
},
"nullable": []
},
"hash": "7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 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.project_id = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ) AS \"is_in_tech_review!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_in_tech_review!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.local_status AS \"local_status?: DelphiStatus\",\n didws.global_status AS \"global_status?: DelphiStatus\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
"describe": {
"columns": [
{
@@ -52,6 +52,38 @@
},
{
"ordinal": 7,
"name": "local_status?: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
},
{
"ordinal": 8,
"name": "global_status?: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
},
{
"ordinal": 9,
"name": "status!: DelphiStatus",
"type_info": {
"Custom": {
@@ -80,8 +112,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "951700c659d040068dae8e2f91b82b6a3af676439b47ff76d72000ddeca0e334"
"hash": "9070b1b6a5b1e93eb1fd1838c522fa6084ad6a5c86fafbfe8448ea7cc86f38ba"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT didws.project_id AS \"project_id!: DBProjectId\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.key = ANY($1::text[])\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id!: DBProjectId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": [
true
]
},
"hash": "ba7585f55df2a4596caae265ac956eb23268f26aef664234b898b17d13c09f91"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_global_detail_verdicts dgdv\n ON dgdv.detail_key = incoming.detail_key\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_unflagged_issue_details!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "bab0f71c84902bac7589a30923e95cbcd76d340c21ac6116d9bd27878786bd26"
}
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH project_ids AS (\n SELECT unnest($1::bigint[]) AS project_id\n )\n SELECT\n p.project_id AS \"project_id!: DBProjectId\",\n EXISTS(\n SELECT 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.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type != $3\n ) AS \"has_pending_detail!\",\n EXISTS(\n SELECT 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.project_id = p.project_id\n AND didws.status = 'unsafe'\n AND dri.issue_type != $3\n ) AS \"has_unsafe_detail!\",\n EXISTS(\n SELECT 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.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type = $3\n ) AS \"has_dummy!\",\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = p.project_id\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = p.project_id\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\",\n (\n SELECT dr.id\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n WHERE v.mod_id = p.project_id\n ORDER BY dr.created DESC, dr.id DESC\n LIMIT 1\n ) AS \"report_id: DelphiReportId\"\n FROM project_ids p\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id!: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "has_pending_detail!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "has_unsafe_detail!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "has_dummy!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "last_tech_review_message_type",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "report_id: DelphiReportId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"TextArray",
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30"
}
@@ -1,6 +1,6 @@
{
"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 coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n ) AS \"data!: sqlx::types::Json<FileIssue>\"\n FROM delphi_report_issues dri\n WHERE dri.id = $1\n ",
"query": "\n SELECT\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'local_status', didws.local_status,\n 'global_status', didws.global_status,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.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": [
{
@@ -18,5 +18,5 @@
null
]
},
"hash": "112bc4904c32afc9ffba30528a9c0e7b7fe259c58411afa5ef86a7ae4bde2703"
"hash": "c3598ed9f64f7151b83d47a15fabfdcf015bda1b3c0a7f2358c7f284feb532c3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT didws.project_id AS \"project_id!: DBProjectId\"\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 = ANY($1::bigint[])\n AND dri.issue_type != '__dummy'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id!: DBProjectId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
true
]
},
"hash": "e3c356bd41074ba4b3474dfab5cc0349636abea5b21faf5fc18d9f69fd21db1d"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH dummy_issue AS (\n INSERT INTO delphi_report_issues (report_id, issue_type)\n VALUES ($1, $2)\n ON CONFLICT (report_id, issue_type)\n DO UPDATE SET issue_type = EXCLUDED.issue_type\n RETURNING id\n )\n INSERT INTO delphi_report_issue_details (\n issue_id,\n key,\n jar,\n file_path,\n decompiled_source,\n data,\n severity\n )\n SELECT\n id,\n '',\n NULL,\n '',\n NULL,\n '{}'::jsonb,\n 'low'::delphi_severity\n FROM dummy_issue\n WHERE NOT EXISTS (\n SELECT 1\n FROM delphi_report_issue_details drid\n WHERE drid.issue_id = dummy_issue.id\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "fc6c20348de487e4039ee7d53212ecd1856bde7aa4be6e366c98cef1321a7279"
}
@@ -0,0 +1,20 @@
DROP VIEW delphi_issue_details_with_statuses;
CREATE VIEW delphi_issue_details_with_statuses AS
SELECT
drid.*,
m.id AS project_id,
didv.verdict AS local_status,
dgdv.verdict AS global_status,
COALESCE(dgdv.verdict, 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_global_detail_verdicts dgdv
ON drid.key = dgdv.detail_key
LEFT JOIN delphi_issue_detail_verdicts didv
ON m.id = didv.project_id
AND drid.key = didv.detail_key;
@@ -254,6 +254,10 @@ pub struct ReportIssueDetail {
pub data: HashMap<String, serde_json::Value>,
/// How important is this issue, as flagged by Delphi?
pub severity: DelphiSeverity,
/// Project-local verdict for this detail, if one exists.
pub local_status: Option<DelphiStatus>,
/// Global verdict for this detail's key, if one exists.
pub global_status: Option<DelphiStatus>,
/// Has this issue detail been marked as safe or unsafe?
pub status: DelphiStatus,
}
+8
View File
@@ -114,6 +114,14 @@ impl From<crate::models::v3::threads::MessageBody> for LegacyMessageBody {
associated_images: Vec::new(),
}
}
crate::models::v3::threads::MessageBody::TechReviewExited => {
LegacyMessageBody::Text {
body: "(legacy) Exited 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(),
+6 -1
View File
@@ -28,8 +28,11 @@ pub struct ThreadMessage {
pub hide_identity: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, strum::AsRefStr,
)]
#[serde(tag = "type", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum MessageBody {
Text {
body: String,
@@ -47,6 +50,7 @@ pub enum MessageBody {
verdict: DelphiVerdict,
},
TechReviewEntered,
TechReviewExited,
TechReviewExitFileDeleted,
ThreadClosure,
ThreadReopen,
@@ -62,6 +66,7 @@ impl MessageBody {
Self::Text { private, .. } | Self::Deleted { private } => *private,
Self::TechReview { .. }
| Self::TechReviewEntered
| Self::TechReviewExited
| Self::TechReviewExitFileDeleted => true,
Self::StatusChange { .. }
| Self::ThreadClosure
+12 -182
View File
@@ -13,20 +13,18 @@ use crate::{
auth::check_is_moderator_from_headers,
database::{
models::{
DBFileId, DBProjectId, DBThreadId, DelphiReportId,
DelphiReportIssueDetailsId, DelphiReportIssueId,
DBFileId, DBProjectId, 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,
@@ -34,6 +32,7 @@ use crate::{
};
pub mod rescan;
pub mod tech_review_sync;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(
@@ -211,107 +210,6 @@ async fn ingest_report_deserialized(
"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")?;
let issue_detail_keys = report
.issues
.values()
.flatten()
.map(|issue_detail| issue_detail.key.0.clone())
.collect::<Vec<_>>();
let has_unflagged_issue_details = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1
FROM unnest($2::text[]) AS incoming(detail_key)
LEFT JOIN delphi_global_detail_verdicts dgdv
ON dgdv.detail_key = incoming.detail_key
LEFT JOIN delphi_issue_detail_verdicts didv
ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key
WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL
) AS "has_unflagged_issue_details!"
"#,
DBProjectId::from(report.project_id) as _,
&issue_detail_keys
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("failed to check if report has unflagged issue details")?;
let should_enter_tech_review = !record.pending_issue_details_exist
&& has_unflagged_issue_details.has_unflagged_issue_details;
if should_enter_tech_review {
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")?;
} else {
info!(
"File's project is not entering tech review queue (already pending or no new unflagged issue details)"
);
}
// 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.
if should_enter_tech_review {
let dummy_issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
report_id,
issue_type: "__dummy".into(),
}
.upsert(&mut transaction)
.await
.wrap_internal_err("failed to upsert dummy Delphi report issue")?;
ReportIssueDetail {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id: dummy_issue_id,
key: "".into(),
jar: None,
file_path: "".into(),
decompiled_source: None,
data: HashMap::new(),
severity: DelphiSeverity::Low,
status: DelphiStatus::Pending,
}
.insert(&mut transaction)
.await
.wrap_internal_err(
"failed to insert dummy Delphi report issue detail",
)?;
}
for (issue_type, issue_details) in report.issues {
let issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
@@ -340,6 +238,8 @@ async fn ingest_report_deserialized(
decompiled_source: decompiled_source.cloned().flatten(),
data: issue_detail.data,
severity: issue_detail.severity,
local_status: None,
global_status: None,
status: DelphiStatus::Pending,
}
.insert(&mut transaction)
@@ -348,6 +248,13 @@ async fn ingest_report_deserialized(
}
}
tech_review_sync::sync_project_tech_review_state(
&[DBProjectId::from(report.project_id)],
tech_review_sync::TechReviewExitReason::Resolved,
&mut transaction,
)
.await?;
transaction
.commit()
.await
@@ -397,83 +304,6 @@ pub async fn run(
Ok(HttpResponse::NoContent().finish())
}
pub async fn is_project_in_tech_review(
project_id: DBProjectId,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, ApiError> {
let row = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.project_id = $1
AND didws.status = 'pending'
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
) AS "is_in_tech_review!"
"#,
project_id as _,
)
.fetch_one(exec)
.await
.wrap_internal_err("failed to fetch project tech review state")?;
Ok(row.is_in_tech_review)
}
pub async fn send_tech_review_exit_file_deleted_message(
project_id: DBProjectId,
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<(), ApiError> {
let thread = sqlx::query!(
r#"
SELECT id AS "thread_id: DBThreadId"
FROM threads
WHERE mod_id = $1
LIMIT 1
"#,
project_id as _,
)
.fetch_optional(&mut *txn)
.await
.wrap_internal_err("failed to fetch thread for tech review exit message")?;
if let Some(thread) = thread {
ThreadMessageBuilder {
author_id: None,
body: MessageBody::TechReviewExitFileDeleted,
thread_id: thread.thread_id,
hide_identity: false,
}
.insert(txn)
.await
.wrap_internal_err("failed to add tech review exit message")?;
}
Ok(())
}
pub async fn send_tech_review_exit_file_deleted_message_if_exited(
project_id: DBProjectId,
was_in_tech_review: bool,
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<(), ApiError> {
if !was_in_tech_review {
return Ok(());
}
let is_still_in_tech_review =
is_project_in_tech_review(project_id, &mut *txn).await?;
if !is_still_in_tech_review {
send_tech_review_exit_file_deleted_message(project_id, txn).await?;
}
Ok(())
}
/// Run Delphi.
#[utoipa::path(
context_path = "/delphi",
@@ -0,0 +1,411 @@
//! Synchronizes moderation thread messages and dummy queue blockers with the
//! current computed tech review state of affected projects.
//!
//! When a project has a Delphi report submitted for it, or when a moderator
//! updates one of its issue details' rows (like flagging a detail as *globally*
//! safe or unsafe), we will need to recheck if the project still belongs in the
//! tech review queue or if it needs to exit now.
//!
//! Side-note: "entering the queue" or "exiting the queue" right now just means
//! adding a new message to the project's moderation thread which indicates if
//! it entered/exited. In the future this should be replaced with a more proper
//! audit log table, or "is project currently in tech review" table.
//!
//! A project is considered to need tech review when it has at least one
//! non-dummy issue detail whose effective status is pending or unsafe, or when
//! it already has a dummy pending detail blocking the final review submission.
//! Effective status is just the local detail's verdict (from
//! `delphi_issue_detail_verdicts`), or if it's null then the global verdict for
//! the same `drid.key` (from `delphi_global_detail_verdicts`).
//!
//! Some examples of how this behavior manifests: let's assume you have projects
//! _A_ and _B_ currently in tech review. They each have one (unresolved) issue
//! detail with key _K_.
//! - If you mark _K_ on _A_ as locally safe/unsafe, then _A_ is fully resolved,
//! but we still have the `__dummy` detail, which means it's still in the
//! queue until the moderator submits the actual report. _B_ is entirely
//! unaffected.
//! - If you mark _K_ on _A_ as globally safe, then _A_ and _B_ both get fully
//! resolved, but both still have the `__dummy` detail, so they also still
//! need the final report to be submitted by the moderator.
//!
//! In practice, this means that some projects may have e.g. "100/100 traces
//! are safe" reported, but they will just be waiting for final moderator
//! approval.
//!
//! The logic for checking whether a project is now in tech review or not, and
//! sending the appropriate message, is complex! That's why this module exists:
//! to act as a single chokepoint which (correctly) syncs all the state, instead
//! of having each mutation run its own ad-hoc update logic.
use itertools::Itertools;
use crate::{
database::{
PgTransaction,
models::{
DBProjectId, DBThreadId, DelphiReportId,
delphi_report_item::DelphiVerdict,
thread_item::ThreadMessageBuilder,
},
},
models::threads::MessageBody,
routes::ApiError,
util::error::Context,
};
const DUMMY_ISSUE_TYPE: &str = "__dummy";
#[derive(Debug, Clone, Copy)]
pub enum TechReviewExitReason {
Resolved,
FileDeleted,
}
struct ProjectTechReviewState {
has_pending_detail: bool,
has_unsafe_detail: bool,
has_dummy: bool,
thread_id: Option<DBThreadId>,
report_id: Option<DelphiReportId>,
last_tech_review_message_type: Option<String>,
}
pub async fn sync_project_tech_review_state(
project_ids: &[DBProjectId],
exit_reason: TechReviewExitReason,
txn: &mut PgTransaction<'_>,
) -> Result<(), ApiError> {
let project_ids = project_ids.iter().copied().unique().collect::<Vec<_>>();
if project_ids.is_empty() {
return Ok(());
}
let project_ids_raw = project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
let tech_review_message_types = tech_review_message_types();
let rows = sqlx::query!(
r#"
WITH project_ids AS (
SELECT unnest($1::bigint[]) AS project_id
)
SELECT
p.project_id AS "project_id!: DBProjectId",
EXISTS(
SELECT 1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.project_id = p.project_id
AND didws.status = 'pending'
AND dri.issue_type != $3
) AS "has_pending_detail!",
EXISTS(
SELECT 1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.project_id = p.project_id
AND didws.status = 'unsafe'
AND dri.issue_type != $3
) AS "has_unsafe_detail!",
EXISTS(
SELECT 1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.project_id = p.project_id
AND didws.status = 'pending'
AND dri.issue_type = $3
) AS "has_dummy!",
(
SELECT t.id
FROM threads t
WHERE t.mod_id = p.project_id
ORDER BY t.id
LIMIT 1
) AS "thread_id: DBThreadId",
(
SELECT tm.body->>'type'
FROM threads t
INNER JOIN threads_messages tm ON tm.thread_id = t.id
WHERE
t.mod_id = p.project_id
AND tm.body->>'type' = ANY($2::text[])
ORDER BY tm.created DESC, tm.id DESC
LIMIT 1
) AS "last_tech_review_message_type",
(
SELECT dr.id
FROM versions v
INNER JOIN files f ON f.version_id = v.id
INNER JOIN delphi_reports dr ON dr.file_id = f.id
WHERE v.mod_id = p.project_id
ORDER BY dr.created DESC, dr.id DESC
LIMIT 1
) AS "report_id: DelphiReportId"
FROM project_ids p
"#,
&project_ids_raw,
&tech_review_message_types,
DUMMY_ISSUE_TYPE,
)
.fetch_all(&mut *txn)
.await
.wrap_internal_err("failed to fetch project tech review state")?;
for row in rows {
let state = ProjectTechReviewState {
has_pending_detail: row.has_pending_detail,
has_unsafe_detail: row.has_unsafe_detail,
has_dummy: row.has_dummy,
thread_id: row.thread_id,
report_id: row.report_id,
last_tech_review_message_type: row.last_tech_review_message_type,
};
sync_one_project_tech_review_state(state, exit_reason, txn).await?;
}
Ok(())
}
pub async fn sync_detail_key_tech_review_state(
detail_keys: &[String],
exit_reason: TechReviewExitReason,
txn: &mut PgTransaction<'_>,
) -> Result<(), ApiError> {
let detail_keys = detail_keys.iter().cloned().unique().collect::<Vec<_>>();
if detail_keys.is_empty() {
return Ok(());
}
let rows = sqlx::query!(
r#"
SELECT DISTINCT didws.project_id AS "project_id!: DBProjectId"
FROM delphi_issue_details_with_statuses didws
WHERE didws.key = ANY($1::text[])
"#,
&detail_keys,
)
.fetch_all(&mut *txn)
.await
.wrap_internal_err("failed to fetch projects affected by detail keys")?;
let project_ids = rows
.into_iter()
.map(|row| row.project_id)
.collect::<Vec<_>>();
sync_project_tech_review_state(&project_ids, exit_reason, txn).await
}
pub async fn sync_deleted_project_tech_review_exit(
project_id: DBProjectId,
txn: &mut PgTransaction<'_>,
) -> Result<(), ApiError> {
let tech_review_message_types = tech_review_message_types();
let row = sqlx::query!(
r#"
SELECT
(
SELECT t.id
FROM threads t
WHERE t.mod_id = $1
ORDER BY t.id
LIMIT 1
) AS "thread_id: DBThreadId",
(
SELECT tm.body->>'type'
FROM threads t
INNER JOIN threads_messages tm ON tm.thread_id = t.id
WHERE
t.mod_id = $1
AND tm.body->>'type' = ANY($2::text[])
ORDER BY tm.created DESC, tm.id DESC
LIMIT 1
) AS "last_tech_review_message_type"
"#,
project_id as DBProjectId,
&tech_review_message_types,
)
.fetch_one(&mut *txn)
.await
.wrap_internal_err("failed to fetch deleted project tech review state")?;
if let Some(thread_id) = row.thread_id
&& should_send_exit(row.last_tech_review_message_type.as_deref())
{
insert_exit_message(thread_id, TechReviewExitReason::FileDeleted, txn)
.await?;
}
Ok(())
}
async fn sync_one_project_tech_review_state(
state: ProjectTechReviewState,
exit_reason: TechReviewExitReason,
txn: &mut PgTransaction<'_>,
) -> Result<(), ApiError> {
let needs_tech_review =
state.has_pending_detail || state.has_unsafe_detail || state.has_dummy;
if needs_tech_review {
if (state.has_pending_detail || state.has_unsafe_detail)
&& !state.has_dummy
&& let Some(report_id) = state.report_id
{
// TODO: Currently, the queue query determines whether a project is
// in tech review by checking whether it has any pending issue
// details. If all visible issue details are marked safe or unsafe
// before the final report is submitted, the project would otherwise
// leave the tech review queue without a final tech review verdict
// message.
//
// This should be replaced with explicit tech review state, such as
// an append-only project tech review event table where the latest
// enter/exit event is the current state. Until then, this dummy
// issue detail acts as the pending queue blocker.
ensure_dummy_issue_detail(report_id, txn).await?;
}
if let Some(thread_id) = state.thread_id
&& state.last_tech_review_message_type.as_deref()
!= Some(MessageBody::TechReviewEntered.as_ref())
{
ThreadMessageBuilder {
author_id: None,
body: MessageBody::TechReviewEntered,
thread_id,
hide_identity: false,
}
.insert(txn)
.await
.wrap_internal_err("failed to add entering tech review message")?;
}
return Ok(());
}
if matches!(exit_reason, TechReviewExitReason::Resolved)
&& state.last_tech_review_message_type.as_deref()
== Some(MessageBody::TechReviewEntered.as_ref())
{
if let Some(report_id) = state.report_id {
ensure_dummy_issue_detail(report_id, txn).await?;
}
return Ok(());
}
if let Some(thread_id) = state.thread_id
&& should_send_exit(state.last_tech_review_message_type.as_deref())
{
insert_exit_message(thread_id, exit_reason, txn).await?;
}
Ok(())
}
fn should_send_exit(last_tech_review_message_type: Option<&str>) -> bool {
matches!(last_tech_review_message_type, Some(message_type) if !matches!(
message_type,
message_type if message_type == MessageBody::TechReviewExited.as_ref()
|| message_type == MessageBody::TechReviewExitFileDeleted.as_ref()
|| message_type == tech_review_completed_message_type()
))
}
async fn insert_exit_message(
thread_id: DBThreadId,
exit_reason: TechReviewExitReason,
txn: &mut PgTransaction<'_>,
) -> Result<(), ApiError> {
let body = match exit_reason {
TechReviewExitReason::Resolved => MessageBody::TechReviewExited,
TechReviewExitReason::FileDeleted => {
MessageBody::TechReviewExitFileDeleted
}
};
ThreadMessageBuilder {
author_id: None,
body,
thread_id,
hide_identity: false,
}
.insert(txn)
.await
.wrap_internal_err("failed to add exiting tech review message")?;
Ok(())
}
async fn ensure_dummy_issue_detail(
report_id: DelphiReportId,
txn: &mut PgTransaction<'_>,
) -> Result<(), ApiError> {
sqlx::query!(
r#"
WITH dummy_issue AS (
INSERT INTO delphi_report_issues (report_id, issue_type)
VALUES ($1, $2)
ON CONFLICT (report_id, issue_type)
DO UPDATE SET issue_type = EXCLUDED.issue_type
RETURNING id
)
INSERT INTO delphi_report_issue_details (
issue_id,
key,
jar,
file_path,
decompiled_source,
data,
severity
)
SELECT
id,
'',
NULL,
'',
NULL,
'{}'::jsonb,
'low'::delphi_severity
FROM dummy_issue
WHERE NOT EXISTS (
SELECT 1
FROM delphi_report_issue_details drid
WHERE drid.issue_id = dummy_issue.id
)
"#,
report_id as DelphiReportId,
DUMMY_ISSUE_TYPE,
)
.execute(&mut *txn)
.await
.wrap_internal_err("failed to ensure dummy Delphi report issue detail")?;
Ok(())
}
fn tech_review_message_types() -> Vec<String> {
[
MessageBody::TechReviewEntered.as_ref(),
MessageBody::TechReviewExited.as_ref(),
MessageBody::TechReviewExitFileDeleted.as_ref(),
tech_review_completed_message_type(),
]
.into_iter()
.map(|message_type| message_type.to_string())
.collect()
}
fn tech_review_completed_message_type() -> &'static str {
MessageBody::TechReview {
verdict: DelphiVerdict::Safe,
}
.as_ref()
}
@@ -31,7 +31,13 @@ use crate::{
threads::{MessageBody, Thread},
},
queue::session::AuthQueue,
routes::{ApiError, internal::moderation::Ownership},
routes::{
ApiError,
internal::{
delphi::tech_review_sync::{self, TechReviewExitReason},
moderation::Ownership,
},
},
search::SearchState,
util::error::Context,
};
@@ -238,6 +244,8 @@ pub async fn get_issue(
'decompiled_source', didws.decompiled_source,
'data', didws.data,
'severity', didws.severity,
'local_status', didws.local_status,
'global_status', didws.global_status,
'status', didws.status
)
), '[]'::jsonb)
@@ -313,6 +321,8 @@ pub async fn get_report(
'decompiled_source', didws.decompiled_source,
'data', didws.data,
'severity', didws.severity,
'local_status', didws.local_status,
'global_status', didws.global_status,
'status', didws.status
)
), '[]'::jsonb)
@@ -513,6 +523,8 @@ async fn fetch_project_reports(
didws.file_path AS "file_path!: String",
didws.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
didws.severity AS "severity!: DelphiSeverity",
didws.local_status AS "local_status?: DelphiStatus",
didws.global_status AS "global_status?: DelphiStatus",
didws.status AS "status!: DelphiStatus"
FROM delphi_issue_details_with_statuses didws
WHERE didws.issue_id = ANY($1::bigint[])
@@ -571,6 +583,8 @@ async fn fetch_project_reports(
decompiled_source: None,
data: d.data.0,
severity: d.severity,
local_status: d.local_status,
global_status: d.global_status,
status: d.status,
})
.into_group_map_by(|d| d.issue_id);
@@ -1177,7 +1191,9 @@ pub struct UpdateGlobalIssue {
/// Key of the issue detail to update globally.
pub detail_key: String,
/// What the moderator has decided the outcome of this issue is globally.
pub verdict: DelphiVerdict,
///
/// `pending` removes the global verdict for this issue detail key.
pub verdict: DelphiStatus,
}
/// Update technical review issue details.
@@ -1297,6 +1313,35 @@ pub async fn update_issue_details(
return Err(ApiError::Request(eyre!("issue detail does not exist")));
}
let affected_projects = sqlx::query!(
r#"
SELECT DISTINCT didws.project_id AS "project_id!: DBProjectId"
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.id = ANY($1::bigint[])
AND dri.issue_type != '__dummy'
"#,
&detail_ids,
)
.fetch_all(&mut txn)
.await
.wrap_internal_err(
"failed to fetch projects affected by issue detail updates",
)?;
let affected_project_ids = affected_projects
.into_iter()
.map(|row| row.project_id)
.collect::<Vec<_>>();
tech_review_sync::sync_project_tech_review_state(
&affected_project_ids,
TechReviewExitReason::Resolved,
&mut txn,
)
.await?;
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
@@ -1306,7 +1351,8 @@ pub async fn update_issue_details(
/// Update global technical review issue detail verdicts.
///
/// This marks every issue detail with a matching key as safe or unsafe.
/// This marks every issue detail with a matching key as safe or unsafe, or
/// unsets the global verdict with `pending`.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
@@ -1347,8 +1393,9 @@ pub async fn update_global_issue_details(
let verdicts = updates
.iter()
.map(|u| match u.verdict {
DelphiVerdict::Safe => "safe".to_string(),
DelphiVerdict::Unsafe => "unsafe".to_string(),
DelphiStatus::Safe => "safe".to_string(),
DelphiStatus::Unsafe => "unsafe".to_string(),
DelphiStatus::Pending => "pending".to_string(),
})
.collect::<Vec<_>>();
@@ -1363,16 +1410,31 @@ pub async fn update_global_issue_details(
SELECT *
FROM unnest($1::text[], $2::text[]) WITH ORDINALITY
AS u(detail_key, verdict, ord)
),
latest AS (
SELECT DISTINCT ON (detail_key)
detail_key,
verdict
FROM incoming
ORDER BY detail_key, ord DESC
),
deleted AS (
DELETE FROM delphi_global_detail_verdicts dgdv
USING latest
WHERE
dgdv.detail_key = latest.detail_key
AND latest.verdict = 'pending'
RETURNING 1
)
INSERT INTO delphi_global_detail_verdicts (
detail_key,
verdict
)
SELECT DISTINCT ON (detail_key)
SELECT
detail_key,
verdict::delphi_report_issue_status
FROM incoming
ORDER BY detail_key, ord DESC
FROM latest
WHERE verdict != 'pending'
ON CONFLICT (detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
"#,
@@ -1383,6 +1445,13 @@ pub async fn update_global_issue_details(
.await
.wrap_internal_err("failed to update global issue details")?;
tech_review_sync::sync_detail_key_tech_review_state(
&detail_keys,
TechReviewExitReason::Resolved,
&mut txn,
)
.await?;
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
+5 -11
View File
@@ -2781,17 +2781,11 @@ pub async fn project_delete_internal(
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
let was_in_tech_review =
delphi::is_project_in_tech_review(project.inner.id, &mut transaction)
.await?;
if was_in_tech_review {
delphi::send_tech_review_exit_file_deleted_message(
project.inner.id,
&mut transaction,
)
.await?;
}
delphi::tech_review_sync::sync_deleted_project_tech_review_exit(
project.inner.id,
&mut transaction,
)
.await?;
let context = ImageContext::Project {
project_id: Some(project.inner.id.into()),
+3 -6
View File
@@ -910,9 +910,6 @@ pub async fn delete_file(
}
let mut transaction = pool.begin().await?;
let was_in_tech_review =
delphi::is_project_in_tech_review(row.project_id, &mut transaction)
.await?;
sqlx::query!(
"
@@ -937,9 +934,9 @@ pub async fn delete_file(
database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction)
.await?;
delphi::send_tech_review_exit_file_deleted_message_if_exited(
row.project_id,
was_in_tech_review,
delphi::tech_review_sync::sync_project_tech_review_state(
&[row.project_id],
delphi::tech_review_sync::TechReviewExitReason::FileDeleted,
&mut transaction,
)
.await?;
+3 -8
View File
@@ -1218,11 +1218,6 @@ pub async fn version_delete(
}
let mut transaction = pool.begin().await?;
let was_in_tech_review = delphi::is_project_in_tech_review(
version.inner.project_id,
&mut transaction,
)
.await?;
let context = ImageContext::Version {
version_id: Some(version.inner.id.into()),
@@ -1243,9 +1238,9 @@ pub async fn version_delete(
)
.await?;
delphi::send_tech_review_exit_file_deleted_message_if_exited(
version.inner.project_id,
was_in_tech_review,
delphi::tech_review_sync::sync_project_tech_review_state(
&[version.inner.project_id],
delphi::tech_review_sync::TechReviewExitReason::FileDeleted,
&mut transaction,
)
.await?;