Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions apps/frontend/src/components/ui/moderation/ProjectC2paScanModal.vue
Original file line number Diff line number Diff line change
@@ -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>
17 changes: 17 additions & 0 deletions apps/frontend/src/pages/[type]/[project]/versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
ref="create-project-version-modal"
></CreateProjectVersionModal>

<ProjectC2paScanModal ref="project-c2pa-scan-modal"></ProjectC2paScanModal>

<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -279,6 +293,7 @@ import {
LinkIcon,
MoreVerticalIcon,
ReportIcon,
ScanEyeIcon,
ShareIcon,
SpinnerIcon,
TrashIcon,
Expand All @@ -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'

Expand Down Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions apps/frontend/src/pages/collection/[collection].vue
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ import { onServerPrefetch } from 'vue'

import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'

useSeoMeta({
robots: 'noindex',
})

const { handleError } = injectNotificationManager()
const api = injectModrinthClient()
const { formatMessage } = useVIntl()
Expand Down Expand Up @@ -614,6 +618,26 @@ const creator = computed(() =>

const supportsMarkdown = computed(() => creator.value?.id === '2REoufqX')

// Query for public projects
const { data: creatorPublicProjectsSearch } = useQuery({
queryKey: computed(() => ['user', creator.value?.username, 'public-projects-search']),
queryFn: () =>
api.labrinth.projects_v3.search({
facets: [[`author:${creator.value.username}`]],
limit: 1,
}),
enabled: computed(
() =>
!isFollowingCollection.value &&
collection.value?.status === 'listed' &&
!!creator.value?.username,
),
})

const creatorHasPublicProjects = computed(
() => (creatorPublicProjectsSearch.value?.total_hits ?? 0) > 0,
)

// Query for followed projects
const {
data: followedProjects,
Expand Down Expand Up @@ -673,10 +697,21 @@ onServerPrefetch(async () => {
})

if (collectionData?.user) {
await queryClient.ensureQueryData({
const creatorData = await queryClient.ensureQueryData({
queryKey: ['user', collectionData.user],
queryFn: () => api.labrinth.users_v2.get(collectionData.user),
})

if (collectionData.status === 'listed' && creatorData?.username) {
await queryClient.ensureQueryData({
queryKey: ['user', creatorData.username, 'public-projects-search'],
queryFn: () =>
api.labrinth.projects_v3.search({
facets: [[`author:${creatorData.username}`]],
limit: 1,
}),
})
}
}

if (collectionData?.projects?.length) {
Expand All @@ -688,8 +723,8 @@ onServerPrefetch(async () => {
})

watch(
[collection, creator],
([col, cre]) => {
[collection, creator, creatorHasPublicProjects],
([col, cre, hasPublicProjects]) => {
if (col && cre) {
const canonicalUrl = col ? `https://modrinth.com/collection/${col.id}` : undefined
useSeoMeta({
Expand All @@ -703,7 +738,7 @@ watch(
ogDescription: col.description,
ogImage: col.icon_url ?? 'https://cdn-raw.modrinth.com/placeholder-square.png',
ogUrl: canonicalUrl,
robots: col.status === 'listed' ? 'all' : 'noindex',
robots: col.status === 'listed' && hasPublicProjects ? 'all' : 'noindex',
})
useHead({
link: [
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/components/user/account-choice.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading