@michaelthielemann/kestrel 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +6 -8
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { media } from '../../collections/media'
|
|
3
|
-
import { useStorageDriver } from '../../../../core/server/utils/storage'
|
|
4
|
-
import { deleteAffected } from '../../utils/media-ops'
|
|
5
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
6
|
-
|
|
7
|
-
export default defineEventHandler(async (event) => {
|
|
8
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
9
|
-
requireMediaCollection()
|
|
10
|
-
const id = requireId(event)
|
|
11
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
12
|
-
const db = useDb()
|
|
13
|
-
// deleteAffected silently no-ops on a missing row, so keep an explicit 404 guard.
|
|
14
|
-
const row = db.select({ id: cols.id }).from(media).where(eq(cols.id, id)).get() as { id: number } | undefined
|
|
15
|
-
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
16
|
-
await deleteAffected(db, useStorageDriver(), [{ type: 'file', id }], false)
|
|
17
|
-
return { deleted: true, id }
|
|
18
|
-
})
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import media from '../../collections/media'
|
|
2
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler((event) => {
|
|
5
|
-
requireMediaCollection()
|
|
6
|
-
const query = getQuery(event)
|
|
7
|
-
const depth = Number(query.depth ?? 0)
|
|
8
|
-
const locale = query.locale as string | undefined
|
|
9
|
-
return getOne(useDb(), media, requireId(event), depth, locale)
|
|
10
|
-
})
|
|
@@ -1,66 +0,0 @@
|
|
|
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,65 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import builtMedia, { media } from '../../collections/media'
|
|
3
|
-
import { mergeTranslations, type Translations } from '../../utils/translations'
|
|
4
|
-
import { emitMediaWrite } from '../../utils/media-write'
|
|
5
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
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
|
-
|
|
30
|
-
export default defineEventHandler(async (event) => {
|
|
31
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
32
|
-
requireMediaCollection()
|
|
33
|
-
const id = requireId(event)
|
|
34
|
-
const body = await readBody(event)
|
|
35
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
36
|
-
const db = useDb()
|
|
37
|
-
const expectedUpdatedAt = readIfUnmodifiedSince(event)
|
|
38
|
-
const patch: Record<string, unknown> = { updatedAt: new Date() }
|
|
39
|
-
// `folder` is immutable via PATCH: relocating a file means moving its storage object and every
|
|
40
|
-
// derivative, which belongs to the media-library slice. Writing only the column would desync it
|
|
41
|
-
// from the folder baked into storageKey, which is what the public URL is built from.
|
|
42
|
-
const needsCurrent = expectedUpdatedAt !== undefined
|
|
43
|
-
|| (body?.translations && typeof body.translations === 'object' && !Array.isArray(body.translations))
|
|
44
|
-
const current = needsCurrent
|
|
45
|
-
? db.select().from(media).where(eq(cols.id, id)).get() as { translations?: Translations; updatedAt?: Date } | undefined
|
|
46
|
-
: undefined
|
|
47
|
-
// Optimistic concurrency: honor the same X-Kestrel-If-Unmodified-Since precondition the collection CRUD
|
|
48
|
-
// does, so two media-viewer tabs editing the same asset's alt text can't silently overwrite each other.
|
|
49
|
-
if (expectedUpdatedAt !== undefined && current) {
|
|
50
|
-
const cur = current.updatedAt instanceof Date ? current.updatedAt.getTime() : new Date(current.updatedAt as never).getTime()
|
|
51
|
-
if (cur !== expectedUpdatedAt) {
|
|
52
|
-
throw createError({ statusCode: 409, statusMessage: 'This media item changed since you opened it. Reload before saving.' })
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (body?.translations && typeof body.translations === 'object' && !Array.isArray(body.translations)) {
|
|
56
|
-
// Media keeps all locales in one JSON column. Deep-merge per locale so a partial PATCH (e.g. just
|
|
57
|
-
// `en.alt` from the media viewer) keeps the other locales AND the locale's other fields intact.
|
|
58
|
-
patch.translations = mergeTranslations(current?.translations, body.translations as Translations)
|
|
59
|
-
}
|
|
60
|
-
Object.assign(patch, readAiDisclosure(body))
|
|
61
|
-
const row = db.update(media).set(patch).where(eq(cols.id, id)).returning().get() as Record<string, unknown> | undefined
|
|
62
|
-
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
63
|
-
emitMediaWrite({ id }, row) // alt/title/description changed → re-render embedding pages (fresh alt text)
|
|
64
|
-
return row
|
|
65
|
-
})
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { runBackfill } from '../../utils/backfill'
|
|
2
|
-
import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
|
|
3
|
-
import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
|
|
4
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Admin-triggered variant backfill/prune (the Mediathek "Regenerate/Prune" action). `{ check: true }` is a
|
|
8
|
-
* dry-run reporting the plan (rows / would-generate / would-prune) — the UI shows it before applying.
|
|
9
|
-
* Synchronous: fine for a dry-run and moderate libraries; a very large library should run the
|
|
10
|
-
* `media:backfill` task on a schedule instead (a full-original GET + sharp per row).
|
|
11
|
-
*/
|
|
12
|
-
export default defineEventHandler(async (event) => {
|
|
13
|
-
requireAdmin(event)
|
|
14
|
-
requireMediaCollection()
|
|
15
|
-
const body = (await readBody(event).catch(() => ({}))) as { check?: boolean }
|
|
16
|
-
const policy = mediaRuntimeConfig().imagePolicy ?? DEFAULT_IMAGE_POLICY
|
|
17
|
-
return runBackfill(useDb(), useStorageDriver(), policy, { check: !!body?.check })
|
|
18
|
-
})
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { runRelocation } from '../../utils/relocate-ops'
|
|
2
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler((event) => {
|
|
5
|
-
requireMediaCollection()
|
|
6
|
-
return runRelocation(event, (items, body) => ({ type: 'copy', items, dest: typeof body.dest === 'string' ? body.dest : '' }))
|
|
7
|
-
})
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { deleteAffected } from '../../utils/media-ops'
|
|
2
|
-
import { coerceOpItems } from '../../utils/relocate-ops'
|
|
3
|
-
import { useStorageDriver } from '../../../../core/server/utils/storage'
|
|
4
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
5
|
-
|
|
6
|
-
export default defineEventHandler(async (event) => {
|
|
7
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
8
|
-
requireMediaCollection()
|
|
9
|
-
const body = await readBody(event)
|
|
10
|
-
const items = coerceOpItems(body?.items)
|
|
11
|
-
if (!items.length) throw createError({ statusCode: 400, statusMessage: 'No items to delete' })
|
|
12
|
-
return deleteAffected(useDb(), useStorageDriver(), items, body?.dryRun === true)
|
|
13
|
-
})
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { ensureFolder } from '../../utils/folders'
|
|
2
|
-
import { sanitizeFolder } from '../../utils/naming'
|
|
3
|
-
import { useStorageDriver } from '../../../../core/server/utils/storage'
|
|
4
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
5
|
-
|
|
6
|
-
export default defineEventHandler(async (event) => {
|
|
7
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
8
|
-
requireMediaCollection()
|
|
9
|
-
const body = await readBody(event)
|
|
10
|
-
const path = sanitizeFolder(typeof body?.path === 'string' ? body.path : '')
|
|
11
|
-
if (!path) throw createError({ statusCode: 400, statusMessage: 'A non-empty folder path is required' })
|
|
12
|
-
// ensureDir (mkdir, can throw ENAMETOOLONG/ENOTDIR/etc.) runs BEFORE the row commit — a folder row
|
|
13
|
-
// with no backing directory would list forever with every upload/retry into it failing the same way.
|
|
14
|
-
await useStorageDriver().ensureDir?.(path)
|
|
15
|
-
const db = useDb()
|
|
16
|
-
ensureFolder(db, path)
|
|
17
|
-
return { path }
|
|
18
|
-
})
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import media from '../../collections/media'
|
|
2
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler((event) => {
|
|
5
|
-
requireMediaCollection()
|
|
6
|
-
const query = getQuery(event)
|
|
7
|
-
return list(useDb(), media, {
|
|
8
|
-
locale: query.locale as string | undefined,
|
|
9
|
-
sort: query.sort as string | undefined,
|
|
10
|
-
page: query.page ? Number(query.page) : undefined,
|
|
11
|
-
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
12
|
-
filter: parseFilter(query as Record<string, unknown>),
|
|
13
|
-
depth: query.depth ? Number(query.depth) : 0,
|
|
14
|
-
})
|
|
15
|
-
})
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { eq, inArray, getTableColumns, isNull } from 'drizzle-orm'
|
|
3
|
-
import builtMedia, { media } from '../../collections/media'
|
|
4
|
-
import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
|
|
5
|
-
import { sniffMime, extForMime, resolveAllowedMimes } from '../../utils/sniff'
|
|
6
|
-
import { sanitizeFolder, buildKey, suggestFreeName, withExtension } from '../../utils/naming'
|
|
7
|
-
import { sanitizeSvg } from '../../utils/sanitize-svg'
|
|
8
|
-
import { deriveImage, RASTER, type DerivedImage } from '../../utils/derive'
|
|
9
|
-
import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
|
|
10
|
-
import { buildMediaValues, derivativeKey, type DerivativeManifest } from '../../utils/record'
|
|
11
|
-
import { persistUpload } from '../../utils/persist-upload'
|
|
12
|
-
import type { Translations } from '../../utils/translations'
|
|
13
|
-
import { activeVariants } from '../../utils/variants'
|
|
14
|
-
import { ensureFolder } from '../../utils/folders'
|
|
15
|
-
import { primaryLocale } from '../../../../core/server/utils/locale'
|
|
16
|
-
import { isUniqueViolation } from '../../../../core/server/utils/crud'
|
|
17
|
-
import { withLock, mediaLockKey } from '../../../../core/server/utils/key-lock'
|
|
18
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
19
|
-
import { emitMediaWrite } from '../../utils/media-write'
|
|
20
|
-
import { detectAiSignal, aiSignalNote } from '../../utils/ai-signal-detect'
|
|
21
|
-
import { aiDisclosureEnabled } from '../../utils/ai-disclosure-enabled'
|
|
22
|
-
|
|
23
|
-
export default defineEventHandler(async (event) => {
|
|
24
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
25
|
-
requireMediaCollection()
|
|
26
|
-
const cfg = mediaRuntimeConfig()
|
|
27
|
-
const len = Number(getRequestHeader(event, 'content-length'))
|
|
28
|
-
// A missing/garbage Content-Length means a chunked transfer, which readMultipartFormData would
|
|
29
|
-
// buffer with no ceiling — refuse it up front. The declared length is then a cheap pre-check; the
|
|
30
|
-
// real cap is re-asserted on the parsed bytes below (a client can under-declare Content-Length).
|
|
31
|
-
if (!Number.isFinite(len) || len < 0) {
|
|
32
|
-
throw createError({ statusCode: 411, statusMessage: 'Length required' })
|
|
33
|
-
}
|
|
34
|
-
if (len > cfg.maxUploadBytes) {
|
|
35
|
-
throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const parts = await readMultipartFormData(event)
|
|
39
|
-
if (!parts) throw createError({ statusCode: 400, statusMessage: 'Expected multipart/form-data' })
|
|
40
|
-
const filePart = parts.find((p) => p.name === 'file' && p.filename)
|
|
41
|
-
if (!filePart || !filePart.data?.length) throw createError({ statusCode: 400, statusMessage: 'Missing file' })
|
|
42
|
-
if (filePart.data.length > cfg.maxUploadBytes) throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
|
|
43
|
-
const text = (n: string) => parts.find((p) => p.name === n && !p.filename)?.data.toString('utf8')
|
|
44
|
-
|
|
45
|
-
let bytes = filePart.data as Buffer
|
|
46
|
-
const allowed = resolveAllowedMimes(cfg.allowedMimes)
|
|
47
|
-
const mime = await sniffMime(bytes)
|
|
48
|
-
if (!mime || !allowed.has(mime)) {
|
|
49
|
-
throw createError({ statusCode: 415, statusMessage: `Unsupported media type${mime ? `: ${mime}` : ''}` })
|
|
50
|
-
}
|
|
51
|
-
const ext = extForMime(mime)
|
|
52
|
-
|
|
53
|
-
if (mime === 'image/svg+xml') bytes = Buffer.from(sanitizeSvg(bytes.toString('utf8')))
|
|
54
|
-
|
|
55
|
-
// server-controlled key only (folder + filename are both sanitized → no path traversal)
|
|
56
|
-
const folder = sanitizeFolder(text('folder') ?? '')
|
|
57
|
-
const overwrite = text('overwrite') === 'true'
|
|
58
|
-
// The stored extension follows the sniffed type, never the client filename (content-type confusion).
|
|
59
|
-
const filename = withExtension(text('filename') ?? filePart.filename ?? 'file', ext)
|
|
60
|
-
const storageKey = buildKey(folder, filename)
|
|
61
|
-
|
|
62
|
-
const db = useDb()
|
|
63
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
64
|
-
const driver = useStorageDriver()
|
|
65
|
-
|
|
66
|
-
const checksum = createHash('sha256').update(bytes).digest('hex')
|
|
67
|
-
// Narrow generation: derive exactly the currently-registered variant set (the media_settings registry),
|
|
68
|
-
// falling back to the config policy's variants when the registry is empty (fresh project / no scan yet).
|
|
69
|
-
// Done BEFORE the per-key lock so uploads of different files derive in parallel (sharp is the hot path).
|
|
70
|
-
const policy = cfg.imagePolicy ?? DEFAULT_IMAGE_POLICY
|
|
71
|
-
// A raster that sniffs fine but is truncated/corrupt (or a misconfigured crop position slipping past
|
|
72
|
-
// validation) makes sharp reject mid-pipeline — a bare unwrapped throw here would 500 with an internal
|
|
73
|
-
// vips error string. Surface it as a real, unprocessable-upload 4xx instead.
|
|
74
|
-
let derived: DerivedImage | undefined
|
|
75
|
-
if (RASTER.has(mime)) {
|
|
76
|
-
try {
|
|
77
|
-
derived = await deriveImage(bytes, { ...policy, variants: activeVariants(db, policy.variants, policy.presets) })
|
|
78
|
-
} catch (error) {
|
|
79
|
-
throw createError({ statusCode: 422, statusMessage: 'Could not process this image', data: { reason: (error as Error)?.message } })
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const alt = text('alt')
|
|
84
|
-
const title = text('title')
|
|
85
|
-
const description = text('description')
|
|
86
|
-
const translations = alt || title || description ? { [primaryLocale()]: { alt, title, description } } : {}
|
|
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
|
-
|
|
99
|
-
// Serialize the collision-check → put → insert per storageKey: two concurrent uploads to the SAME key
|
|
100
|
-
// (or a backfill re-deriving it) must not interleave, or last-writer-wins would leave the winning row
|
|
101
|
-
// describing the loser's bytes. Different keys never share a lock, so throughput is unaffected.
|
|
102
|
-
const { row, created } = await withLock(mediaLockKey(storageKey), async (): Promise<{ row: Record<string, unknown>; created: boolean }> => {
|
|
103
|
-
const existing = db.select().from(media).where(eq(cols.storageKey, storageKey)).get() as
|
|
104
|
-
| { id: number; derivatives?: DerivativeManifest; translations?: Translations; aiNote?: string | null }
|
|
105
|
-
| undefined
|
|
106
|
-
|
|
107
|
-
// An object on disk with no media row (another media's derivative, or an orphan) must never be
|
|
108
|
-
// clobbered — not even under overwrite, which only replaces a managed media row.
|
|
109
|
-
const blockedByDisk = !existing && (await driver.exists?.(storageKey))
|
|
110
|
-
if ((existing && !overwrite) || blockedByDisk) {
|
|
111
|
-
// One query: the filenames already taken in this folder, used to suggest a free name in memory.
|
|
112
|
-
const takenNames = new Set(
|
|
113
|
-
db.select({ f: cols.filename }).from(media)
|
|
114
|
-
.where(folder ? eq(cols.folder, folder) : isNull(cols.folder))
|
|
115
|
-
.all().map((r) => (r as { f: string }).f),
|
|
116
|
-
)
|
|
117
|
-
if (blockedByDisk) takenNames.add(filename)
|
|
118
|
-
throw createError({
|
|
119
|
-
statusCode: 409,
|
|
120
|
-
statusMessage: 'A file with this name already exists',
|
|
121
|
-
data: { storageKey, existingId: existing?.id, suggestion: suggestFreeName(filename, (n) => !takenNames.has(n)) },
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Reserve the derivative namespace: `derivativeKey` mints a syntactically valid ORIGINAL key
|
|
126
|
-
// (`<key>-<name>.<format>`), so a same-named upload elsewhere (or a coincidentally matching filename)
|
|
127
|
-
// can already own the exact key this derive is about to `driver.put` over. The create-path write below
|
|
128
|
-
// has no per-key collision check of its own, so refuse up front rather than silently overwrite another
|
|
129
|
-
// row's blob out from under it.
|
|
130
|
-
const derivKeys = (derived?.variants ?? []).map((v) => derivativeKey(storageKey, v.name, v.format))
|
|
131
|
-
if (derivKeys.length) {
|
|
132
|
-
const clash = db.select({ storageKey: cols.storageKey }).from(media).where(inArray(cols.storageKey, derivKeys)).all() as { storageKey: string }[]
|
|
133
|
-
if (clash.length) {
|
|
134
|
-
// Not a 409: the uploader reads that status as the rename/overwrite/skip name-conflict contract,
|
|
135
|
-
// and neither branch resolves this — the requested name is free, and overwriting it re-derives the
|
|
136
|
-
// very same blocked keys. The message names the file the user actually dropped, plus the existing
|
|
137
|
-
// one standing in its way, so the remedy (rename either) is visible from the upload dialog.
|
|
138
|
-
throw createError({
|
|
139
|
-
statusCode: 422,
|
|
140
|
-
statusMessage: `Cannot store "${filename}": the existing file "${clash[0]!.storageKey}" already uses a name reserved for one of its generated image sizes. Rename either file, then upload again.`,
|
|
141
|
-
data: { storageKey, clashKeys: clash.map((c) => c.storageKey) },
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (folder) ensureFolder(db, folder)
|
|
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 })
|
|
151
|
-
try {
|
|
152
|
-
// persistUpload writes the objects then the row; on a create-path failure it removes the freshly-written
|
|
153
|
-
// blobs so a partial upload never strands orphans that permanently 409-block the filename.
|
|
154
|
-
const saved = await persistUpload(db, driver, { storageKey, bytes, mime, derived, values, existing, overwrite })
|
|
155
|
-
return { row: saved, created: !(existing && overwrite) }
|
|
156
|
-
} catch (error) {
|
|
157
|
-
// A double-submit that slips through the check surfaces as a UNIQUE violation → 409, not a raw 500.
|
|
158
|
-
if (isUniqueViolation(error)) {
|
|
159
|
-
throw createError({ statusCode: 409, statusMessage: 'A file with this name already exists', data: { storageKey } })
|
|
160
|
-
}
|
|
161
|
-
throw error
|
|
162
|
-
}
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
// Only an overwrite can invalidate published output: it rewrites the bytes, dimensions, thumbhash and
|
|
166
|
-
// derivative manifest under an unchanged id, so every page embedding it holds srcset candidates that no
|
|
167
|
-
// longer exist. Gate on `created`, not the requested `overwrite` flag — an overwrite request that finds
|
|
168
|
-
// no row inserts a fresh one, which nothing published can reference yet.
|
|
169
|
-
if (!created) emitMediaWrite({ id: row.id as number }, row)
|
|
170
|
-
|
|
171
|
-
setResponseStatus(event, created ? 201 : 200)
|
|
172
|
-
return { ...row, url: driver.publicUrl(storageKey) }
|
|
173
|
-
})
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { listLibrary } from '../../utils/library'
|
|
2
|
-
import { useStorageDriver } from '../../../../core/server/utils/storage'
|
|
3
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
4
|
-
|
|
5
|
-
export default defineEventHandler((event) => {
|
|
6
|
-
requireMediaCollection()
|
|
7
|
-
const query = getQuery(event)
|
|
8
|
-
const driver = useStorageDriver()
|
|
9
|
-
return listLibrary(useDb(), {
|
|
10
|
-
folder: typeof query.folder === 'string' ? query.folder : '',
|
|
11
|
-
search: typeof query.search === 'string' ? query.search : undefined,
|
|
12
|
-
type: query.type === 'image' ? 'image' : 'all',
|
|
13
|
-
page: query.page ? Number(query.page) : undefined,
|
|
14
|
-
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
15
|
-
sort: typeof query.sort === 'string' ? query.sort : undefined,
|
|
16
|
-
}, (key) => driver.publicUrl(key))
|
|
17
|
-
})
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { runRelocation } from '../../utils/relocate-ops'
|
|
2
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler((event) => {
|
|
5
|
-
requireMediaCollection()
|
|
6
|
-
return runRelocation(event, (items, body) => ({ type: 'move', items, dest: typeof body.dest === 'string' ? body.dest : '' }))
|
|
7
|
-
})
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { runRelocation } from '../../utils/relocate-ops'
|
|
2
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler((event) => {
|
|
5
|
-
requireMediaCollection()
|
|
6
|
-
return runRelocation(event, (items, body) => ({ type: 'rename', items, name: typeof body.name === 'string' ? body.name : '' }))
|
|
7
|
-
})
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { resolveManyByIds } from '../../utils/resolve'
|
|
2
|
-
import { useStorageDriver } from '../../../../core/server/utils/storage'
|
|
3
|
-
import { primaryLocale } from '../../../../core/server/utils/locale'
|
|
4
|
-
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
5
|
-
|
|
6
|
-
export default defineEventHandler((event) => {
|
|
7
|
-
requireMediaCollection()
|
|
8
|
-
const q = getQuery(event)
|
|
9
|
-
const ids = String(q.ids ?? '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0)
|
|
10
|
-
const locale = typeof q.locale === 'string' ? q.locale : primaryLocale()
|
|
11
|
-
const driver = useStorageDriver()
|
|
12
|
-
return { data: resolveManyByIds(useDb(), ids, locale, (k) => driver.publicUrl(k)) }
|
|
13
|
-
})
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
-
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
-
|
|
4
|
-
// The usage-driven variant REGISTRY: a single-row store of the active image-variant set (name × size ×
|
|
5
|
-
// format presets) that the upload path derives and the generate-time scan reconciles. A singleton so it
|
|
6
|
-
// reuses the schema engine + (later) the singleton editor, but `nav: false` — it is a system/config store,
|
|
7
|
-
// not content, and must not appear as a flat top-level rail item beside the media library. It is surfaced
|
|
8
|
-
// (if at all) through a Media sub-menu once the rail supports nested navigation.
|
|
9
|
-
const built = buildCollection(defineCollection({
|
|
10
|
-
name: 'media_settings',
|
|
11
|
-
mode: 'single',
|
|
12
|
-
translatable: false,
|
|
13
|
-
builtin: true,
|
|
14
|
-
nav: false,
|
|
15
|
-
label: { singular: 'Media settings', plural: 'Media settings' },
|
|
16
|
-
icon: 'sliders',
|
|
17
|
-
fields: {
|
|
18
|
-
// The resolved variant set (`StoredVariant[]`): each entry carries its provenance (`source`/`pinned`)
|
|
19
|
-
// so the scan reconcile can replace discovered entries while preserving hand-authored ones.
|
|
20
|
-
variants: { type: 'json' },
|
|
21
|
-
},
|
|
22
|
-
}))
|
|
23
|
-
|
|
24
|
-
export const mediaSettings = built.table
|
|
25
|
-
export default built
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
-
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
-
|
|
4
|
-
const built = buildCollection(defineCollection({
|
|
5
|
-
name: 'media',
|
|
6
|
-
mode: 'multi',
|
|
7
|
-
translatable: false,
|
|
8
|
-
builtin: true,
|
|
9
|
-
label: { singular: 'Media', plural: 'Media' },
|
|
10
|
-
icon: 'image',
|
|
11
|
-
fields: {
|
|
12
|
-
storageKey: { type: 'text', required: true, unique: true },
|
|
13
|
-
// Indexed: the library lists/filters/rolls-up recursive sizes by folder on every request.
|
|
14
|
-
folder: { type: 'text', index: true },
|
|
15
|
-
filename: { type: 'text', required: true },
|
|
16
|
-
mime: { type: 'text', required: true },
|
|
17
|
-
ext: { type: 'text', required: true },
|
|
18
|
-
size: { type: 'number', required: true },
|
|
19
|
-
width: { type: 'number' },
|
|
20
|
-
height: { type: 'number' },
|
|
21
|
-
checksum: { type: 'text' },
|
|
22
|
-
thumbhash: { type: 'text' },
|
|
23
|
-
derivatives: { type: 'json' },
|
|
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' },
|
|
39
|
-
},
|
|
40
|
-
}))
|
|
41
|
-
|
|
42
|
-
export const media = built.table
|
|
43
|
-
export default built
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
|
|
3
|
-
// Path-based folder registry: persists folders (incl. empty ones) and is the authoritative
|
|
4
|
-
// tree for media navigation. `path` is the normalized folder path (root '' is never a row).
|
|
5
|
-
export const folders = sqliteTable('folders', {
|
|
6
|
-
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
7
|
-
path: text('path').notNull().unique(),
|
|
8
|
-
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
9
|
-
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
10
|
-
})
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
2
|
-
import { planMediaDeletion } from '../utils/media-cleanup'
|
|
3
|
-
import { useStorageDriver } from '../../../core/server/utils/storage'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Storage GC for media-record deletes. The media library's own `deleteAffected` deletes storage inline
|
|
7
|
-
* (and bypasses the write bus); this covers EVERY OTHER delete path — the generic CRUD `remove()`, which
|
|
8
|
-
* emits a delete event — by removing the row's original + all derivatives from storage. So a media record
|
|
9
|
-
* is never removed while leaving orphaned files: principle 4 (output ≡ DB) for media, on every path.
|
|
10
|
-
* Fire-and-forget (best-effort, like the publisher); the driver's delete is idempotent, errors are logged.
|
|
11
|
-
*/
|
|
12
|
-
export default defineNitroPlugin(() => {
|
|
13
|
-
registerWriteListener((event) => {
|
|
14
|
-
const keys = planMediaDeletion(event)
|
|
15
|
-
if (!keys.length) return
|
|
16
|
-
const driver = useStorageDriver()
|
|
17
|
-
void Promise.all(keys.map((key) => driver.delete(key)))
|
|
18
|
-
.catch((error) => console.error('[kestrel] media storage cleanup failed:', error))
|
|
19
|
-
})
|
|
20
|
-
})
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|