@michaelthielemann/kestrel 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,132 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { ResolvedImagePolicy, ResolvedVariant, VariantFormat } from '../../../core/server/utils/kestrel-config'
4
+ import type { StorageDriver } from '../../../core/server/utils/storage'
5
+ import { media } from '../collections/media'
6
+ import { deriveImage, RASTER } from './derive'
7
+ import { derivativeKey, type DerivativeManifest } from './record'
8
+ import { activeVariants } from './variants'
9
+ import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
10
+ import { emitMediaWrite } from './media-write'
11
+
12
+ // Mirrors index.post.ts: a long-lived (non-immutable, so a replace isn't pinned) cache for originals +
13
+ // derivatives. Backfilled objects MUST carry the same header or the CDN caches them differently.
14
+ const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
15
+
16
+ export interface BackfillPlan {
17
+ /** Specs, each carrying ONLY its missing formats, to derive + add. */
18
+ missing: ResolvedVariant[]
19
+ /** Object keys of manifest entries no longer in the active set (deregistered) — to prune. */
20
+ orphanKeys: string[]
21
+ }
22
+
23
+ /**
24
+ * Pure diff of a media row's derivative manifest against the active variant set: what to generate, what to
25
+ * prune. A proportional spec wider than the source is never "missing" (deriveImage would skip it, so it can
26
+ * never be satisfied). Orphans are keyed per `<name>.<format>`, so a deregistered FORMAT of a still-active
27
+ * name is pruned too.
28
+ */
29
+ export function planBackfill(row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[]): BackfillPlan {
30
+ const manifest = row.derivatives ?? {}
31
+ const activeKeys = new Set<string>()
32
+ const missing: ResolvedVariant[] = []
33
+ for (const spec of specs) {
34
+ const applicable = spec.height != null || !row.width || spec.width <= row.width
35
+ const missingFormats: VariantFormat[] = []
36
+ for (const f of spec.formats) {
37
+ activeKeys.add(`${spec.name}.${f}`)
38
+ if (applicable && !(`${spec.name}.${f}` in manifest)) missingFormats.push(f)
39
+ }
40
+ if (missingFormats.length) missing.push({ ...spec, formats: missingFormats })
41
+ }
42
+ const orphanKeys: string[] = []
43
+ for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
44
+ return { missing, orphanKeys }
45
+ }
46
+
47
+ interface BackfillRow { id: number; storageKey: string; mime: string; width: number | null; derivatives: DerivativeManifest | null }
48
+
49
+ /**
50
+ * Reconcile ONE media row to the active set: derive its missing variants from the original (put-first),
51
+ * write the updated manifest, then prune orphan objects LAST (a crash never dangles the manifest). The pruned
52
+ * objects are this row's OWN deregistered derivatives — self-owned, so no cross-row ownership guard is needed
53
+ * (that is Slice 9's published-media GC, a different concern).
54
+ */
55
+ export async function backfillRow(
56
+ db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy,
57
+ ): Promise<{ generated: number; pruned: number }> {
58
+ const plan = planBackfill(row, specs)
59
+ if (!plan.missing.length && !plan.orphanKeys.length) return { generated: 0, pruned: 0 }
60
+
61
+ const orphaned = new Set(plan.orphanKeys)
62
+ const manifest: DerivativeManifest = {}
63
+ for (const [k, e] of Object.entries(row.derivatives ?? {})) if (!orphaned.has(e.key)) manifest[k] = e
64
+
65
+ let generated = 0
66
+ if (plan.missing.length) {
67
+ if (typeof driver.get !== 'function') throw new Error('storage driver cannot read originals back (no get()) — backfill needs it')
68
+ const derived = await deriveImage(await driver.get(row.storageKey), { ...policy, variants: plan.missing })
69
+ for (const v of derived.variants) {
70
+ const key = derivativeKey(row.storageKey, v.name, v.format)
71
+ await driver.put(key, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
72
+ manifest[`${v.name}.${v.format}`] = { key, width: v.width, height: v.height, mime: v.mime }
73
+ generated++
74
+ }
75
+ }
76
+
77
+ const cols = getTableColumns(media) as Record<string, never>
78
+ db.update(media).set({ derivatives: manifest, updatedAt: new Date() } as never).where(eq(cols.id, row.id)).run()
79
+ // Published pages hold this row's srcset candidates, so a rewritten manifest — especially a pruned
80
+ // key, which now 404s — has to re-render them. Emitted before the prune: a delete that throws must
81
+ // not leave the already-written manifest un-republished.
82
+ emitMediaWrite({ id: row.id }, { id: row.id })
83
+ for (const key of plan.orphanKeys) await driver.delete(key)
84
+ return { generated, pruned: plan.orphanKeys.length }
85
+ }
86
+
87
+ export interface BackfillReport { rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean }
88
+
89
+ /**
90
+ * Iterate every raster media row, reconciling each to the active variant set. Sequential (sharp CPU + a
91
+ * full-original GET per row) and best-effort per row — a corrupt original is logged and skipped, never
92
+ * aborting the whole run. `check` reports the plan (rows / would-generate / would-prune) without writing.
93
+ */
94
+ export async function runBackfill(
95
+ db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, opts: { check?: boolean } = {},
96
+ ): Promise<BackfillReport> {
97
+ const specs = activeVariants(db, policy.variants, policy.presets)
98
+ const rows = db.select().from(media).all() as BackfillRow[]
99
+ const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check }
100
+ for (const snapshot of rows) {
101
+ if (!RASTER.has(snapshot.mime)) continue
102
+ if (opts.check) {
103
+ // Dry-run reads only (against the snapshot) — no mutation, so no lock.
104
+ const plan = planBackfill(snapshot, specs)
105
+ const toGenerate = plan.missing.reduce((n, s) => n + s.formats.length, 0)
106
+ if (!toGenerate && !plan.orphanKeys.length) continue
107
+ report.rowsChanged++
108
+ report.generated += toGenerate
109
+ report.pruned += plan.orphanKeys.length
110
+ continue
111
+ }
112
+ // Serialize per storageKey with the upload path, and re-read the row FRESH inside the lock: a concurrent
113
+ // overwrite may have replaced the bytes/dims/manifest since the up-front snapshot, and reconciling the
114
+ // stale snapshot against the new object would write a manifest whose dims describe the OLD image.
115
+ await withLock(mediaLockKey(snapshot.storageKey), async () => {
116
+ const cols = getTableColumns(media) as Record<string, never>
117
+ const row = db.select().from(media).where(eq(cols.id, snapshot.id)).get() as BackfillRow | undefined
118
+ if (!row || !RASTER.has(row.mime)) return
119
+ const plan = planBackfill(row, specs)
120
+ if (!plan.missing.length && !plan.orphanKeys.length) return
121
+ report.rowsChanged++
122
+ try {
123
+ const r = await backfillRow(db, driver, row, specs, policy)
124
+ report.generated += r.generated
125
+ report.pruned += r.pruned
126
+ } catch (error) {
127
+ console.error(`[kestrel] backfill failed for media ${row.id} (${row.storageKey}):`, (error as Error)?.message ?? error)
128
+ }
129
+ })
130
+ }
131
+ return report
132
+ }
@@ -0,0 +1,59 @@
1
+ import sharp from 'sharp'
2
+ import { rgbaToThumbHash } from 'thumbhash'
3
+ import type { ResolvedImagePolicy } from '../../../core/server/utils/kestrel-config'
4
+
5
+ // The derivation policy is resolved once from kestrel config (KESTREL_MEDIA_IMAGE_* env → config → default)
6
+ // and threaded in by the upload handler. `DEFAULT_IMAGE_POLICY` is auto-imported from kestrel-config
7
+ // (the single export site); re-exporting it here would register a duplicate auto-import.
8
+ export type ImagePolicy = ResolvedImagePolicy
9
+
10
+ // Raster mimes that receive a derivative ladder. GIF is intentionally excluded: deriving WebP flattens it
11
+ // to a static first frame. Shared by the upload handler and the backfill task so they never disagree.
12
+ export const RASTER = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/avif'])
13
+
14
+ export interface DerivedImage {
15
+ width: number
16
+ height: number
17
+ thumbhash: string
18
+ variants: { name: string; width: number; height: number; format: 'webp' | 'jpeg'; mime: string; bytes: Buffer }[]
19
+ }
20
+
21
+ export async function deriveImage(input: Buffer, policy: ImagePolicy): Promise<DerivedImage> {
22
+ const meta = await sharp(input, { limitInputPixels: 268402689 }).metadata()
23
+ // sharp's metadata reports the stored (pre-EXIF-rotation) dimensions; .rotate() only affects
24
+ // the pixel pipeline. For EXIF orientation 5-8 (90°/270°, e.g. phone portrait photos) the
25
+ // displayed image — and every variant below, which applies .rotate() — has width/height swapped.
26
+ const swap = (meta.orientation ?? 0) >= 5
27
+ const width = (swap ? meta.height : meta.width) ?? 0
28
+ const height = (swap ? meta.width : meta.height) ?? 0
29
+
30
+ const small = await sharp(input).rotate().resize(100, 100, { fit: 'inside' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
31
+ const thumbhash = Buffer.from(rgbaToThumbHash(small.info.width, small.info.height, small.data)).toString('base64')
32
+
33
+ // Derive exactly the active variant set (name × format). A proportional spec wider than the source is
34
+ // skipped (never upscale); a crop clamps via withoutEnlargement. The stored width/height are the REAL
35
+ // pipeline-output dims (correct for crops and for clamped proportionals — not an aspect-ratio guess).
36
+ const variants: DerivedImage['variants'] = []
37
+ for (const spec of policy.variants) {
38
+ // sharp throws on a non-positive-integer dimension — coerce so a malformed spec (e.g. a stale
39
+ // `height: ''`) degrades to a proportional resize instead of 500ing the upload.
40
+ const specWidth = toPosInt(spec.width)
41
+ if (specWidth == null) continue
42
+ const cropHeight = toPosInt(spec.height)
43
+ if (cropHeight == null && width > 0 && specWidth > width) continue
44
+ for (const format of spec.formats) {
45
+ const resized = cropHeight == null
46
+ ? sharp(input).rotate().resize({ width: specWidth, withoutEnlargement: true })
47
+ : sharp(input).rotate().resize(specWidth, cropHeight, { fit: spec.fit, position: spec.position, withoutEnlargement: true })
48
+ const encoded = format === 'jpeg' ? resized.jpeg({ quality: policy.jpegQuality }) : resized.webp({ quality: policy.webpQuality })
49
+ const { data, info } = await encoded.toBuffer({ resolveWithObject: true })
50
+ variants.push({ name: spec.name, width: info.width, height: info.height, format, mime: format === 'jpeg' ? 'image/jpeg' : 'image/webp', bytes: data })
51
+ }
52
+ }
53
+ return { width, height, thumbhash, variants }
54
+ }
55
+
56
+ function toPosInt(value: unknown): number | null {
57
+ const n = Math.floor(Number(value))
58
+ return Number.isFinite(n) && n >= 1 ? n : null
59
+ }
@@ -0,0 +1,42 @@
1
+ /** Parent folder path; '' for a top-level folder; null for the root itself. */
2
+ export function parentOf(path: string): string | null {
3
+ if (path === '') return null
4
+ const i = path.lastIndexOf('/')
5
+ return i === -1 ? '' : path.slice(0, i)
6
+ }
7
+
8
+ /** Every ancestor folder (excluding the path itself and the root ''), root-first. */
9
+ export function ancestorsOf(path: string): string[] {
10
+ const out: string[] = []
11
+ const segs = path.split('/').filter(Boolean)
12
+ for (let i = 1; i < segs.length; i++) out.push(segs.slice(0, i).join('/'))
13
+ return out
14
+ }
15
+
16
+ /** The path plus all its ancestors (excluding root ''), root-first. */
17
+ export function selfAndAncestors(path: string): string[] {
18
+ return path === '' ? [] : [...ancestorsOf(path), path]
19
+ }
20
+
21
+ /** True when `path` is exactly one level below `parent`. */
22
+ export function isImmediateChild(parent: string, path: string): boolean {
23
+ return parentOf(path) === parent
24
+ }
25
+
26
+ /** Last path segment. */
27
+ export function childName(path: string): string {
28
+ const i = path.lastIndexOf('/')
29
+ return i === -1 ? path : path.slice(i + 1)
30
+ }
31
+
32
+ /** True when `path` is `prefix` itself or a descendant of it (anchored on a '/' boundary). */
33
+ export function isUnder(prefix: string, path: string): boolean {
34
+ return path === prefix || path.startsWith(`${prefix}/`)
35
+ }
36
+
37
+ /** Re-base `path` from the `from` subtree onto `to` (path unchanged if not under `from`). */
38
+ export function rewritePrefix(path: string, from: string, to: string): string {
39
+ if (path === from) return to
40
+ if (path.startsWith(`${from}/`)) return to + path.slice(from.length)
41
+ return path
42
+ }
@@ -0,0 +1,12 @@
1
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
2
+ import { folders } from '../database/folders'
3
+ import { sanitizeFolder } from './naming'
4
+ import { selfAndAncestors } from './folder-paths'
5
+
6
+ /** Ensure a folder row exists for `path` and every ancestor (after sanitizing). Idempotent. */
7
+ export function ensureFolder(db: BetterSQLite3Database, path: string): void {
8
+ const clean = sanitizeFolder(path)
9
+ for (const p of selfAndAncestors(clean)) {
10
+ db.insert(folders).values({ path: p }).onConflictDoNothing().run()
11
+ }
12
+ }
@@ -0,0 +1,90 @@
1
+ import { and, eq, like, count, getTableColumns, sql, isNull, or, asc, desc } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
4
+ import { media } from '../collections/media'
5
+ import { folders as foldersTable } from '../database/folders'
6
+ import { resolveMedia } from './resolve'
7
+ import { childName, isImmediateChild } from './folder-paths'
8
+ import { primaryLocale } from '../../../core/server/utils/locale'
9
+ import { escapeLike } from '../../../core/server/utils/sql'
10
+
11
+ export interface LibraryQuery { folder?: string; search?: string; type?: 'image' | 'all'; page?: number; perPage?: number; sort?: string }
12
+ export interface LibraryFolder { path: string; name: string; size: number }
13
+
14
+ // Sortable table columns → media column. `sort` is `field` (asc) or `-field` (desc); unknown → name.
15
+ const SORT_FIELDS = ['name', 'size', 'type'] as const
16
+ function parseSort(sort: string | undefined): { field: (typeof SORT_FIELDS)[number]; desc: boolean } {
17
+ const isDesc = (sort ?? '').startsWith('-')
18
+ const field = (sort ?? '').replace(/^-/, '') as (typeof SORT_FIELDS)[number]
19
+ return { field: SORT_FIELDS.includes(field) ? field : 'name', desc: isDesc }
20
+ }
21
+
22
+ export function listLibrary(db: BetterSQLite3Database, q: LibraryQuery, publicUrl: (key: string) => string) {
23
+ const folder = q.folder ?? ''
24
+ const cols = getTableColumns(media) as Record<string, never>
25
+ const t = media as AnySQLiteTable
26
+
27
+ // root files are stored with folder NULL (the uploader maps '' → null), so match both at root
28
+ const conds = [folder === '' ? or(isNull(cols.folder), eq(cols.folder, '')) : eq(cols.folder, folder)]
29
+ if (q.search) conds.push(sql`${cols.filename} like ${`%${escapeLike(q.search)}%`} escape '\\'`)
30
+ if (q.type === 'image') conds.push(like(cols.mime, 'image/%'))
31
+ const where = and(...conds)
32
+
33
+ // `?? default` catches only null/undefined, NOT NaN (`?perPage=abc` → Number('abc') === NaN), and
34
+ // .limit(NaN) binds LIMIT NULL / 500s in SQLite — guard with Number.isFinite (mirrors crud.list).
35
+ const num = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback)
36
+ const page = Math.max(1, Math.floor(num(q.page, 1)))
37
+ const perPage = Math.min(200, Math.max(1, Math.floor(num(q.perPage, 60))))
38
+
39
+ // Files are paginated, so sorting must happen in SQL. Tie-break by filename for a stable order.
40
+ const sort = parseSort(q.sort)
41
+ const sortCol = sort.field === 'size' ? cols.size : sort.field === 'type' ? cols.mime : cols.filename
42
+ const dir = sort.desc ? desc : asc
43
+ const rows = db.select().from(t).where(where).orderBy(dir(sortCol), asc(cols.filename))
44
+ .limit(perPage).offset((page - 1) * perPage).all() as Record<string, unknown>[]
45
+ const totalRow = db.select({ value: count() }).from(t).where(where).get() as { value: number } | undefined
46
+
47
+ // Resolve in the configured PRIMARY locale — the same locale the viewer edits alt in (and the locale
48
+ // resolveMedia itself falls back to), so an alt edit round-trips into the list for any primary, not
49
+ // just 'en'. A literal 'en' here would let an 'en' translation mask a non-en primary's alt.
50
+ const locale = primaryLocale()
51
+ const files = rows.map((r) => {
52
+ const m = resolveMedia(r as never, locale, publicUrl)
53
+ // serialize the webp ladder to an <img srcset> string; non-derivable files (pdf/svg) → undefined
54
+ // so the grid renders its ext badge instead of a broken <img> (an empty array is truthy)
55
+ const srcset = m.srcset.length ? m.srcset.map((s) => `${s.url} ${s.width}w`).join(', ') : undefined
56
+ return {
57
+ id: m.id, filename: r.filename as string, mime: m.mime, folder: r.folder as string,
58
+ size: r.size as number, width: m.width, height: m.height, thumbhash: m.thumbhash,
59
+ src: m.src, srcset, alt: m.alt, createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
60
+ }
61
+ })
62
+
63
+ const allFolders = db.select({ path: foldersTable.path }).from(foldersTable).all()
64
+
65
+ // Recursive folder sizes: one grouped query sums bytes per exact folder across the current subtree,
66
+ // then each immediate child's whole subtree is rolled up in JS (cheap: one row per distinct folder).
67
+ const sizeBase = db.select({ folder: cols.folder, total: sql<number>`coalesce(sum(${cols.size}), 0)` }).from(t).groupBy(cols.folder)
68
+ const sizeRows = (folder === ''
69
+ ? sizeBase
70
+ : sizeBase.where(or(eq(cols.folder, folder), sql`${cols.folder} like ${`${escapeLike(folder)}/%`} escape '\\'`))
71
+ ).all() as { folder: string | null; total: number }[]
72
+ const sizeOf = (prefix: string): number =>
73
+ sizeRows.reduce((sum, r) => (((r.folder ?? '') === prefix || (r.folder ?? '').startsWith(`${prefix}/`)) ? sum + Number(r.total ?? 0) : sum), 0)
74
+
75
+ // Folders always render before files; within the group they follow the same sort (by name, or by
76
+ // recursive size; type is uniform for folders so it falls back to name).
77
+ const folderList: LibraryFolder[] = allFolders
78
+ .filter((f) => isImmediateChild(folder, f.path))
79
+ .map((f) => ({ path: f.path, name: childName(f.path), size: sizeOf(f.path) }))
80
+ .sort((a, b) => {
81
+ const c = sort.field === 'size' ? a.size - b.size : a.name.localeCompare(b.name)
82
+ return sort.desc ? -c : c
83
+ })
84
+
85
+ // The root always exists; any other folder must have a row in the registry. Lets the client tell a
86
+ // genuinely-empty folder apart from a path that does not exist (→ error instead of a blank listing).
87
+ const exists = folder === '' || allFolders.some((f) => f.path === folder)
88
+
89
+ return { folder, exists, folders: folderList, files, total: Number(totalRow?.value ?? 0), page, perPage }
90
+ }
@@ -0,0 +1,27 @@
1
+ import type { WriteEvent } from '../../../core/server/utils/write-events'
2
+ import type { DerivativeManifest } from './record'
3
+
4
+ /** Every storage key a media row owns — the original plus each generated derivative (the webp ladder). */
5
+ export function mediaStorageKeys(row: Record<string, unknown>): string[] {
6
+ const keys: string[] = []
7
+ if (typeof row.storageKey === 'string' && row.storageKey) keys.push(row.storageKey)
8
+ const derivatives = row.derivatives as DerivativeManifest | null | undefined
9
+ if (derivatives && typeof derivatives === 'object') {
10
+ for (const d of Object.values(derivatives)) {
11
+ if (d && typeof d.key === 'string' && d.key) keys.push(d.key)
12
+ }
13
+ }
14
+ return keys
15
+ }
16
+
17
+ /**
18
+ * Storage keys to delete when a write event is a media-record DELETE — the original + ALL derivatives;
19
+ * `[]` for anything else. This makes deleting a media record remove its files on EVERY delete path:
20
+ * the generic CRUD `remove()` emits a delete event handled here, complementing the media library's own
21
+ * `deleteAffected` (which deletes storage inline). So a media record can never be removed while leaving
22
+ * orphaned objects in storage — principle 4 (output ≡ DB) for media. Pure → unit-testable.
23
+ */
24
+ export function planMediaDeletion(event: WriteEvent): string[] {
25
+ if (event.def.name !== 'media' || event.after !== null || !event.before) return []
26
+ return mediaStorageKeys(event.before)
27
+ }
@@ -0,0 +1,23 @@
1
+ import { createError } from 'h3'
2
+ import { collectionEnabled } from '../../../core/server/schema/bootstrap'
3
+ import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
4
+ import mediaCollection from '../collections/media'
5
+
6
+ /**
7
+ * Whether the `media` built-in is active for this consumer. The media routes are static files rather than
8
+ * registry-driven, so they have to ask the SAME predicate the registry plugin and the schema engine use.
9
+ * Without it a consumer who turned the built-in off keeps serving endpoints that query a table the schema
10
+ * engine deliberately never created.
11
+ */
12
+ export function mediaCollectionEnabled(): boolean {
13
+ const toggles = (serverRuntimeConfig()?.kestrel?.collections ?? resolveServerKestrel().collections) as
14
+ | Record<string, boolean>
15
+ | undefined
16
+ return collectionEnabled(mediaCollection.def, toggles)
17
+ }
18
+
19
+ /** Route guard: with the built-in disabled the endpoint genuinely does not exist, so 404 — not a 500 from
20
+ * the missing table. */
21
+ export function requireMediaCollection(): void {
22
+ if (!mediaCollectionEnabled()) throw createError({ statusCode: 404, statusMessage: 'Media collection is disabled' })
23
+ }
@@ -0,0 +1,118 @@
1
+ import { eq, inArray, or, getTableColumns, sql } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import { findMediaUsagesForMany, type MediaUsage } from './usages'
4
+ import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
5
+ import { media } from '../collections/media'
6
+ import { folders } from '../database/folders'
7
+ import { isUnder } from './folder-paths'
8
+ import type { DerivativeManifest } from './record'
9
+ import { emitMediaWrite } from './media-write'
10
+ import type { StorageDriver } from '../../../core/server/utils/storage'
11
+ import { escapeLike } from '../../../core/server/utils/sql'
12
+
13
+ export type OpItem = { type: 'file'; id: number } | { type: 'folder'; path: string }
14
+
15
+ export interface AffectedMedia {
16
+ id: number; storageKey: string; folder: string | null; filename: string
17
+ size: number; derivatives: DerivativeManifest | null
18
+ }
19
+ export interface AffectedSet { media: AffectedMedia[]; folders: string[] }
20
+
21
+ /** Resolve op items into the concrete affected media rows + folder paths. A `folder` item
22
+ * expands to itself + every descendant (media + folder rows). Folders are returned deepest-first
23
+ * (so a delete removes children before parents); media is deduped by id. */
24
+ export function collectAffected(db: BetterSQLite3Database, items: OpItem[]): AffectedSet {
25
+ const cols = getTableColumns(media) as Record<string, never>
26
+ const t = media as AnySQLiteTable
27
+ const mediaById = new Map<number, AffectedMedia>()
28
+ const folderSet = new Set<string>()
29
+ // Only a folder item ever consults the full folder list (to find its descendants) — an all-file list
30
+ // never touches it, so skip the read rather than materialize every row for nothing.
31
+ const allFolders = items.some((i) => i.type === 'folder')
32
+ ? db.select({ path: folders.path }).from(folders).all().map((f) => f.path)
33
+ : []
34
+
35
+ const add = (rows: Record<string, unknown>[]) => {
36
+ for (const r of rows) mediaById.set(r.id as number, {
37
+ id: r.id as number, storageKey: r.storageKey as string, folder: (r.folder as string | null) ?? null,
38
+ filename: r.filename as string, size: r.size as number, derivatives: (r.derivatives as DerivativeManifest | null) ?? null,
39
+ })
40
+ }
41
+
42
+ for (const item of items) {
43
+ if (item.type === 'file') {
44
+ const row = db.select().from(t).where(eq(cols.id, item.id)).get() as Record<string, unknown> | undefined
45
+ if (row) add([row])
46
+ } else {
47
+ for (const fp of allFolders) if (isUnder(item.path, fp)) folderSet.add(fp)
48
+ folderSet.add(item.path)
49
+ const rows = db.select().from(t).where(
50
+ or(eq(cols.folder, item.path), sql`${cols.folder} like ${`${escapeLike(item.path)}/%`} escape '\\'`),
51
+ ).all() as Record<string, unknown>[]
52
+ add(rows)
53
+ }
54
+ }
55
+
56
+ const folderList = [...folderSet].sort((a, b) => b.split('/').length - a.split('/').length)
57
+ return { media: [...mediaById.values()], folders: folderList }
58
+ }
59
+
60
+ /** Reverse-usage lookup for many media ids at once: id -> the records referencing it. */
61
+ export function bulkUsages(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
62
+ return findMediaUsagesForMany(db, ids)
63
+ }
64
+
65
+ export interface DeleteReport {
66
+ summary: { files: number; folders: number; totalBytes: number }
67
+ usages?: Record<number, MediaUsage[]>
68
+ /** storageKeys whose blob delete failed — the row is already gone (rows commit before storage), so a
69
+ * failure here is an orphaned object, not a retryable half-delete; surfaced for the caller to log/alert. */
70
+ failedKeys?: string[]
71
+ }
72
+
73
+ /** Two-wave delete. dryRun → report impact + per-media usages, no mutation. Otherwise → delete
74
+ * all affected media rows + folder rows in one transaction, then delete the storage objects
75
+ * (original + derivatives). Folder cascade included. */
76
+ export async function deleteAffected(
77
+ db: BetterSQLite3Database, driver: StorageDriver, items: OpItem[], dryRun: boolean,
78
+ ): Promise<DeleteReport> {
79
+ const affected = collectAffected(db, items)
80
+ const summary = {
81
+ files: affected.media.length,
82
+ folders: affected.folders.length,
83
+ totalBytes: affected.media.reduce((n, m) => n + (m.size || 0), 0),
84
+ }
85
+ if (dryRun) return { summary, usages: bulkUsages(db, affected.media.map((m) => m.id)) }
86
+
87
+ const cols = getTableColumns(media) as Record<string, never>
88
+ const mediaIds = affected.media.map((m) => m.id)
89
+ db.transaction((tx) => {
90
+ if (mediaIds.length) tx.delete(media).where(inArray(cols.id, mediaIds)).run()
91
+ if (affected.folders.length) tx.delete(folders).where(inArray(folders.path, affected.folders)).run()
92
+ })
93
+
94
+ // Best-effort per item: the rows are already committed gone, so a storage error here means an orphan
95
+ // object, not a rollback candidate — one bad key (a throttled/5xx S3 call) must not abort the loop and
96
+ // leave every LATER item's blobs undeleted too with no row left to find them again.
97
+ const failedKeys: string[] = []
98
+ for (const m of affected.media) {
99
+ try {
100
+ await driver.delete(m.storageKey)
101
+ for (const d of Object.values(m.derivatives ?? {})) await driver.delete(d.key)
102
+ } catch (error) {
103
+ console.error(`[kestrel] media storage delete failed for ${m.storageKey}:`, (error as Error)?.message ?? error)
104
+ failedKeys.push(m.storageKey)
105
+ }
106
+ emitMediaWrite({ id: m.id }, null) // deleted media → re-render listings that query it
107
+ }
108
+ // Clean up the folder dir ONLY when nothing unmanaged remains under it. removeDir is a recursive subtree
109
+ // wipe, so a blind call would destroy any non-media object living under the same path — e.g. an extension's
110
+ // encrypted blob namespace, or any consumer blob written via useStorageDriver — which no media row (and no
111
+ // dryRun count) accounts for. If anything is still there, leave the whole path alone.
112
+ for (const it of items) {
113
+ if (it.type !== 'folder') continue
114
+ const remaining = driver.listPrefix ? await driver.listPrefix(it.path) : ['guard']
115
+ if (remaining.length === 0) await driver.removeDir?.(it.path)
116
+ }
117
+ return failedKeys.length ? { summary, failedKeys } : { summary }
118
+ }
@@ -0,0 +1,13 @@
1
+ import { emitWrite } from '../../../core/server/utils/write-events'
2
+ import mediaCollection from '../collections/media'
3
+
4
+ /**
5
+ * Notify the publish runtime that a media row changed. The media-library write paths (relocate / duplicate
6
+ * / delete / alt-edit) bypass core CRUD, so — unlike a normal content write — they emit no write event on
7
+ * their own. Without this, a page that embeds the media (dep-tagged `media:<id>`) is never re-rendered, so
8
+ * the static output keeps the old (now 404) URL or stale alt text. `before`/`after` need only carry `id`
9
+ * for the media classification (non-pageLike, no status column).
10
+ */
11
+ export function emitMediaWrite(before: Record<string, unknown> | null, after: Record<string, unknown> | null): void {
12
+ emitWrite(mediaCollection.def, before, after)
13
+ }
@@ -0,0 +1,52 @@
1
+ export function sanitizeFilename(name: string): string {
2
+ const base = name.split(/[/\\]/).pop() ?? ''
3
+ const cleaned = base.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_').slice(0, 120)
4
+ if (cleaned === '' || cleaned === '.' || cleaned === '..') return 'file'
5
+ return cleaned
6
+ }
7
+
8
+ /** The last extension of a filename (no leading dot), or '' when there is none. */
9
+ export function extensionOf(name: string): string {
10
+ const dot = name.lastIndexOf('.')
11
+ return dot > 0 ? name.slice(dot + 1) : ''
12
+ }
13
+
14
+ /** Sanitize `name` and force its extension to `ext` (drops any client-supplied extension).
15
+ * The stored extension must follow the authenticated content type, never the upload filename. */
16
+ export function withExtension(name: string, ext: string): string {
17
+ const cleaned = sanitizeFilename(name)
18
+ const dot = cleaned.lastIndexOf('.')
19
+ const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned
20
+ return ext ? `${stem}.${ext}` : stem
21
+ }
22
+
23
+ /** Safe relative folder: drops `.`/`..` segments (no traversal) and cleans each segment.
24
+ * Deliberately NOT length-capped: truncating would silently map two distinct folders onto one
25
+ * storage path. An unusable path is caught where it is created — folders.post.ts runs `ensureDir`
26
+ * before committing the row, so an over-long name fails there instead of stranding a row. */
27
+ export function sanitizeFolder(folder: string): string {
28
+ return folder
29
+ .split(/[/\\]+/)
30
+ .map((seg) => seg.trim())
31
+ .filter((seg) => seg && seg !== '.' && seg !== '..')
32
+ .map((seg) => seg.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_'))
33
+ .filter((seg) => seg.length > 0)
34
+ .join('/')
35
+ }
36
+
37
+ export function buildKey(folder: string, filename: string): string {
38
+ const f = sanitizeFolder(folder)
39
+ return f ? `${f}/${filename}` : filename
40
+ }
41
+
42
+ export function suggestFreeName(filename: string, isFree: (name: string) => boolean): string {
43
+ if (isFree(filename)) return filename
44
+ const dot = filename.lastIndexOf('.')
45
+ const stem = dot > 0 ? filename.slice(0, dot) : filename
46
+ const ext = dot > 0 ? filename.slice(dot) : ''
47
+ for (let i = 2; i < 10000; i++) {
48
+ const candidate = `${stem}-${i}${ext}`
49
+ if (isFree(candidate)) return candidate
50
+ }
51
+ throw new Error('no free filename found')
52
+ }
@@ -0,0 +1,69 @@
1
+ import { inArray, getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { ResolvedImagePolicy, VariantFit } from '../../../core/server/utils/kestrel-config'
4
+ import type { StorageDriver } from '../../../core/server/utils/storage'
5
+ import { media } from '../collections/media'
6
+ import { deriveImage, RASTER } from './derive'
7
+
8
+ const FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
9
+
10
+ /** The storage key a request addresses, or null when the request is not for media at all: outside the
11
+ * configured base URL, bare, or carrying a malformed %-escape (which must degrade to a static 404, never
12
+ * a 500). `baseUrl` is the media base as resolved config holds it — its default is the same `/uploads`. */
13
+ export function variantKeyFromPath(path: string, baseUrl: string | undefined): string | null {
14
+ const base = (baseUrl || '/uploads').replace(/\/+$/, '')
15
+ const clean = path.split('?')[0]!
16
+ if (!clean.startsWith(`${base}/`)) return null
17
+ try {
18
+ return decodeURIComponent(clean.slice(base.length + 1)) || null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /** Inverse of variantName: `w<width>` | `c<w>x<h>[-<fit>]` → the shape to derive, or null for anything else. */
25
+ export function parseVariantName(name: string): { width: number; height: number | null; fit: VariantFit } | null {
26
+ const w = /^w(\d+)$/.exec(name)
27
+ if (w) return { width: +w[1], height: null, fit: 'cover' }
28
+ const c = /^c(\d+)x(\d+)(?:-([a-z]+))?$/.exec(name)
29
+ if (c) {
30
+ const fit = (c[3] ?? 'cover') as VariantFit
31
+ return FITS.has(fit) ? { width: +c[1], height: +c[2], fit } : null
32
+ }
33
+ return null
34
+ }
35
+
36
+ export interface VariantRequest { storageKey: string; mime: string; name: string; format: string }
37
+
38
+ // requestedKey is `<storageKey>-<name>.<format>`; storageKey contains dots and a name may contain '-', so
39
+ // resolve the original by trying every '-' split and matching a real media row (longest storageKey wins).
40
+ export function resolveVariantRequest(db: BetterSQLite3Database, requestedKey: string): VariantRequest | null {
41
+ const dot = requestedKey.lastIndexOf('.')
42
+ if (dot < 0) return null
43
+ const format = requestedKey.slice(dot + 1)
44
+ const stem = requestedKey.slice(0, dot)
45
+ const candidates: string[] = []
46
+ for (let i = 1; i < stem.length; i++) if (stem[i] === '-') candidates.push(stem.slice(0, i))
47
+ if (!candidates.length) return null
48
+ const cols = getTableColumns(media) as Record<string, never>
49
+ const rows = db.select({ storageKey: cols.storageKey, mime: cols.mime }).from(media).where(inArray(cols.storageKey, candidates)).all() as { storageKey: string; mime: string }[]
50
+ const match = rows.map((r) => ({ ...r, name: stem.slice(r.storageKey.length + 1), format })).sort((a, b) => b.storageKey.length - a.storageKey.length)[0]
51
+ return match ?? null
52
+ }
53
+
54
+ const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
55
+
56
+ // DEV-only: derive one missing variant from the stored original and cache it. Registry-INDEPENDENT (parses
57
+ // the name), so a size/format a KestrelImg declares but the scan hasn't registered yet still shows in the
58
+ // editor preview without a full publish.
59
+ export async function deriveOnDemand(db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, requestedKey: string): Promise<{ bytes: Buffer; mime: string } | null> {
60
+ const req = resolveVariantRequest(db, requestedKey)
61
+ if (!req || !RASTER.has(req.mime) || (req.format !== 'webp' && req.format !== 'jpeg')) return null
62
+ const shape = parseVariantName(req.name)
63
+ if (!shape || typeof driver.get !== 'function') return null
64
+ const spec = { name: req.name, width: shape.width, height: shape.height, fit: shape.fit, position: 'centre', formats: [req.format] as ('webp' | 'jpeg')[] }
65
+ const v = (await deriveImage(await driver.get(req.storageKey), { ...policy, variants: [spec] })).variants[0]
66
+ if (!v) return null
67
+ await driver.put(requestedKey, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
68
+ return { bytes: v.bytes, mime: v.mime }
69
+ }