@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.
Files changed (55) hide show
  1. package/README.md +15 -8
  2. package/layers/admin/app/components/CollectionList.vue +5 -1
  3. package/layers/admin/app/components/PageFields.vue +5 -0
  4. package/layers/admin/app/components/SeoFields.vue +42 -0
  5. package/layers/admin/app/composables/useEditForm.ts +9 -3
  6. package/layers/admin/app/utils/edit-form.ts +9 -2
  7. package/layers/core/modules/kestrel/index.ts +11 -0
  8. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  9. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  10. package/layers/core/server/utils/collection-types.ts +4 -3
  11. package/layers/core/server/utils/defineCollection.ts +8 -1
  12. package/layers/core/server/utils/kestrel-config.ts +35 -1
  13. package/layers/core/server/utils/seo.ts +18 -0
  14. package/layers/core/server/utils/write-effects.ts +40 -0
  15. package/layers/fields/server/utils/buildCollection.ts +9 -7
  16. package/layers/media/app/components/KestrelImg.vue +29 -0
  17. package/layers/media/app/components/MediaLibrary.vue +7 -5
  18. package/layers/media/app/components/MediaViewer.vue +56 -7
  19. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  20. package/layers/media/app/utils/library.ts +2 -0
  21. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  22. package/layers/media/server/api/media/[id].patch.ts +25 -1
  23. package/layers/media/server/api/media/index.post.ts +19 -3
  24. package/layers/media/server/collections/media.ts +14 -0
  25. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  26. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  27. package/layers/media/server/utils/library.ts +2 -1
  28. package/layers/media/server/utils/record.ts +8 -0
  29. package/layers/media/server/utils/resolve.ts +12 -0
  30. package/layers/public/app/pages/[...slug].vue +30 -1
  31. package/layers/public/app/utils/json-ld.ts +139 -0
  32. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  33. package/layers/public/modules/prerender-routes/index.ts +5 -2
  34. package/layers/public/server/api/route.get.ts +9 -1
  35. package/layers/public/server/collections/redirects.ts +75 -0
  36. package/layers/public/server/plugins/03.redirects.ts +37 -0
  37. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  38. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  39. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  40. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  41. package/layers/public/server/utils/llms-full.ts +125 -0
  42. package/layers/public/server/utils/llms.ts +13 -0
  43. package/layers/public/server/utils/page-resolve.ts +112 -4
  44. package/layers/public/server/utils/publish/invalidation.ts +48 -3
  45. package/layers/public/server/utils/publish/publisher.ts +11 -5
  46. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  47. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  48. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  49. package/layers/public/server/utils/site-url.ts +8 -0
  50. package/layers/public/server/utils/sitemap.ts +5 -3
  51. package/layers/ui/app/components/field/Choice.vue +6 -1
  52. package/layers/ui/app/i18n/de.ts +13 -0
  53. package/layers/ui/app/i18n/en.ts +13 -0
  54. package/package.json +2 -1
  55. 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 onSaveAlt(alt: string) {
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 precondition header lets the
62
- // server 409 instead of silently losing a concurrent edit from another open viewer tab.
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="onSaveAlt" />
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); everything else is read-only metadata.
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
- const emit = defineEmits<{ 'update:open': [boolean]; save: [string] }>()
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
- // Seed (and re-seed) the draft whenever the dialog opens — immediate so a viewer mounted already-open
23
- // (or re-opened on a different file) shows the current alt rather than a stale/empty value.
24
- watch(() => props.open, (o) => { if (o) alt.value = props.file?.alt ?? '' }, { immediate: true })
25
- const dirty = computed(() => isImage.value && alt.value !== (props.file?.alt ?? ''))
26
- function save() { if (dirty.value && !props.busy) emit('save', alt.value) }
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
- const values = buildMediaValues({ storageKey, folder, filename, mime, ext, size: bytes.length, checksum, derived, translations })
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, createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
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