add AI metadata checking to block image uploads with notice

This commit is contained in:
Prospector
2026-08-07 17:30:58 -07:00
parent 8ab484a298
commit d8e53cf7dc
12 changed files with 226 additions and 31 deletions
+1
View File
@@ -38,6 +38,7 @@
"wrangler": "^4.115.0"
},
"dependencies": {
"@contentauth/c2pa-web": "^0.13.1",
"@formatjs/intl-localematcher": "^0.5.4",
"@ltd/j-toml": "^1.38.0",
"@modrinth/api-client": "workspace:*",
@@ -0,0 +1,52 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="false"
fade="warning"
max-width="544px"
>
<div class="flex flex-col">
<p class="mb-4 mt-0 leading-normal">
{{ formatMessage(messages.body) }}
</p>
<div class="flex justify-end">
<Button type="colored" color="brand" native-type="button" @click="hide">
<CheckCircleIcon class="size-4" />
{{ formatMessage(commonMessages.iUnderstandButton) }}
</Button>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from '@modrinth/assets'
import { Button, commonMessages, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
const { formatMessage } = useVIntl()
const modal = useTemplateRef('modal')
const messages = defineMessages({
title: {
id: 'project.ai-image-warning-modal.title',
defaultMessage: 'AI-generated images not allowed',
},
body: {
id: 'project.ai-image-warning-modal.body',
defaultMessage:
'Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended.',
},
})
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
+41
View File
@@ -0,0 +1,41 @@
import type { C2paSdk } from '@contentauth/c2pa-web'
import { createC2pa, isSupportedReaderFormat } from '@contentauth/c2pa-web/inline'
// https://cv.iptc.org/newscodes/digitalsourcetype/
// only detecting generative AI types
const AI_DIGITAL_SOURCE_TYPES = [
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia',
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData',
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia',
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic',
]
let c2paInstance: Promise<C2paSdk> | null = null
function getC2pa() {
if (!c2paInstance) {
c2paInstance = createC2pa()
}
return c2paInstance
}
// reference: https://github.com/contentauth/c2pa-js/blob/main/packages/c2pa-web/README.md
export async function fileDeclaresAi(file: File): Promise<boolean> {
try {
if (!isSupportedReaderFormat(file.type)) {
return false
}
const c2pa = await getC2pa()
const reader = await c2pa.reader.fromBlob(file.type, file)
const manifestStore = await reader.manifestStore()
await reader.free()
const manifestJson = JSON.stringify(manifestStore)
return AI_DIGITAL_SOURCE_TYPES.some((type) => manifestJson.includes(type))
} catch {
return false
}
}
@@ -3485,6 +3485,12 @@
"project.actions.servers-promo.title": {
"message": "Create a server"
},
"project.ai-image-warning-modal.body": {
"message": "Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended."
},
"project.ai-image-warning-modal.title": {
"message": "AI-generated images not allowed"
},
"project.collections.create-new": {
"message": "Create new collection"
},
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<Modal
v-if="currentMember"
ref="modal_edit_item"
@@ -18,12 +19,7 @@
:accept="acceptFileTypes"
:max-size="5242880"
aria-label="Replace image"
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
@change="replaceEditFile"
>
<TransferIcon aria-hidden="true" />
</FileButton>
@@ -315,6 +311,8 @@ import {
} from '@modrinth/ui'
import { useEventListener } from '@vueuse/core'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
const formatDate = useFormatDateTime({
@@ -333,6 +331,7 @@ const {
} = injectProjectPageContext()
// Template refs
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
const modalEditItem = useTemplateRef('modal_edit_item')
const modalConfirm = useTemplateRef('modal_confirm')
@@ -439,14 +438,35 @@ function resetEdit() {
previewImage.value = null
}
function handleFiles(files: File[]) {
async function handleFiles(files: File[]) {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
resetEdit()
editFile.value = files[0]
editFile.value = file
showPreviewImage()
modalEditItem.value?.show()
}
async function replaceEditFile(files: File[]) {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
editFile.value = file
showPreviewImage()
}
function showPreviewImage() {
const reader = new FileReader()
if (editFile.value instanceof Blob) {
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<ConfirmLeaveModal ref="confirmLeaveModal" />
<div class="universal-card">
<div class="markdown-disclaimer">
@@ -51,11 +52,14 @@ import {
useSavable,
} from '@modrinth/ui'
import { TeamMemberPermission } from '@modrinth/utils'
import { computed } from 'vue'
import { computed, useTemplateRef } from 'vue'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { fileDeclaresAi } from '~/helpers/c2pa'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.description)
@@ -80,6 +84,10 @@ const descriptionWarning = computed(() => {
})
async function onUploadHandler(file: File) {
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
const response = await useImageUpload(file, {
context: 'project',
projectID: project.value.id,
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<Modal
v-if="currentMember"
ref="modal_edit_item"
@@ -18,12 +19,7 @@
:accept="acceptFileTypes"
:max-size="5242880"
aria-label="Replace image"
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
@change="replaceEditFile"
>
<TransferIcon aria-hidden="true" />
</FileButton>
@@ -305,6 +301,8 @@ import {
useFormatDateTime,
} from '@modrinth/ui'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
const formatDate = useFormatDateTime({
@@ -323,6 +321,7 @@ const {
useProjectSettingsHeadTitle(commonProjectSettingsMessages.gallery)
const aiImageWarningModal = ref(null)
const modal_edit_item = ref(null)
const modal_confirm = ref(null)
@@ -380,14 +379,35 @@ const resetEdit = () => {
previewImage.value = null
}
const handleFiles = (files) => {
const handleFiles = async (files) => {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
resetEdit()
editFile.value = files[0]
editFile.value = file
showPreviewImage()
modal_edit_item.value.show()
}
const replaceEditFile = async (files) => {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
editFile.value = file
showPreviewImage()
}
const showPreviewImage = () => {
const reader = new FileReader()
if (editFile.value instanceof Blob) {
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(messages.deleteConfirmationTitle)"
@@ -322,7 +323,9 @@ import {
} from '@modrinth/ui'
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { useAuth } from '~/composables/auth.js'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
const auth = await useAuth()
@@ -337,6 +340,7 @@ const {
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
@@ -553,9 +557,17 @@ async function handleSave() {
}
}
const showPreviewImage = (files) => {
const showPreviewImage = async (files) => {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
const reader = new FileReader()
icon.value = files[0]
icon.value = file
deletedIcon.value = false
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
@@ -23,7 +23,7 @@
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
<Button type="colored" color="brand" native-type="button" @click="acknowledge">
<CheckCircleIcon class="size-4" />
{{ formatMessage(messages.iUnderstand) }}
{{ formatMessage(commonMessages.iUnderstandButton) }}
</Button>
</div>
</div>
@@ -37,6 +37,7 @@ import { ref, useTemplateRef } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import Checkbox from '../base/Checkbox.vue'
import NewModal from './NewModal.vue'
@@ -72,10 +73,6 @@ const messages = defineMessages({
id: 'search.photosensitivity-warning-modal.dont-show-again',
defaultMessage: "Don't show this again",
},
iUnderstand: {
id: 'search.photosensitivity-warning-modal.i-understand',
defaultMessage: 'I understand',
},
})
function show() {
+3 -3
View File
@@ -206,6 +206,9 @@
"button.hide-snapshots": {
"defaultMessage": "Hide snapshots"
},
"button.i-understand": {
"defaultMessage": "I understand"
},
"button.install": {
"defaultMessage": "Install"
},
@@ -3989,9 +3992,6 @@
"search.photosensitivity-warning-modal.dont-show-again": {
"defaultMessage": "Don't show this again"
},
"search.photosensitivity-warning-modal.i-understand": {
"defaultMessage": "I understand"
},
"search.photosensitivity-warning-modal.title": {
"defaultMessage": "Results are not guaranteed to be safe"
},
+4
View File
@@ -176,6 +176,10 @@ export const commonMessages = defineMessages({
id: 'input.view.grid',
defaultMessage: 'Grid view',
},
iUnderstandButton: {
id: 'button.i-understand',
defaultMessage: 'I understand',
},
listInputView: {
id: 'input.view.list',
defaultMessage: 'Rows view',
+38 -4
View File
@@ -263,6 +263,9 @@ importers:
apps/frontend:
dependencies:
'@contentauth/c2pa-web':
specifier: ^0.13.1
version: 0.13.1
'@formatjs/intl-localematcher':
specifier: ^0.5.4
version: 0.5.10
@@ -1230,6 +1233,15 @@ packages:
'@codemirror/view@6.39.12':
resolution: {integrity: sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==}
'@contentauth/c2pa-types@0.7.2':
resolution: {integrity: sha512-SH+q4a6ZS2z2YhlqfTB2GzN1S4M9koLhJETGPxL5J4JVhJEFSyEQK52PnwOhee+ObiZdDZK18NI+s+1IrUSdTQ==}
'@contentauth/c2pa-wasm@0.11.0':
resolution: {integrity: sha512-pL7tGZnM/qQz7JFCPB0hWSZM41L4aUEF/SV9B1fL+0L2sUZT1Zj7UGcUjSmiRWeXLagQ8Jds7RF+XQ9gxZiG7g==}
'@contentauth/c2pa-web@0.13.1':
resolution: {integrity: sha512-K4GLdhqIdLE3DUdO0Ub2M6rnyopAnCtdOQhFx2ACayVXo6uLuYlMPRlBivqc4RlvuXMpvJcW2BMu6B1FZpbYhw==}
'@crowdin/crowdin-api-client@1.55.2':
resolution: {integrity: sha512-kEMiz25j9tSfhsyj8dZWdhKPdtTeVu9lWZFBIc+adHXZ8vs6zd4/6xaiYr3PamYhV3rIOBEv8ElyzpUSSO5LGg==}
engines: {node: '>=12.9.0'}
@@ -6815,6 +6827,9 @@ packages:
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
highgain@0.1.0:
resolution: {integrity: sha512-Vw/g2upw4MYLZ9e95k79+idMoPFEKvTQVTce9LCDfI+T6AiG+SdiR4WDKszB1grQy0O+uhxd6cqbdi3VhECdbA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
@@ -9470,6 +9485,10 @@ packages:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
ts-deepmerge@8.0.0:
resolution: {integrity: sha512-133O+10nJmVI8w5xeVZPEv5PIrv7iaUae07wv1aH8XJH95Ur6YIhWAPhPyP1YPlbPS9fCVcNIZTu7m8urRVF0A==}
engines: {node: '>=14.13.1'}
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -10174,8 +10193,8 @@ packages:
vue-component-type-helpers@3.2.4:
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
vue-component-type-helpers@3.3.8:
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
vue-component-type-helpers@3.3.9:
resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==}
vue-confetti-explosion@1.0.2:
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
@@ -11029,6 +11048,17 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@contentauth/c2pa-types@0.7.2': {}
'@contentauth/c2pa-wasm@0.11.0': {}
'@contentauth/c2pa-web@0.13.1':
dependencies:
'@contentauth/c2pa-types': 0.7.2
'@contentauth/c2pa-wasm': 0.11.0
highgain: 0.1.0
ts-deepmerge: 8.0.0
'@crowdin/crowdin-api-client@1.55.2':
dependencies:
axios: 1.16.1
@@ -13822,7 +13852,7 @@ snapshots:
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.27(typescript@5.9.3)
vue-component-type-helpers: 3.3.8
vue-component-type-helpers: 3.3.9
'@stripe/stripe-js@7.9.0': {}
@@ -17067,6 +17097,8 @@ snapshots:
hey-listen@1.0.8: {}
highgain@0.1.0: {}
highlight.js@11.11.1: {}
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e: {}
@@ -20384,6 +20416,8 @@ snapshots:
ts-dedent@2.2.0: {}
ts-deepmerge@8.0.0: {}
ts-interface-checker@0.1.13: {}
ts-map@1.0.3: {}
@@ -21040,7 +21074,7 @@ snapshots:
vue-component-type-helpers@3.2.4: {}
vue-component-type-helpers@3.3.8: {}
vue-component-type-helpers@3.3.9: {}
vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
dependencies: