@michaelthielemann/kestrel 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) 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 +6 -8
  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/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,18 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import { media } from '../../collections/media'
3
- import { useStorageDriver } from '../../../../core/server/utils/storage'
4
- import { deleteAffected } from '../../utils/media-ops'
5
- import { requireMediaCollection } from '../../utils/media-enabled'
6
-
7
- export default defineEventHandler(async (event) => {
8
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
9
- requireMediaCollection()
10
- const id = requireId(event)
11
- const cols = getTableColumns(media) as Record<string, never>
12
- const db = useDb()
13
- // deleteAffected silently no-ops on a missing row, so keep an explicit 404 guard.
14
- const row = db.select({ id: cols.id }).from(media).where(eq(cols.id, id)).get() as { id: number } | undefined
15
- if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
16
- await deleteAffected(db, useStorageDriver(), [{ type: 'file', id }], false)
17
- return { deleted: true, id }
18
- })
@@ -1,10 +0,0 @@
1
- import media from '../../collections/media'
2
- import { requireMediaCollection } from '../../utils/media-enabled'
3
-
4
- export default defineEventHandler((event) => {
5
- requireMediaCollection()
6
- const query = getQuery(event)
7
- const depth = Number(query.depth ?? 0)
8
- const locale = query.locale as string | undefined
9
- return getOne(useDb(), media, requireId(event), depth, locale)
10
- })
@@ -1,66 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { eq, getTableColumns } from 'drizzle-orm'
3
- import { createError } from 'h3'
4
- import { createTestDb } from '../../../../../test/helpers/db'
5
- import { create } from '../../../../core/server/utils/crud'
6
- import builtMedia, { media } from '../../collections/media'
7
-
8
- interface FakeEvent { body: Record<string, unknown> }
9
-
10
- let db: ReturnType<typeof createTestDb>
11
- let id: number
12
-
13
- // The handler is a Nitro route: its auto-imported helpers are plain globals in a node test.
14
- Object.assign(globalThis, {
15
- defineEventHandler: (handler: unknown) => handler,
16
- requireAdmin: () => {},
17
- createError,
18
- requireId: () => id,
19
- readIfUnmodifiedSince: () => undefined,
20
- readBody: async (event: FakeEvent) => event.body,
21
- useDb: () => db,
22
- useRuntimeConfig: () => ({ kestrel: {} }),
23
- })
24
-
25
- const handler = (await import('./[id].patch')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
26
- const patch = (body: Record<string, unknown>) => handler({ body })
27
-
28
- const cols = getTableColumns(media) as Record<string, never>
29
- const row = () => db.select().from(media).where(eq(cols.id, id)).get() as Record<string, unknown>
30
-
31
- beforeEach(() => {
32
- db = createTestDb()
33
- const created = create(db, builtMedia, { storageKey: 'a/one.png', folder: 'a', filename: 'one.png', mime: 'image/png', ext: 'png', size: 1 }) as { id: number }
34
- id = created.id
35
- })
36
-
37
- describe('PATCH /api/media/:id — EU AI Act disclosure', () => {
38
- it('persists both disclosure columns', async () => {
39
- await patch({ aiSourceType: 'algorithmicallyEnhanced', aiNote: 'upscaled' })
40
- expect(row()).toMatchObject({ aiSourceType: 'algorithmicallyEnhanced', aiNote: 'upscaled' })
41
- })
42
-
43
- it('clears the classification with an explicit null', async () => {
44
- await patch({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'Midjourney v7' })
45
- await patch({ aiSourceType: null })
46
- expect(row().aiSourceType).toBeNull()
47
- expect(row().aiNote).toBe('Midjourney v7') // untouched — only the sent keys are written
48
- })
49
-
50
- it('rejects an unknown source type with a 400 instead of writing it', async () => {
51
- await expect(patch({ aiSourceType: 'nonsense' })).rejects.toThrowError(expect.objectContaining({ statusCode: 400 }))
52
- expect(row().aiSourceType).toBeNull()
53
- })
54
-
55
- it('stores a blanked note as null rather than an empty string a badge would render', async () => {
56
- await patch({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'Midjourney v7' })
57
- await patch({ aiNote: ' ' })
58
- expect(row().aiNote).toBeNull()
59
- })
60
-
61
- it('leaves the columns alone when the body does not mention them', async () => {
62
- await patch({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'note' })
63
- await patch({ translations: { en: { alt: 'a kitten' } } })
64
- expect(row()).toMatchObject({ aiSourceType: 'trainedAlgorithmicMedia', aiNote: 'note' })
65
- })
66
- })
@@ -1,65 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import builtMedia, { media } from '../../collections/media'
3
- import { mergeTranslations, type Translations } from '../../utils/translations'
4
- import { emitMediaWrite } from '../../utils/media-write'
5
- import { requireMediaCollection } from '../../utils/media-enabled'
6
-
7
- const AI_KEYS = ['aiSourceType', 'aiNote'] as const
8
-
9
- /**
10
- * The EU AI Act disclosure columns are top-level (not per-locale), so they are patched as plain siblings of
11
- * `translations`. Only the keys the body actually sent are written — omitting one must not clear it. The
12
- * allow-list of source types is NOT duplicated here: the collection's own update schema (built from the
13
- * `choice` field's `choices`) is the single source of truth, so an unknown value 400s instead of storing.
14
- */
15
- function readAiDisclosure(body: Record<string, unknown> | undefined | null): Record<string, unknown> {
16
- const sent = AI_KEYS.filter((k) => Object.hasOwn(body ?? {}, k))
17
- if (!sent.length) return {}
18
- const parsed = builtMedia.update.safeParse(Object.fromEntries(sent.map((k) => [k, body![k]])))
19
- if (!parsed.success) {
20
- throw createError({ statusCode: 400, statusMessage: `Invalid AI disclosure: ${parsed.error.issues[0]?.message ?? 'unknown value'}` })
21
- }
22
- const value = parsed.data as Record<string, unknown>
23
- const patch: Record<string, unknown> = {}
24
- for (const k of sent) patch[k] = value[k] ?? null
25
- // A blanked note must round-trip as "no note", not as an empty string a badge would render as blank text.
26
- if (patch.aiNote === '') patch.aiNote = null
27
- return patch
28
- }
29
-
30
- export default defineEventHandler(async (event) => {
31
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
32
- requireMediaCollection()
33
- const id = requireId(event)
34
- const body = await readBody(event)
35
- const cols = getTableColumns(media) as Record<string, never>
36
- const db = useDb()
37
- const expectedUpdatedAt = readIfUnmodifiedSince(event)
38
- const patch: Record<string, unknown> = { updatedAt: new Date() }
39
- // `folder` is immutable via PATCH: relocating a file means moving its storage object and every
40
- // derivative, which belongs to the media-library slice. Writing only the column would desync it
41
- // from the folder baked into storageKey, which is what the public URL is built from.
42
- const needsCurrent = expectedUpdatedAt !== undefined
43
- || (body?.translations && typeof body.translations === 'object' && !Array.isArray(body.translations))
44
- const current = needsCurrent
45
- ? db.select().from(media).where(eq(cols.id, id)).get() as { translations?: Translations; updatedAt?: Date } | undefined
46
- : undefined
47
- // Optimistic concurrency: honor the same X-Kestrel-If-Unmodified-Since precondition the collection CRUD
48
- // does, so two media-viewer tabs editing the same asset's alt text can't silently overwrite each other.
49
- if (expectedUpdatedAt !== undefined && current) {
50
- const cur = current.updatedAt instanceof Date ? current.updatedAt.getTime() : new Date(current.updatedAt as never).getTime()
51
- if (cur !== expectedUpdatedAt) {
52
- throw createError({ statusCode: 409, statusMessage: 'This media item changed since you opened it. Reload before saving.' })
53
- }
54
- }
55
- if (body?.translations && typeof body.translations === 'object' && !Array.isArray(body.translations)) {
56
- // Media keeps all locales in one JSON column. Deep-merge per locale so a partial PATCH (e.g. just
57
- // `en.alt` from the media viewer) keeps the other locales AND the locale's other fields intact.
58
- patch.translations = mergeTranslations(current?.translations, body.translations as Translations)
59
- }
60
- Object.assign(patch, readAiDisclosure(body))
61
- const row = db.update(media).set(patch).where(eq(cols.id, id)).returning().get() as Record<string, unknown> | undefined
62
- if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
63
- emitMediaWrite({ id }, row) // alt/title/description changed → re-render embedding pages (fresh alt text)
64
- return row
65
- })
@@ -1,18 +0,0 @@
1
- import { runBackfill } from '../../utils/backfill'
2
- import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
3
- import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
4
- import { requireMediaCollection } from '../../utils/media-enabled'
5
-
6
- /**
7
- * Admin-triggered variant backfill/prune (the Mediathek "Regenerate/Prune" action). `{ check: true }` is a
8
- * dry-run reporting the plan (rows / would-generate / would-prune) — the UI shows it before applying.
9
- * Synchronous: fine for a dry-run and moderate libraries; a very large library should run the
10
- * `media:backfill` task on a schedule instead (a full-original GET + sharp per row).
11
- */
12
- export default defineEventHandler(async (event) => {
13
- requireAdmin(event)
14
- requireMediaCollection()
15
- const body = (await readBody(event).catch(() => ({}))) as { check?: boolean }
16
- const policy = mediaRuntimeConfig().imagePolicy ?? DEFAULT_IMAGE_POLICY
17
- return runBackfill(useDb(), useStorageDriver(), policy, { check: !!body?.check })
18
- })
@@ -1,7 +0,0 @@
1
- import { runRelocation } from '../../utils/relocate-ops'
2
- import { requireMediaCollection } from '../../utils/media-enabled'
3
-
4
- export default defineEventHandler((event) => {
5
- requireMediaCollection()
6
- return runRelocation(event, (items, body) => ({ type: 'copy', items, dest: typeof body.dest === 'string' ? body.dest : '' }))
7
- })
@@ -1,13 +0,0 @@
1
- import { deleteAffected } from '../../utils/media-ops'
2
- import { coerceOpItems } from '../../utils/relocate-ops'
3
- import { useStorageDriver } from '../../../../core/server/utils/storage'
4
- import { requireMediaCollection } from '../../utils/media-enabled'
5
-
6
- export default defineEventHandler(async (event) => {
7
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
8
- requireMediaCollection()
9
- const body = await readBody(event)
10
- const items = coerceOpItems(body?.items)
11
- if (!items.length) throw createError({ statusCode: 400, statusMessage: 'No items to delete' })
12
- return deleteAffected(useDb(), useStorageDriver(), items, body?.dryRun === true)
13
- })
@@ -1,18 +0,0 @@
1
- import { ensureFolder } from '../../utils/folders'
2
- import { sanitizeFolder } from '../../utils/naming'
3
- import { useStorageDriver } from '../../../../core/server/utils/storage'
4
- import { requireMediaCollection } from '../../utils/media-enabled'
5
-
6
- export default defineEventHandler(async (event) => {
7
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
8
- requireMediaCollection()
9
- const body = await readBody(event)
10
- const path = sanitizeFolder(typeof body?.path === 'string' ? body.path : '')
11
- if (!path) throw createError({ statusCode: 400, statusMessage: 'A non-empty folder path is required' })
12
- // ensureDir (mkdir, can throw ENAMETOOLONG/ENOTDIR/etc.) runs BEFORE the row commit — a folder row
13
- // with no backing directory would list forever with every upload/retry into it failing the same way.
14
- await useStorageDriver().ensureDir?.(path)
15
- const db = useDb()
16
- ensureFolder(db, path)
17
- return { path }
18
- })
@@ -1,15 +0,0 @@
1
- import media from '../../collections/media'
2
- import { requireMediaCollection } from '../../utils/media-enabled'
3
-
4
- export default defineEventHandler((event) => {
5
- requireMediaCollection()
6
- const query = getQuery(event)
7
- return list(useDb(), media, {
8
- locale: query.locale as string | undefined,
9
- sort: query.sort as string | undefined,
10
- page: query.page ? Number(query.page) : undefined,
11
- perPage: query.perPage ? Number(query.perPage) : undefined,
12
- filter: parseFilter(query as Record<string, unknown>),
13
- depth: query.depth ? Number(query.depth) : 0,
14
- })
15
- })
@@ -1,173 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { eq, inArray, getTableColumns, isNull } from 'drizzle-orm'
3
- import builtMedia, { media } from '../../collections/media'
4
- import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
5
- import { sniffMime, extForMime, resolveAllowedMimes } from '../../utils/sniff'
6
- import { sanitizeFolder, buildKey, suggestFreeName, withExtension } from '../../utils/naming'
7
- import { sanitizeSvg } from '../../utils/sanitize-svg'
8
- import { deriveImage, RASTER, type DerivedImage } from '../../utils/derive'
9
- import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
10
- import { buildMediaValues, derivativeKey, type DerivativeManifest } from '../../utils/record'
11
- import { persistUpload } from '../../utils/persist-upload'
12
- import type { Translations } from '../../utils/translations'
13
- import { activeVariants } from '../../utils/variants'
14
- import { ensureFolder } from '../../utils/folders'
15
- import { primaryLocale } from '../../../../core/server/utils/locale'
16
- import { isUniqueViolation } from '../../../../core/server/utils/crud'
17
- import { withLock, mediaLockKey } from '../../../../core/server/utils/key-lock'
18
- import { requireMediaCollection } from '../../utils/media-enabled'
19
- import { emitMediaWrite } from '../../utils/media-write'
20
- import { detectAiSignal, aiSignalNote } from '../../utils/ai-signal-detect'
21
- import { aiDisclosureEnabled } from '../../utils/ai-disclosure-enabled'
22
-
23
- export default defineEventHandler(async (event) => {
24
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
25
- requireMediaCollection()
26
- const cfg = mediaRuntimeConfig()
27
- const len = Number(getRequestHeader(event, 'content-length'))
28
- // A missing/garbage Content-Length means a chunked transfer, which readMultipartFormData would
29
- // buffer with no ceiling — refuse it up front. The declared length is then a cheap pre-check; the
30
- // real cap is re-asserted on the parsed bytes below (a client can under-declare Content-Length).
31
- if (!Number.isFinite(len) || len < 0) {
32
- throw createError({ statusCode: 411, statusMessage: 'Length required' })
33
- }
34
- if (len > cfg.maxUploadBytes) {
35
- throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
36
- }
37
-
38
- const parts = await readMultipartFormData(event)
39
- if (!parts) throw createError({ statusCode: 400, statusMessage: 'Expected multipart/form-data' })
40
- const filePart = parts.find((p) => p.name === 'file' && p.filename)
41
- if (!filePart || !filePart.data?.length) throw createError({ statusCode: 400, statusMessage: 'Missing file' })
42
- if (filePart.data.length > cfg.maxUploadBytes) throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
43
- const text = (n: string) => parts.find((p) => p.name === n && !p.filename)?.data.toString('utf8')
44
-
45
- let bytes = filePart.data as Buffer
46
- const allowed = resolveAllowedMimes(cfg.allowedMimes)
47
- const mime = await sniffMime(bytes)
48
- if (!mime || !allowed.has(mime)) {
49
- throw createError({ statusCode: 415, statusMessage: `Unsupported media type${mime ? `: ${mime}` : ''}` })
50
- }
51
- const ext = extForMime(mime)
52
-
53
- if (mime === 'image/svg+xml') bytes = Buffer.from(sanitizeSvg(bytes.toString('utf8')))
54
-
55
- // server-controlled key only (folder + filename are both sanitized → no path traversal)
56
- const folder = sanitizeFolder(text('folder') ?? '')
57
- const overwrite = text('overwrite') === 'true'
58
- // The stored extension follows the sniffed type, never the client filename (content-type confusion).
59
- const filename = withExtension(text('filename') ?? filePart.filename ?? 'file', ext)
60
- const storageKey = buildKey(folder, filename)
61
-
62
- const db = useDb()
63
- const cols = getTableColumns(media) as Record<string, never>
64
- const driver = useStorageDriver()
65
-
66
- const checksum = createHash('sha256').update(bytes).digest('hex')
67
- // Narrow generation: derive exactly the currently-registered variant set (the media_settings registry),
68
- // falling back to the config policy's variants when the registry is empty (fresh project / no scan yet).
69
- // Done BEFORE the per-key lock so uploads of different files derive in parallel (sharp is the hot path).
70
- const policy = cfg.imagePolicy ?? DEFAULT_IMAGE_POLICY
71
- // A raster that sniffs fine but is truncated/corrupt (or a misconfigured crop position slipping past
72
- // validation) makes sharp reject mid-pipeline — a bare unwrapped throw here would 500 with an internal
73
- // vips error string. Surface it as a real, unprocessable-upload 4xx instead.
74
- let derived: DerivedImage | undefined
75
- if (RASTER.has(mime)) {
76
- try {
77
- derived = await deriveImage(bytes, { ...policy, variants: activeVariants(db, policy.variants, policy.presets) })
78
- } catch (error) {
79
- throw createError({ statusCode: 422, statusMessage: 'Could not process this image', data: { reason: (error as Error)?.message } })
80
- }
81
- }
82
-
83
- const alt = text('alt')
84
- const title = text('title')
85
- const description = text('description')
86
- const translations = alt || title || description ? { [primaryLocale()]: { alt, title, description } } : {}
87
-
88
- // EU AI Act Art. 50 disclosure. The classification is validated against the collection's own choice
89
- // schema so the allow-list has a single source of truth (same as the PATCH route).
90
- const aiSourceType = text('aiSourceType')?.trim() || undefined
91
- if (aiSourceType && !builtMedia.update.safeParse({ aiSourceType }).success) {
92
- throw createError({ statusCode: 400, statusMessage: `Invalid AI disclosure: unknown source type "${aiSourceType}"` })
93
- }
94
- const uploadedAiNote = text('aiNote')?.trim() || undefined
95
- // Only parse when the feature is on: consumers who leave it off pay nothing for it. What the scan finds
96
- // is EVIDENCE for the free-text note — it never asserts `aiSourceType`, which stays a human decision.
97
- const signal = aiDisclosureEnabled() && !uploadedAiNote ? await detectAiSignal(bytes, mime).catch(() => null) : null
98
-
99
- // Serialize the collision-check → put → insert per storageKey: two concurrent uploads to the SAME key
100
- // (or a backfill re-deriving it) must not interleave, or last-writer-wins would leave the winning row
101
- // describing the loser's bytes. Different keys never share a lock, so throughput is unaffected.
102
- const { row, created } = await withLock(mediaLockKey(storageKey), async (): Promise<{ row: Record<string, unknown>; created: boolean }> => {
103
- const existing = db.select().from(media).where(eq(cols.storageKey, storageKey)).get() as
104
- | { id: number; derivatives?: DerivativeManifest; translations?: Translations; aiNote?: string | null }
105
- | undefined
106
-
107
- // An object on disk with no media row (another media's derivative, or an orphan) must never be
108
- // clobbered — not even under overwrite, which only replaces a managed media row.
109
- const blockedByDisk = !existing && (await driver.exists?.(storageKey))
110
- if ((existing && !overwrite) || blockedByDisk) {
111
- // One query: the filenames already taken in this folder, used to suggest a free name in memory.
112
- const takenNames = new Set(
113
- db.select({ f: cols.filename }).from(media)
114
- .where(folder ? eq(cols.folder, folder) : isNull(cols.folder))
115
- .all().map((r) => (r as { f: string }).f),
116
- )
117
- if (blockedByDisk) takenNames.add(filename)
118
- throw createError({
119
- statusCode: 409,
120
- statusMessage: 'A file with this name already exists',
121
- data: { storageKey, existingId: existing?.id, suggestion: suggestFreeName(filename, (n) => !takenNames.has(n)) },
122
- })
123
- }
124
-
125
- // Reserve the derivative namespace: `derivativeKey` mints a syntactically valid ORIGINAL key
126
- // (`<key>-<name>.<format>`), so a same-named upload elsewhere (or a coincidentally matching filename)
127
- // can already own the exact key this derive is about to `driver.put` over. The create-path write below
128
- // has no per-key collision check of its own, so refuse up front rather than silently overwrite another
129
- // row's blob out from under it.
130
- const derivKeys = (derived?.variants ?? []).map((v) => derivativeKey(storageKey, v.name, v.format))
131
- if (derivKeys.length) {
132
- const clash = db.select({ storageKey: cols.storageKey }).from(media).where(inArray(cols.storageKey, derivKeys)).all() as { storageKey: string }[]
133
- if (clash.length) {
134
- // Not a 409: the uploader reads that status as the rename/overwrite/skip name-conflict contract,
135
- // and neither branch resolves this — the requested name is free, and overwriting it re-derives the
136
- // very same blocked keys. The message names the file the user actually dropped, plus the existing
137
- // one standing in its way, so the remedy (rename either) is visible from the upload dialog.
138
- throw createError({
139
- statusCode: 422,
140
- statusMessage: `Cannot store "${filename}": the existing file "${clash[0]!.storageKey}" already uses a name reserved for one of its generated image sizes. Rename either file, then upload again.`,
141
- data: { storageKey, clashKeys: clash.map((c) => c.storageKey) },
142
- })
143
- }
144
- }
145
-
146
- if (folder) ensureFolder(db, folder)
147
- // The scan only ever fills a note that would otherwise be empty — it must not talk over an uploader's
148
- // own text, nor over one an editor already curated on the row this upload is replacing.
149
- const aiNote = uploadedAiNote ?? (existing?.aiNote ? undefined : signal && aiSignalNote(signal))
150
- const values = buildMediaValues({ storageKey, folder, filename, mime, ext, size: bytes.length, checksum, derived, translations, aiSourceType, aiNote })
151
- try {
152
- // persistUpload writes the objects then the row; on a create-path failure it removes the freshly-written
153
- // blobs so a partial upload never strands orphans that permanently 409-block the filename.
154
- const saved = await persistUpload(db, driver, { storageKey, bytes, mime, derived, values, existing, overwrite })
155
- return { row: saved, created: !(existing && overwrite) }
156
- } catch (error) {
157
- // A double-submit that slips through the check surfaces as a UNIQUE violation → 409, not a raw 500.
158
- if (isUniqueViolation(error)) {
159
- throw createError({ statusCode: 409, statusMessage: 'A file with this name already exists', data: { storageKey } })
160
- }
161
- throw error
162
- }
163
- })
164
-
165
- // Only an overwrite can invalidate published output: it rewrites the bytes, dimensions, thumbhash and
166
- // derivative manifest under an unchanged id, so every page embedding it holds srcset candidates that no
167
- // longer exist. Gate on `created`, not the requested `overwrite` flag — an overwrite request that finds
168
- // no row inserts a fresh one, which nothing published can reference yet.
169
- if (!created) emitMediaWrite({ id: row.id as number }, row)
170
-
171
- setResponseStatus(event, created ? 201 : 200)
172
- return { ...row, url: driver.publicUrl(storageKey) }
173
- })
@@ -1,17 +0,0 @@
1
- import { listLibrary } from '../../utils/library'
2
- import { useStorageDriver } from '../../../../core/server/utils/storage'
3
- import { requireMediaCollection } from '../../utils/media-enabled'
4
-
5
- export default defineEventHandler((event) => {
6
- requireMediaCollection()
7
- const query = getQuery(event)
8
- const driver = useStorageDriver()
9
- return listLibrary(useDb(), {
10
- folder: typeof query.folder === 'string' ? query.folder : '',
11
- search: typeof query.search === 'string' ? query.search : undefined,
12
- type: query.type === 'image' ? 'image' : 'all',
13
- page: query.page ? Number(query.page) : undefined,
14
- perPage: query.perPage ? Number(query.perPage) : undefined,
15
- sort: typeof query.sort === 'string' ? query.sort : undefined,
16
- }, (key) => driver.publicUrl(key))
17
- })
@@ -1,7 +0,0 @@
1
- import { runRelocation } from '../../utils/relocate-ops'
2
- import { requireMediaCollection } from '../../utils/media-enabled'
3
-
4
- export default defineEventHandler((event) => {
5
- requireMediaCollection()
6
- return runRelocation(event, (items, body) => ({ type: 'move', items, dest: typeof body.dest === 'string' ? body.dest : '' }))
7
- })
@@ -1,7 +0,0 @@
1
- import { runRelocation } from '../../utils/relocate-ops'
2
- import { requireMediaCollection } from '../../utils/media-enabled'
3
-
4
- export default defineEventHandler((event) => {
5
- requireMediaCollection()
6
- return runRelocation(event, (items, body) => ({ type: 'rename', items, name: typeof body.name === 'string' ? body.name : '' }))
7
- })
@@ -1,13 +0,0 @@
1
- import { resolveManyByIds } from '../../utils/resolve'
2
- import { useStorageDriver } from '../../../../core/server/utils/storage'
3
- import { primaryLocale } from '../../../../core/server/utils/locale'
4
- import { requireMediaCollection } from '../../utils/media-enabled'
5
-
6
- export default defineEventHandler((event) => {
7
- requireMediaCollection()
8
- const q = getQuery(event)
9
- const ids = String(q.ids ?? '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0)
10
- const locale = typeof q.locale === 'string' ? q.locale : primaryLocale()
11
- const driver = useStorageDriver()
12
- return { data: resolveManyByIds(useDb(), ids, locale, (k) => driver.publicUrl(k)) }
13
- })
@@ -1,25 +0,0 @@
1
- import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
- import { defineCollection } from '../../../core/server/utils/defineCollection'
3
-
4
- // The usage-driven variant REGISTRY: a single-row store of the active image-variant set (name × size ×
5
- // format presets) that the upload path derives and the generate-time scan reconciles. A singleton so it
6
- // reuses the schema engine + (later) the singleton editor, but `nav: false` — it is a system/config store,
7
- // not content, and must not appear as a flat top-level rail item beside the media library. It is surfaced
8
- // (if at all) through a Media sub-menu once the rail supports nested navigation.
9
- const built = buildCollection(defineCollection({
10
- name: 'media_settings',
11
- mode: 'single',
12
- translatable: false,
13
- builtin: true,
14
- nav: false,
15
- label: { singular: 'Media settings', plural: 'Media settings' },
16
- icon: 'sliders',
17
- fields: {
18
- // The resolved variant set (`StoredVariant[]`): each entry carries its provenance (`source`/`pinned`)
19
- // so the scan reconcile can replace discovered entries while preserving hand-authored ones.
20
- variants: { type: 'json' },
21
- },
22
- }))
23
-
24
- export const mediaSettings = built.table
25
- export default built
@@ -1,43 +0,0 @@
1
- import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
- import { defineCollection } from '../../../core/server/utils/defineCollection'
3
-
4
- const built = buildCollection(defineCollection({
5
- name: 'media',
6
- mode: 'multi',
7
- translatable: false,
8
- builtin: true,
9
- label: { singular: 'Media', plural: 'Media' },
10
- icon: 'image',
11
- fields: {
12
- storageKey: { type: 'text', required: true, unique: true },
13
- // Indexed: the library lists/filters/rolls-up recursive sizes by folder on every request.
14
- folder: { type: 'text', index: true },
15
- filename: { type: 'text', required: true },
16
- mime: { type: 'text', required: true },
17
- ext: { type: 'text', required: true },
18
- size: { type: 'number', required: true },
19
- width: { type: 'number' },
20
- height: { type: 'number' },
21
- checksum: { type: 'text' },
22
- thumbhash: { type: 'text' },
23
- derivatives: { type: 'json' },
24
- translations: { type: 'json' },
25
- // EU AI Act Art. 50 disclosure. Top-level, NOT per-locale (`translations`): how an asset was produced
26
- // does not change per translation. The vocabulary mirrors the disclosure-relevant subset of IPTC's
27
- // Digital Source Type, so a later metadata-embedding slice can map 1:1.
28
- aiSourceType: {
29
- type: 'choice',
30
- options: {
31
- choices: [
32
- { label: 'Fully AI-generated', value: 'trainedAlgorithmicMedia' },
33
- { label: 'AI content composited into real media', value: 'compositeWithTrainedAlgorithmicMedia' },
34
- { label: 'AI-enhanced / algorithmically edited', value: 'algorithmicallyEnhanced' },
35
- ],
36
- },
37
- },
38
- aiNote: { type: 'text' },
39
- },
40
- }))
41
-
42
- export const media = built.table
43
- export default built
@@ -1,10 +0,0 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
-
3
- // Path-based folder registry: persists folders (incl. empty ones) and is the authoritative
4
- // tree for media navigation. `path` is the normalized folder path (root '' is never a row).
5
- export const folders = sqliteTable('folders', {
6
- id: integer('id').primaryKey({ autoIncrement: true }),
7
- path: text('path').notNull().unique(),
8
- createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
9
- updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
10
- })
@@ -1,20 +0,0 @@
1
- import { registerWriteListener } from '../../../core/server/utils/write-events'
2
- import { planMediaDeletion } from '../utils/media-cleanup'
3
- import { useStorageDriver } from '../../../core/server/utils/storage'
4
-
5
- /**
6
- * Storage GC for media-record deletes. The media library's own `deleteAffected` deletes storage inline
7
- * (and bypasses the write bus); this covers EVERY OTHER delete path — the generic CRUD `remove()`, which
8
- * emits a delete event — by removing the row's original + all derivatives from storage. So a media record
9
- * is never removed while leaving orphaned files: principle 4 (output ≡ DB) for media, on every path.
10
- * Fire-and-forget (best-effort, like the publisher); the driver's delete is idempotent, errors are logged.
11
- */
12
- export default defineNitroPlugin(() => {
13
- registerWriteListener((event) => {
14
- const keys = planMediaDeletion(event)
15
- if (!keys.length) return
16
- const driver = useStorageDriver()
17
- void Promise.all(keys.map((key) => driver.delete(key)))
18
- .catch((error) => console.error('[kestrel] media storage cleanup failed:', error))
19
- })
20
- })
@@ -1,4 +0,0 @@
1
- import { folders } from '../database/folders'
2
-
3
- // Registers the standalone folders table with the schema engine (the `#kestrel/schema-tables` virtual).
4
- export default folders
@@ -1,16 +0,0 @@
1
- import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
2
-
3
- /**
4
- * Whether the EU AI Act disclosure feature is switched on for this consumer (`aiDisclosure.enabled`).
5
- *
6
- * It gates the ADMIN UI and the upload-time signal scan — never the data: `ResolvedMedia.aiDisclosure` is
7
- * always resolved from whatever the columns hold, so turning the flag off hides the editor without
8
- * touching (or hiding) a disclosure already recorded. Same runtimeConfig-then-own-config fallback as
9
- * `mediaCollectionEnabled`, so non-Nitro callers (scripts, build) resolve it too.
10
- */
11
- export function aiDisclosureEnabled(): boolean {
12
- const cfg = (serverRuntimeConfig()?.kestrel?.aiDisclosure ?? resolveServerKestrel().aiDisclosure) as
13
- | { enabled?: boolean }
14
- | undefined
15
- return cfg?.enabled === true
16
- }