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