@michaelthielemann/kestrel 1.7.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 (104) hide show
  1. package/README.md +23 -12
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/admin/app/components/BlocksBody.vue +2 -1
  4. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  5. package/layers/admin/app/components/CollectionList.vue +13 -5
  6. package/layers/admin/app/components/EditorStatus.vue +11 -0
  7. package/layers/admin/app/components/PageFields.vue +5 -0
  8. package/layers/admin/app/components/SeoFields.vue +43 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +15 -6
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/edit-form.ts +9 -2
  17. package/layers/admin/app/utils/editor-expose.ts +8 -0
  18. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  19. package/layers/core/modules/kestrel/index.ts +12 -0
  20. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  21. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  22. package/layers/core/server/schema/introspect.ts +1 -1
  23. package/layers/core/server/schema/sync.ts +1 -1
  24. package/layers/core/server/utils/collection-types.ts +4 -3
  25. package/layers/core/server/utils/crud.ts +5 -4
  26. package/layers/core/server/utils/defineCollection.ts +8 -1
  27. package/layers/core/server/utils/kestrel-config.ts +42 -1
  28. package/layers/core/server/utils/seo.ts +18 -0
  29. package/layers/core/server/utils/write-effects.ts +40 -0
  30. package/layers/fields/server/field-registry/index.ts +2 -1
  31. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  32. package/layers/fields/server/utils/buildCollection.ts +9 -7
  33. package/layers/media/app/components/KestrelImg.vue +29 -0
  34. package/layers/media/app/components/MediaLibrary.vue +11 -6
  35. package/layers/media/app/components/MediaToolbar.vue +1 -1
  36. package/layers/media/app/components/MediaViewer.vue +56 -7
  37. package/layers/media/app/components/field/Media.vue +2 -0
  38. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  39. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  40. package/layers/media/app/utils/library.ts +2 -0
  41. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  42. package/layers/media/server/api/media/[id].patch.ts +25 -1
  43. package/layers/media/server/api/media/index.post.ts +19 -3
  44. package/layers/media/server/collections/media.ts +14 -0
  45. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  46. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  47. package/layers/media/server/utils/library.ts +2 -1
  48. package/layers/media/server/utils/record.ts +8 -0
  49. package/layers/media/server/utils/resolve.ts +12 -0
  50. package/layers/public/app/pages/[...slug].vue +57 -7
  51. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  52. package/layers/public/app/utils/json-ld.ts +139 -0
  53. package/layers/public/app/utils/preview-protocol.ts +36 -0
  54. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  55. package/layers/public/modules/prerender-routes/index.ts +5 -2
  56. package/layers/public/server/api/preview.get.ts +28 -0
  57. package/layers/public/server/api/preview.post.ts +93 -0
  58. package/layers/public/server/api/publish-status.get.ts +23 -9
  59. package/layers/public/server/api/publish.post.ts +84 -0
  60. package/layers/public/server/api/route.get.ts +9 -1
  61. package/layers/public/server/collections/redirects.ts +75 -0
  62. package/layers/public/server/plugins/03.redirects.ts +37 -0
  63. package/layers/public/server/plugins/zz.publish.ts +12 -3
  64. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  65. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  66. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  67. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  68. package/layers/public/server/tasks/publish/run.ts +2 -1
  69. package/layers/public/server/utils/llms-full.ts +125 -0
  70. package/layers/public/server/utils/llms.ts +13 -0
  71. package/layers/public/server/utils/page-resolve.ts +112 -4
  72. package/layers/public/server/utils/preview-token.ts +109 -0
  73. package/layers/public/server/utils/publish/invalidation.ts +72 -3
  74. package/layers/public/server/utils/publish/pending.ts +74 -0
  75. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  76. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  77. package/layers/public/server/utils/publish/publisher.ts +85 -14
  78. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  79. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  80. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  81. package/layers/public/server/utils/site-url.ts +8 -0
  82. package/layers/public/server/utils/sitemap.ts +5 -3
  83. package/layers/ui/app/components/field/Choice.vue +6 -1
  84. package/layers/ui/app/components/field/Datetime.vue +2 -0
  85. package/layers/ui/app/components/field/Repeater.vue +2 -0
  86. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  87. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  88. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  89. package/layers/ui/app/components/ui/Field.vue +1 -0
  90. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  91. package/layers/ui/app/components/ui/Icon.vue +2 -2
  92. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  93. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  94. package/layers/ui/app/components/ui/Select.vue +1 -0
  95. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  96. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  97. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  98. package/layers/ui/app/i18n/de.ts +23 -0
  99. package/layers/ui/app/i18n/en.ts +23 -0
  100. package/package.json +7 -1
  101. package/scripts/kestrel.mjs +7 -3
  102. package/scripts/lib/scaffold.mjs +23 -1
  103. package/templates/starter/app/blocks/Prose.vue +1 -0
  104. package/templates/starter/nuxt.config.ts +3 -0
