@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,341 +0,0 @@
|
|
|
1
|
-
import { eq, inArray, getTableColumns, sql } from 'drizzle-orm'
|
|
2
|
-
import { createError, type H3Event } from 'h3'
|
|
3
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
-
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
5
|
-
import { media } from '../collections/media'
|
|
6
|
-
import { folders } from '../database/folders'
|
|
7
|
-
import { isUnder, parentOf, childName, rewritePrefix } from './folder-paths'
|
|
8
|
-
import { sanitizeFolder, buildKey, suggestFreeName } from './naming'
|
|
9
|
-
import { planRelocateInto, planRename, type ItemPlan } from './relocate-plan'
|
|
10
|
-
import { relocateMedia, duplicateMedia } from './storage-relocate'
|
|
11
|
-
import { ensureFolder } from './folders'
|
|
12
|
-
import { collectAffected, deleteAffected, type OpItem, type AffectedSet } from './media-ops'
|
|
13
|
-
import { useStorageDriver, type StorageDriver } from '../../../core/server/utils/storage'
|
|
14
|
-
import { escapeLike } from '../../../core/server/utils/sql'
|
|
15
|
-
|
|
16
|
-
export type OpType = 'move' | 'copy' | 'rename'
|
|
17
|
-
export interface MediaOp { type: OpType; items: OpItem[]; dest?: string; name?: string }
|
|
18
|
-
export interface Conflict { item: OpItem; targetPath: string; type: 'file-exists' | 'folder-exists' }
|
|
19
|
-
export interface RelocationReport { summary: { files: number; folders: number; totalBytes: number }; conflicts: Conflict[] }
|
|
20
|
-
|
|
21
|
-
/** Parse untrusted request input into well-formed op items (drops malformed entries). */
|
|
22
|
-
export function coerceOpItems(raw: unknown): OpItem[] {
|
|
23
|
-
if (!Array.isArray(raw)) return []
|
|
24
|
-
const out: OpItem[] = []
|
|
25
|
-
for (const it of raw) {
|
|
26
|
-
if (!it || typeof it !== 'object') continue
|
|
27
|
-
const o = it as Record<string, unknown>
|
|
28
|
-
if (o.type === 'file' && Number.isInteger(o.id)) out.push({ type: 'file', id: o.id as number })
|
|
29
|
-
else if (o.type === 'folder' && typeof o.path === 'string') {
|
|
30
|
-
// Sanitize at the boundary so the disk cascade (removeDir) targets the same normalized path
|
|
31
|
-
// the DB cascade matches — a raw `..` would diverge them. Empty == root, not a valid item.
|
|
32
|
-
const path = sanitizeFolder(o.path)
|
|
33
|
-
if (path) out.push({ type: 'folder', path })
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return out
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Resolve one op item into its affected set + relocation plan (shared by preview and execute). */
|
|
40
|
-
export function planItem(db: BetterSQLite3Database, op: MediaOp, item: OpItem): { affected: AffectedSet; plan: ItemPlan } {
|
|
41
|
-
const affected = collectAffected(db, [item])
|
|
42
|
-
const plan = op.type === 'rename'
|
|
43
|
-
? planRename(item, affected, op.name ?? '')
|
|
44
|
-
: planRelocateInto(item, affected, op.dest ?? '')
|
|
45
|
-
return { affected, plan }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Reject malformed ops before any work: empty items, bad rename args, folder-into-itself moves. */
|
|
49
|
-
export function validateOp(op: MediaOp): void {
|
|
50
|
-
if (!op.items.length) throw createError({ statusCode: 400, statusMessage: 'No items' })
|
|
51
|
-
if (op.type === 'rename') {
|
|
52
|
-
if (op.items.length !== 1 || !op.name) throw createError({ statusCode: 400, statusMessage: 'Rename needs exactly one item and a name' })
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
const dest = sanitizeFolder(op.dest ?? '')
|
|
56
|
-
for (const item of op.items) {
|
|
57
|
-
if (item.type === 'folder' && isUnder(item.path, dest)) {
|
|
58
|
-
throw createError({ statusCode: 400, statusMessage: 'Cannot move a folder into itself' })
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Wave 1 (dryRun): plan the op, then report impact + target-occupancy conflicts. Mutates nothing. */
|
|
64
|
-
export function previewRelocation(db: BetterSQLite3Database, op: MediaOp): RelocationReport {
|
|
65
|
-
validateOp(op)
|
|
66
|
-
const planned = op.items.map((item) => ({ item, ...planItem(db, op, item) }))
|
|
67
|
-
|
|
68
|
-
const summary = {
|
|
69
|
-
files: planned.reduce((n, p) => n + p.affected.media.length, 0),
|
|
70
|
-
folders: planned.reduce((n, p) => n + p.affected.folders.length, 0),
|
|
71
|
-
totalBytes: planned.reduce((n, p) => n + p.affected.media.reduce((s, m) => s + (m.size || 0), 0), 0),
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Bounded index lookups instead of materializing the whole media/folders tables: this runs once per
|
|
75
|
-
// item during execute for any onConflict other than 'abort' (applyStrategy re-previews in isolation),
|
|
76
|
-
// so a full scan here is O(items × table size) on a bulk move/copy. Both probed columns are uniquely
|
|
77
|
-
// indexed, so only the keys/paths THIS op's own items could possibly land on ever need checking —
|
|
78
|
-
// the loop below never queries either Set outside that set.
|
|
79
|
-
const probeKeys = new Set<string>()
|
|
80
|
-
const probeRoots = new Set<string>()
|
|
81
|
-
for (const { item, plan } of planned) {
|
|
82
|
-
if (item.type === 'file') {
|
|
83
|
-
const k = plan.media[0]?.toKey
|
|
84
|
-
if (k) probeKeys.add(k)
|
|
85
|
-
} else {
|
|
86
|
-
probeRoots.add(plan.toRoot)
|
|
87
|
-
probeKeys.add(plan.toRoot)
|
|
88
|
-
for (const m of plan.media) probeKeys.add(m.toKey)
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const allKeys = probeKeys.size
|
|
92
|
-
? new Set((db.select({ k: getTableColumns(media).storageKey }).from(media as AnySQLiteTable)
|
|
93
|
-
.where(inArray(getTableColumns(media).storageKey, [...probeKeys])).all() as { k: string }[]).map((r) => r.k))
|
|
94
|
-
: new Set<string>()
|
|
95
|
-
const allFolderPaths = probeRoots.size
|
|
96
|
-
? new Set(db.select({ path: folders.path }).from(folders).where(inArray(folders.path, [...probeRoots])).all().map((f) => f.path))
|
|
97
|
-
: new Set<string>()
|
|
98
|
-
|
|
99
|
-
// Preview frees all source keys set-wise (order-independent), but execute vacates them one at a time — so a key-rotation batch (move A onto B's key while moving B) can pass preview yet hit relocateMedia's per-key 409 mid-execute; acceptable under the per-item compensating model.
|
|
100
|
-
const frees = op.type === 'copy' ? { keys: new Set<string>(), folders: new Set<string>() } : {
|
|
101
|
-
keys: new Set(planned.flatMap((p) => p.plan.media.map((m) => m.fromKey))),
|
|
102
|
-
folders: new Set(planned.flatMap((p) => p.affected.folders)),
|
|
103
|
-
}
|
|
104
|
-
const isOccupiedKey = (key: string) => allKeys.has(key) && !frees.keys.has(key)
|
|
105
|
-
const isOccupiedFolder = (path: string) => allFolderPaths.has(path) && !frees.folders.has(path)
|
|
106
|
-
|
|
107
|
-
// Targets claimed by earlier items in this same op, so two items whose new
|
|
108
|
-
// targets land on the same slot collide (the later one is flagged).
|
|
109
|
-
const claimedKeys = new Set<string>()
|
|
110
|
-
const claimedRoots = new Set<string>()
|
|
111
|
-
|
|
112
|
-
const conflicts: Conflict[] = []
|
|
113
|
-
for (const { item, plan } of planned) {
|
|
114
|
-
if (item.type === 'file') {
|
|
115
|
-
const toKey = plan.media[0]?.toKey
|
|
116
|
-
if (toKey && (isOccupiedKey(toKey) || claimedKeys.has(toKey))) conflicts.push({ item, targetPath: toKey, type: 'file-exists' })
|
|
117
|
-
// folder-exists ⇒ the destination subtree is occupied: an existing folder row, a FILE whose key equals the
|
|
118
|
-
// target root (a folder onto a file would mkdir over a file → 500 / DB-vs-disk desync), a descendant file
|
|
119
|
-
// key, or an intra-batch claim.
|
|
120
|
-
} else if (isOccupiedFolder(plan.toRoot) || isOccupiedKey(plan.toRoot) || claimedRoots.has(plan.toRoot) || claimedKeys.has(plan.toRoot) || plan.media.some((m) => isOccupiedKey(m.toKey) || claimedKeys.has(m.toKey))) {
|
|
121
|
-
conflicts.push({ item, targetPath: plan.toRoot, type: 'folder-exists' })
|
|
122
|
-
}
|
|
123
|
-
if (item.type === 'folder') claimedRoots.add(plan.toRoot)
|
|
124
|
-
for (const m of plan.media) claimedKeys.add(m.toKey)
|
|
125
|
-
}
|
|
126
|
-
return { summary, conflicts }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export interface OpResult {
|
|
130
|
-
item: OpItem
|
|
131
|
-
status: 'moved' | 'copied' | 'renamed' | 'skipped' | 'overwritten' | 'renamed-auto'
|
|
132
|
-
newPath?: string
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Move/rename folder-item branch: repoint the folder rows in one transaction. */
|
|
136
|
-
function relocateFolderRows(db: BetterSQLite3Database, plan: ItemPlan): void {
|
|
137
|
-
db.transaction(() => {
|
|
138
|
-
// Ensure the destination's PARENT chain (not toRoot) first: toRoot is created by re-pathing
|
|
139
|
-
// the source root row, so pre-inserting it would clash on the folders UNIQUE(path).
|
|
140
|
-
ensureFolder(db, parentOf(plan.toRoot) ?? '')
|
|
141
|
-
for (const f of plan.folders) db.update(folders).set({ path: f.to, updatedAt: new Date() }).where(eq(folders.path, f.from)).run()
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Copy folder-item branch: create the destination folder rows. */
|
|
146
|
-
function createCopiedFolderRows(db: BetterSQLite3Database, plan: ItemPlan): void {
|
|
147
|
-
for (const f of plan.folders) ensureFolder(db, f.to)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** An item's result plus the media ids it actually LANDED on the target: for a copy those are the rows
|
|
151
|
-
* `duplicateMedia` created (the source rows never move), for a move/rename the source rows themselves.
|
|
152
|
-
* Internal to the batch loop — the wire shape stays `OpResult`. */
|
|
153
|
-
type ExecutedItem = OpResult & { landedIds: number[] }
|
|
154
|
-
|
|
155
|
-
/** Execute one already-planned item: relocate/duplicate its media rows and repoint/create its
|
|
156
|
-
* folder rows. No-op items short-circuit to 'skipped'. */
|
|
157
|
-
async function executeItem(
|
|
158
|
-
db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp, item: OpItem, plan: ItemPlan,
|
|
159
|
-
): Promise<ExecutedItem> {
|
|
160
|
-
if (plan.media.every((m) => m.toKey === m.fromKey) && plan.folders.every((f) => f.from === f.to)) {
|
|
161
|
-
return { item, status: 'skipped', landedIds: [] }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (op.type === 'copy') {
|
|
165
|
-
const landedIds: number[] = []
|
|
166
|
-
for (const m of plan.media) landedIds.push((await duplicateMedia(db, driver, m.id, { folder: m.toFolder, filename: m.toFilename })).id)
|
|
167
|
-
if (item.type === 'folder') {
|
|
168
|
-
createCopiedFolderRows(db, plan)
|
|
169
|
-
for (const f of plan.folders) await driver.ensureDir?.(f.to)
|
|
170
|
-
} else ensureFolder(db, plan.toRoot)
|
|
171
|
-
return { item, status: 'copied', newPath: item.type === 'folder' ? plan.toRoot : plan.media[0].toKey, landedIds }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
for (const m of plan.media) await relocateMedia(db, driver, m.id, { folder: m.toFolder, filename: m.toFilename })
|
|
175
|
-
if (item.type === 'folder') {
|
|
176
|
-
relocateFolderRows(db, plan)
|
|
177
|
-
for (const f of plan.folders) await driver.ensureDir?.(f.to) // dest dirs incl. empty subfolders
|
|
178
|
-
// Clean the vacated source subtree ONLY when nothing unmanaged remains — removeDir is a recursive wipe,
|
|
179
|
-
// so a blind call would destroy non-media objects (extension/consumer blobs) living under the same path.
|
|
180
|
-
const remaining = driver.listPrefix ? await driver.listPrefix(item.path) : ['guard']
|
|
181
|
-
if (remaining.length === 0) await driver.removeDir?.(item.path)
|
|
182
|
-
} else ensureFolder(db, plan.toRoot)
|
|
183
|
-
return {
|
|
184
|
-
item, status: op.type === 'rename' ? 'renamed' : 'moved',
|
|
185
|
-
newPath: item.type === 'folder' ? plan.toRoot : plan.media[0].toKey,
|
|
186
|
-
landedIds: plan.media.map((m) => m.id),
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Wave 2 (execute): re-validate (TOCTOU), then move/copy/rename each item in order, re-planning
|
|
191
|
-
* per item against the now-mutated db. `abort` rejects 409 up front if any conflict exists. */
|
|
192
|
-
export async function executeRelocation(
|
|
193
|
-
db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp,
|
|
194
|
-
onConflict: 'abort' | 'skip' | 'overwrite' | 'rename' = 'abort',
|
|
195
|
-
): Promise<OpResult[]> {
|
|
196
|
-
validateOp(op)
|
|
197
|
-
const report = previewRelocation(db, op)
|
|
198
|
-
if (onConflict === 'abort' && report.conflicts.length) {
|
|
199
|
-
throw createError({ statusCode: 409, statusMessage: 'Conflicts prevent the operation', data: report })
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Ids this batch has already relocated ONTO their target — so a later item's 'overwrite' never treats
|
|
203
|
-
// an earlier item's freshly-landed row as a stale occupant to destroy (see deleteOccupant below).
|
|
204
|
-
const relocatedIds = new Set<number>()
|
|
205
|
-
const results: OpResult[] = []
|
|
206
|
-
for (const item of op.items) {
|
|
207
|
-
const { landedIds, ...result } = await applyStrategy(db, driver, op, item, onConflict, relocatedIds)
|
|
208
|
-
results.push(result)
|
|
209
|
-
for (const id of landedIds) relocatedIds.add(id)
|
|
210
|
-
}
|
|
211
|
-
return results
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Shared body for the move/copy/rename POST handlers (they differ only in the op shape): auth backstop,
|
|
216
|
-
* parse + non-empty-check the items, build the op via `buildOp`, parse `onConflict`, then dispatch a
|
|
217
|
-
* dry-run preview vs a real execute. Each route becomes a one-liner supplying only its op.
|
|
218
|
-
*/
|
|
219
|
-
export async function runRelocation(event: H3Event, buildOp: (items: OpItem[], body: Record<string, unknown>) => MediaOp) {
|
|
220
|
-
requireAdmin(event)
|
|
221
|
-
const body = (await readBody(event)) as Record<string, unknown> | null
|
|
222
|
-
const items = coerceOpItems(body?.items)
|
|
223
|
-
if (!items.length) throw createError({ statusCode: 400, statusMessage: 'No items' })
|
|
224
|
-
const op = buildOp(items, body ?? {})
|
|
225
|
-
const oc = body?.onConflict
|
|
226
|
-
const onConflict = oc === 'skip' || oc === 'overwrite' || oc === 'rename' ? oc : 'abort'
|
|
227
|
-
const db = useDb()
|
|
228
|
-
if (body?.dryRun === true) return previewRelocation(db, op)
|
|
229
|
-
return executeRelocation(db, useStorageDriver(), op, onConflict)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Per-item conflict dispatch. Re-checks this item's status against the live db (earlier items in
|
|
233
|
-
* the batch are already committed), then resolves per `onConflict`. No-Merge: a folder conflict is
|
|
234
|
-
* handled at the subtree root (skip/overwrite/rename the whole subtree, never per file). */
|
|
235
|
-
async function applyStrategy(
|
|
236
|
-
db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp, item: OpItem,
|
|
237
|
-
onConflict: 'abort' | 'skip' | 'overwrite' | 'rename', relocatedIds: Set<number>,
|
|
238
|
-
): Promise<ExecutedItem> {
|
|
239
|
-
const conflicts = onConflict === 'abort' ? [] : previewRelocation(db, { ...op, items: [item] }).conflicts
|
|
240
|
-
const { plan } = planItem(db, op, item)
|
|
241
|
-
|
|
242
|
-
if (conflicts.length) {
|
|
243
|
-
if (onConflict === 'skip') return { item, status: 'skipped', landedIds: [] }
|
|
244
|
-
if (onConflict === 'overwrite') {
|
|
245
|
-
// Copy/paste into the file's own folder: the "occupant" of the target key IS the source.
|
|
246
|
-
// Overwriting a file with a copy of itself is a no-op — deleting the occupant here would
|
|
247
|
-
// destroy the only copy (executeItem then short-circuits as a no-op and copies nothing).
|
|
248
|
-
const isNoop = plan.media.every((m) => m.toKey === m.fromKey) && plan.folders.every((f) => f.from === f.to)
|
|
249
|
-
if (isNoop) return { item, status: 'skipped', landedIds: [] }
|
|
250
|
-
// The per-item re-preview above sees only the LIVE db, so it can't tell a genuine pre-existing
|
|
251
|
-
// occupant from an earlier item in THIS SAME batch that already landed there — deleting the latter
|
|
252
|
-
// would silently destroy real data this very operation just placed, one item after reporting it
|
|
253
|
-
// as a success. Refuse and skip this item instead (its source is left untouched).
|
|
254
|
-
if (!(await deleteOccupant(db, driver, item, plan, relocatedIds))) return { item, status: 'skipped', landedIds: [] }
|
|
255
|
-
const r = await executeItem(db, driver, op, item, plan)
|
|
256
|
-
return { ...r, status: 'overwritten' }
|
|
257
|
-
}
|
|
258
|
-
const freePlan = freeRenamePlan(db, item, plan)
|
|
259
|
-
const r = await executeItem(db, driver, op, item, freePlan)
|
|
260
|
-
const newPath = item.type === 'file' ? freePlan.media[0]?.toKey : freePlan.toRoot
|
|
261
|
-
return { ...r, status: 'renamed-auto', newPath }
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return executeItem(db, driver, op, item, plan)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** True when no media row currently occupies this storage key. */
|
|
268
|
-
function keyIsFree(db: BetterSQLite3Database, key: string): boolean {
|
|
269
|
-
const cols = getTableColumns(media)
|
|
270
|
-
const row = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, key)).get()
|
|
271
|
-
return !row
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** True when no folders row has this path, no FILE sits exactly at it, AND no media key sits under it. */
|
|
275
|
-
function folderRootIsFree(db: BetterSQLite3Database, path: string): boolean {
|
|
276
|
-
const cols = getTableColumns(media)
|
|
277
|
-
const folderRow = db.select({ path: folders.path }).from(folders).where(eq(folders.path, path)).get()
|
|
278
|
-
if (folderRow) return false
|
|
279
|
-
// A file whose storageKey EQUALS this path blocks the folder root too — a folder can't live where a file
|
|
280
|
-
// already is (mkdir over a file 500s; the per-media guard only checks descendant keys, never the root).
|
|
281
|
-
const fileAtRoot = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, path)).get()
|
|
282
|
-
if (fileAtRoot) return false
|
|
283
|
-
const descendant = db.select({ id: cols.id }).from(media as AnySQLiteTable)
|
|
284
|
-
.where(sql`${cols.storageKey} like ${`${escapeLike(path)}/%`} escape '\\'`).get()
|
|
285
|
-
return !descendant
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Delete whatever currently occupies the item's target so the move/copy can proceed. No-op if free.
|
|
289
|
-
* Returns false (refuses to delete) when the occupant is itself a media id THIS SAME BATCH already
|
|
290
|
-
* relocated onto that target — an intra-batch name collision, not a stale pre-existing occupant. */
|
|
291
|
-
async function deleteOccupant(
|
|
292
|
-
db: BetterSQLite3Database, driver: StorageDriver, item: OpItem, plan: ItemPlan, relocatedIds: Set<number>,
|
|
293
|
-
): Promise<boolean> {
|
|
294
|
-
// Occupant is deleted before the move commits, so a runtime fault mid-move loses it — inherent to overwrite.
|
|
295
|
-
if (item.type === 'file') {
|
|
296
|
-
const key = plan.media[0]?.toKey
|
|
297
|
-
if (!key) return true
|
|
298
|
-
const cols = getTableColumns(media)
|
|
299
|
-
const row = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, key)).get() as { id: number } | undefined
|
|
300
|
-
if (row) {
|
|
301
|
-
if (relocatedIds.has(row.id)) return false
|
|
302
|
-
await deleteAffected(db, driver, [{ type: 'file', id: row.id }], false)
|
|
303
|
-
}
|
|
304
|
-
return true
|
|
305
|
-
}
|
|
306
|
-
const occupant = collectAffected(db, [{ type: 'folder', path: plan.toRoot }])
|
|
307
|
-
if (occupant.media.some((m) => relocatedIds.has(m.id))) return false
|
|
308
|
-
await deleteAffected(db, driver, [{ type: 'folder', path: plan.toRoot }], false)
|
|
309
|
-
return true
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** Build a plan whose target is free, for the rename strategy (file → freed filename; folder → freed root). */
|
|
313
|
-
function freeRenamePlan(db: BetterSQLite3Database, item: OpItem, plan: ItemPlan): ItemPlan {
|
|
314
|
-
if (item.type === 'file') {
|
|
315
|
-
const m = plan.media[0]
|
|
316
|
-
const name = suggestFreeName(m.toFilename, (n) => keyIsFree(db, buildKey(m.toFolder, n)))
|
|
317
|
-
return { ...plan, media: [{ ...m, toFilename: name, toKey: buildKey(m.toFolder, name) }] }
|
|
318
|
-
}
|
|
319
|
-
const freeRoot = freeFolderRoot(db, plan.toRoot)
|
|
320
|
-
return rebasePlan(plan, plan.toRoot, freeRoot)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** Pick a fresh sibling segment for a colliding folder root: `archive/sub` → `archive/sub-2`. */
|
|
324
|
-
function freeFolderRoot(db: BetterSQLite3Database, toRoot: string): string {
|
|
325
|
-
const parent = parentOf(toRoot) ?? ''
|
|
326
|
-
const seg = childName(toRoot)
|
|
327
|
-
const freeSeg = suggestFreeName(seg, (s) => folderRootIsFree(db, parent === '' ? s : `${parent}/${s}`))
|
|
328
|
-
return parent === '' ? freeSeg : `${parent}/${freeSeg}`
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/** Re-base every target in a folder plan from `from` onto `to` (the whole subtree moves under the new root). */
|
|
332
|
-
function rebasePlan(plan: ItemPlan, from: string, to: string): ItemPlan {
|
|
333
|
-
return {
|
|
334
|
-
toRoot: to,
|
|
335
|
-
media: plan.media.map((m) => {
|
|
336
|
-
const toFolder = rewritePrefix(m.toFolder, from, to)
|
|
337
|
-
return { ...m, toFolder, toKey: buildKey(toFolder, m.toFilename) }
|
|
338
|
-
}),
|
|
339
|
-
folders: plan.folders.map((f) => ({ from: f.from, to: rewritePrefix(f.to, from, to) })),
|
|
340
|
-
}
|
|
341
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { createError } from 'h3'
|
|
2
|
-
import { sanitizeFolder, buildKey, withExtension, extensionOf } from './naming'
|
|
3
|
-
import { childName, parentOf, rewritePrefix } from './folder-paths'
|
|
4
|
-
import type { OpItem, AffectedSet, AffectedMedia } from './media-ops'
|
|
5
|
-
|
|
6
|
-
export interface PlannedMedia {
|
|
7
|
-
id: number
|
|
8
|
-
fromKey: string
|
|
9
|
-
toFolder: string
|
|
10
|
-
toFilename: string
|
|
11
|
-
toKey: string
|
|
12
|
-
}
|
|
13
|
-
export interface PlannedFolder { from: string; to: string }
|
|
14
|
-
export interface ItemPlan { toRoot: string; media: PlannedMedia[]; folders: PlannedFolder[] }
|
|
15
|
-
|
|
16
|
-
/** A rename must not introduce a path separator: take the last clean segment, never empty. */
|
|
17
|
-
function sanitizeSegment(name: string): string {
|
|
18
|
-
const seg = childName(sanitizeFolder(name))
|
|
19
|
-
if (!seg) throw createError({ statusCode: 400, statusMessage: 'Invalid name' })
|
|
20
|
-
return seg
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const planMedia = (m: AffectedMedia, toFolder: string): PlannedMedia => ({
|
|
24
|
-
id: m.id, fromKey: m.storageKey, toFolder, toFilename: m.filename, toKey: buildKey(toFolder, m.filename),
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
/** Re-base every affected media row + folder path from the `from` subtree onto `to`. */
|
|
28
|
-
function cascade(affected: AffectedSet, from: string, to: string): Pick<ItemPlan, 'media' | 'folders'> {
|
|
29
|
-
return {
|
|
30
|
-
media: affected.media.map((m) => planMedia(m, rewritePrefix(m.folder ?? '', from, to))),
|
|
31
|
-
folders: affected.folders.map((fp) => ({ from: fp, to: rewritePrefix(fp, from, to) })),
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Targets for moving/copying an item into `dest` (file → dest unchanged; folder → dest/<name>). */
|
|
36
|
-
export function planRelocateInto(item: OpItem, affected: AffectedSet, dest: string): ItemPlan {
|
|
37
|
-
const to = sanitizeFolder(dest)
|
|
38
|
-
if (item.type === 'file') {
|
|
39
|
-
const m = affected.media[0]
|
|
40
|
-
if (!m) throw createError({ statusCode: 404, statusMessage: 'Media not found' })
|
|
41
|
-
return { toRoot: to, media: [planMedia(m, to)], folders: [] }
|
|
42
|
-
}
|
|
43
|
-
const toRoot = to === '' ? childName(item.path) : `${to}/${childName(item.path)}`
|
|
44
|
-
return { toRoot, ...cascade(affected, item.path, toRoot) }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Targets for renaming an item in place (file → new filename; folder → new last segment). */
|
|
48
|
-
export function planRename(item: OpItem, affected: AffectedSet, name: string): ItemPlan {
|
|
49
|
-
if (item.type === 'file') {
|
|
50
|
-
const m = affected.media[0]
|
|
51
|
-
if (!m) throw createError({ statusCode: 404, statusMessage: 'Media not found' })
|
|
52
|
-
const toFolder = m.folder ?? ''
|
|
53
|
-
const toFilename = withExtension(name, extensionOf(m.filename))
|
|
54
|
-
return {
|
|
55
|
-
toRoot: toFolder, folders: [],
|
|
56
|
-
media: [{ id: m.id, fromKey: m.storageKey, toFolder, toFilename, toKey: buildKey(toFolder, toFilename) }],
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
const parent = parentOf(item.path) ?? ''
|
|
60
|
-
const seg = sanitizeSegment(name)
|
|
61
|
-
const toRoot = parent === '' ? seg : `${parent}/${seg}`
|
|
62
|
-
return { toRoot, ...cascade(affected, item.path, toRoot) }
|
|
63
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import { primaryLocale } from '../../../core/server/utils/locale'
|
|
4
|
-
import { media } from '../collections/media'
|
|
5
|
-
import type { DerivativeManifest } from './record'
|
|
6
|
-
|
|
7
|
-
/** One derivative, tagged with its parsed name + format so `<picture>` rendering can group by format. */
|
|
8
|
-
export interface MediaVariant { name: string; format: string; url: string; width: number; height: number }
|
|
9
|
-
|
|
10
|
-
/** The disclosure-relevant subset of the IPTC Digital Source Type vocabulary (EU AI Act Art. 50). */
|
|
11
|
-
export type AiSourceType = 'trainedAlgorithmicMedia' | 'compositeWithTrainedAlgorithmicMedia' | 'algorithmicallyEnhanced'
|
|
12
|
-
|
|
13
|
-
export interface ResolvedMedia {
|
|
14
|
-
id: number
|
|
15
|
-
/** The media's storage folder ('' at the media root) — lets the field open the picker there. */
|
|
16
|
-
folder: string
|
|
17
|
-
alt: string | null
|
|
18
|
-
title: string | null
|
|
19
|
-
description: string | null
|
|
20
|
-
mime: string
|
|
21
|
-
width: number | null
|
|
22
|
-
height: number | null
|
|
23
|
-
thumbhash: string | null
|
|
24
|
-
src: string
|
|
25
|
-
/** WebP-only responsive srcset (kept for the admin thumb/grid/field/viewer `<img>`s). */
|
|
26
|
-
srcset: { url: string; width: number }[]
|
|
27
|
-
/** Every derivative, name+format-tagged — the source `KestrelImg`/`useMediaVariant` build `<picture>` from. */
|
|
28
|
-
variants: MediaVariant[]
|
|
29
|
-
/** EU AI Act Art. 50 disclosure, set by an editor — null when unset. Always resolved regardless of
|
|
30
|
-
* `kestrel.config.ts`'s `aiDisclosure.enabled` (that flag only gates the admin UI). Kestrel never
|
|
31
|
-
* renders this automatically; read it directly or opt into `KestrelImg`'s `aiBadge` prop. */
|
|
32
|
-
aiDisclosure: { sourceType: AiSourceType; note: string | null } | null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface MediaRow {
|
|
36
|
-
id: number
|
|
37
|
-
folder: string | null
|
|
38
|
-
storageKey: string
|
|
39
|
-
mime: string
|
|
40
|
-
width: number | null
|
|
41
|
-
height: number | null
|
|
42
|
-
thumbhash: string | null
|
|
43
|
-
derivatives: DerivativeManifest | null
|
|
44
|
-
translations: Record<string, { alt?: string; title?: string; description?: string }> | null
|
|
45
|
-
aiSourceType: string | null
|
|
46
|
-
aiNote: string | null
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: string) => string): ResolvedMedia {
|
|
50
|
-
// Per-FIELD locale fallback, not whole-locale: a locale entry that carries e.g. only `title` (the PATCH
|
|
51
|
-
// API creates partial per-locale objects) must not suppress the primary's `alt` — that silently drops
|
|
52
|
-
// accessibility text on that locale's pages. Each field falls back to the primary locale independently.
|
|
53
|
-
const loc = row.translations?.[locale] ?? {}
|
|
54
|
-
const prim = row.translations?.[primaryLocale()] ?? {}
|
|
55
|
-
const t = { alt: loc.alt ?? prim.alt, title: loc.title ?? prim.title, description: loc.description ?? prim.description }
|
|
56
|
-
// Every derivative, name+format-tagged (the manifest key is `<name>.<format>`; names are dot-free so the
|
|
57
|
-
// last dot splits cleanly). `srcset` is the webp slice, kept for the admin `<img>`s.
|
|
58
|
-
const variants = Object.entries(row.derivatives ?? {}).map(([mk, d]) => {
|
|
59
|
-
const dot = mk.lastIndexOf('.')
|
|
60
|
-
const name = dot > 0 ? mk.slice(0, dot) : mk
|
|
61
|
-
const format = dot > 0 ? mk.slice(dot + 1) : (d.mime.split('/').pop() ?? '')
|
|
62
|
-
return { name, format, url: publicUrl(d.key), width: d.width, height: d.height }
|
|
63
|
-
})
|
|
64
|
-
const srcset = variants
|
|
65
|
-
.filter((v) => v.format === 'webp')
|
|
66
|
-
.map((v) => ({ url: v.url, width: v.width }))
|
|
67
|
-
.sort((a, b) => a.width - b.width)
|
|
68
|
-
return {
|
|
69
|
-
id: row.id,
|
|
70
|
-
folder: row.folder ?? '', // root files store folder NULL → ''
|
|
71
|
-
alt: t.alt ?? null,
|
|
72
|
-
title: t.title ?? null,
|
|
73
|
-
description: t.description ?? null,
|
|
74
|
-
mime: row.mime,
|
|
75
|
-
width: row.width,
|
|
76
|
-
height: row.height,
|
|
77
|
-
thumbhash: row.thumbhash,
|
|
78
|
-
src: publicUrl(row.storageKey),
|
|
79
|
-
srcset,
|
|
80
|
-
variants,
|
|
81
|
-
// A note without a source type is only evidence (e.g. the upload scan's pre-fill), not a disclosure —
|
|
82
|
-
// never emit a half-filled object a consumer might render as one.
|
|
83
|
-
aiDisclosure: row.aiSourceType ? { sourceType: row.aiSourceType as AiSourceType, note: row.aiNote ?? null } : null,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Reorder rows to match the requested id order, dropping ids that resolved to nothing. */
|
|
88
|
-
export function orderById<T extends { id: number }>(ids: number[], rows: T[]): T[] {
|
|
89
|
-
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
90
|
-
return ids.map((id) => byId.get(id)).filter((r): r is T => !!r)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function resolveManyByIds(db: BetterSQLite3Database, ids: number[], locale: string, publicUrl: (key: string) => string): ResolvedMedia[] {
|
|
94
|
-
if (!ids.length) return []
|
|
95
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
96
|
-
const rows = db.select().from(media).where(inArray(cols.id, ids)).all() as MediaRow[]
|
|
97
|
-
return orderById(ids, rows.map((r) => resolveMedia(r, locale, publicUrl)))
|
|
98
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { JSDOM } from 'jsdom'
|
|
2
|
-
import createDOMPurify from 'dompurify'
|
|
3
|
-
|
|
4
|
-
const purify = createDOMPurify(new JSDOM('').window as unknown as Parameters<typeof createDOMPurify>[0])
|
|
5
|
-
|
|
6
|
-
export function sanitizeSvg(svg: string): string {
|
|
7
|
-
return purify.sanitize(svg, {
|
|
8
|
-
USE_PROFILES: { svg: true, svgFilters: true },
|
|
9
|
-
FORBID_TAGS: ['script', 'foreignObject'],
|
|
10
|
-
FORBID_ATTR: ['onload', 'onerror', 'onclick'],
|
|
11
|
-
})
|
|
12
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { fileTypeFromBuffer } from 'file-type'
|
|
2
|
-
|
|
3
|
-
/** Default upload allow-list — sniffable, safe formats. NO executable/active types (html/js/swf →
|
|
4
|
-
* XSS from the media origin) and no plain-text (txt/csv: no magic bytes → unsniffable). Override
|
|
5
|
-
* the whole set via KESTREL_MEDIA_ALLOWED_MIME (see resolveAllowedMimes). Exact file-type@22 MIMEs. */
|
|
6
|
-
export const DEFAULT_ALLOWED_MIME = new Set([
|
|
7
|
-
'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml',
|
|
8
|
-
'application/pdf',
|
|
9
|
-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
10
|
-
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
11
|
-
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
12
|
-
'application/vnd.oasis.opendocument.text',
|
|
13
|
-
'application/vnd.oasis.opendocument.spreadsheet',
|
|
14
|
-
'application/vnd.oasis.opendocument.presentation',
|
|
15
|
-
'video/mp4', 'video/webm', 'video/quicktime',
|
|
16
|
-
// audio (file-type emits 'audio/x-m4a' for typical m4a, 'audio/ogg; codecs=opus' for opus)
|
|
17
|
-
'audio/mpeg', 'audio/ogg', 'audio/ogg; codecs=opus', 'audio/wav', 'audio/flac', 'audio/x-m4a', 'audio/mp4',
|
|
18
|
-
])
|
|
19
|
-
|
|
20
|
-
/** Resolve the effective allow-list: a non-empty comma list overrides the default entirely. */
|
|
21
|
-
export function resolveAllowedMimes(configured: string | undefined): Set<string> {
|
|
22
|
-
const list = (configured ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
23
|
-
return list.length ? new Set(list) : DEFAULT_ALLOWED_MIME
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const EXT_BY_MIME: Record<string, string> = {
|
|
27
|
-
'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
|
|
28
|
-
'image/avif': 'avif', 'application/pdf': 'pdf', 'image/svg+xml': 'svg',
|
|
29
|
-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
30
|
-
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
31
|
-
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
|
32
|
-
'application/vnd.oasis.opendocument.text': 'odt',
|
|
33
|
-
'application/vnd.oasis.opendocument.spreadsheet': 'ods',
|
|
34
|
-
'application/vnd.oasis.opendocument.presentation': 'odp',
|
|
35
|
-
'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov',
|
|
36
|
-
'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/ogg; codecs=opus': 'opus',
|
|
37
|
-
'audio/wav': 'wav', 'audio/flac': 'flac', 'audio/x-m4a': 'm4a', 'audio/mp4': 'm4a',
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function looksLikeSvg(bytes: Buffer): boolean {
|
|
41
|
-
let head = bytes.subarray(0, 1024).toString('utf8').trimStart()
|
|
42
|
-
// Skip any leading XML declaration, DOCTYPE, and comments (some exporters emit these before <svg>),
|
|
43
|
-
// then require the root element to be <svg>.
|
|
44
|
-
for (let prev = ''; head !== prev; ) {
|
|
45
|
-
prev = head
|
|
46
|
-
head = head
|
|
47
|
-
.replace(/^<\?xml\b[^>]*\?>\s*/i, '')
|
|
48
|
-
.replace(/^<!DOCTYPE\b[^>]*>\s*/i, '')
|
|
49
|
-
.replace(/^<!--[\s\S]*?-->\s*/, '')
|
|
50
|
-
.trimStart()
|
|
51
|
-
}
|
|
52
|
-
return /^<svg[\s>]/i.test(head)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Detect the real MIME from the bytes (magic-byte sniff, then an SVG text heuristic). Pure
|
|
56
|
-
* detection — authorization against the allow-list is the caller's job. */
|
|
57
|
-
export async function sniffMime(bytes: Buffer): Promise<string | null> {
|
|
58
|
-
const ft = await fileTypeFromBuffer(bytes)
|
|
59
|
-
// An SVG that opens with an XML declaration / DOCTYPE is reported by the magic-byte sniff as generic XML;
|
|
60
|
-
// promote it to image/svg+xml when the root element is actually <svg>. A non-XML detection is trusted as-is.
|
|
61
|
-
if (ft && ft.mime !== 'application/xml' && ft.mime !== 'text/xml') return ft.mime
|
|
62
|
-
if (looksLikeSvg(bytes)) return 'image/svg+xml'
|
|
63
|
-
return ft ? ft.mime : null
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function extForMime(mime: string): string {
|
|
67
|
-
return EXT_BY_MIME[mime] ?? 'bin'
|
|
68
|
-
}
|