@michaelthielemann/kestrel 2.0.0 → 2.1.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 +15 -8
- package/layers/admin/app/components/CollectionList.vue +5 -1
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +42 -0
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/core/modules/kestrel/index.ts +11 -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 -1
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaLibrary.vue +7 -5
- package/layers/media/app/components/MediaViewer.vue +56 -7
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- 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 +30 -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/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/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/package.json +2 -1
- package/templates/starter/nuxt.config.ts +3 -0
|
@@ -51,18 +51,20 @@ function onOpenItem(item: LibraryItem) {
|
|
|
51
51
|
viewerError.value = ''
|
|
52
52
|
viewerOpen.value = true
|
|
53
53
|
}
|
|
54
|
-
async function
|
|
54
|
+
async function onSaveMeta(alt: string, ai?: { aiSourceType: string | null; aiNote: string | null }) {
|
|
55
55
|
const f = viewerFile.value as LibraryFileRow | null
|
|
56
56
|
if (!f) return
|
|
57
57
|
viewerBusy.value = true
|
|
58
58
|
viewerError.value = ''
|
|
59
59
|
try {
|
|
60
60
|
// Alt lives in the per-locale translations JSON; write the primary locale (the library resolves
|
|
61
|
-
// alt in that same locale, so this round-trips into the list). The
|
|
62
|
-
//
|
|
61
|
+
// alt in that same locale, so this round-trips into the list). The AI disclosure is NOT per-locale,
|
|
62
|
+
// so it rides alongside as top-level keys — and only when the viewer actually offered it, so a save
|
|
63
|
+
// from a consumer with the feature off never clears a recorded disclosure. The precondition header
|
|
64
|
+
// lets the server 409 instead of silently losing a concurrent edit from another open viewer tab.
|
|
63
65
|
await $fetch(`/api/media/${f.id}`, {
|
|
64
66
|
method: 'PATCH',
|
|
65
|
-
body: { translations: { [primary]: { alt } } },
|
|
67
|
+
body: { translations: { [primary]: { alt } }, ...(ai ?? {}) },
|
|
66
68
|
...(f.updatedAt ? { headers: { 'x-kestrel-if-unmodified-since': String(new Date(f.updatedAt).getTime()) } } : {}),
|
|
67
69
|
})
|
|
68
70
|
} catch (e) {
|
|
@@ -336,7 +338,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
|
|
|
336
338
|
@update:open="(v) => { if (!v) upload.resolveAll('skip') }"
|
|
337
339
|
/>
|
|
338
340
|
<MediaNewFolderDialog v-model:open="newFolderOpen" @create="onCreateFolder" />
|
|
339
|
-
<MediaViewer :open="viewerOpen" :file="viewerFile" :busy="viewerBusy" :error="viewerError" @update:open="(v) => { viewerOpen = v }" @save="
|
|
341
|
+
<MediaViewer :open="viewerOpen" :file="viewerFile" :busy="viewerBusy" :error="viewerError" @update:open="(v) => { viewerOpen = v }" @save="onSaveMeta" />
|
|
340
342
|
<UiAlert v-if="opError && !deleteOpen && !renameOpen" variant="error">{{ opError }}</UiAlert>
|
|
341
343
|
<MediaDeleteDialog
|
|
342
344
|
:open="deleteOpen"
|
|
@@ -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,12 +24,42 @@ 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>
|
|
@@ -46,6 +82,18 @@ function save() { if (dirty.value && !props.busy) emit('save', alt.value) }
|
|
|
46
82
|
<UiTextInput v-model="alt" v-bind="f" @keydown.enter="save" />
|
|
47
83
|
</template>
|
|
48
84
|
</UiField>
|
|
85
|
+
<div v-if="showAi" class="media-viewer__ai">
|
|
86
|
+
<UiField :label="t('mediaViewer.aiSourceTypeLabel')" :hint="t('mediaViewer.aiSourceTypeHint')">
|
|
87
|
+
<template #default="f">
|
|
88
|
+
<UiSelect v-model="aiSourceType" :options="aiSourceTypeOptions" v-bind="f" />
|
|
89
|
+
</template>
|
|
90
|
+
</UiField>
|
|
91
|
+
<UiField :label="t('mediaViewer.aiNote')" :hint="t('mediaViewer.aiNoteHint')">
|
|
92
|
+
<template #default="f">
|
|
93
|
+
<UiTextInput v-model="aiNote" v-bind="f" @keydown.enter="save" />
|
|
94
|
+
</template>
|
|
95
|
+
</UiField>
|
|
96
|
+
</div>
|
|
49
97
|
<UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
|
|
50
98
|
<!-- Optional per-file extra panel (e.g. proofing comments). Empty by default. -->
|
|
51
99
|
<slot name="extra" :file="file" />
|
|
@@ -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); }
|
|
@@ -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 =
|
|
@@ -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
|
|
|
@@ -7,6 +7,9 @@ import type { DerivativeManifest } from './record'
|
|
|
7
7
|
/** One derivative, tagged with its parsed name + format so `<picture>` rendering can group by format. */
|
|
8
8
|
export interface MediaVariant { name: string; format: string; url: string; width: number; height: number }
|
|
9
9
|
|
|
10
|
+
/** The disclosure-relevant subset of the IPTC Digital Source Type vocabulary (EU AI Act Art. 50). */
|
|
11
|
+
export type AiSourceType = 'trainedAlgorithmicMedia' | 'compositeWithTrainedAlgorithmicMedia' | 'algorithmicallyEnhanced'
|
|
12
|
+
|
|
10
13
|
export interface ResolvedMedia {
|
|
11
14
|
id: number
|
|
12
15
|
/** The media's storage folder ('' at the media root) — lets the field open the picker there. */
|
|
@@ -23,6 +26,10 @@ export interface ResolvedMedia {
|
|
|
23
26
|
srcset: { url: string; width: number }[]
|
|
24
27
|
/** Every derivative, name+format-tagged — the source `KestrelImg`/`useMediaVariant` build `<picture>` from. */
|
|
25
28
|
variants: MediaVariant[]
|
|
29
|
+
/** EU AI Act Art. 50 disclosure, set by an editor — null when unset. Always resolved regardless of
|
|
30
|
+
* `kestrel.config.ts`'s `aiDisclosure.enabled` (that flag only gates the admin UI). Kestrel never
|
|
31
|
+
* renders this automatically; read it directly or opt into `KestrelImg`'s `aiBadge` prop. */
|
|
32
|
+
aiDisclosure: { sourceType: AiSourceType; note: string | null } | null
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
interface MediaRow {
|
|
@@ -35,6 +42,8 @@ interface MediaRow {
|
|
|
35
42
|
thumbhash: string | null
|
|
36
43
|
derivatives: DerivativeManifest | null
|
|
37
44
|
translations: Record<string, { alt?: string; title?: string; description?: string }> | null
|
|
45
|
+
aiSourceType: string | null
|
|
46
|
+
aiNote: string | null
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: string) => string): ResolvedMedia {
|
|
@@ -69,6 +78,9 @@ export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: str
|
|
|
69
78
|
src: publicUrl(row.storageKey),
|
|
70
79
|
srcset,
|
|
71
80
|
variants,
|
|
81
|
+
// A note without a source type is only evidence (e.g. the upload scan's pre-fill), not a disclosure —
|
|
82
|
+
// never emit a half-filled object a consumer might render as one.
|
|
83
|
+
aiDisclosure: row.aiSourceType ? { sourceType: row.aiSourceType as AiSourceType, note: row.aiNote ?? null } : null,
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
|