@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.
- package/README.md +23 -12
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +13 -5
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +43 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +15 -6
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +12 -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/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/defineCollection.ts +8 -1
- package/layers/core/server/utils/kestrel-config.ts +42 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- 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 +11 -6
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/MediaViewer.vue +56 -7
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- 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 +57 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/app/utils/preview-protocol.ts +36 -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/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/api/route.get.ts +9 -1
- package/layers/public/server/collections/redirects.ts +75 -0
- package/layers/public/server/plugins/03.redirects.ts +37 -0
- package/layers/public/server/plugins/zz.publish.ts +12 -3
- 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/tasks/publish/run.ts +2 -1
- 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/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +72 -3
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +85 -14
- package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
- package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
- package/layers/public/server/utils/richtext-markdown.ts +260 -0
- package/layers/public/server/utils/site-url.ts +8 -0
- package/layers/public/server/utils/sitemap.ts +5 -3
- package/layers/ui/app/components/field/Choice.vue +6 -1
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +23 -0
- package/layers/ui/app/i18n/en.ts +23 -0
- package/package.json +7 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- package/templates/starter/app/blocks/Prose.vue +1 -0
- 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', '
|
|
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
|
|
55
|
-
* when
|
|
56
|
-
* (it can't see siblings)
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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) {
|
|
@@ -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)
|
|
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="
|
|
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)
|
|
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); }
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
+
}
|