@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.
Files changed (341) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +1 -3
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  133. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  134. package/layers/ui/app/composables/useLinkField.ts +1 -1
  135. package/layers/ui/app/composables/useRepeater.ts +1 -1
  136. package/layers/ui/app/i18n/de.ts +7 -7
  137. package/layers/ui/app/i18n/en.ts +7 -39
  138. package/layers/ui/app/utils/field-component.ts +1 -1
  139. package/layers/ui/app/utils/field-registry.ts +1 -1
  140. package/layers/ui/app/utils/icons.ts +2 -6
  141. package/layers/ui/app/utils/localized.ts +1 -1
  142. package/nuxt.config.ts +1 -1
  143. package/package.json +53 -19
  144. package/scripts/consumer-template-ci.mjs +157 -0
  145. package/scripts/coverage-all.mjs +163 -0
  146. package/scripts/dashboard.mjs +89 -0
  147. package/scripts/hash-password.mjs +1 -1
  148. package/scripts/lib/coverage-model.mjs +51 -0
  149. package/scripts/port-graph.mjs +350 -0
  150. package/scripts/typecheck.mjs +11 -0
  151. package/templates/starter/app/blocks/Prose.vue +1 -1
  152. package/layers/access/server/utils/csrf.ts +0 -39
  153. package/layers/access/server/utils/grant-registry.ts +0 -36
  154. package/layers/access/server/utils/guard.ts +0 -44
  155. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  156. package/layers/access/server/utils/policy.ts +0 -98
  157. package/layers/access/server/utils/public-resources.ts +0 -11
  158. package/layers/access/server/utils/render-context.ts +0 -41
  159. package/layers/access/server/utils/require-admin.ts +0 -15
  160. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  161. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  162. package/layers/auth/server/api/auth/login.post.ts +0 -38
  163. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  164. package/layers/auth/server/api/auth/session.get.ts +0 -1
  165. package/layers/auth/server/utils/admin-credential.ts +0 -42
  166. package/layers/auth/server/utils/client-ip.ts +0 -88
  167. package/layers/auth/server/utils/login-throttle.ts +0 -126
  168. package/layers/auth/server/utils/password.ts +0 -52
  169. package/layers/auth/server/utils/session-cookie.ts +0 -44
  170. package/layers/auth/server/utils/session-epoch.ts +0 -96
  171. package/layers/auth/server/utils/session.ts +0 -88
  172. package/layers/collections/server/collections/pages.ts +0 -19
  173. package/layers/collections/server/utils/populate-relations.ts +0 -79
  174. package/layers/core/app/utils/bulk-actions.ts +0 -9
  175. package/layers/core/app/utils/filter-ops.ts +0 -98
  176. package/layers/core/app/utils/list-limits.ts +0 -27
  177. package/layers/core/app/utils/locale-path.ts +0 -12
  178. package/layers/core/app/utils/slugify.ts +0 -16
  179. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  180. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  181. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  182. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  183. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  184. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  185. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  186. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  187. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  188. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  189. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  190. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  191. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  192. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  193. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  194. package/layers/core/server/api/collections/[name].get.ts +0 -4
  195. package/layers/core/server/api/collections/index.get.ts +0 -3
  196. package/layers/core/server/api/references/broken.get.ts +0 -9
  197. package/layers/core/server/api/references/referrers.get.ts +0 -24
  198. package/layers/core/server/database/record-refs.ts +0 -20
  199. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  200. package/layers/core/server/schema/bootstrap.ts +0 -41
  201. package/layers/core/server/schema/desired.ts +0 -99
  202. package/layers/core/server/schema/dev-sync.ts +0 -23
  203. package/layers/core/server/schema/dialect.ts +0 -61
  204. package/layers/core/server/schema/diff.ts +0 -110
  205. package/layers/core/server/schema/introspect.ts +0 -68
  206. package/layers/core/server/schema/model.ts +0 -61
  207. package/layers/core/server/schema/render-sqlite.ts +0 -89
  208. package/layers/core/server/schema/sync.ts +0 -201
  209. package/layers/core/server/utils/blocks.ts +0 -6
  210. package/layers/core/server/utils/collection-types.ts +0 -28
  211. package/layers/core/server/utils/crud.ts +0 -578
  212. package/layers/core/server/utils/db.ts +0 -26
  213. package/layers/core/server/utils/defineCollection.ts +0 -213
  214. package/layers/core/server/utils/duplicate.ts +0 -162
  215. package/layers/core/server/utils/field-layout.ts +0 -125
  216. package/layers/core/server/utils/filter-predicate.ts +0 -101
  217. package/layers/core/server/utils/http.ts +0 -50
  218. package/layers/core/server/utils/if-unmodified.ts +0 -13
  219. package/layers/core/server/utils/kestrel-config.ts +0 -451
  220. package/layers/core/server/utils/key-lock.ts +0 -33
  221. package/layers/core/server/utils/locale.ts +0 -53
  222. package/layers/core/server/utils/page-route.ts +0 -58
  223. package/layers/core/server/utils/page-slug.ts +0 -127
  224. package/layers/core/server/utils/picker.ts +0 -82
  225. package/layers/core/server/utils/populate.ts +0 -50
  226. package/layers/core/server/utils/prerender-memo.ts +0 -23
  227. package/layers/core/server/utils/read-capture.ts +0 -37
  228. package/layers/core/server/utils/record-ref-index.ts +0 -200
  229. package/layers/core/server/utils/registry.ts +0 -24
  230. package/layers/core/server/utils/resolve-scope.ts +0 -79
  231. package/layers/core/server/utils/seo.ts +0 -42
  232. package/layers/core/server/utils/serialize-collection.ts +0 -147
  233. package/layers/core/server/utils/sql.ts +0 -2
  234. package/layers/core/server/utils/storage.local.ts +0 -117
  235. package/layers/core/server/utils/storage.s3.ts +0 -197
  236. package/layers/core/server/utils/storage.ts +0 -90
  237. package/layers/core/server/utils/write-effects.ts +0 -40
  238. package/layers/core/server/utils/write-events.ts +0 -31
  239. package/layers/fields/app/utils/condition.ts +0 -71
  240. package/layers/fields/app/utils/define-block.ts +0 -13
  241. package/layers/fields/app/utils/field-constraints.ts +0 -17
  242. package/layers/fields/app/utils/field-meta.ts +0 -33
  243. package/layers/fields/app/utils/field-validate.ts +0 -75
  244. package/layers/fields/app/utils/field-value.ts +0 -7
  245. package/layers/fields/app/utils/richtext-links.ts +0 -48
  246. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  247. package/layers/fields/server/field-registry/index.ts +0 -249
  248. package/layers/fields/server/field-registry/naming.ts +0 -10
  249. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  250. package/layers/fields/server/field-registry/types.ts +0 -19
  251. package/layers/fields/server/utils/block-ids.ts +0 -43
  252. package/layers/fields/server/utils/block-populate.ts +0 -41
  253. package/layers/fields/server/utils/buildCollection.ts +0 -90
  254. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  255. package/layers/fields/server/utils/buildTable.ts +0 -113
  256. package/layers/fields/server/utils/conditional-required.ts +0 -25
  257. package/layers/fields/server/utils/defineBlock.ts +0 -86
  258. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  259. package/layers/fields/server/utils/extract-refs.ts +0 -152
  260. package/layers/fields/server/utils/field-populate.ts +0 -68
  261. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  262. package/layers/media/server/api/media/[id].delete.ts +0 -18
  263. package/layers/media/server/api/media/[id].get.ts +0 -10
  264. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  265. package/layers/media/server/api/media/[id].patch.ts +0 -65
  266. package/layers/media/server/api/media/backfill.post.ts +0 -18
  267. package/layers/media/server/api/media/copy.post.ts +0 -7
  268. package/layers/media/server/api/media/delete.post.ts +0 -13
  269. package/layers/media/server/api/media/folders.post.ts +0 -18
  270. package/layers/media/server/api/media/index.get.ts +0 -15
  271. package/layers/media/server/api/media/index.post.ts +0 -173
  272. package/layers/media/server/api/media/library.get.ts +0 -17
  273. package/layers/media/server/api/media/move.post.ts +0 -7
  274. package/layers/media/server/api/media/rename.post.ts +0 -7
  275. package/layers/media/server/api/media/resolve.get.ts +0 -13
  276. package/layers/media/server/collections/media-settings.ts +0 -25
  277. package/layers/media/server/collections/media.ts +0 -43
  278. package/layers/media/server/database/folders.ts +0 -10
  279. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  280. package/layers/media/server/schema-tables/folders.ts +0 -4
  281. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  282. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  283. package/layers/media/server/utils/backfill.ts +0 -144
  284. package/layers/media/server/utils/derive.ts +0 -59
  285. package/layers/media/server/utils/folder-paths.ts +0 -42
  286. package/layers/media/server/utils/folders.ts +0 -12
  287. package/layers/media/server/utils/library.ts +0 -91
  288. package/layers/media/server/utils/media-cleanup.ts +0 -27
  289. package/layers/media/server/utils/media-enabled.ts +0 -23
  290. package/layers/media/server/utils/media-ops.ts +0 -118
  291. package/layers/media/server/utils/media-write.ts +0 -13
  292. package/layers/media/server/utils/naming.ts +0 -52
  293. package/layers/media/server/utils/ondemand.ts +0 -69
  294. package/layers/media/server/utils/persist-upload.ts +0 -62
  295. package/layers/media/server/utils/populate.ts +0 -47
  296. package/layers/media/server/utils/record.ts +0 -71
  297. package/layers/media/server/utils/relocate-ops.ts +0 -341
  298. package/layers/media/server/utils/relocate-plan.ts +0 -63
  299. package/layers/media/server/utils/resolve.ts +0 -98
  300. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  301. package/layers/media/server/utils/sniff.ts +0 -68
  302. package/layers/media/server/utils/storage-relocate.ts +0 -139
  303. package/layers/media/server/utils/translations.ts +0 -19
  304. package/layers/media/server/utils/usages.ts +0 -66
  305. package/layers/media/server/utils/variant-capture.ts +0 -70
  306. package/layers/media/server/utils/variants.ts +0 -123
  307. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  308. package/layers/public/server/api/links/resolve.get.ts +0 -19
  309. package/layers/public/server/api/preview.get.ts +0 -28
  310. package/layers/public/server/api/preview.post.ts +0 -93
  311. package/layers/public/server/api/publish-status.get.ts +0 -58
  312. package/layers/public/server/api/publish.post.ts +0 -84
  313. package/layers/public/server/api/route.get.ts +0 -54
  314. package/layers/public/server/collections/redirects.ts +0 -75
  315. package/layers/public/server/collections/site.ts +0 -44
  316. package/layers/public/server/database/publish-deps.ts +0 -12
  317. package/layers/public/server/database/publish-status.ts +0 -19
  318. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  319. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  320. package/layers/public/server/utils/link-resolve.ts +0 -38
  321. package/layers/public/server/utils/llms-full.ts +0 -125
  322. package/layers/public/server/utils/llms.ts +0 -52
  323. package/layers/public/server/utils/page-resolve.ts +0 -208
  324. package/layers/public/server/utils/populate-links.ts +0 -66
  325. package/layers/public/server/utils/preview-token.ts +0 -109
  326. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  327. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  328. package/layers/public/server/utils/publish/deps.ts +0 -87
  329. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  330. package/layers/public/server/utils/publish/pending.ts +0 -74
  331. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  332. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  333. package/layers/public/server/utils/publish/publisher.ts +0 -362
  334. package/layers/public/server/utils/publish/queue.ts +0 -67
  335. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  336. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  337. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  338. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  339. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  340. package/layers/public/server/utils/site-url.ts +0 -40
  341. 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
- }