@michaelthielemann/kestrel 3.0.1 → 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 +1 -3
- 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/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -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,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Upload-time scan for signals that a file was AI-generated or -manipulated (EU AI Act Art. 50).
|
|
3
|
-
*
|
|
4
|
-
* **This produces EVIDENCE, never a classification.** Everything it finds is quoted into the free-text
|
|
5
|
-
* `aiNote`; the legal `aiSourceType` stays a deliberate human decision, because a signal is neither proof
|
|
6
|
-
* nor its absence a disproof: metadata survives no re-save, screenshot or re-encode, and an upstream file
|
|
7
|
-
* can be mislabeled or forged. In particular a C2PA manifest is reported as PRESENT only — verifying its
|
|
8
|
-
* signature needs the full C2PA SDK plus a trust list, which Kestrel deliberately does not ship.
|
|
9
|
-
*
|
|
10
|
-
* Pure (no DB, no Nitro) so it stays unit-testable, and never throws: an unreadable file is simply no
|
|
11
|
-
* evidence, which must not fail the upload it came with.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/** Tools that name themselves in EXIF `Software`. Matched case-insensitively as whole phrases, so an
|
|
15
|
-
* ordinary "Adobe Photoshop" is not caught by the "Adobe Firefly" entry. */
|
|
16
|
-
const KNOWN_GENERATORS = [
|
|
17
|
-
'midjourney', 'dall-e', 'dall·e', 'adobe firefly', 'stable diffusion', 'leonardo.ai',
|
|
18
|
-
'nightcafe', 'bing image creator', 'google imagefx', 'imagen', 'flux.1', 'ideogram',
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
/** PNG text-chunk keywords the Stable-Diffusion / ComfyUI families write their generation graph under. */
|
|
22
|
-
const GENERATION_KEYWORDS = ['parameters', 'prompt', 'workflow']
|
|
23
|
-
|
|
24
|
-
export interface AiSignal {
|
|
25
|
-
/** One human-readable line per signal found, in a stable order. Never empty (the result is null instead). */
|
|
26
|
-
evidence: string[]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Walk PNG chunks after the 8-byte signature, yielding `[type, data]`. Bails out on any malformed length. */
|
|
30
|
-
function* pngChunks(bytes: Buffer): Generator<[string, Buffer]> {
|
|
31
|
-
if (bytes.length < 8 || bytes.readUInt32BE(0) !== 0x89504E47) return
|
|
32
|
-
let at = 8
|
|
33
|
-
while (at + 8 <= bytes.length) {
|
|
34
|
-
const len = bytes.readUInt32BE(at)
|
|
35
|
-
const end = at + 8 + len
|
|
36
|
-
if (len > bytes.length || end + 4 > bytes.length) return
|
|
37
|
-
yield [bytes.toString('latin1', at + 4, at + 8), bytes.subarray(at + 8, end)]
|
|
38
|
-
at = end + 4 // skip the trailing CRC
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Walk JPEG marker segments after SOI, yielding `[marker, payload]`. Stops at SOS (entropy-coded data). */
|
|
43
|
-
function* jpegSegments(bytes: Buffer): Generator<[number, Buffer]> {
|
|
44
|
-
if (bytes.length < 4 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return
|
|
45
|
-
let at = 2
|
|
46
|
-
while (at + 4 <= bytes.length) {
|
|
47
|
-
if (bytes[at] !== 0xFF) return
|
|
48
|
-
const marker = bytes[at + 1]!
|
|
49
|
-
if (marker === 0xDA || marker === 0xD9) return
|
|
50
|
-
const len = bytes.readUInt16BE(at + 2)
|
|
51
|
-
if (len < 2 || at + 2 + len > bytes.length) return
|
|
52
|
-
yield [marker, bytes.subarray(at + 4, at + 2 + len)]
|
|
53
|
-
at += 2 + len
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Walk RIFF/WebP chunks, yielding `[fourcc, data]`. Chunks are padded to an even length. */
|
|
58
|
-
function* riffChunks(bytes: Buffer): Generator<[string, Buffer]> {
|
|
59
|
-
if (bytes.length < 12 || bytes.toString('latin1', 0, 4) !== 'RIFF') return
|
|
60
|
-
let at = 12
|
|
61
|
-
while (at + 8 <= bytes.length) {
|
|
62
|
-
const len = bytes.readUInt32LE(at + 4)
|
|
63
|
-
const end = at + 8 + len
|
|
64
|
-
if (len > bytes.length || end > bytes.length) return
|
|
65
|
-
yield [bytes.toString('latin1', at, at + 4), bytes.subarray(at + 8, end)]
|
|
66
|
-
at = end + (len % 2)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The IPTC Digital Source Type a generator self-declared, read straight out of the embedded XMP packet.
|
|
72
|
-
* Deliberately a byte-level scan rather than a metadata library: XMP is the same XML in every container,
|
|
73
|
-
* so this also covers the ones exifr cannot open (WebP), and it costs one `indexOf` when absent.
|
|
74
|
-
*/
|
|
75
|
-
function findDigitalSourceType(bytes: Buffer): string | null {
|
|
76
|
-
const at = bytes.indexOf('DigitalSourceType', 0, 'latin1')
|
|
77
|
-
if (at < 0) return null
|
|
78
|
-
const window = bytes.toString('latin1', at, Math.min(at + 512, bytes.length))
|
|
79
|
-
// Both XMP serializations: as an rdf:Description attribute, or as its own element.
|
|
80
|
-
const value = /^DigitalSourceType\s*=\s*"([^"]*)"/.exec(window)?.[1]
|
|
81
|
-
?? /^DigitalSourceType[^>]*>([^<]*)</.exec(window)?.[1]
|
|
82
|
-
const trimmed = value?.trim()
|
|
83
|
-
return trimmed ? trimmed : null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** True when the file structurally carries a C2PA/JUMBF manifest store. Presence only — never verified. */
|
|
87
|
-
function hasC2paManifest(bytes: Buffer): boolean {
|
|
88
|
-
for (const [type] of pngChunks(bytes)) if (type === 'caBX') return true
|
|
89
|
-
// C2PA-in-JPEG lives in APP11 segments whose payload starts with the JUMBF "JP" identifier.
|
|
90
|
-
for (const [marker, payload] of jpegSegments(bytes)) {
|
|
91
|
-
if (marker === 0xEB && payload.length >= 2 && payload.toString('latin1', 0, 2) === 'JP') return true
|
|
92
|
-
}
|
|
93
|
-
for (const [fourcc] of riffChunks(bytes)) if (fourcc === 'C2PA') return true
|
|
94
|
-
return false
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The generation-parameter keyword a PNG text chunk is stored under, or null. Covers tEXt/zTXt/iTXt —
|
|
98
|
-
* in all three the keyword is the leading null-terminated string, so a compressed payload needs no
|
|
99
|
-
* inflating to be recognised (exifr only decodes the uncompressed tEXt form). */
|
|
100
|
-
function findPngGenerationChunk(bytes: Buffer): string | null {
|
|
101
|
-
for (const [type, data] of pngChunks(bytes)) {
|
|
102
|
-
if (type !== 'tEXt' && type !== 'zTXt' && type !== 'iTXt') continue
|
|
103
|
-
const nul = data.indexOf(0)
|
|
104
|
-
const keyword = data.toString('latin1', 0, nul < 0 ? data.length : nul).toLowerCase()
|
|
105
|
-
if (GENERATION_KEYWORDS.includes(keyword)) return keyword
|
|
106
|
-
}
|
|
107
|
-
return null
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** The EXIF `Software`/`ProcessingSoftware` value, when it names a tool on the known-generator list. */
|
|
111
|
-
async function findGeneratorSoftware(bytes: Buffer): Promise<string | null> {
|
|
112
|
-
let tags: Record<string, unknown> | undefined
|
|
113
|
-
try {
|
|
114
|
-
// Loaded lazily: consumers who never turn the feature on never pay the module's parse cost.
|
|
115
|
-
const exifr = (await import('exifr')).default
|
|
116
|
-
tags = await exifr.parse(bytes, { tiff: true, exif: true, mergeOutput: true }) as Record<string, unknown> | undefined
|
|
117
|
-
} catch {
|
|
118
|
-
return null // an unsupported container or a corrupt header is simply no evidence
|
|
119
|
-
}
|
|
120
|
-
for (const key of ['Software', 'ProcessingSoftware']) {
|
|
121
|
-
const value = tags?.[key]
|
|
122
|
-
if (typeof value !== 'string' || !value.trim()) continue
|
|
123
|
-
const haystack = value.toLowerCase()
|
|
124
|
-
if (KNOWN_GENERATORS.some((g) => haystack.includes(g))) return value.trim()
|
|
125
|
-
}
|
|
126
|
-
return null
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Scan an uploaded file for AI-origin signals. Returns null when nothing matched — which is NOT evidence
|
|
131
|
-
* of non-AI origin, only the absence of a declaration.
|
|
132
|
-
*/
|
|
133
|
-
export async function detectAiSignal(bytes: Buffer, _mime: string): Promise<AiSignal | null> {
|
|
134
|
-
const evidence: string[] = []
|
|
135
|
-
|
|
136
|
-
const sourceType = findDigitalSourceType(bytes)
|
|
137
|
-
if (sourceType) evidence.push(`IPTC/XMP Digital Source Type: ${sourceType}`)
|
|
138
|
-
|
|
139
|
-
const software = await findGeneratorSoftware(bytes)
|
|
140
|
-
if (software) evidence.push(`EXIF Software: ${software}`)
|
|
141
|
-
|
|
142
|
-
if (hasC2paManifest(bytes)) {
|
|
143
|
-
evidence.push('C2PA content-credentials manifest present (unverified — presence only, no signature check)')
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const keyword = findPngGenerationChunk(bytes)
|
|
147
|
-
if (keyword) evidence.push(`PNG text chunk "${keyword}" present (Stable-Diffusion-style generation parameters)`)
|
|
148
|
-
|
|
149
|
-
return evidence.length ? { evidence } : null
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** How the scan's findings are worded into `aiNote`. Kept here so the wording has one home. */
|
|
153
|
-
export function aiSignalNote(signal: AiSignal): string {
|
|
154
|
-
return `Detected at upload: ${signal.evidence.join('; ')}`
|
|
155
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import type { ResolvedImagePolicy, ResolvedVariant, VariantFormat } from '../../../core/server/utils/kestrel-config'
|
|
4
|
-
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
5
|
-
import { media } from '../collections/media'
|
|
6
|
-
import { deriveImage, RASTER } from './derive'
|
|
7
|
-
import { derivativeKey, type DerivativeManifest } from './record'
|
|
8
|
-
import { readVariantRegistry, resolveActiveVariants } from './variants'
|
|
9
|
-
import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
|
|
10
|
-
import { emitMediaWrite } from './media-write'
|
|
11
|
-
|
|
12
|
-
// Mirrors index.post.ts: a long-lived (non-immutable, so a replace isn't pinned) cache for originals +
|
|
13
|
-
// derivatives. Backfilled objects MUST carry the same header or the CDN caches them differently.
|
|
14
|
-
const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
|
|
15
|
-
|
|
16
|
-
export interface BackfillPlan {
|
|
17
|
-
/** Specs, each carrying ONLY its missing formats, to derive + add. */
|
|
18
|
-
missing: ResolvedVariant[]
|
|
19
|
-
/** Object keys of manifest entries no longer in the active set (deregistered) — to prune. Always empty
|
|
20
|
-
* when the caller passes `prune: false`, i.e. the active set is a fallback rather than the registry. */
|
|
21
|
-
orphanKeys: string[]
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Pure diff of a media row's derivative manifest against the active variant set: what to generate, what to
|
|
26
|
-
* prune. A proportional spec wider than the source is never "missing" (deriveImage would skip it, so it can
|
|
27
|
-
* never be satisfied). Orphans are keyed per `<name>.<format>`, so a deregistered FORMAT of a still-active
|
|
28
|
-
* name is pruned too.
|
|
29
|
-
*/
|
|
30
|
-
export function planBackfill(
|
|
31
|
-
row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[], prune = true,
|
|
32
|
-
): BackfillPlan {
|
|
33
|
-
const manifest = row.derivatives ?? {}
|
|
34
|
-
const activeKeys = new Set<string>()
|
|
35
|
-
const missing: ResolvedVariant[] = []
|
|
36
|
-
for (const spec of specs) {
|
|
37
|
-
const applicable = spec.height != null || !row.width || spec.width <= row.width
|
|
38
|
-
const missingFormats: VariantFormat[] = []
|
|
39
|
-
for (const f of spec.formats) {
|
|
40
|
-
activeKeys.add(`${spec.name}.${f}`)
|
|
41
|
-
if (applicable && !(`${spec.name}.${f}` in manifest)) missingFormats.push(f)
|
|
42
|
-
}
|
|
43
|
-
if (missingFormats.length) missing.push({ ...spec, formats: missingFormats })
|
|
44
|
-
}
|
|
45
|
-
const orphanKeys: string[] = []
|
|
46
|
-
if (prune) for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
|
|
47
|
-
return { missing, orphanKeys }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
interface BackfillRow { id: number; storageKey: string; mime: string; width: number | null; derivatives: DerivativeManifest | null }
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Reconcile ONE media row to the active set: derive its missing variants from the original (put-first),
|
|
54
|
-
* write the updated manifest, then prune orphan objects LAST (a crash never dangles the manifest). The pruned
|
|
55
|
-
* objects are this row's OWN deregistered derivatives — self-owned, so no cross-row ownership guard is needed
|
|
56
|
-
* (that is Slice 9's published-media GC, a different concern).
|
|
57
|
-
*/
|
|
58
|
-
export async function backfillRow(
|
|
59
|
-
db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy, prune = true,
|
|
60
|
-
): Promise<{ generated: number; pruned: number }> {
|
|
61
|
-
const plan = planBackfill(row, specs, prune)
|
|
62
|
-
if (!plan.missing.length && !plan.orphanKeys.length) return { generated: 0, pruned: 0 }
|
|
63
|
-
|
|
64
|
-
const orphaned = new Set(plan.orphanKeys)
|
|
65
|
-
const manifest: DerivativeManifest = {}
|
|
66
|
-
for (const [k, e] of Object.entries(row.derivatives ?? {})) if (!orphaned.has(e.key)) manifest[k] = e
|
|
67
|
-
|
|
68
|
-
let generated = 0
|
|
69
|
-
if (plan.missing.length) {
|
|
70
|
-
if (typeof driver.get !== 'function') throw new Error('storage driver cannot read originals back (no get()) — backfill needs it')
|
|
71
|
-
const derived = await deriveImage(await driver.get(row.storageKey), { ...policy, variants: plan.missing })
|
|
72
|
-
for (const v of derived.variants) {
|
|
73
|
-
const key = derivativeKey(row.storageKey, v.name, v.format)
|
|
74
|
-
await driver.put(key, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
|
|
75
|
-
manifest[`${v.name}.${v.format}`] = { key, width: v.width, height: v.height, mime: v.mime }
|
|
76
|
-
generated++
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
81
|
-
db.update(media).set({ derivatives: manifest, updatedAt: new Date() } as never).where(eq(cols.id, row.id)).run()
|
|
82
|
-
// Published pages hold this row's srcset candidates, so a rewritten manifest — especially a pruned
|
|
83
|
-
// key, which now 404s — has to re-render them. Emitted before the prune: a delete that throws must
|
|
84
|
-
// not leave the already-written manifest un-republished.
|
|
85
|
-
emitMediaWrite({ id: row.id }, { id: row.id })
|
|
86
|
-
for (const key of plan.orphanKeys) await driver.delete(key)
|
|
87
|
-
return { generated, pruned: plan.orphanKeys.length }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface BackfillReport {
|
|
91
|
-
rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean
|
|
92
|
-
/** The run declined to prune because the active set is the config fallback, not the registry — so a
|
|
93
|
-
* `pruned: 0` here means "could not tell what is registered", not "nothing was deregistered". */
|
|
94
|
-
pruneWithheld: boolean
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Iterate every raster media row, reconciling each to the active variant set. Sequential (sharp CPU + a
|
|
99
|
-
* full-original GET per row) and best-effort per row — a corrupt original is logged and skipped, never
|
|
100
|
-
* aborting the whole run. `check` reports the plan (rows / would-generate / would-prune) without writing.
|
|
101
|
-
*/
|
|
102
|
-
export async function runBackfill(
|
|
103
|
-
db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, opts: { check?: boolean } = {},
|
|
104
|
-
): Promise<BackfillReport> {
|
|
105
|
-
// Deriving a superset is harmless, deleting against one is not, so the prune follows the resolver's own
|
|
106
|
-
// verdict: an unmigrated, unreadable, empty or wholly-rejected registry resolves to the CONFIG fallback,
|
|
107
|
-
// under which every registered variant of every row looks deregistered.
|
|
108
|
-
const { specs, fromRegistry: prune } = resolveActiveVariants(readVariantRegistry(db), policy.variants, policy.presets)
|
|
109
|
-
if (!prune) console.warn('[kestrel] backfill: no usable variant registry — generating against the config fallback and withholding the prune. Run a full publish so the prerender scan populates media_settings.')
|
|
110
|
-
const rows = db.select().from(media).all() as BackfillRow[]
|
|
111
|
-
const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check, pruneWithheld: !prune }
|
|
112
|
-
for (const snapshot of rows) {
|
|
113
|
-
if (!RASTER.has(snapshot.mime)) continue
|
|
114
|
-
if (opts.check) {
|
|
115
|
-
// Dry-run reads only (against the snapshot) — no mutation, so no lock.
|
|
116
|
-
const plan = planBackfill(snapshot, specs, prune)
|
|
117
|
-
const toGenerate = plan.missing.reduce((n, s) => n + s.formats.length, 0)
|
|
118
|
-
if (!toGenerate && !plan.orphanKeys.length) continue
|
|
119
|
-
report.rowsChanged++
|
|
120
|
-
report.generated += toGenerate
|
|
121
|
-
report.pruned += plan.orphanKeys.length
|
|
122
|
-
continue
|
|
123
|
-
}
|
|
124
|
-
// Serialize per storageKey with the upload path, and re-read the row FRESH inside the lock: a concurrent
|
|
125
|
-
// overwrite may have replaced the bytes/dims/manifest since the up-front snapshot, and reconciling the
|
|
126
|
-
// stale snapshot against the new object would write a manifest whose dims describe the OLD image.
|
|
127
|
-
await withLock(mediaLockKey(snapshot.storageKey), async () => {
|
|
128
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
129
|
-
const row = db.select().from(media).where(eq(cols.id, snapshot.id)).get() as BackfillRow | undefined
|
|
130
|
-
if (!row || !RASTER.has(row.mime)) return
|
|
131
|
-
const plan = planBackfill(row, specs, prune)
|
|
132
|
-
if (!plan.missing.length && !plan.orphanKeys.length) return
|
|
133
|
-
report.rowsChanged++
|
|
134
|
-
try {
|
|
135
|
-
const r = await backfillRow(db, driver, row, specs, policy, prune)
|
|
136
|
-
report.generated += r.generated
|
|
137
|
-
report.pruned += r.pruned
|
|
138
|
-
} catch (error) {
|
|
139
|
-
console.error(`[kestrel] backfill failed for media ${row.id} (${row.storageKey}):`, (error as Error)?.message ?? error)
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
}
|
|
143
|
-
return report
|
|
144
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import sharp from 'sharp'
|
|
2
|
-
import { rgbaToThumbHash } from 'thumbhash'
|
|
3
|
-
import type { ResolvedImagePolicy } from '../../../core/server/utils/kestrel-config'
|
|
4
|
-
|
|
5
|
-
// The derivation policy is resolved once from kestrel config (KESTREL_MEDIA_IMAGE_* env → config → default)
|
|
6
|
-
// and threaded in by the upload handler. `DEFAULT_IMAGE_POLICY` is auto-imported from kestrel-config
|
|
7
|
-
// (the single export site); re-exporting it here would register a duplicate auto-import.
|
|
8
|
-
export type ImagePolicy = ResolvedImagePolicy
|
|
9
|
-
|
|
10
|
-
// Raster mimes that receive a derivative ladder. GIF is intentionally excluded: deriving WebP flattens it
|
|
11
|
-
// to a static first frame. Shared by the upload handler and the backfill task so they never disagree.
|
|
12
|
-
export const RASTER = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/avif'])
|
|
13
|
-
|
|
14
|
-
export interface DerivedImage {
|
|
15
|
-
width: number
|
|
16
|
-
height: number
|
|
17
|
-
thumbhash: string
|
|
18
|
-
variants: { name: string; width: number; height: number; format: 'webp' | 'jpeg'; mime: string; bytes: Buffer }[]
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function deriveImage(input: Buffer, policy: ImagePolicy): Promise<DerivedImage> {
|
|
22
|
-
const meta = await sharp(input, { limitInputPixels: 268402689 }).metadata()
|
|
23
|
-
// sharp's metadata reports the stored (pre-EXIF-rotation) dimensions; .rotate() only affects
|
|
24
|
-
// the pixel pipeline. For EXIF orientation 5-8 (90°/270°, e.g. phone portrait photos) the
|
|
25
|
-
// displayed image — and every variant below, which applies .rotate() — has width/height swapped.
|
|
26
|
-
const swap = (meta.orientation ?? 0) >= 5
|
|
27
|
-
const width = (swap ? meta.height : meta.width) ?? 0
|
|
28
|
-
const height = (swap ? meta.width : meta.height) ?? 0
|
|
29
|
-
|
|
30
|
-
const small = await sharp(input).rotate().resize(100, 100, { fit: 'inside' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
31
|
-
const thumbhash = Buffer.from(rgbaToThumbHash(small.info.width, small.info.height, small.data)).toString('base64')
|
|
32
|
-
|
|
33
|
-
// Derive exactly the active variant set (name × format). A proportional spec wider than the source is
|
|
34
|
-
// skipped (never upscale); a crop clamps via withoutEnlargement. The stored width/height are the REAL
|
|
35
|
-
// pipeline-output dims (correct for crops and for clamped proportionals — not an aspect-ratio guess).
|
|
36
|
-
const variants: DerivedImage['variants'] = []
|
|
37
|
-
for (const spec of policy.variants) {
|
|
38
|
-
// sharp throws on a non-positive-integer dimension — coerce so a malformed spec (e.g. a stale
|
|
39
|
-
// `height: ''`) degrades to a proportional resize instead of 500ing the upload.
|
|
40
|
-
const specWidth = toPosInt(spec.width)
|
|
41
|
-
if (specWidth == null) continue
|
|
42
|
-
const cropHeight = toPosInt(spec.height)
|
|
43
|
-
if (cropHeight == null && width > 0 && specWidth > width) continue
|
|
44
|
-
for (const format of spec.formats) {
|
|
45
|
-
const resized = cropHeight == null
|
|
46
|
-
? sharp(input).rotate().resize({ width: specWidth, withoutEnlargement: true })
|
|
47
|
-
: sharp(input).rotate().resize(specWidth, cropHeight, { fit: spec.fit, position: spec.position, withoutEnlargement: true })
|
|
48
|
-
const encoded = format === 'jpeg' ? resized.jpeg({ quality: policy.jpegQuality }) : resized.webp({ quality: policy.webpQuality })
|
|
49
|
-
const { data, info } = await encoded.toBuffer({ resolveWithObject: true })
|
|
50
|
-
variants.push({ name: spec.name, width: info.width, height: info.height, format, mime: format === 'jpeg' ? 'image/jpeg' : 'image/webp', bytes: data })
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { width, height, thumbhash, variants }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function toPosInt(value: unknown): number | null {
|
|
57
|
-
const n = Math.floor(Number(value))
|
|
58
|
-
return Number.isFinite(n) && n >= 1 ? n : null
|
|
59
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/** Parent folder path; '' for a top-level folder; null for the root itself. */
|
|
2
|
-
export function parentOf(path: string): string | null {
|
|
3
|
-
if (path === '') return null
|
|
4
|
-
const i = path.lastIndexOf('/')
|
|
5
|
-
return i === -1 ? '' : path.slice(0, i)
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/** Every ancestor folder (excluding the path itself and the root ''), root-first. */
|
|
9
|
-
export function ancestorsOf(path: string): string[] {
|
|
10
|
-
const out: string[] = []
|
|
11
|
-
const segs = path.split('/').filter(Boolean)
|
|
12
|
-
for (let i = 1; i < segs.length; i++) out.push(segs.slice(0, i).join('/'))
|
|
13
|
-
return out
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** The path plus all its ancestors (excluding root ''), root-first. */
|
|
17
|
-
export function selfAndAncestors(path: string): string[] {
|
|
18
|
-
return path === '' ? [] : [...ancestorsOf(path), path]
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** True when `path` is exactly one level below `parent`. */
|
|
22
|
-
export function isImmediateChild(parent: string, path: string): boolean {
|
|
23
|
-
return parentOf(path) === parent
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Last path segment. */
|
|
27
|
-
export function childName(path: string): string {
|
|
28
|
-
const i = path.lastIndexOf('/')
|
|
29
|
-
return i === -1 ? path : path.slice(i + 1)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** True when `path` is `prefix` itself or a descendant of it (anchored on a '/' boundary). */
|
|
33
|
-
export function isUnder(prefix: string, path: string): boolean {
|
|
34
|
-
return path === prefix || path.startsWith(`${prefix}/`)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Re-base `path` from the `from` subtree onto `to` (path unchanged if not under `from`). */
|
|
38
|
-
export function rewritePrefix(path: string, from: string, to: string): string {
|
|
39
|
-
if (path === from) return to
|
|
40
|
-
if (path.startsWith(`${from}/`)) return to + path.slice(from.length)
|
|
41
|
-
return path
|
|
42
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
2
|
-
import { folders } from '../database/folders'
|
|
3
|
-
import { sanitizeFolder } from './naming'
|
|
4
|
-
import { selfAndAncestors } from './folder-paths'
|
|
5
|
-
|
|
6
|
-
/** Ensure a folder row exists for `path` and every ancestor (after sanitizing). Idempotent. */
|
|
7
|
-
export function ensureFolder(db: BetterSQLite3Database, path: string): void {
|
|
8
|
-
const clean = sanitizeFolder(path)
|
|
9
|
-
for (const p of selfAndAncestors(clean)) {
|
|
10
|
-
db.insert(folders).values({ path: p }).onConflictDoNothing().run()
|
|
11
|
-
}
|
|
12
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { and, eq, like, count, getTableColumns, sql, isNull, or, asc, desc } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
4
|
-
import { media } from '../collections/media'
|
|
5
|
-
import { folders as foldersTable } from '../database/folders'
|
|
6
|
-
import { resolveMedia } from './resolve'
|
|
7
|
-
import { childName, isImmediateChild } from './folder-paths'
|
|
8
|
-
import { primaryLocale } from '../../../core/server/utils/locale'
|
|
9
|
-
import { escapeLike } from '../../../core/server/utils/sql'
|
|
10
|
-
|
|
11
|
-
export interface LibraryQuery { folder?: string; search?: string; type?: 'image' | 'all'; page?: number; perPage?: number; sort?: string }
|
|
12
|
-
export interface LibraryFolder { path: string; name: string; size: number }
|
|
13
|
-
|
|
14
|
-
// Sortable table columns → media column. `sort` is `field` (asc) or `-field` (desc); unknown → name.
|
|
15
|
-
const SORT_FIELDS = ['name', 'size', 'type'] as const
|
|
16
|
-
function parseSort(sort: string | undefined): { field: (typeof SORT_FIELDS)[number]; desc: boolean } {
|
|
17
|
-
const isDesc = (sort ?? '').startsWith('-')
|
|
18
|
-
const field = (sort ?? '').replace(/^-/, '') as (typeof SORT_FIELDS)[number]
|
|
19
|
-
return { field: SORT_FIELDS.includes(field) ? field : 'name', desc: isDesc }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function listLibrary(db: BetterSQLite3Database, q: LibraryQuery, publicUrl: (key: string) => string) {
|
|
23
|
-
const folder = q.folder ?? ''
|
|
24
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
25
|
-
const t = media as AnySQLiteTable
|
|
26
|
-
|
|
27
|
-
// root files are stored with folder NULL (the uploader maps '' → null), so match both at root
|
|
28
|
-
const conds = [folder === '' ? or(isNull(cols.folder), eq(cols.folder, '')) : eq(cols.folder, folder)]
|
|
29
|
-
if (q.search) conds.push(sql`${cols.filename} like ${`%${escapeLike(q.search)}%`} escape '\\'`)
|
|
30
|
-
if (q.type === 'image') conds.push(like(cols.mime, 'image/%'))
|
|
31
|
-
const where = and(...conds)
|
|
32
|
-
|
|
33
|
-
// `?? default` catches only null/undefined, NOT NaN (`?perPage=abc` → Number('abc') === NaN), and
|
|
34
|
-
// .limit(NaN) binds LIMIT NULL / 500s in SQLite — guard with Number.isFinite (mirrors crud.list).
|
|
35
|
-
const num = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback)
|
|
36
|
-
const page = Math.max(1, Math.floor(num(q.page, 1)))
|
|
37
|
-
const perPage = Math.min(200, Math.max(1, Math.floor(num(q.perPage, 60))))
|
|
38
|
-
|
|
39
|
-
// Files are paginated, so sorting must happen in SQL. Tie-break by filename for a stable order.
|
|
40
|
-
const sort = parseSort(q.sort)
|
|
41
|
-
const sortCol = sort.field === 'size' ? cols.size : sort.field === 'type' ? cols.mime : cols.filename
|
|
42
|
-
const dir = sort.desc ? desc : asc
|
|
43
|
-
const rows = db.select().from(t).where(where).orderBy(dir(sortCol), asc(cols.filename))
|
|
44
|
-
.limit(perPage).offset((page - 1) * perPage).all() as Record<string, unknown>[]
|
|
45
|
-
const totalRow = db.select({ value: count() }).from(t).where(where).get() as { value: number } | undefined
|
|
46
|
-
|
|
47
|
-
// Resolve in the configured PRIMARY locale — the same locale the viewer edits alt in (and the locale
|
|
48
|
-
// resolveMedia itself falls back to), so an alt edit round-trips into the list for any primary, not
|
|
49
|
-
// just 'en'. A literal 'en' here would let an 'en' translation mask a non-en primary's alt.
|
|
50
|
-
const locale = primaryLocale()
|
|
51
|
-
const files = rows.map((r) => {
|
|
52
|
-
const m = resolveMedia(r as never, locale, publicUrl)
|
|
53
|
-
// serialize the webp ladder to an <img srcset> string; non-derivable files (pdf/svg) → undefined
|
|
54
|
-
// so the grid renders its ext badge instead of a broken <img> (an empty array is truthy)
|
|
55
|
-
const srcset = m.srcset.length ? m.srcset.map((s) => `${s.url} ${s.width}w`).join(', ') : undefined
|
|
56
|
-
return {
|
|
57
|
-
id: m.id, filename: r.filename as string, mime: m.mime, folder: r.folder as string,
|
|
58
|
-
size: r.size as number, width: m.width, height: m.height, thumbhash: m.thumbhash,
|
|
59
|
-
src: m.src, srcset, alt: m.alt, aiDisclosure: m.aiDisclosure,
|
|
60
|
-
createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
const allFolders = db.select({ path: foldersTable.path }).from(foldersTable).all()
|
|
65
|
-
|
|
66
|
-
// Recursive folder sizes: one grouped query sums bytes per exact folder across the current subtree,
|
|
67
|
-
// then each immediate child's whole subtree is rolled up in JS (cheap: one row per distinct folder).
|
|
68
|
-
const sizeBase = db.select({ folder: cols.folder, total: sql<number>`coalesce(sum(${cols.size}), 0)` }).from(t).groupBy(cols.folder)
|
|
69
|
-
const sizeRows = (folder === ''
|
|
70
|
-
? sizeBase
|
|
71
|
-
: sizeBase.where(or(eq(cols.folder, folder), sql`${cols.folder} like ${`${escapeLike(folder)}/%`} escape '\\'`))
|
|
72
|
-
).all() as { folder: string | null; total: number }[]
|
|
73
|
-
const sizeOf = (prefix: string): number =>
|
|
74
|
-
sizeRows.reduce((sum, r) => (((r.folder ?? '') === prefix || (r.folder ?? '').startsWith(`${prefix}/`)) ? sum + Number(r.total ?? 0) : sum), 0)
|
|
75
|
-
|
|
76
|
-
// Folders always render before files; within the group they follow the same sort (by name, or by
|
|
77
|
-
// recursive size; type is uniform for folders so it falls back to name).
|
|
78
|
-
const folderList: LibraryFolder[] = allFolders
|
|
79
|
-
.filter((f) => isImmediateChild(folder, f.path))
|
|
80
|
-
.map((f) => ({ path: f.path, name: childName(f.path), size: sizeOf(f.path) }))
|
|
81
|
-
.sort((a, b) => {
|
|
82
|
-
const c = sort.field === 'size' ? a.size - b.size : a.name.localeCompare(b.name)
|
|
83
|
-
return sort.desc ? -c : c
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
// The root always exists; any other folder must have a row in the registry. Lets the client tell a
|
|
87
|
-
// genuinely-empty folder apart from a path that does not exist (→ error instead of a blank listing).
|
|
88
|
-
const exists = folder === '' || allFolders.some((f) => f.path === folder)
|
|
89
|
-
|
|
90
|
-
return { folder, exists, folders: folderList, files, total: Number(totalRow?.value ?? 0), page, perPage }
|
|
91
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type { WriteEvent } from '../../../core/server/utils/write-events'
|
|
2
|
-
import type { DerivativeManifest } from './record'
|
|
3
|
-
|
|
4
|
-
/** Every storage key a media row owns — the original plus each generated derivative (the webp ladder). */
|
|
5
|
-
export function mediaStorageKeys(row: Record<string, unknown>): string[] {
|
|
6
|
-
const keys: string[] = []
|
|
7
|
-
if (typeof row.storageKey === 'string' && row.storageKey) keys.push(row.storageKey)
|
|
8
|
-
const derivatives = row.derivatives as DerivativeManifest | null | undefined
|
|
9
|
-
if (derivatives && typeof derivatives === 'object') {
|
|
10
|
-
for (const d of Object.values(derivatives)) {
|
|
11
|
-
if (d && typeof d.key === 'string' && d.key) keys.push(d.key)
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
return keys
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Storage keys to delete when a write event is a media-record DELETE — the original + ALL derivatives;
|
|
19
|
-
* `[]` for anything else. This makes deleting a media record remove its files on EVERY delete path:
|
|
20
|
-
* the generic CRUD `remove()` emits a delete event handled here, complementing the media library's own
|
|
21
|
-
* `deleteAffected` (which deletes storage inline). So a media record can never be removed while leaving
|
|
22
|
-
* orphaned objects in storage — principle 4 (output ≡ DB) for media. Pure → unit-testable.
|
|
23
|
-
*/
|
|
24
|
-
export function planMediaDeletion(event: WriteEvent): string[] {
|
|
25
|
-
if (event.def.name !== 'media' || event.after !== null || !event.before) return []
|
|
26
|
-
return mediaStorageKeys(event.before)
|
|
27
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { createError } from 'h3'
|
|
2
|
-
import { collectionEnabled } from '../../../core/server/schema/bootstrap'
|
|
3
|
-
import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
|
|
4
|
-
import mediaCollection from '../collections/media'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Whether the `media` built-in is active for this consumer. The media routes are static files rather than
|
|
8
|
-
* registry-driven, so they have to ask the SAME predicate the registry plugin and the schema engine use.
|
|
9
|
-
* Without it a consumer who turned the built-in off keeps serving endpoints that query a table the schema
|
|
10
|
-
* engine deliberately never created.
|
|
11
|
-
*/
|
|
12
|
-
export function mediaCollectionEnabled(): boolean {
|
|
13
|
-
const toggles = (serverRuntimeConfig()?.kestrel?.collections ?? resolveServerKestrel().collections) as
|
|
14
|
-
| Record<string, boolean>
|
|
15
|
-
| undefined
|
|
16
|
-
return collectionEnabled(mediaCollection.def, toggles)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Route guard: with the built-in disabled the endpoint genuinely does not exist, so 404 — not a 500 from
|
|
20
|
-
* the missing table. */
|
|
21
|
-
export function requireMediaCollection(): void {
|
|
22
|
-
if (!mediaCollectionEnabled()) throw createError({ statusCode: 404, statusMessage: 'Media collection is disabled' })
|
|
23
|
-
}
|