feat: scan file for c2pa content (#7297)

* feat: scan file for c2pa content

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
ThatGravyBoat
2026-08-25 22:41:39 +00:00
committed by GitHub
co-authored by Prospector
parent 1007b7b69e
commit a921cfdb7a
3 changed files with 138 additions and 0 deletions
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { ButtonLink, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { fileDeclaresAi } from '~/helpers/c2pa.ts'
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const isScanning = ref(false)
const fileUrl = ref<string | null>(null)
const totalFiles = ref(0)
const checkedFiles = ref(0)
const aiFiles = ref<string[]>([])
async function scanFile(url: string) {
if (isScanning.value) return
fileUrl.value = url
isScanning.value = true
totalFiles.value = 0
checkedFiles.value = 0
aiFiles.value = []
try {
const { BlobReader, ZipReader } = await import('@zip.js/zip.js')
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.statusText}`)
}
const blob = await response.blob()
const reader = new ZipReader(new BlobReader(blob))
const entries = await reader.getEntries()
totalFiles.value = entries.length
for (const entry of entries) {
checkedFiles.value++
if (entry.directory) continue
if (!entry.filename.endsWith('.png')) continue
const buffer = await entry.arrayBuffer()
const isAiFile = await fileDeclaresAi(
new File([buffer], entry.filename, { type: 'image/png' }),
)
if (isAiFile) {
aiFiles.value.push(entry.filename)
}
}
} finally {
isScanning.value = false
}
}
function createSlicerUrl(path: string) {
const downloadUrl = new URL(fileUrl.value!)
const fileName = downloadUrl.pathname.split('/').pop()
return `https://slicer.run/?file=${encodeURIComponent(`${fileName}/${path}`)}&url=${encodeURIComponent(downloadUrl.toString())}`
}
function openC2paModal(url: string) {
modalRef.value?.show()
void scanFile(url)
}
function hide() {
modalRef.value?.hide()
}
defineExpose({ openC2paModal, hide })
</script>
<template>
<NewModal ref="modalRef" width="40vw" :disable-close="isScanning">
<template #title>
<span class="text-2xl font-semibold text-contrast">C2PA Scan Info</span>
</template>
<div class="w-full">
<div v-if="isScanning" class="flex items-center justify-center">
<span class="rounded-xl bg-highlight-blue px-4 py-1"
>Scanning {{ checkedFiles }}/{{ totalFiles }} files.</span
>
</div>
<div v-else>
<div v-if="aiFiles.length === 0" class="flex items-center justify-center">
<span class="rounded-xl bg-highlight-green px-4 py-1">No AI-generated files found.</span>
</div>
<div v-if="aiFiles.length > 0" class="flex flex-col gap-1">
<span class="ml-2 text-xl font-semibold text-contrast"
>AI-generated files ({{ aiFiles.length }})</span
>
<div
v-for="file in aiFiles"
:key="file"
class="flex flex-row flex-wrap items-center justify-between gap-2 rounded-2xl bg-surface-2 p-4 font-semibold text-secondary"
>
{{ file }}
<ButtonLink :href="createSlicerUrl(file)" target="_blank">
Open <ExternalIcon />
</ButtonLink>
</div>
</div>
</div>
</div>
</NewModal>
</template>
@@ -12,6 +12,8 @@
ref="create-project-version-modal"
></CreateProjectVersionModal>
<ProjectC2paScanModal ref="project-c2pa-scan-modal"></ProjectC2paScanModal>
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
@@ -156,6 +158,14 @@
auth.user ? reportVersion(version.id) : navigateTo(getSignInRouteObj(route)),
shown: !currentMember,
},
{ type: 'divider', shown: isStaff(auth.user) },
{
id: 'view-c2pa-info',
label: 'View C2PA info',
tone: 'orange',
action: () => projectC2paScanModal.openC2paModal(createDownloadUrl(version)),
shown: isStaff(auth.user),
},
{ type: 'divider', shown: currentMember || flags.developerMode },
{
id: 'copy-id',
@@ -243,6 +253,10 @@
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #view-c2pa-info>
<ScanEyeIcon aria-hidden="true" />
View C2PA Info
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
@@ -279,6 +293,7 @@ import {
LinkIcon,
MoreVerticalIcon,
ReportIcon,
ScanEyeIcon,
ShareIcon,
SpinnerIcon,
TrashIcon,
@@ -297,6 +312,7 @@ import { isStaff } from '@modrinth/utils'
import { onMounted, useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import ProjectC2paScanModal from '~/components/ui/moderation/ProjectC2paScanModal.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { reportVersion } from '~/utils/report-helpers.ts'
@@ -337,6 +353,7 @@ onMounted(() => {
const deleteVersionModal = ref()
const selectedVersion = ref(null)
const createProjectVersionModal = useTemplateRef('create-project-version-modal')
const projectC2paScanModal = useTemplateRef('project-c2pa-scan-modal')
const handleOpenCreateVersionModal = () => {
if (!currentMember.value) return
@@ -0,0 +1,8 @@
import type { Labrinth } from '@modrinth/api-client'
export type AccountChoice = {
id: string
username: string
avatarUrl?: string | null
role?: Labrinth.Users.v2.Role | null
}