@michaelthielemann/kestrel 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/layers/admin/app/components/AdminAccount.vue +3 -3
- package/layers/admin/app/components/AdminNav.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +2 -2
- package/layers/admin/app/components/BlockPreview.vue +8 -8
- package/layers/admin/app/components/BlockTree.vue +10 -10
- package/layers/admin/app/components/BlocksBody.vue +4 -4
- package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
- package/layers/admin/app/components/CollectionEditor.vue +3 -3
- package/layers/admin/app/components/CollectionList.vue +91 -788
- package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
- package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
- package/layers/admin/app/components/CollectionListPager.vue +70 -0
- package/layers/admin/app/components/CollectionListTable.vue +308 -0
- package/layers/admin/app/components/EditorStatus.vue +4 -4
- package/layers/admin/app/components/FieldsBody.vue +1 -1
- package/layers/admin/app/components/LocaleBar.vue +4 -4
- package/layers/admin/app/components/PageFields.vue +17 -12
- package/layers/admin/app/components/PageFieldsPane.vue +2 -2
- package/layers/admin/app/components/SeoFields.vue +52 -10
- package/layers/admin/app/components/SingletonEditor.vue +7 -7
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/composables/useListBatchActions.ts +61 -0
- package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
- package/layers/admin/app/composables/useListRows.ts +70 -0
- package/layers/admin/app/composables/useListSelection.ts +28 -0
- package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
- package/layers/admin/app/layouts/admin.vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
- package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
- package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
- package/layers/admin/app/pages/admin/index.vue +3 -3
- package/layers/admin/app/pages/admin/login.vue +6 -6
- package/layers/admin/app/pages/admin/media.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +3 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/list-cell.ts +46 -0
- package/layers/admin/nuxt.config.ts +3 -0
- package/layers/core/modules/component-namespace/index.ts +20 -0
- package/layers/core/modules/component-namespace/shared.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +11 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
- package/layers/core/server/api/[collection]/index.put.ts +18 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/defineCollection.ts +8 -2
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/serialize-collection.ts +0 -2
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/app/utils/field-factories.ts +1 -1
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaConflictDialog.vue +7 -7
- package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
- package/layers/media/app/components/MediaGrid.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +26 -24
- package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
- package/layers/media/app/components/MediaPathBar.vue +1 -1
- package/layers/media/app/components/MediaPicker.vue +3 -3
- package/layers/media/app/components/MediaRenameDialog.vue +8 -8
- package/layers/media/app/components/MediaTable.vue +2 -2
- package/layers/media/app/components/MediaToolbar.vue +4 -4
- package/layers/media/app/components/MediaUploadDialog.vue +9 -9
- package/layers/media/app/components/MediaViewer.vue +64 -15
- package/layers/media/app/components/field/Media.vue +9 -9
- package/layers/media/app/plugins/field-media.client.ts +3 -3
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- package/layers/media/nuxt.config.ts +5 -1
- package/layers/media/server/api/media/[id].patch.test.ts +66 -0
- package/layers/media/server/api/media/[id].patch.ts +25 -1
- package/layers/media/server/api/media/index.post.ts +19 -3
- package/layers/media/server/collections/media.ts +14 -0
- package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
- package/layers/media/server/utils/ai-signal-detect.ts +155 -0
- package/layers/media/server/utils/library.ts +2 -1
- package/layers/media/server/utils/record.ts +8 -0
- package/layers/media/server/utils/resolve.ts +12 -0
- package/layers/public/app/pages/[...slug].vue +32 -3
- package/layers/public/app/pages/__kestrel/preview.vue +1 -1
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
- package/layers/public/modules/prerender-routes/index.ts +5 -2
- package/layers/public/nuxt.config.ts +3 -0
- package/layers/public/server/api/route.get.ts +9 -1
- package/layers/public/server/collections/redirects.ts +75 -0
- package/layers/public/server/plugins/03.redirects.ts +37 -0
- package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
- package/layers/public/server/routes/llms.txt.get.ts +1 -12
- package/layers/public/server/routes/redirects.json.get.ts +58 -0
- package/layers/public/server/routes/robots.txt.get.ts +1 -0
- package/layers/public/server/utils/llms-full.ts +125 -0
- package/layers/public/server/utils/llms.ts +13 -0
- package/layers/public/server/utils/page-resolve.ts +112 -4
- package/layers/public/server/utils/publish/invalidation.ts +48 -3
- package/layers/public/server/utils/publish/publisher.ts +11 -5
- package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
- package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
- package/layers/public/server/utils/richtext-markdown.ts +260 -0
- package/layers/public/server/utils/site-url.ts +8 -0
- package/layers/public/server/utils/sitemap.ts +5 -3
- package/layers/ui/app/components/field/Choice.vue +6 -1
- package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
- package/layers/ui/app/components/field/Slug.vue +2 -2
- package/layers/ui/app/components/ui/Button.vue +1 -1
- package/layers/ui/app/components/ui/EmptyState.vue +1 -1
- package/layers/ui/app/components/ui/TextInput.vue +1 -1
- package/layers/ui/app/components/ui/Toasts.vue +1 -1
- package/layers/ui/app/composables/useToast.ts +1 -1
- package/layers/ui/app/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/layers/ui/nuxt.config.ts +5 -1
- package/package.json +2 -1
- package/templates/starter/nuxt.config.ts +3 -0
|
@@ -3,9 +3,15 @@ import { ref, computed, watch } from 'vue'
|
|
|
3
3
|
import { humanizeSize, type LibraryFile } from '../utils/library'
|
|
4
4
|
|
|
5
5
|
// Fullscreen-ish preview + general info for a single file. Images additionally expose an editable alt
|
|
6
|
-
// text (the only field worth maintaining from the library)
|
|
6
|
+
// text (the only field worth maintaining from the library) and — when the consumer switched the feature
|
|
7
|
+
// on — the EU AI Act Art. 50 disclosure; everything else is read-only metadata.
|
|
7
8
|
const props = defineProps<{ open: boolean; file: LibraryFile | null; busy?: boolean; error?: string | null }>()
|
|
8
|
-
|
|
9
|
+
// The disclosure rides along as a SECOND positional argument rather than reshaping the first: an outside
|
|
10
|
+
// consumer (`extensions/galleries-secure`) handles `save` as `(alt: string)` and simply ignores the extra.
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
'update:open': [boolean]
|
|
13
|
+
save: [alt: string, ai?: { aiSourceType: string | null; aiNote: string | null }]
|
|
14
|
+
}>()
|
|
9
15
|
const { t, lang } = useT()
|
|
10
16
|
|
|
11
17
|
const isImage = computed(() => props.file?.mime.startsWith('image/') ?? false)
|
|
@@ -18,16 +24,46 @@ const uploaded = computed(() => {
|
|
|
18
24
|
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(lang.value)
|
|
19
25
|
})
|
|
20
26
|
|
|
27
|
+
// Gates the disclosure controls only — the data is always resolved server-side, so a consumer who turns
|
|
28
|
+
// the flag back off keeps whatever was recorded, it just stops being editable here.
|
|
29
|
+
const aiEnabled = computed(() => useRuntimeConfig().public.aiDisclosureEnabled === true)
|
|
30
|
+
const showAi = computed(() => isImage.value && aiEnabled.value)
|
|
31
|
+
const AI_SOURCE_TYPES = ['trainedAlgorithmicMedia', 'compositeWithTrainedAlgorithmicMedia', 'algorithmicallyEnhanced'] as const
|
|
32
|
+
// A leading empty option is what makes "no disclosure recorded" both representable and clearable
|
|
33
|
+
// (mirrors how `field/Choice.vue` renders a non-required single choice).
|
|
34
|
+
const aiSourceTypeOptions = computed(() => [
|
|
35
|
+
{ label: '—', value: '' },
|
|
36
|
+
...AI_SOURCE_TYPES.map((v) => ({ label: t(`mediaViewer.aiSourceType.${v}`), value: v })),
|
|
37
|
+
])
|
|
38
|
+
|
|
21
39
|
const alt = ref('')
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
40
|
+
const aiSourceType = ref('')
|
|
41
|
+
const aiNote = ref('')
|
|
42
|
+
const fileAlt = computed(() => props.file?.alt ?? '')
|
|
43
|
+
const fileAiSourceType = computed(() => props.file?.aiDisclosure?.sourceType ?? '')
|
|
44
|
+
const fileAiNote = computed(() => props.file?.aiDisclosure?.note ?? '')
|
|
45
|
+
// Seed (and re-seed) the drafts whenever the dialog opens — immediate so a viewer mounted already-open
|
|
46
|
+
// (or re-opened on a different file) shows the current values rather than stale/empty ones.
|
|
47
|
+
watch(() => props.open, (o) => {
|
|
48
|
+
if (!o) return
|
|
49
|
+
alt.value = fileAlt.value
|
|
50
|
+
aiSourceType.value = fileAiSourceType.value
|
|
51
|
+
aiNote.value = fileAiNote.value
|
|
52
|
+
}, { immediate: true })
|
|
53
|
+
|
|
54
|
+
const aiDirty = computed(() => showAi.value && (aiSourceType.value !== fileAiSourceType.value || aiNote.value !== fileAiNote.value))
|
|
55
|
+
const dirty = computed(() => isImage.value && (alt.value !== fileAlt.value || aiDirty.value))
|
|
56
|
+
function save() {
|
|
57
|
+
if (!dirty.value || props.busy) return
|
|
58
|
+
// With the feature off the payload is omitted entirely, so an alt-only save can never blank a
|
|
59
|
+
// disclosure the consumer recorded while it was on.
|
|
60
|
+
if (!showAi.value) { emit('save', alt.value); return }
|
|
61
|
+
emit('save', alt.value, { aiSourceType: aiSourceType.value || null, aiNote: aiNote.value.trim() || null })
|
|
62
|
+
}
|
|
27
63
|
</script>
|
|
28
64
|
|
|
29
65
|
<template>
|
|
30
|
-
<
|
|
66
|
+
<KestrelUiDialog :open="open" size="xl" :title="file?.filename ?? ''" @update:open="(v) => emit('update:open', v)">
|
|
31
67
|
<div v-if="file" class="media-viewer">
|
|
32
68
|
<div class="media-viewer__preview">
|
|
33
69
|
<img v-if="isImage" :src="file.src" :srcset="file.srcset" sizes="(max-width: 60rem) 100vw, 60rem" :alt="file.alt ?? file.filename" />
|
|
@@ -41,21 +77,33 @@ function save() { if (dirty.value && !props.busy) emit('save', alt.value) }
|
|
|
41
77
|
<div><dt>{{ t('mediaViewer.folder') }}</dt><dd>{{ file.folder || '/' }}</dd></div>
|
|
42
78
|
<div><dt>{{ t('mediaViewer.uploaded') }}</dt><dd>{{ uploaded }}</dd></div>
|
|
43
79
|
</dl>
|
|
44
|
-
<
|
|
80
|
+
<KestrelUiField v-if="isImage" :label="t('mediaViewer.alt')" :hint="t('mediaViewer.altHint')">
|
|
45
81
|
<template #default="f">
|
|
46
|
-
<
|
|
82
|
+
<KestrelUiTextInput v-model="alt" v-bind="f" @keydown.enter="save" />
|
|
47
83
|
</template>
|
|
48
|
-
</
|
|
49
|
-
<
|
|
84
|
+
</KestrelUiField>
|
|
85
|
+
<div v-if="showAi" class="media-viewer__ai">
|
|
86
|
+
<KestrelUiField :label="t('mediaViewer.aiSourceTypeLabel')" :hint="t('mediaViewer.aiSourceTypeHint')">
|
|
87
|
+
<template #default="f">
|
|
88
|
+
<KestrelUiSelect v-model="aiSourceType" :options="aiSourceTypeOptions" v-bind="f" />
|
|
89
|
+
</template>
|
|
90
|
+
</KestrelUiField>
|
|
91
|
+
<KestrelUiField :label="t('mediaViewer.aiNote')" :hint="t('mediaViewer.aiNoteHint')">
|
|
92
|
+
<template #default="f">
|
|
93
|
+
<KestrelUiTextInput v-model="aiNote" v-bind="f" @keydown.enter="save" />
|
|
94
|
+
</template>
|
|
95
|
+
</KestrelUiField>
|
|
96
|
+
</div>
|
|
97
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
50
98
|
<!-- Optional per-file extra panel (e.g. proofing comments). Empty by default. -->
|
|
51
99
|
<slot name="extra" :file="file" />
|
|
52
100
|
</aside>
|
|
53
101
|
</div>
|
|
54
102
|
<template v-if="file" #footer>
|
|
55
|
-
<
|
|
56
|
-
<
|
|
103
|
+
<KestrelUiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.close') }}</KestrelUiButton>
|
|
104
|
+
<KestrelUiButton v-if="isImage" variant="primary" :disabled="busy || !dirty" @click="save">{{ t('common.save') }}</KestrelUiButton>
|
|
57
105
|
</template>
|
|
58
|
-
</
|
|
106
|
+
</KestrelUiDialog>
|
|
59
107
|
</template>
|
|
60
108
|
|
|
61
109
|
<style lang="scss" scoped>
|
|
@@ -81,6 +129,7 @@ function save() { if (dirty.value && !props.busy) emit('save', alt.value) }
|
|
|
81
129
|
.media-viewer__preview img { max-width: 100%; max-height: 70svh; object-fit: contain; }
|
|
82
130
|
.media-viewer__ext { padding: var(--space-7); font-size: var(--text-xl); font-weight: var(--weight-bold); color: var(--color-text-muted); }
|
|
83
131
|
.media-viewer__details { display: flex; flex-direction: column; gap: var(--space-4); }
|
|
132
|
+
.media-viewer__ai { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
84
133
|
.media-viewer__info { display: flex; flex-direction: column; gap: var(--space-2); margin: 0; }
|
|
85
134
|
.media-viewer__info > div { display: flex; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
|
86
135
|
.media-viewer__info dt { color: var(--color-text-muted); }
|
|
@@ -114,7 +114,7 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
|
|
|
114
114
|
</script>
|
|
115
115
|
|
|
116
116
|
<template>
|
|
117
|
-
<
|
|
117
|
+
<KestrelUiField :id="id" :label="name" :error="error" :required="required">
|
|
118
118
|
<template #default="f">
|
|
119
119
|
<div class="field-media">
|
|
120
120
|
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
|
|
@@ -135,7 +135,7 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
|
|
|
135
135
|
@drop.prevent="onDrop(i)"
|
|
136
136
|
@dragend="onDragEnd"
|
|
137
137
|
>
|
|
138
|
-
<
|
|
138
|
+
<KestrelMediaThumb v-if="isImage(r)" draggable="false" class="field-media__thumb" :src="r.src" :srcset="srcsetOf(r)" sizes="80px" :alt="r.alt ?? ''" :thumbhash="r.thumbhash" />
|
|
139
139
|
<a v-else draggable="false" class="field-media__file" :href="r.src" target="_blank" rel="noopener noreferrer" :title="filenameOf(r)">
|
|
140
140
|
<span class="field-media__badge" aria-hidden="true">{{ fileExt(r) }}</span>
|
|
141
141
|
<span class="field-media__filename">{{ filenameOf(r) }}</span>
|
|
@@ -148,7 +148,7 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
|
|
|
148
148
|
:aria-label="t('field.media.move_earlier', { name: nameOf(r, i) })"
|
|
149
149
|
:disabled="i === 0"
|
|
150
150
|
@click="moveItem(i, -1)"
|
|
151
|
-
><
|
|
151
|
+
><KestrelUiIcon name="chevron-left" size="0.875rem" /></button>
|
|
152
152
|
<button
|
|
153
153
|
type="button"
|
|
154
154
|
draggable="false"
|
|
@@ -156,13 +156,13 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
|
|
|
156
156
|
:aria-label="t('field.media.move_later', { name: nameOf(r, i) })"
|
|
157
157
|
:disabled="i === resolved.length - 1"
|
|
158
158
|
@click="moveItem(i, 1)"
|
|
159
|
-
><
|
|
159
|
+
><KestrelUiIcon name="chevron-right" size="0.875rem" /></button>
|
|
160
160
|
</template>
|
|
161
|
-
<button v-if="!disabled" type="button" draggable="false" class="field-media__remove" :aria-label="t('field.media.remove', { name: nameOf(r, i) })" @click="removeId(r.id)"><
|
|
161
|
+
<button v-if="!disabled" type="button" draggable="false" class="field-media__remove" :aria-label="t('field.media.remove', { name: nameOf(r, i) })" @click="removeId(r.id)"><KestrelUiIcon name="x" size="1rem" /></button>
|
|
162
162
|
</li>
|
|
163
163
|
</ul>
|
|
164
164
|
<p v-else class="field-media__empty">{{ t('field.media.empty') }}</p>
|
|
165
|
-
<
|
|
165
|
+
<KestrelUiButton
|
|
166
166
|
v-if="!disabled"
|
|
167
167
|
:id="f.id"
|
|
168
168
|
type="button"
|
|
@@ -172,12 +172,12 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
|
|
|
172
172
|
@click="pickerOpen = true"
|
|
173
173
|
>
|
|
174
174
|
{{ multiple ? t('field.media.add') : (resolved.length ? t('field.media.replace') : t('field.media.select')) }}
|
|
175
|
-
</
|
|
175
|
+
</KestrelUiButton>
|
|
176
176
|
<span class="field-media__live" aria-live="polite">{{ liveMessage }}</span>
|
|
177
177
|
</div>
|
|
178
|
-
<
|
|
178
|
+
<KestrelMediaPicker v-model:open="pickerOpen" :multiple="multiple" :accept="accept" :initial-folder="initialFolder" :initial-selected="ids" @confirm="onConfirm" />
|
|
179
179
|
</template>
|
|
180
|
-
</
|
|
180
|
+
</KestrelUiField>
|
|
181
181
|
</template>
|
|
182
182
|
|
|
183
183
|
<style lang="scss" scoped>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// components are NOT auto-imported in a plugin's script context — pull
|
|
2
|
-
import {
|
|
1
|
+
// components are NOT auto-imported in a plugin's script context — pull KestrelFieldMedia from #components
|
|
2
|
+
import { KestrelFieldMedia } from '#components'
|
|
3
3
|
|
|
4
4
|
export default defineNuxtPlugin(() => {
|
|
5
|
-
registerFieldComponent('media',
|
|
5
|
+
registerFieldComponent('media', KestrelFieldMedia)
|
|
6
6
|
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AiSourceType } from '../../server/utils/resolve'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default human-readable label for an EU AI Act source type — the badge text when no editor note was
|
|
5
|
+
* entered. English-only on purpose: it is a fallback, not a translation layer, and a consumer who wants
|
|
6
|
+
* their own wording (or locale) writes the `aiNote`, styles `.kestrel-img__ai-badge`, or reads
|
|
7
|
+
* `ResolvedMedia.aiDisclosure` and renders their own element. Kept pure so it stays unit-testable.
|
|
8
|
+
*
|
|
9
|
+
* An unrecognised value is returned VERBATIM rather than mapped to a generic label: the column is plain
|
|
10
|
+
* text at the DB level, and quietly relabelling an unknown value would state a disclosure nobody made.
|
|
11
|
+
*/
|
|
12
|
+
export function aiSourceTypeLabel(sourceType: AiSourceType | string): string {
|
|
13
|
+
switch (sourceType) {
|
|
14
|
+
case 'trainedAlgorithmicMedia': return 'AI-generated'
|
|
15
|
+
case 'compositeWithTrainedAlgorithmicMedia': return 'Contains AI-generated content'
|
|
16
|
+
case 'algorithmicallyEnhanced': return 'AI-edited'
|
|
17
|
+
default: return sourceType
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -7,6 +7,8 @@ export type { LibraryFolder }
|
|
|
7
7
|
export interface LibraryFile {
|
|
8
8
|
id: number; filename: string; mime: string; folder: string; size: number
|
|
9
9
|
width?: number; height?: number; thumbhash?: string; src: string; srcset?: string; alt?: string
|
|
10
|
+
/** EU AI Act Art. 50 disclosure — always listed; `kestrel.config.ts`'s flag only gates the editor. */
|
|
11
|
+
aiDisclosure?: { sourceType: string; note: string | null } | null
|
|
10
12
|
createdAt?: string
|
|
11
13
|
}
|
|
12
14
|
export type LibraryItem =
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { kestrelComponents } from '../core/modules/component-namespace/shared'
|
|
2
|
+
|
|
1
3
|
// Marks `media` as a Nuxt layer (so its components/server code are auto-registered). The media config
|
|
2
4
|
// + the `kestrel` module that resolves it now live in the core layer (single non-auth config source).
|
|
3
|
-
export default defineNuxtConfig({
|
|
5
|
+
export default defineNuxtConfig({
|
|
6
|
+
components: [kestrelComponents(import.meta.url)],
|
|
7
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
3
|
+
import { createError } from 'h3'
|
|
4
|
+
import { createTestDb } from '../../../../../test/helpers/db'
|
|
5
|
+
import { create } from '../../../../core/server/utils/crud'
|
|
6
|
+
import builtMedia, { media } from '../../collections/media'
|
|
7
|
+
|
|
8
|
+
interface FakeEvent { body: Record<string, unknown> }
|
|
9
|
+
|
|
10
|
+
let db: ReturnType<typeof createTestDb>
|
|
11
|
+
let id: number
|
|
12
|
+
|
|
13
|
+
// The handler is a Nitro route: its auto-imported helpers are plain globals in a node test.
|
|
14
|
+
Object.assign(globalThis, {
|
|
15
|
+
defineEventHandler: (handler: unknown) => handler,
|
|
16
|
+
requireAdmin: () => {},
|
|
17
|
+
createError,
|
|
18
|
+
requireId: () => id,
|
|
19
|
+
readIfUnmodifiedSince: () => undefined,
|
|
20
|
+
readBody: async (event: FakeEvent) => event.body,
|
|
21
|
+
useDb: () => db,
|
|
22
|
+
useRuntimeConfig: () => ({ kestrel: {} }),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const handler = (await import('./[id].patch')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
|
|
26
|
+
const patch = (body: Record<string, unknown>) => handler({ body })
|
|
27
|
+
|
|
28
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
29
|
+
const row = () => db.select().from(media).where(eq(cols.id, id)).get() as Record<string, unknown>
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
db = createTestDb()
|
|
33
|
+
const created = create(db, builtMedia, { storageKey: 'a/one.png', folder: 'a', filename: 'one.png', mime: 'image/png', ext: 'png', size: 1 }) as { id: number }
|
|
34
|
+
id = created.id
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('PATCH /api/media/:id — EU AI Act disclosure', () => {
|
|
38
|
+
it('persists both disclosure columns', async () => {
|
|
39
|
+
await patch({ aiSourceType: 'algorithmicallyEnhanced', aiNote: 'upscaled' })
|
|
40
|
+
expect(row()).toMatchObject({ aiSourceType: 'algorithmicallyEnhanced', aiNote: 'upscaled' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('clears the classification with an explicit null', async () => {
|
|
44
|
+
await patch({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'Midjourney v7' })
|
|
45
|
+
await patch({ aiSourceType: null })
|
|
46
|
+
expect(row().aiSourceType).toBeNull()
|
|
47
|
+
expect(row().aiNote).toBe('Midjourney v7') // untouched — only the sent keys are written
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects an unknown source type with a 400 instead of writing it', async () => {
|
|
51
|
+
await expect(patch({ aiSourceType: 'nonsense' })).rejects.toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
52
|
+
expect(row().aiSourceType).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('stores a blanked note as null rather than an empty string a badge would render', async () => {
|
|
56
|
+
await patch({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'Midjourney v7' })
|
|
57
|
+
await patch({ aiNote: ' ' })
|
|
58
|
+
expect(row().aiNote).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('leaves the columns alone when the body does not mention them', async () => {
|
|
62
|
+
await patch({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'note' })
|
|
63
|
+
await patch({ translations: { en: { alt: 'a kitten' } } })
|
|
64
|
+
expect(row()).toMatchObject({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'note' })
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { media } from '../../collections/media'
|
|
2
|
+
import builtMedia, { media } from '../../collections/media'
|
|
3
3
|
import { mergeTranslations, type Translations } from '../../utils/translations'
|
|
4
4
|
import { emitMediaWrite } from '../../utils/media-write'
|
|
5
5
|
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
6
6
|
|
|
7
|
+
const AI_KEYS = ['aiSourceType', 'aiNote'] as const
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The EU AI Act disclosure columns are top-level (not per-locale), so they are patched as plain siblings of
|
|
11
|
+
* `translations`. Only the keys the body actually sent are written — omitting one must not clear it. The
|
|
12
|
+
* allow-list of source types is NOT duplicated here: the collection's own update schema (built from the
|
|
13
|
+
* `choice` field's `choices`) is the single source of truth, so an unknown value 400s instead of storing.
|
|
14
|
+
*/
|
|
15
|
+
function readAiDisclosure(body: Record<string, unknown> | undefined | null): Record<string, unknown> {
|
|
16
|
+
const sent = AI_KEYS.filter((k) => Object.hasOwn(body ?? {}, k))
|
|
17
|
+
if (!sent.length) return {}
|
|
18
|
+
const parsed = builtMedia.update.safeParse(Object.fromEntries(sent.map((k) => [k, body![k]])))
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
throw createError({ statusCode: 400, statusMessage: `Invalid AI disclosure: ${parsed.error.issues[0]?.message ?? 'unknown value'}` })
|
|
21
|
+
}
|
|
22
|
+
const value = parsed.data as Record<string, unknown>
|
|
23
|
+
const patch: Record<string, unknown> = {}
|
|
24
|
+
for (const k of sent) patch[k] = value[k] ?? null
|
|
25
|
+
// A blanked note must round-trip as "no note", not as an empty string a badge would render as blank text.
|
|
26
|
+
if (patch.aiNote === '') patch.aiNote = null
|
|
27
|
+
return patch
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
export default defineEventHandler(async (event) => {
|
|
8
31
|
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
9
32
|
requireMediaCollection()
|
|
@@ -34,6 +57,7 @@ export default defineEventHandler(async (event) => {
|
|
|
34
57
|
// `en.alt` from the media viewer) keeps the other locales AND the locale's other fields intact.
|
|
35
58
|
patch.translations = mergeTranslations(current?.translations, body.translations as Translations)
|
|
36
59
|
}
|
|
60
|
+
Object.assign(patch, readAiDisclosure(body))
|
|
37
61
|
const row = db.update(media).set(patch).where(eq(cols.id, id)).returning().get() as Record<string, unknown> | undefined
|
|
38
62
|
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
39
63
|
emitMediaWrite({ id }, row) // alt/title/description changed → re-render embedding pages (fresh alt text)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { eq, inArray, getTableColumns, isNull } from 'drizzle-orm'
|
|
3
|
-
import { media } from '../../collections/media'
|
|
3
|
+
import builtMedia, { media } from '../../collections/media'
|
|
4
4
|
import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
|
|
5
5
|
import { sniffMime, extForMime, resolveAllowedMimes } from '../../utils/sniff'
|
|
6
6
|
import { sanitizeFolder, buildKey, suggestFreeName, withExtension } from '../../utils/naming'
|
|
@@ -17,6 +17,8 @@ import { isUniqueViolation } from '../../../../core/server/utils/crud'
|
|
|
17
17
|
import { withLock, mediaLockKey } from '../../../../core/server/utils/key-lock'
|
|
18
18
|
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
19
19
|
import { emitMediaWrite } from '../../utils/media-write'
|
|
20
|
+
import { detectAiSignal, aiSignalNote } from '../../utils/ai-signal-detect'
|
|
21
|
+
import { aiDisclosureEnabled } from '../../utils/ai-disclosure-enabled'
|
|
20
22
|
|
|
21
23
|
export default defineEventHandler(async (event) => {
|
|
22
24
|
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
@@ -83,12 +85,23 @@ export default defineEventHandler(async (event) => {
|
|
|
83
85
|
const description = text('description')
|
|
84
86
|
const translations = alt || title || description ? { [primaryLocale()]: { alt, title, description } } : {}
|
|
85
87
|
|
|
88
|
+
// EU AI Act Art. 50 disclosure. The classification is validated against the collection's own choice
|
|
89
|
+
// schema so the allow-list has a single source of truth (same as the PATCH route).
|
|
90
|
+
const aiSourceType = text('aiSourceType')?.trim() || undefined
|
|
91
|
+
if (aiSourceType && !builtMedia.update.safeParse({ aiSourceType }).success) {
|
|
92
|
+
throw createError({ statusCode: 400, statusMessage: `Invalid AI disclosure: unknown source type "${aiSourceType}"` })
|
|
93
|
+
}
|
|
94
|
+
const uploadedAiNote = text('aiNote')?.trim() || undefined
|
|
95
|
+
// Only parse when the feature is on: consumers who leave it off pay nothing for it. What the scan finds
|
|
96
|
+
// is EVIDENCE for the free-text note — it never asserts `aiSourceType`, which stays a human decision.
|
|
97
|
+
const signal = aiDisclosureEnabled() && !uploadedAiNote ? await detectAiSignal(bytes, mime).catch(() => null) : null
|
|
98
|
+
|
|
86
99
|
// Serialize the collision-check → put → insert per storageKey: two concurrent uploads to the SAME key
|
|
87
100
|
// (or a backfill re-deriving it) must not interleave, or last-writer-wins would leave the winning row
|
|
88
101
|
// describing the loser's bytes. Different keys never share a lock, so throughput is unaffected.
|
|
89
102
|
const { row, created } = await withLock(mediaLockKey(storageKey), async (): Promise<{ row: Record<string, unknown>; created: boolean }> => {
|
|
90
103
|
const existing = db.select().from(media).where(eq(cols.storageKey, storageKey)).get() as
|
|
91
|
-
| { id: number; derivatives?: DerivativeManifest; translations?: Translations }
|
|
104
|
+
| { id: number; derivatives?: DerivativeManifest; translations?: Translations; aiNote?: string | null }
|
|
92
105
|
| undefined
|
|
93
106
|
|
|
94
107
|
// An object on disk with no media row (another media's derivative, or an orphan) must never be
|
|
@@ -131,7 +144,10 @@ export default defineEventHandler(async (event) => {
|
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
if (folder) ensureFolder(db, folder)
|
|
134
|
-
|
|
147
|
+
// The scan only ever fills a note that would otherwise be empty — it must not talk over an uploader's
|
|
148
|
+
// own text, nor over one an editor already curated on the row this upload is replacing.
|
|
149
|
+
const aiNote = uploadedAiNote ?? (existing?.aiNote ? undefined : signal && aiSignalNote(signal))
|
|
150
|
+
const values = buildMediaValues({ storageKey, folder, filename, mime, ext, size: bytes.length, checksum, derived, translations, aiSourceType, aiNote })
|
|
135
151
|
try {
|
|
136
152
|
// persistUpload writes the objects then the row; on a create-path failure it removes the freshly-written
|
|
137
153
|
// blobs so a partial upload never strands orphans that permanently 409-block the filename.
|
|
@@ -22,6 +22,20 @@ const built = buildCollection(defineCollection({
|
|
|
22
22
|
thumbhash: { type: 'text' },
|
|
23
23
|
derivatives: { type: 'json' },
|
|
24
24
|
translations: { type: 'json' },
|
|
25
|
+
// EU AI Act Art. 50 disclosure. Top-level, NOT per-locale (`translations`): how an asset was produced
|
|
26
|
+
// does not change per translation. The vocabulary mirrors the disclosure-relevant subset of IPTC's
|
|
27
|
+
// Digital Source Type, so a later metadata-embedding slice can map 1:1.
|
|
28
|
+
aiSourceType: {
|
|
29
|
+
type: 'choice',
|
|
30
|
+
options: {
|
|
31
|
+
choices: [
|
|
32
|
+
{ label: 'Fully AI-generated', value: 'trainedAlgorithmicMedia' },
|
|
33
|
+
{ label: 'AI content composited into real media', value: 'compositeWithTrainedAlgorithmicMedia' },
|
|
34
|
+
{ label: 'AI-enhanced / algorithmically edited', value: 'algorithmicallyEnhanced' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
aiNote: { type: 'text' },
|
|
25
39
|
},
|
|
26
40
|
}))
|
|
27
41
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the EU AI Act disclosure feature is switched on for this consumer (`aiDisclosure.enabled`).
|
|
5
|
+
*
|
|
6
|
+
* It gates the ADMIN UI and the upload-time signal scan — never the data: `ResolvedMedia.aiDisclosure` is
|
|
7
|
+
* always resolved from whatever the columns hold, so turning the flag off hides the editor without
|
|
8
|
+
* touching (or hiding) a disclosure already recorded. Same runtimeConfig-then-own-config fallback as
|
|
9
|
+
* `mediaCollectionEnabled`, so non-Nitro callers (scripts, build) resolve it too.
|
|
10
|
+
*/
|
|
11
|
+
export function aiDisclosureEnabled(): boolean {
|
|
12
|
+
const cfg = (serverRuntimeConfig()?.kestrel?.aiDisclosure ?? resolveServerKestrel().aiDisclosure) as
|
|
13
|
+
| { enabled?: boolean }
|
|
14
|
+
| undefined
|
|
15
|
+
return cfg?.enabled === true
|
|
16
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload-time scan for signals that a file was AI-generated or -manipulated (EU AI Act Art. 50).
|
|
3
|
+
*
|
|
4
|
+
* **This produces EVIDENCE, never a classification.** Everything it finds is quoted into the free-text
|
|
5
|
+
* `aiNote`; the legal `aiSourceType` stays a deliberate human decision, because a signal is neither proof
|
|
6
|
+
* nor its absence a disproof: metadata survives no re-save, screenshot or re-encode, and an upstream file
|
|
7
|
+
* can be mislabeled or forged. In particular a C2PA manifest is reported as PRESENT only — verifying its
|
|
8
|
+
* signature needs the full C2PA SDK plus a trust list, which Kestrel deliberately does not ship.
|
|
9
|
+
*
|
|
10
|
+
* Pure (no DB, no Nitro) so it stays unit-testable, and never throws: an unreadable file is simply no
|
|
11
|
+
* evidence, which must not fail the upload it came with.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Tools that name themselves in EXIF `Software`. Matched case-insensitively as whole phrases, so an
|
|
15
|
+
* ordinary "Adobe Photoshop" is not caught by the "Adobe Firefly" entry. */
|
|
16
|
+
const KNOWN_GENERATORS = [
|
|
17
|
+
'midjourney', 'dall-e', 'dall·e', 'adobe firefly', 'stable diffusion', 'leonardo.ai',
|
|
18
|
+
'nightcafe', 'bing image creator', 'google imagefx', 'imagen', 'flux.1', 'ideogram',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/** PNG text-chunk keywords the Stable-Diffusion / ComfyUI families write their generation graph under. */
|
|
22
|
+
const GENERATION_KEYWORDS = ['parameters', 'prompt', 'workflow']
|
|
23
|
+
|
|
24
|
+
export interface AiSignal {
|
|
25
|
+
/** One human-readable line per signal found, in a stable order. Never empty (the result is null instead). */
|
|
26
|
+
evidence: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Walk PNG chunks after the 8-byte signature, yielding `[type, data]`. Bails out on any malformed length. */
|
|
30
|
+
function* pngChunks(bytes: Buffer): Generator<[string, Buffer]> {
|
|
31
|
+
if (bytes.length < 8 || bytes.readUInt32BE(0) !== 0x89504E47) return
|
|
32
|
+
let at = 8
|
|
33
|
+
while (at + 8 <= bytes.length) {
|
|
34
|
+
const len = bytes.readUInt32BE(at)
|
|
35
|
+
const end = at + 8 + len
|
|
36
|
+
if (len > bytes.length || end + 4 > bytes.length) return
|
|
37
|
+
yield [bytes.toString('latin1', at + 4, at + 8), bytes.subarray(at + 8, end)]
|
|
38
|
+
at = end + 4 // skip the trailing CRC
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Walk JPEG marker segments after SOI, yielding `[marker, payload]`. Stops at SOS (entropy-coded data). */
|
|
43
|
+
function* jpegSegments(bytes: Buffer): Generator<[number, Buffer]> {
|
|
44
|
+
if (bytes.length < 4 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return
|
|
45
|
+
let at = 2
|
|
46
|
+
while (at + 4 <= bytes.length) {
|
|
47
|
+
if (bytes[at] !== 0xFF) return
|
|
48
|
+
const marker = bytes[at + 1]!
|
|
49
|
+
if (marker === 0xDA || marker === 0xD9) return
|
|
50
|
+
const len = bytes.readUInt16BE(at + 2)
|
|
51
|
+
if (len < 2 || at + 2 + len > bytes.length) return
|
|
52
|
+
yield [marker, bytes.subarray(at + 4, at + 2 + len)]
|
|
53
|
+
at += 2 + len
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Walk RIFF/WebP chunks, yielding `[fourcc, data]`. Chunks are padded to an even length. */
|
|
58
|
+
function* riffChunks(bytes: Buffer): Generator<[string, Buffer]> {
|
|
59
|
+
if (bytes.length < 12 || bytes.toString('latin1', 0, 4) !== 'RIFF') return
|
|
60
|
+
let at = 12
|
|
61
|
+
while (at + 8 <= bytes.length) {
|
|
62
|
+
const len = bytes.readUInt32LE(at + 4)
|
|
63
|
+
const end = at + 8 + len
|
|
64
|
+
if (len > bytes.length || end > bytes.length) return
|
|
65
|
+
yield [bytes.toString('latin1', at, at + 4), bytes.subarray(at + 8, end)]
|
|
66
|
+
at = end + (len % 2)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The IPTC Digital Source Type a generator self-declared, read straight out of the embedded XMP packet.
|
|
72
|
+
* Deliberately a byte-level scan rather than a metadata library: XMP is the same XML in every container,
|
|
73
|
+
* so this also covers the ones exifr cannot open (WebP), and it costs one `indexOf` when absent.
|
|
74
|
+
*/
|
|
75
|
+
function findDigitalSourceType(bytes: Buffer): string | null {
|
|
76
|
+
const at = bytes.indexOf('DigitalSourceType', 0, 'latin1')
|
|
77
|
+
if (at < 0) return null
|
|
78
|
+
const window = bytes.toString('latin1', at, Math.min(at + 512, bytes.length))
|
|
79
|
+
// Both XMP serializations: as an rdf:Description attribute, or as its own element.
|
|
80
|
+
const value = /^DigitalSourceType\s*=\s*"([^"]*)"/.exec(window)?.[1]
|
|
81
|
+
?? /^DigitalSourceType[^>]*>([^<]*)</.exec(window)?.[1]
|
|
82
|
+
const trimmed = value?.trim()
|
|
83
|
+
return trimmed ? trimmed : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when the file structurally carries a C2PA/JUMBF manifest store. Presence only — never verified. */
|
|
87
|
+
function hasC2paManifest(bytes: Buffer): boolean {
|
|
88
|
+
for (const [type] of pngChunks(bytes)) if (type === 'caBX') return true
|
|
89
|
+
// C2PA-in-JPEG lives in APP11 segments whose payload starts with the JUMBF "JP" identifier.
|
|
90
|
+
for (const [marker, payload] of jpegSegments(bytes)) {
|
|
91
|
+
if (marker === 0xEB && payload.length >= 2 && payload.toString('latin1', 0, 2) === 'JP') return true
|
|
92
|
+
}
|
|
93
|
+
for (const [fourcc] of riffChunks(bytes)) if (fourcc === 'C2PA') return true
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The generation-parameter keyword a PNG text chunk is stored under, or null. Covers tEXt/zTXt/iTXt —
|
|
98
|
+
* in all three the keyword is the leading null-terminated string, so a compressed payload needs no
|
|
99
|
+
* inflating to be recognised (exifr only decodes the uncompressed tEXt form). */
|
|
100
|
+
function findPngGenerationChunk(bytes: Buffer): string | null {
|
|
101
|
+
for (const [type, data] of pngChunks(bytes)) {
|
|
102
|
+
if (type !== 'tEXt' && type !== 'zTXt' && type !== 'iTXt') continue
|
|
103
|
+
const nul = data.indexOf(0)
|
|
104
|
+
const keyword = data.toString('latin1', 0, nul < 0 ? data.length : nul).toLowerCase()
|
|
105
|
+
if (GENERATION_KEYWORDS.includes(keyword)) return keyword
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The EXIF `Software`/`ProcessingSoftware` value, when it names a tool on the known-generator list. */
|
|
111
|
+
async function findGeneratorSoftware(bytes: Buffer): Promise<string | null> {
|
|
112
|
+
let tags: Record<string, unknown> | undefined
|
|
113
|
+
try {
|
|
114
|
+
// Loaded lazily: consumers who never turn the feature on never pay the module's parse cost.
|
|
115
|
+
const exifr = (await import('exifr')).default
|
|
116
|
+
tags = await exifr.parse(bytes, { tiff: true, exif: true, mergeOutput: true }) as Record<string, unknown> | undefined
|
|
117
|
+
} catch {
|
|
118
|
+
return null // an unsupported container or a corrupt header is simply no evidence
|
|
119
|
+
}
|
|
120
|
+
for (const key of ['Software', 'ProcessingSoftware']) {
|
|
121
|
+
const value = tags?.[key]
|
|
122
|
+
if (typeof value !== 'string' || !value.trim()) continue
|
|
123
|
+
const haystack = value.toLowerCase()
|
|
124
|
+
if (KNOWN_GENERATORS.some((g) => haystack.includes(g))) return value.trim()
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scan an uploaded file for AI-origin signals. Returns null when nothing matched — which is NOT evidence
|
|
131
|
+
* of non-AI origin, only the absence of a declaration.
|
|
132
|
+
*/
|
|
133
|
+
export async function detectAiSignal(bytes: Buffer, _mime: string): Promise<AiSignal | null> {
|
|
134
|
+
const evidence: string[] = []
|
|
135
|
+
|
|
136
|
+
const sourceType = findDigitalSourceType(bytes)
|
|
137
|
+
if (sourceType) evidence.push(`IPTC/XMP Digital Source Type: ${sourceType}`)
|
|
138
|
+
|
|
139
|
+
const software = await findGeneratorSoftware(bytes)
|
|
140
|
+
if (software) evidence.push(`EXIF Software: ${software}`)
|
|
141
|
+
|
|
142
|
+
if (hasC2paManifest(bytes)) {
|
|
143
|
+
evidence.push('C2PA content-credentials manifest present (unverified — presence only, no signature check)')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const keyword = findPngGenerationChunk(bytes)
|
|
147
|
+
if (keyword) evidence.push(`PNG text chunk "${keyword}" present (Stable-Diffusion-style generation parameters)`)
|
|
148
|
+
|
|
149
|
+
return evidence.length ? { evidence } : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** How the scan's findings are worded into `aiNote`. Kept here so the wording has one home. */
|
|
153
|
+
export function aiSignalNote(signal: AiSignal): string {
|
|
154
|
+
return `Detected at upload: ${signal.evidence.join('; ')}`
|
|
155
|
+
}
|
|
@@ -56,7 +56,8 @@ export function listLibrary(db: BetterSQLite3Database, q: LibraryQuery, publicUr
|
|
|
56
56
|
return {
|
|
57
57
|
id: m.id, filename: r.filename as string, mime: m.mime, folder: r.folder as string,
|
|
58
58
|
size: r.size as number, width: m.width, height: m.height, thumbhash: m.thumbhash,
|
|
59
|
-
src: m.src, srcset, alt: m.alt,
|
|
59
|
+
src: m.src, srcset, alt: m.alt, aiDisclosure: m.aiDisclosure,
|
|
60
|
+
createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
|
|
60
61
|
}
|
|
61
62
|
})
|
|
62
63
|
|
|
@@ -14,6 +14,10 @@ export interface MediaInput {
|
|
|
14
14
|
checksum: string
|
|
15
15
|
derived?: DerivedImage
|
|
16
16
|
translations?: Record<string, { alt?: string; title?: string; description?: string }>
|
|
17
|
+
/** EU AI Act disclosure to write. Omitted/null ⇒ the column is left out of the values entirely, which is
|
|
18
|
+
* what keeps an overwrite from wiping a disclosure the re-upload did not re-send. */
|
|
19
|
+
aiSourceType?: string | null
|
|
20
|
+
aiNote?: string | null
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -46,6 +50,10 @@ export function buildMediaValues(input: MediaInput): Record<string, unknown> {
|
|
|
46
50
|
thumbhash: input.derived?.thumbhash ?? null,
|
|
47
51
|
derivatives: manifest,
|
|
48
52
|
translations: input.translations ?? {},
|
|
53
|
+
// Written only when there is something to write: the overwrite path feeds these same values to an
|
|
54
|
+
// UPDATE, and a null here would silently clear a disclosure an editor set on the existing row.
|
|
55
|
+
...(input.aiSourceType != null ? { aiSourceType: input.aiSourceType } : {}),
|
|
56
|
+
...(input.aiNote != null ? { aiNote: input.aiNote } : {}),
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
|