@@ -1,16 +1,19 @@
1
1
  import sanitizeHtml from 'sanitize-html'
2
2
  import { RICHTEXT_LINK_SCHEME } from '../../app/utils/richtext-links'
3
3
 
4
+ // Kept deliberately in step with the editor's schema (`ui/Richtext.vue`): a tag allowed here that no
5
+ // extension can parse is not "supported", it is a delayed deletion — the editor drops it on load and the
6
+ // next save persists the loss. `richtext.dom.test.ts` asserts the two lists agree, so widening this one
7
+ // means teaching the editor the tag in the same change. Images belong in a media field or an image
8
+ // block, not in flow text; tables await an editor that can hold them.
4
9
  export const RICHTEXT_ALLOWLIST: sanitizeHtml.IOptions = {
5
10
  allowedTags: [
6
11
  'p', 'br', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'sub', 'sup', 'mark',
7
12
  'blockquote', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
8
- 'ul', 'ol', 'li', 'a', 'img', 'figure', 'figcaption', 'hr',
9
- 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
13
+ 'ul', 'ol', 'li', 'a', 'hr',
10
14
  ],
11
15
  allowedAttributes: {
12
16
  a: ['href', 'title', 'target', 'rel'],
13
- img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
14
17
  '*': ['class', 'style'],
15
18
  },
16
19
  allowedStyles: {
@@ -51,22 +51,24 @@ export function ensureBuilt(c: CollectionDef | BuiltCollection): BuiltCollection
51
51
  }
52
52
 
53
53
  /**
54
- * Build the `applyConditions` hook for a collection with conditional required fields, or `undefined`
55
- * when it has none (so CRUD skips it). The per-field schema relaxed every conditional field to optional
56
- * (it can't see siblings); this re-enforces `required` for the ones whose condition is met against the
57
- * whole record. Issues are keyed by the field's def name (so they map to the editor's per-field errors),
58
- * resolving each value at its column key (`<name>Id` for single relation/media).
54
+ * Build the `applyConditions` hook every pre-write check the per-field schema can't do — or `undefined`
55
+ * when the collection needs none (so CRUD skips it). Two sources, both of which need the whole record:
56
+ * the per-field schema relaxed every conditional field to optional (it can't see siblings), so `required`
57
+ * is re-enforced here for the ones whose condition is met; and the collection's own `def.validate`, for a
58
+ * rule spanning fields. Issues are keyed by the field's def name (so they map to the editor's per-field
59
+ * errors), resolving each value at its column key (`<name>Id` for single relation/media).
59
60
  */
60
61
  function buildApplyConditions(def: CollectionDef): BuiltCollection['applyConditions'] {
61
62
  const required = Object.entries(def.fields).filter(([, f]) => f.condition && f.required)
62
- if (!required.length) return undefined
63
+ if (!required.length && !def.validate) return undefined
64
+ if (!required.length) return (record) => ({ issues: def.validate!(record) })
63
65
  return (record) => {
64
66
  const scope: Record<string, unknown> = {}
65
67
  for (const [key, field] of Object.entries(def.fields)) scope[key] = record[resolveColumnName(key, field).jsKey]
66
68
  const issues = required
67
69
  .filter(([key, field]) => evaluateCondition(field.condition!, scope) && isEmptyValue(record[resolveColumnName(key, field).jsKey]))
68
70
  .map(([key]) => ({ path: [key], message: 'This field is required.' }))
69
- return { issues }
71
+ return { issues: [...issues, ...(def.validate?.(record) ?? [])] }
70
72
  }
71
73
  }
72
74
 
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useBlurUp } from '../composables/useBlurUp'
3
3
  import { useMediaVariant } from '../composables/useMediaVariant'
