@michaelthielemann/kestrel 1.2.1
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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,132 @@
|
|
|
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 { activeVariants } 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. */
|
|
20
|
+
orphanKeys: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pure diff of a media row's derivative manifest against the active variant set: what to generate, what to
|
|
25
|
+
* prune. A proportional spec wider than the source is never "missing" (deriveImage would skip it, so it can
|
|
26
|
+
* never be satisfied). Orphans are keyed per `<name>.<format>`, so a deregistered FORMAT of a still-active
|
|
27
|
+
* name is pruned too.
|
|
28
|
+
*/
|
|
29
|
+
export function planBackfill(row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[]): BackfillPlan {
|
|
30
|
+
const manifest = row.derivatives ?? {}
|
|
31
|
+
const activeKeys = new Set<string>()
|
|
32
|
+
const missing: ResolvedVariant[] = []
|
|
33
|
+
for (const spec of specs) {
|
|
34
|
+
const applicable = spec.height != null || !row.width || spec.width <= row.width
|
|
35
|
+
const missingFormats: VariantFormat[] = []
|
|
36
|
+
for (const f of spec.formats) {
|
|
37
|
+
activeKeys.add(`${spec.name}.${f}`)
|
|
38
|
+
if (applicable && !(`${spec.name}.${f}` in manifest)) missingFormats.push(f)
|
|
39
|
+
}
|
|
40
|
+
if (missingFormats.length) missing.push({ ...spec, formats: missingFormats })
|
|
41
|
+
}
|
|
42
|
+
const orphanKeys: string[] = []
|
|
43
|
+
for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
|
|
44
|
+
return { missing, orphanKeys }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BackfillRow { id: number; storageKey: string; mime: string; width: number | null; derivatives: DerivativeManifest | null }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reconcile ONE media row to the active set: derive its missing variants from the original (put-first),
|
|
51
|
+
* write the updated manifest, then prune orphan objects LAST (a crash never dangles the manifest). The pruned
|
|
52
|
+
* objects are this row's OWN deregistered derivatives — self-owned, so no cross-row ownership guard is needed
|
|
53
|
+
* (that is Slice 9's published-media GC, a different concern).
|
|
54
|
+
*/
|
|
55
|
+
export async function backfillRow(
|
|
56
|
+
db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy,
|
|
57
|
+
): Promise<{ generated: number; pruned: number }> {
|
|
58
|
+
const plan = planBackfill(row, specs)
|
|
59
|
+
if (!plan.missing.length && !plan.orphanKeys.length) return { generated: 0, pruned: 0 }
|
|
60
|
+
|
|
61
|
+
const orphaned = new Set(plan.orphanKeys)
|
|
62
|
+
const manifest: DerivativeManifest = {}
|
|
63
|
+
for (const [k, e] of Object.entries(row.derivatives ?? {})) if (!orphaned.has(e.key)) manifest[k] = e
|
|
64
|
+
|
|
65
|
+
let generated = 0
|
|
66
|
+
if (plan.missing.length) {
|
|
67
|
+
if (typeof driver.get !== 'function') throw new Error('storage driver cannot read originals back (no get()) — backfill needs it')
|
|
68
|
+
const derived = await deriveImage(await driver.get(row.storageKey), { ...policy, variants: plan.missing })
|
|
69
|
+
for (const v of derived.variants) {
|
|
70
|
+
const key = derivativeKey(row.storageKey, v.name, v.format)
|
|
71
|
+
await driver.put(key, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
|
|
72
|
+
manifest[`${v.name}.${v.format}`] = { key, width: v.width, height: v.height, mime: v.mime }
|
|
73
|
+
generated++
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
78
|
+
db.update(media).set({ derivatives: manifest, updatedAt: new Date() } as never).where(eq(cols.id, row.id)).run()
|
|
79
|
+
// Published pages hold this row's srcset candidates, so a rewritten manifest — especially a pruned
|
|
80
|
+
// key, which now 404s — has to re-render them. Emitted before the prune: a delete that throws must
|
|
81
|
+
// not leave the already-written manifest un-republished.
|
|
82
|
+
emitMediaWrite({ id: row.id }, { id: row.id })
|
|
83
|
+
for (const key of plan.orphanKeys) await driver.delete(key)
|
|
84
|
+
return { generated, pruned: plan.orphanKeys.length }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BackfillReport { rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Iterate every raster media row, reconciling each to the active variant set. Sequential (sharp CPU + a
|
|
91
|
+
* full-original GET per row) and best-effort per row — a corrupt original is logged and skipped, never
|
|
92
|
+
* aborting the whole run. `check` reports the plan (rows / would-generate / would-prune) without writing.
|
|
93
|
+
*/
|
|
94
|
+
export async function runBackfill(
|
|
95
|
+
db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, opts: { check?: boolean } = {},
|
|
96
|
+
): Promise<BackfillReport> {
|
|
97
|
+
const specs = activeVariants(db, policy.variants, policy.presets)
|
|
98
|
+
const rows = db.select().from(media).all() as BackfillRow[]
|
|
99
|
+
const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check }
|
|
100
|
+
for (const snapshot of rows) {
|
|
101
|
+
if (!RASTER.has(snapshot.mime)) continue
|
|
102
|
+
if (opts.check) {
|
|
103
|
+
// Dry-run reads only (against the snapshot) — no mutation, so no lock.
|
|
104
|
+
const plan = planBackfill(snapshot, specs)
|
|
105
|
+
const toGenerate = plan.missing.reduce((n, s) => n + s.formats.length, 0)
|
|
106
|
+
if (!toGenerate && !plan.orphanKeys.length) continue
|
|
107
|
+
report.rowsChanged++
|
|
108
|
+
report.generated += toGenerate
|
|
109
|
+
report.pruned += plan.orphanKeys.length
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
// Serialize per storageKey with the upload path, and re-read the row FRESH inside the lock: a concurrent
|
|
113
|
+
// overwrite may have replaced the bytes/dims/manifest since the up-front snapshot, and reconciling the
|
|
114
|
+
// stale snapshot against the new object would write a manifest whose dims describe the OLD image.
|
|
115
|
+
await withLock(mediaLockKey(snapshot.storageKey), async () => {
|
|
116
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
117
|
+
const row = db.select().from(media).where(eq(cols.id, snapshot.id)).get() as BackfillRow | undefined
|
|
118
|
+
if (!row || !RASTER.has(row.mime)) return
|
|
119
|
+
const plan = planBackfill(row, specs)
|
|
120
|
+
if (!plan.missing.length && !plan.orphanKeys.length) return
|
|
121
|
+
report.rowsChanged++
|
|
122
|
+
try {
|
|
123
|
+
const r = await backfillRow(db, driver, row, specs, policy)
|
|
124
|
+
report.generated += r.generated
|
|
125
|
+
report.pruned += r.pruned
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`[kestrel] backfill failed for media ${row.id} (${row.storageKey}):`, (error as Error)?.message ?? error)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return report
|
|
132
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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, createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const allFolders = db.select({ path: foldersTable.path }).from(foldersTable).all()
|
|
64
|
+
|
|
65
|
+
// Recursive folder sizes: one grouped query sums bytes per exact folder across the current subtree,
|
|
66
|
+
// then each immediate child's whole subtree is rolled up in JS (cheap: one row per distinct folder).
|
|
67
|
+
const sizeBase = db.select({ folder: cols.folder, total: sql<number>`coalesce(sum(${cols.size}), 0)` }).from(t).groupBy(cols.folder)
|
|
68
|
+
const sizeRows = (folder === ''
|
|
69
|
+
? sizeBase
|
|
70
|
+
: sizeBase.where(or(eq(cols.folder, folder), sql`${cols.folder} like ${`${escapeLike(folder)}/%`} escape '\\'`))
|
|
71
|
+
).all() as { folder: string | null; total: number }[]
|
|
72
|
+
const sizeOf = (prefix: string): number =>
|
|
73
|
+
sizeRows.reduce((sum, r) => (((r.folder ?? '') === prefix || (r.folder ?? '').startsWith(`${prefix}/`)) ? sum + Number(r.total ?? 0) : sum), 0)
|
|
74
|
+
|
|
75
|
+
// Folders always render before files; within the group they follow the same sort (by name, or by
|
|
76
|
+
// recursive size; type is uniform for folders so it falls back to name).
|
|
77
|
+
const folderList: LibraryFolder[] = allFolders
|
|
78
|
+
.filter((f) => isImmediateChild(folder, f.path))
|
|
79
|
+
.map((f) => ({ path: f.path, name: childName(f.path), size: sizeOf(f.path) }))
|
|
80
|
+
.sort((a, b) => {
|
|
81
|
+
const c = sort.field === 'size' ? a.size - b.size : a.name.localeCompare(b.name)
|
|
82
|
+
return sort.desc ? -c : c
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// The root always exists; any other folder must have a row in the registry. Lets the client tell a
|
|
86
|
+
// genuinely-empty folder apart from a path that does not exist (→ error instead of a blank listing).
|
|
87
|
+
const exists = folder === '' || allFolders.some((f) => f.path === folder)
|
|
88
|
+
|
|
89
|
+
return { folder, exists, folders: folderList, files, total: Number(totalRow?.value ?? 0), page, perPage }
|
|
90
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { eq, inArray, or, getTableColumns, sql } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import { findMediaUsagesForMany, type MediaUsage } from './usages'
|
|
4
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
5
|
+
import { media } from '../collections/media'
|
|
6
|
+
import { folders } from '../database/folders'
|
|
7
|
+
import { isUnder } from './folder-paths'
|
|
8
|
+
import type { DerivativeManifest } from './record'
|
|
9
|
+
import { emitMediaWrite } from './media-write'
|
|
10
|
+
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
11
|
+
import { escapeLike } from '../../../core/server/utils/sql'
|
|
12
|
+
|
|
13
|
+
export type OpItem = { type: 'file'; id: number } | { type: 'folder'; path: string }
|
|
14
|
+
|
|
15
|
+
export interface AffectedMedia {
|
|
16
|
+
id: number; storageKey: string; folder: string | null; filename: string
|
|
17
|
+
size: number; derivatives: DerivativeManifest | null
|
|
18
|
+
}
|
|
19
|
+
export interface AffectedSet { media: AffectedMedia[]; folders: string[] }
|
|
20
|
+
|
|
21
|
+
/** Resolve op items into the concrete affected media rows + folder paths. A `folder` item
|
|
22
|
+
* expands to itself + every descendant (media + folder rows). Folders are returned deepest-first
|
|
23
|
+
* (so a delete removes children before parents); media is deduped by id. */
|
|
24
|
+
export function collectAffected(db: BetterSQLite3Database, items: OpItem[]): AffectedSet {
|
|
25
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
26
|
+
const t = media as AnySQLiteTable
|
|
27
|
+
const mediaById = new Map<number, AffectedMedia>()
|
|
28
|
+
const folderSet = new Set<string>()
|
|
29
|
+
// Only a folder item ever consults the full folder list (to find its descendants) — an all-file list
|
|
30
|
+
// never touches it, so skip the read rather than materialize every row for nothing.
|
|
31
|
+
const allFolders = items.some((i) => i.type === 'folder')
|
|
32
|
+
? db.select({ path: folders.path }).from(folders).all().map((f) => f.path)
|
|
33
|
+
: []
|
|
34
|
+
|
|
35
|
+
const add = (rows: Record<string, unknown>[]) => {
|
|
36
|
+
for (const r of rows) mediaById.set(r.id as number, {
|
|
37
|
+
id: r.id as number, storageKey: r.storageKey as string, folder: (r.folder as string | null) ?? null,
|
|
38
|
+
filename: r.filename as string, size: r.size as number, derivatives: (r.derivatives as DerivativeManifest | null) ?? null,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.type === 'file') {
|
|
44
|
+
const row = db.select().from(t).where(eq(cols.id, item.id)).get() as Record<string, unknown> | undefined
|
|
45
|
+
if (row) add([row])
|
|
46
|
+
} else {
|
|
47
|
+
for (const fp of allFolders) if (isUnder(item.path, fp)) folderSet.add(fp)
|
|
48
|
+
folderSet.add(item.path)
|
|
49
|
+
const rows = db.select().from(t).where(
|
|
50
|
+
or(eq(cols.folder, item.path), sql`${cols.folder} like ${`${escapeLike(item.path)}/%`} escape '\\'`),
|
|
51
|
+
).all() as Record<string, unknown>[]
|
|
52
|
+
add(rows)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const folderList = [...folderSet].sort((a, b) => b.split('/').length - a.split('/').length)
|
|
57
|
+
return { media: [...mediaById.values()], folders: folderList }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reverse-usage lookup for many media ids at once: id -> the records referencing it. */
|
|
61
|
+
export function bulkUsages(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
|
|
62
|
+
return findMediaUsagesForMany(db, ids)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DeleteReport {
|
|
66
|
+
summary: { files: number; folders: number; totalBytes: number }
|
|
67
|
+
usages?: Record<number, MediaUsage[]>
|
|
68
|
+
/** storageKeys whose blob delete failed — the row is already gone (rows commit before storage), so a
|
|
69
|
+
* failure here is an orphaned object, not a retryable half-delete; surfaced for the caller to log/alert. */
|
|
70
|
+
failedKeys?: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Two-wave delete. dryRun → report impact + per-media usages, no mutation. Otherwise → delete
|
|
74
|
+
* all affected media rows + folder rows in one transaction, then delete the storage objects
|
|
75
|
+
* (original + derivatives). Folder cascade included. */
|
|
76
|
+
export async function deleteAffected(
|
|
77
|
+
db: BetterSQLite3Database, driver: StorageDriver, items: OpItem[], dryRun: boolean,
|
|
78
|
+
): Promise<DeleteReport> {
|
|
79
|
+
const affected = collectAffected(db, items)
|
|
80
|
+
const summary = {
|
|
81
|
+
files: affected.media.length,
|
|
82
|
+
folders: affected.folders.length,
|
|
83
|
+
totalBytes: affected.media.reduce((n, m) => n + (m.size || 0), 0),
|
|
84
|
+
}
|
|
85
|
+
if (dryRun) return { summary, usages: bulkUsages(db, affected.media.map((m) => m.id)) }
|
|
86
|
+
|
|
87
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
88
|
+
const mediaIds = affected.media.map((m) => m.id)
|
|
89
|
+
db.transaction((tx) => {
|
|
90
|
+
if (mediaIds.length) tx.delete(media).where(inArray(cols.id, mediaIds)).run()
|
|
91
|
+
if (affected.folders.length) tx.delete(folders).where(inArray(folders.path, affected.folders)).run()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Best-effort per item: the rows are already committed gone, so a storage error here means an orphan
|
|
95
|
+
// object, not a rollback candidate — one bad key (a throttled/5xx S3 call) must not abort the loop and
|
|
96
|
+
// leave every LATER item's blobs undeleted too with no row left to find them again.
|
|
97
|
+
const failedKeys: string[] = []
|
|
98
|
+
for (const m of affected.media) {
|
|
99
|
+
try {
|
|
100
|
+
await driver.delete(m.storageKey)
|
|
101
|
+
for (const d of Object.values(m.derivatives ?? {})) await driver.delete(d.key)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error(`[kestrel] media storage delete failed for ${m.storageKey}:`, (error as Error)?.message ?? error)
|
|
104
|
+
failedKeys.push(m.storageKey)
|
|
105
|
+
}
|
|
106
|
+
emitMediaWrite({ id: m.id }, null) // deleted media → re-render listings that query it
|
|
107
|
+
}
|
|
108
|
+
// Clean up the folder dir ONLY when nothing unmanaged remains under it. removeDir is a recursive subtree
|
|
109
|
+
// wipe, so a blind call would destroy any non-media object living under the same path — e.g. an extension's
|
|
110
|
+
// encrypted blob namespace, or any consumer blob written via useStorageDriver — which no media row (and no
|
|
111
|
+
// dryRun count) accounts for. If anything is still there, leave the whole path alone.
|
|
112
|
+
for (const it of items) {
|
|
113
|
+
if (it.type !== 'folder') continue
|
|
114
|
+
const remaining = driver.listPrefix ? await driver.listPrefix(it.path) : ['guard']
|
|
115
|
+
if (remaining.length === 0) await driver.removeDir?.(it.path)
|
|
116
|
+
}
|
|
117
|
+
return failedKeys.length ? { summary, failedKeys } : { summary }
|
|
118
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { emitWrite } from '../../../core/server/utils/write-events'
|
|
2
|
+
import mediaCollection from '../collections/media'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Notify the publish runtime that a media row changed. The media-library write paths (relocate / duplicate
|
|
6
|
+
* / delete / alt-edit) bypass core CRUD, so — unlike a normal content write — they emit no write event on
|
|
7
|
+
* their own. Without this, a page that embeds the media (dep-tagged `media:<id>`) is never re-rendered, so
|
|
8
|
+
* the static output keeps the old (now 404) URL or stale alt text. `before`/`after` need only carry `id`
|
|
9
|
+
* for the media classification (non-pageLike, no status column).
|
|
10
|
+
*/
|
|
11
|
+
export function emitMediaWrite(before: Record<string, unknown> | null, after: Record<string, unknown> | null): void {
|
|
12
|
+
emitWrite(mediaCollection.def, before, after)
|
|
13
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function sanitizeFilename(name: string): string {
|
|
2
|
+
const base = name.split(/[/\\]/).pop() ?? ''
|
|
3
|
+
const cleaned = base.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_').slice(0, 120)
|
|
4
|
+
if (cleaned === '' || cleaned === '.' || cleaned === '..') return 'file'
|
|
5
|
+
return cleaned
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The last extension of a filename (no leading dot), or '' when there is none. */
|
|
9
|
+
export function extensionOf(name: string): string {
|
|
10
|
+
const dot = name.lastIndexOf('.')
|
|
11
|
+
return dot > 0 ? name.slice(dot + 1) : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Sanitize `name` and force its extension to `ext` (drops any client-supplied extension).
|
|
15
|
+
* The stored extension must follow the authenticated content type, never the upload filename. */
|
|
16
|
+
export function withExtension(name: string, ext: string): string {
|
|
17
|
+
const cleaned = sanitizeFilename(name)
|
|
18
|
+
const dot = cleaned.lastIndexOf('.')
|
|
19
|
+
const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned
|
|
20
|
+
return ext ? `${stem}.${ext}` : stem
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Safe relative folder: drops `.`/`..` segments (no traversal) and cleans each segment.
|
|
24
|
+
* Deliberately NOT length-capped: truncating would silently map two distinct folders onto one
|
|
25
|
+
* storage path. An unusable path is caught where it is created — folders.post.ts runs `ensureDir`
|
|
26
|
+
* before committing the row, so an over-long name fails there instead of stranding a row. */
|
|
27
|
+
export function sanitizeFolder(folder: string): string {
|
|
28
|
+
return folder
|
|
29
|
+
.split(/[/\\]+/)
|
|
30
|
+
.map((seg) => seg.trim())
|
|
31
|
+
.filter((seg) => seg && seg !== '.' && seg !== '..')
|
|
32
|
+
.map((seg) => seg.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_'))
|
|
33
|
+
.filter((seg) => seg.length > 0)
|
|
34
|
+
.join('/')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildKey(folder: string, filename: string): string {
|
|
38
|
+
const f = sanitizeFolder(folder)
|
|
39
|
+
return f ? `${f}/${filename}` : filename
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function suggestFreeName(filename: string, isFree: (name: string) => boolean): string {
|
|
43
|
+
if (isFree(filename)) return filename
|
|
44
|
+
const dot = filename.lastIndexOf('.')
|
|
45
|
+
const stem = dot > 0 ? filename.slice(0, dot) : filename
|
|
46
|
+
const ext = dot > 0 ? filename.slice(dot) : ''
|
|
47
|
+
for (let i = 2; i < 10000; i++) {
|
|
48
|
+
const candidate = `${stem}-${i}${ext}`
|
|
49
|
+
if (isFree(candidate)) return candidate
|
|
50
|
+
}
|
|
51
|
+
throw new Error('no free filename found')
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import type { ResolvedImagePolicy, VariantFit } 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
|
+
|
|
8
|
+
const FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
|
|
9
|
+
|
|
10
|
+
/** The storage key a request addresses, or null when the request is not for media at all: outside the
|
|
11
|
+
* configured base URL, bare, or carrying a malformed %-escape (which must degrade to a static 404, never
|
|
12
|
+
* a 500). `baseUrl` is the media base as resolved config holds it — its default is the same `/uploads`. */
|
|
13
|
+
export function variantKeyFromPath(path: string, baseUrl: string | undefined): string | null {
|
|
14
|
+
const base = (baseUrl || '/uploads').replace(/\/+$/, '')
|
|
15
|
+
const clean = path.split('?')[0]!
|
|
16
|
+
if (!clean.startsWith(`${base}/`)) return null
|
|
17
|
+
try {
|
|
18
|
+
return decodeURIComponent(clean.slice(base.length + 1)) || null
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Inverse of variantName: `w<width>` | `c<w>x<h>[-<fit>]` → the shape to derive, or null for anything else. */
|
|
25
|
+
export function parseVariantName(name: string): { width: number; height: number | null; fit: VariantFit } | null {
|
|
26
|
+
const w = /^w(\d+)$/.exec(name)
|
|
27
|
+
if (w) return { width: +w[1], height: null, fit: 'cover' }
|
|
28
|
+
const c = /^c(\d+)x(\d+)(?:-([a-z]+))?$/.exec(name)
|
|
29
|
+
if (c) {
|
|
30
|
+
const fit = (c[3] ?? 'cover') as VariantFit
|
|
31
|
+
return FITS.has(fit) ? { width: +c[1], height: +c[2], fit } : null
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface VariantRequest { storageKey: string; mime: string; name: string; format: string }
|
|
37
|
+
|
|
38
|
+
// requestedKey is `<storageKey>-<name>.<format>`; storageKey contains dots and a name may contain '-', so
|
|
39
|
+
// resolve the original by trying every '-' split and matching a real media row (longest storageKey wins).
|
|
40
|
+
export function resolveVariantRequest(db: BetterSQLite3Database, requestedKey: string): VariantRequest | null {
|
|
41
|
+
const dot = requestedKey.lastIndexOf('.')
|
|
42
|
+
if (dot < 0) return null
|
|
43
|
+
const format = requestedKey.slice(dot + 1)
|
|
44
|
+
const stem = requestedKey.slice(0, dot)
|
|
45
|
+
const candidates: string[] = []
|
|
46
|
+
for (let i = 1; i < stem.length; i++) if (stem[i] === '-') candidates.push(stem.slice(0, i))
|
|
47
|
+
if (!candidates.length) return null
|
|
48
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
49
|
+
const rows = db.select({ storageKey: cols.storageKey, mime: cols.mime }).from(media).where(inArray(cols.storageKey, candidates)).all() as { storageKey: string; mime: string }[]
|
|
50
|
+
const match = rows.map((r) => ({ ...r, name: stem.slice(r.storageKey.length + 1), format })).sort((a, b) => b.storageKey.length - a.storageKey.length)[0]
|
|
51
|
+
return match ?? null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
|
|
55
|
+
|
|
56
|
+
// DEV-only: derive one missing variant from the stored original and cache it. Registry-INDEPENDENT (parses
|
|
57
|
+
// the name), so a size/format a KestrelImg declares but the scan hasn't registered yet still shows in the
|
|
58
|
+
// editor preview without a full publish.
|
|
59
|
+
export async function deriveOnDemand(db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, requestedKey: string): Promise<{ bytes: Buffer; mime: string } | null> {
|
|
60
|
+
const req = resolveVariantRequest(db, requestedKey)
|
|
61
|
+
if (!req || !RASTER.has(req.mime) || (req.format !== 'webp' && req.format !== 'jpeg')) return null
|
|
62
|
+
const shape = parseVariantName(req.name)
|
|
63
|
+
if (!shape || typeof driver.get !== 'function') return null
|
|
64
|
+
const spec = { name: req.name, width: shape.width, height: shape.height, fit: shape.fit, position: 'centre', formats: [req.format] as ('webp' | 'jpeg')[] }
|
|
65
|
+
const v = (await deriveImage(await driver.get(req.storageKey), { ...policy, variants: [spec] })).variants[0]
|
|
66
|
+
if (!v) return null
|
|
67
|
+
await driver.put(requestedKey, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
|
|
68
|
+
return { bytes: v.bytes, mime: v.mime }
|
|
69
|
+
}
|