4
+ import { aiSourceTypeLabel } from '../utils/ai-disclosure'
4
5
  import type { ResolvedMedia } from '../../server/utils/resolve'
5
6
  import type { VariantFit, VariantFormat } from '../../../core/server/utils/kestrel-config'
6
7
 
@@ -17,6 +18,11 @@ const props = defineProps<{
17
18
  formats?: VariantFormat[]
18
19
  sizes?: string
19
20
  priority?: boolean
21
+ /** Opt into a structurally-present but UNSTYLED EU AI Act disclosure badge (`.kestrel-img__ai-badge`),
22
+ * rendered only when `media.aiDisclosure` is set. Off by default: Kestrel must never publish a claim
23
+ * the consumer did not ask for, in a place/language/style it cannot know. The alternative escape hatch
24
+ * is to read `media.aiDisclosure` and render your own element — see docs/media-uploads.md. */
25
+ aiBadge?: boolean
20
26
  }>()
21
27
 
22
28
  const { model } = useMediaVariant(
@@ -44,6 +50,11 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
44
50
  decoding="async"
45
51
  @load="onLoad"
46
52
  >
53
+ <span
54
+ v-if="aiBadge && media?.aiDisclosure"
55
+ class="kestrel-img__ai-badge"
56
+ :data-ai-source-type="media.aiDisclosure.sourceType"
57
+ >{{ media.aiDisclosure.note ?? aiSourceTypeLabel(media.aiDisclosure.sourceType) }}</span>
47
58
  </picture>
48
59
  </template>
49
60
 
@@ -63,3 +74,21 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
63
74
  }
64
75
  }
65
76
  </style>
77
+
78
+ <!-- Deliberately UNSCOPED: a scoped rule carries the `[data-v-*]` attribute selector, which out-specifies
79
+ a consumer stylesheet targeting `.kestrel-img__ai-badge` and would make the badge un-restylable.
80
+ Layout only — no color, background, border, radius or font — so nothing is visible as a "badge" until
81
+ the consumer's own CSS says so. Scoped to pictures that actually contain one, so a consumer who never
82
+ sets `ai-badge` gets no rule at all. -->
83
+ <style>
84
+ picture:has(> .kestrel-img__ai-badge) {
85
+ position: relative;
86
+ display: inline-block;
87
+ }
88
+ .kestrel-img__ai-badge {
89
+ position: absolute;
90
+ inset-block-end: 0;
91
+ inset-inline-start: 0;
92
+ pointer-events: none;
93
+ }
94
+ </style>
@@ -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) {
@@ -82,7 +84,8 @@ function onSelect(item: LibraryItem, mods: { toggle: boolean; range: boolean })
82
84
  if (props.pick && props.multiple) {
83
85
  if (item.type !== 'file') return
84
86
  const s = new Set(picked.value)
85
- s.has(item.file.id) ? s.delete(item.file.id) : s.add(item.file.id)
87
+ if (s.has(item.file.id)) s.delete(item.file.id)
88
+ else s.add(item.file.id)
86
89
  picked.value = s
87
90
  return
88
91
  }
@@ -288,6 +291,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
288
291
  </script>
289
292
 
290
293
  <template>
294
+ <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -- @click.self clears selection as a mouse-only bulk convenience; Space-toggle on each MediaGrid/MediaTable item already gives keyboard users the same end state -->
291
295
  <section class="media-library" @click.self="lib.clear()" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
292
296
  <MediaPathBar :folder="folder" @navigate="lib.navigate" />
293
297
  <MediaToolbar
@@ -301,6 +305,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
301
305
  />
302
306
  <UiMenu :items="localizedMenu" @select="onMenuSelect">
303
307
  <!-- @contextmenu.capture sets the menu target before Reka's own handler opens the menu, so menuItems is correct when the menu renders -->
308
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- contextmenu fires natively via Shift+F10/the Menu key when a MediaGrid/MediaTable item has focus, so this capture handler is already keyboard-reachable -->
304
309
  <div class="media-library__items" @contextmenu.capture="onContextMenu">
305
310
  <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
306
311
  <p v-else-if="!loading && !items.length" class="media-library__empty">{{ t('media.folderEmpty') }}</p>
@@ -333,7 +338,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
333
338
  @update:open="(v) => { if (!v) upload.resolveAll('skip') }"
334
339
  />
335
340
  <MediaNewFolderDialog v-model:open="newFolderOpen" @create="onCreateFolder" />
336
- <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" />
337
342
  <UiAlert v-if="opError && !deleteOpen && !renameOpen" variant="error">{{ opError }}</UiAlert>
338
343
  <MediaDeleteDialog
339
344
  :open="deleteOpen"
@@ -47,7 +47,7 @@ function onFiles(e: Event) {
47
47
  <div class="media-toolbar__actions">
48
48
  <UiButtonGroup v-model="viewModel" :options="VIEW_OPTIONS" :aria-label="t('mediaToolbar.viewAriaLabel')" />
49
49
  <span class="media-toolbar__divider" aria-hidden="true"></span>
50
- <input ref="fileInput" type="file" multiple class="media-toolbar__file" @change="onFiles" />
50
+ <input ref="fileInput" type="file" multiple class="media-toolbar__file" :aria-label="t('mediaToolbar.upload')" @change="onFiles" />
51
51
  <UiButton :disabled="disabled" @click="fileInput?.click()"><UiIcon name="upload" :size="16" /> {{ t('mediaToolbar.upload') }}</UiButton>
52
52
  <UiButton :disabled="disabled" @click="emit('new-folder')"><UiIcon name="folder-plus" :size="16" /> {{ t('mediaToolbar.newFolder') }}</UiButton>
53
53
  </div>
@@ -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); }
@@ -117,7 +117,9 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
117
117
  <UiField :id="id" :label="name" :error="error" :required="required">
118
118
  <template #default="f">
119
119
  <div class="field-media">
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 -->
120
121
  <ul v-if="resolved.length" ref="itemsEl" class="field-media__items" @dragleave="onDragLeave">
122
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
121
123
  <li
122
124
  v-for="(r, i) in resolved"
123
125
  :key="r.id"
@@ -119,7 +119,8 @@ export function useMediaLibrary(opts: { urlSync?: boolean; accept?: 'image' | 'a
119
119
  function select(item: LibraryItem) { const k = itemKey(item); selected.value = new Set([k]); anchorKey = k }
120
120
  function toggle(item: LibraryItem) {
121
121
  const k = itemKey(item); const s = new Set(selected.value)
122
- s.has(k) ? s.delete(k) : s.add(k)
122
+ if (s.has(k)) s.delete(k)
123
+ else s.add(k)
123
124
  selected.value = s; anchorKey = k
124
125
  }
125
126
  function range(item: LibraryItem) { selected.value = computeRange(orderedKeys.value, anchorKey ?? itemKey(item), itemKey(item), selected.value) }
@@ -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
+ }