@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,41 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import { media } from '../../collections/media'
3
+ import { mergeTranslations, type Translations } from '../../utils/translations'
4
+ import { emitMediaWrite } from '../../utils/media-write'
5
+ import { requireMediaCollection } from '../../utils/media-enabled'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
9
+ requireMediaCollection()
10
+ const id = requireId(event)
11
+ const body = await readBody(event)
12
+ const cols = getTableColumns(media) as Record<string, never>
13
+ const db = useDb()
14
+ const expectedUpdatedAt = readIfUnmodifiedSince(event)
15
+ const patch: Record<string, unknown> = { updatedAt: new Date() }
16
+ // `folder` is immutable via PATCH: relocating a file means moving its storage object and every
17
+ // derivative, which belongs to the media-library slice. Writing only the column would desync it
18
+ // from the folder baked into storageKey, which is what the public URL is built from.
19
+ const needsCurrent = expectedUpdatedAt !== undefined
20
+ || (body?.translations && typeof body.translations === 'object' && !Array.isArray(body.translations))
21
+ const current = needsCurrent
22
+ ? db.select().from(media).where(eq(cols.id, id)).get() as { translations?: Translations; updatedAt?: Date } | undefined
23
+ : undefined
24
+ // Optimistic concurrency: honor the same X-Kestrel-If-Unmodified-Since precondition the collection CRUD
25
+ // does, so two media-viewer tabs editing the same asset's alt text can't silently overwrite each other.
26
+ if (expectedUpdatedAt !== undefined && current) {
27
+ const cur = current.updatedAt instanceof Date ? current.updatedAt.getTime() : new Date(current.updatedAt as never).getTime()
28
+ if (cur !== expectedUpdatedAt) {
29
+ throw createError({ statusCode: 409, statusMessage: 'This media item changed since you opened it. Reload before saving.' })
30
+ }
31
+ }
32
+ if (body?.translations && typeof body.translations === 'object' && !Array.isArray(body.translations)) {
33
+ // Media keeps all locales in one JSON column. Deep-merge per locale so a partial PATCH (e.g. just
34
+ // `en.alt` from the media viewer) keeps the other locales AND the locale's other fields intact.
35
+ patch.translations = mergeTranslations(current?.translations, body.translations as Translations)
36
+ }
37
+ const row = db.update(media).set(patch).where(eq(cols.id, id)).returning().get() as Record<string, unknown> | undefined
38
+ if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
39
+ emitMediaWrite({ id }, row) // alt/title/description changed → re-render embedding pages (fresh alt text)
40
+ return row
41
+ })
@@ -0,0 +1,18 @@
1
+ import { runBackfill } from '../../utils/backfill'
2
+ import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
3
+ import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
4
+ import { requireMediaCollection } from '../../utils/media-enabled'
5
+
6
+ /**
7
+ * Admin-triggered variant backfill/prune (the Mediathek "Regenerate/Prune" action). `{ check: true }` is a
8
+ * dry-run reporting the plan (rows / would-generate / would-prune) — the UI shows it before applying.
9
+ * Synchronous: fine for a dry-run and moderate libraries; a very large library should run the
10
+ * `media:backfill` task on a schedule instead (a full-original GET + sharp per row).
11
+ */
12
+ export default defineEventHandler(async (event) => {
13
+ requireAdmin(event)
14
+ requireMediaCollection()
15
+ const body = (await readBody(event).catch(() => ({}))) as { check?: boolean }
16
+ const policy = mediaRuntimeConfig().imagePolicy ?? DEFAULT_IMAGE_POLICY
17
+ return runBackfill(useDb(), useStorageDriver(), policy, { check: !!body?.check })
18
+ })
@@ -0,0 +1,7 @@
1
+ import { runRelocation } from '../../utils/relocate-ops'
2
+ import { requireMediaCollection } from '../../utils/media-enabled'
3
+
4
+ export default defineEventHandler((event) => {
5
+ requireMediaCollection()
6
+ return runRelocation(event, (items, body) => ({ type: 'copy', items, dest: typeof body.dest === 'string' ? body.dest : '' }))
7
+ })
@@ -0,0 +1,13 @@
1
+ import { deleteAffected } from '../../utils/media-ops'
2
+ import { coerceOpItems } from '../../utils/relocate-ops'
3
+ import { useStorageDriver } from '../../../../core/server/utils/storage'
4
+ import { requireMediaCollection } from '../../utils/media-enabled'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
8
+ requireMediaCollection()
9
+ const body = await readBody(event)
10
+ const items = coerceOpItems(body?.items)
11
+ if (!items.length) throw createError({ statusCode: 400, statusMessage: 'No items to delete' })
12
+ return deleteAffected(useDb(), useStorageDriver(), items, body?.dryRun === true)
13
+ })
@@ -0,0 +1,18 @@
1
+ import { ensureFolder } from '../../utils/folders'
2
+ import { sanitizeFolder } from '../../utils/naming'
3
+ import { useStorageDriver } from '../../../../core/server/utils/storage'
4
+ import { requireMediaCollection } from '../../utils/media-enabled'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
8
+ requireMediaCollection()
9
+ const body = await readBody(event)
10
+ const path = sanitizeFolder(typeof body?.path === 'string' ? body.path : '')
11
+ if (!path) throw createError({ statusCode: 400, statusMessage: 'A non-empty folder path is required' })
12
+ // ensureDir (mkdir, can throw ENAMETOOLONG/ENOTDIR/etc.) runs BEFORE the row commit — a folder row
13
+ // with no backing directory would list forever with every upload/retry into it failing the same way.
14
+ await useStorageDriver().ensureDir?.(path)
15
+ const db = useDb()
16
+ ensureFolder(db, path)
17
+ return { path }
18
+ })
@@ -0,0 +1,15 @@
1
+ import media from '../../collections/media'
2
+ import { requireMediaCollection } from '../../utils/media-enabled'
3
+
4
+ export default defineEventHandler((event) => {
5
+ requireMediaCollection()
6
+ const query = getQuery(event)
7
+ return list(useDb(), media, {
8
+ locale: query.locale as string | undefined,
9
+ sort: query.sort as string | undefined,
10
+ page: query.page ? Number(query.page) : undefined,
11
+ perPage: query.perPage ? Number(query.perPage) : undefined,
12
+ filter: parseFilter(query as Record<string, unknown>),
13
+ depth: query.depth ? Number(query.depth) : 0,
14
+ })
15
+ })
@@ -0,0 +1,157 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { eq, inArray, getTableColumns, isNull } from 'drizzle-orm'
3
+ import { media } from '../../collections/media'
4
+ import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
5
+ import { sniffMime, extForMime, resolveAllowedMimes } from '../../utils/sniff'
6
+ import { sanitizeFolder, buildKey, suggestFreeName, withExtension } from '../../utils/naming'
7
+ import { sanitizeSvg } from '../../utils/sanitize-svg'
8
+ import { deriveImage, RASTER, type DerivedImage } from '../../utils/derive'
9
+ import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
10
+ import { buildMediaValues, derivativeKey, type DerivativeManifest } from '../../utils/record'
11
+ import { persistUpload } from '../../utils/persist-upload'
12
+ import type { Translations } from '../../utils/translations'
13
+ import { activeVariants } from '../../utils/variants'
14
+ import { ensureFolder } from '../../utils/folders'
15
+ import { primaryLocale } from '../../../../core/server/utils/locale'
16
+ import { isUniqueViolation } from '../../../../core/server/utils/crud'
17
+ import { withLock, mediaLockKey } from '../../../../core/server/utils/key-lock'
18
+ import { requireMediaCollection } from '../../utils/media-enabled'
19
+ import { emitMediaWrite } from '../../utils/media-write'
20
+
21
+ export default defineEventHandler(async (event) => {
22
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
23
+ requireMediaCollection()
24
+ const cfg = mediaRuntimeConfig()
25
+ const len = Number(getRequestHeader(event, 'content-length'))
26
+ // A missing/garbage Content-Length means a chunked transfer, which readMultipartFormData would
27
+ // buffer with no ceiling — refuse it up front. The declared length is then a cheap pre-check; the
28
+ // real cap is re-asserted on the parsed bytes below (a client can under-declare Content-Length).
29
+ if (!Number.isFinite(len) || len < 0) {
30
+ throw createError({ statusCode: 411, statusMessage: 'Length required' })
31
+ }
32
+ if (len > cfg.maxUploadBytes) {
33
+ throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
34
+ }
35
+
36
+ const parts = await readMultipartFormData(event)
37
+ if (!parts) throw createError({ statusCode: 400, statusMessage: 'Expected multipart/form-data' })
38
+ const filePart = parts.find((p) => p.name === 'file' && p.filename)
39
+ if (!filePart || !filePart.data?.length) throw createError({ statusCode: 400, statusMessage: 'Missing file' })
40
+ if (filePart.data.length > cfg.maxUploadBytes) throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
41
+ const text = (n: string) => parts.find((p) => p.name === n && !p.filename)?.data.toString('utf8')
42
+
43
+ let bytes = filePart.data as Buffer
44
+ const allowed = resolveAllowedMimes(cfg.allowedMimes)
45
+ const mime = await sniffMime(bytes)
46
+ if (!mime || !allowed.has(mime)) {
47
+ throw createError({ statusCode: 415, statusMessage: `Unsupported media type${mime ? `: ${mime}` : ''}` })
48
+ }
49
+ const ext = extForMime(mime)
50
+
51
+ if (mime === 'image/svg+xml') bytes = Buffer.from(sanitizeSvg(bytes.toString('utf8')))
52
+
53
+ // server-controlled key only (folder + filename are both sanitized → no path traversal)
54
+ const folder = sanitizeFolder(text('folder') ?? '')
55
+ const overwrite = text('overwrite') === 'true'
56
+ // The stored extension follows the sniffed type, never the client filename (content-type confusion).
57
+ const filename = withExtension(text('filename') ?? filePart.filename ?? 'file', ext)
58
+ const storageKey = buildKey(folder, filename)
59
+
60
+ const db = useDb()
61
+ const cols = getTableColumns(media) as Record<string, never>
62
+ const driver = useStorageDriver()
63
+
64
+ const checksum = createHash('sha256').update(bytes).digest('hex')
65
+ // Narrow generation: derive exactly the currently-registered variant set (the media_settings registry),
66
+ // falling back to the config policy's variants when the registry is empty (fresh project / no scan yet).
67
+ // Done BEFORE the per-key lock so uploads of different files derive in parallel (sharp is the hot path).
68
+ const policy = cfg.imagePolicy ?? DEFAULT_IMAGE_POLICY
69
+ // A raster that sniffs fine but is truncated/corrupt (or a misconfigured crop position slipping past
70
+ // validation) makes sharp reject mid-pipeline — a bare unwrapped throw here would 500 with an internal
71
+ // vips error string. Surface it as a real, unprocessable-upload 4xx instead.
72
+ let derived: DerivedImage | undefined
73
+ if (RASTER.has(mime)) {
74
+ try {
75
+ derived = await deriveImage(bytes, { ...policy, variants: activeVariants(db, policy.variants, policy.presets) })
76
+ } catch (error) {
77
+ throw createError({ statusCode: 422, statusMessage: 'Could not process this image', data: { reason: (error as Error)?.message } })
78
+ }
79
+ }
80
+
81
+ const alt = text('alt')
82
+ const title = text('title')
83
+ const description = text('description')
84
+ const translations = alt || title || description ? { [primaryLocale()]: { alt, title, description } } : {}
85
+
86
+ // Serialize the collision-check → put → insert per storageKey: two concurrent uploads to the SAME key
87
+ // (or a backfill re-deriving it) must not interleave, or last-writer-wins would leave the winning row
88
+ // describing the loser's bytes. Different keys never share a lock, so throughput is unaffected.
89
+ const { row, created } = await withLock(mediaLockKey(storageKey), async (): Promise<{ row: Record<string, unknown>; created: boolean }> => {
90
+ const existing = db.select().from(media).where(eq(cols.storageKey, storageKey)).get() as
91
+ | { id: number; derivatives?: DerivativeManifest; translations?: Translations }
92
+ | undefined
93
+
94
+ // An object on disk with no media row (another media's derivative, or an orphan) must never be
95
+ // clobbered — not even under overwrite, which only replaces a managed media row.
96
+ const blockedByDisk = !existing && (await driver.exists?.(storageKey))
97
+ if ((existing && !overwrite) || blockedByDisk) {
98
+ // One query: the filenames already taken in this folder, used to suggest a free name in memory.
99
+ const takenNames = new Set(
100
+ db.select({ f: cols.filename }).from(media)
101
+ .where(folder ? eq(cols.folder, folder) : isNull(cols.folder))
102
+ .all().map((r) => (r as { f: string }).f),
103
+ )
104
+ if (blockedByDisk) takenNames.add(filename)
105
+ throw createError({
106
+ statusCode: 409,
107
+ statusMessage: 'A file with this name already exists',
108
+ data: { storageKey, existingId: existing?.id, suggestion: suggestFreeName(filename, (n) => !takenNames.has(n)) },
109
+ })
110
+ }
111
+
112
+ // Reserve the derivative namespace: `derivativeKey` mints a syntactically valid ORIGINAL key
113
+ // (`<key>-<name>.<format>`), so a same-named upload elsewhere (or a coincidentally matching filename)
114
+ // can already own the exact key this derive is about to `driver.put` over. The create-path write below
115
+ // has no per-key collision check of its own, so refuse up front rather than silently overwrite another
116
+ // row's blob out from under it.
117
+ const derivKeys = (derived?.variants ?? []).map((v) => derivativeKey(storageKey, v.name, v.format))
118
+ if (derivKeys.length) {
119
+ const clash = db.select({ storageKey: cols.storageKey }).from(media).where(inArray(cols.storageKey, derivKeys)).all() as { storageKey: string }[]
120
+ if (clash.length) {
121
+ // Not a 409: the uploader reads that status as the rename/overwrite/skip name-conflict contract,
122
+ // and neither branch resolves this — the requested name is free, and overwriting it re-derives the
123
+ // very same blocked keys. The message names the file the user actually dropped, plus the existing
124
+ // one standing in its way, so the remedy (rename either) is visible from the upload dialog.
125
+ throw createError({
126
+ statusCode: 422,
127
+ statusMessage: `Cannot store "${filename}": the existing file "${clash[0]!.storageKey}" already uses a name reserved for one of its generated image sizes. Rename either file, then upload again.`,
128
+ data: { storageKey, clashKeys: clash.map((c) => c.storageKey) },
129
+ })
130
+ }
131
+ }
132
+
133
+ if (folder) ensureFolder(db, folder)
134
+ const values = buildMediaValues({ storageKey, folder, filename, mime, ext, size: bytes.length, checksum, derived, translations })
135
+ try {
136
+ // persistUpload writes the objects then the row; on a create-path failure it removes the freshly-written
137
+ // blobs so a partial upload never strands orphans that permanently 409-block the filename.
138
+ const saved = await persistUpload(db, driver, { storageKey, bytes, mime, derived, values, existing, overwrite })
139
+ return { row: saved, created: !(existing && overwrite) }
140
+ } catch (error) {
141
+ // A double-submit that slips through the check surfaces as a UNIQUE violation → 409, not a raw 500.
142
+ if (isUniqueViolation(error)) {
143
+ throw createError({ statusCode: 409, statusMessage: 'A file with this name already exists', data: { storageKey } })
144
+ }
145
+ throw error
146
+ }
147
+ })
148
+
149
+ // Only an overwrite can invalidate published output: it rewrites the bytes, dimensions, thumbhash and
150
+ // derivative manifest under an unchanged id, so every page embedding it holds srcset candidates that no
151
+ // longer exist. Gate on `created`, not the requested `overwrite` flag — an overwrite request that finds
152
+ // no row inserts a fresh one, which nothing published can reference yet.
153
+ if (!created) emitMediaWrite({ id: row.id as number }, row)
154
+
155
+ setResponseStatus(event, created ? 201 : 200)
156
+ return { ...row, url: driver.publicUrl(storageKey) }
157
+ })
@@ -0,0 +1,17 @@
1
+ import { listLibrary } from '../../utils/library'
2
+ import { useStorageDriver } from '../../../../core/server/utils/storage'
3
+ import { requireMediaCollection } from '../../utils/media-enabled'
4
+
5
+ export default defineEventHandler((event) => {
6
+ requireMediaCollection()
7
+ const query = getQuery(event)
8
+ const driver = useStorageDriver()
9
+ return listLibrary(useDb(), {
10
+ folder: typeof query.folder === 'string' ? query.folder : '',
11
+ search: typeof query.search === 'string' ? query.search : undefined,
12
+ type: query.type === 'image' ? 'image' : 'all',
13
+ page: query.page ? Number(query.page) : undefined,
14
+ perPage: query.perPage ? Number(query.perPage) : undefined,
15
+ sort: typeof query.sort === 'string' ? query.sort : undefined,
16
+ }, (key) => driver.publicUrl(key))
17
+ })
@@ -0,0 +1,7 @@
1
+ import { runRelocation } from '../../utils/relocate-ops'
2
+ import { requireMediaCollection } from '../../utils/media-enabled'
3
+
4
+ export default defineEventHandler((event) => {
5
+ requireMediaCollection()
6
+ return runRelocation(event, (items, body) => ({ type: 'move', items, dest: typeof body.dest === 'string' ? body.dest : '' }))
7
+ })
@@ -0,0 +1,7 @@
1
+ import { runRelocation } from '../../utils/relocate-ops'
2
+ import { requireMediaCollection } from '../../utils/media-enabled'
3
+
4
+ export default defineEventHandler((event) => {
5
+ requireMediaCollection()
6
+ return runRelocation(event, (items, body) => ({ type: 'rename', items, name: typeof body.name === 'string' ? body.name : '' }))
7
+ })
@@ -0,0 +1,13 @@
1
+ import { resolveManyByIds } from '../../utils/resolve'
2
+ import { useStorageDriver } from '../../../../core/server/utils/storage'
3
+ import { primaryLocale } from '../../../../core/server/utils/locale'
4
+ import { requireMediaCollection } from '../../utils/media-enabled'
5
+
6
+ export default defineEventHandler((event) => {
7
+ requireMediaCollection()
8
+ const q = getQuery(event)
9
+ const ids = String(q.ids ?? '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0)
10
+ const locale = typeof q.locale === 'string' ? q.locale : primaryLocale()
11
+ const driver = useStorageDriver()
12
+ return { data: resolveManyByIds(useDb(), ids, locale, (k) => driver.publicUrl(k)) }
13
+ })
@@ -0,0 +1,25 @@
1
+ import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
+ import { defineCollection } from '../../../core/server/utils/defineCollection'
3
+
4
+ // The usage-driven variant REGISTRY: a single-row store of the active image-variant set (name × size ×
5
+ // format presets) that the upload path derives and the generate-time scan reconciles. A singleton so it
6
+ // reuses the schema engine + (later) the singleton editor, but `nav: false` — it is a system/config store,
7
+ // not content, and must not appear as a flat top-level rail item beside the media library. It is surfaced
8
+ // (if at all) through a Media sub-menu once the rail supports nested navigation.
9
+ const built = buildCollection(defineCollection({
10
+ name: 'media_settings',
11
+ mode: 'single',
12
+ translatable: false,
13
+ builtin: true,
14
+ nav: false,
15
+ label: { singular: 'Media settings', plural: 'Media settings' },
16
+ icon: 'sliders',
17
+ fields: {
18
+ // The resolved variant set (`StoredVariant[]`): each entry carries its provenance (`source`/`pinned`)
19
+ // so the scan reconcile can replace discovered entries while preserving hand-authored ones.
20
+ variants: { type: 'json' },
21
+ },
22
+ }))
23
+
24
+ export const mediaSettings = built.table
25
+ export default built
@@ -0,0 +1,29 @@
1
+ import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
+ import { defineCollection } from '../../../core/server/utils/defineCollection'
3
+
4
+ const built = buildCollection(defineCollection({
5
+ name: 'media',
6
+ mode: 'multi',
7
+ translatable: false,
8
+ builtin: true,
9
+ label: { singular: 'Media', plural: 'Media' },
10
+ icon: 'image',
11
+ fields: {
12
+ storageKey: { type: 'text', required: true, unique: true },
13
+ // Indexed: the library lists/filters/rolls-up recursive sizes by folder on every request.
14
+ folder: { type: 'text', index: true },
15
+ filename: { type: 'text', required: true },
16
+ mime: { type: 'text', required: true },
17
+ ext: { type: 'text', required: true },
18
+ size: { type: 'number', required: true },
19
+ width: { type: 'number' },
20
+ height: { type: 'number' },
21
+ checksum: { type: 'text' },
22
+ thumbhash: { type: 'text' },
23
+ derivatives: { type: 'json' },
24
+ translations: { type: 'json' },
25
+ },
26
+ }))
27
+
28
+ export const media = built.table
29
+ export default built
@@ -0,0 +1,10 @@
1
+ import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
+
3
+ // Path-based folder registry: persists folders (incl. empty ones) and is the authoritative
4
+ // tree for media navigation. `path` is the normalized folder path (root '' is never a row).
5
+ export const folders = sqliteTable('folders', {
6
+ id: integer('id').primaryKey({ autoIncrement: true }),
7
+ path: text('path').notNull().unique(),
8
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
9
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
10
+ })
@@ -0,0 +1,37 @@
1
+ import type { H3Event } from 'h3'
2
+ import { deriveOnDemand, variantKeyFromPath } from '../utils/ondemand'
3
+ import { useStorageDriver, mediaRuntimeConfig } from '../../../core/server/utils/storage'
4
+ import { DEFAULT_IMAGE_POLICY } from '../../../core/server/utils/kestrel-config'
5
+
6
+ /**
7
+ * Whether the IP allow-list would deny this request, checked directly rather than relied on by ordering:
8
+ * Nitro sorts middleware by layer scan dir first, and media's precedes access's, so the real
9
+ * access/00.ip-allowlist gate has NOT run yet when this middleware short-circuits with a derived response.
10
+ */
11
+ export function deniedByAllowlist(event: H3Event): boolean {
12
+ const mode = allowlistMode(process.env.KESTREL_IP_ALLOWLIST_MODE, process.env.KESTREL_IP_ALLOWLIST)
13
+ if (mode !== 'enforce') return false
14
+ return !ipAllowed(clientIp(event), parseAllowlist(process.env.KESTREL_IP_ALLOWLIST))
15
+ }
16
+
17
+ // Dev only: a GET for a not-yet-generated variant under the media baseUrl derives it from the original and
18
+ // serves it, so a newly-declared size/format shows in the editor preview without a full publish. On a hit
19
+ // this is a no-op — the static /uploads handler serves the cached file.
20
+ export default defineEventHandler(async (event) => {
21
+ if (!import.meta.dev || event.method !== 'GET') return
22
+ if (deniedByAllowlist(event)) return
23
+ const cfg = mediaRuntimeConfig()
24
+ const key = variantKeyFromPath(event.path, cfg.local.baseUrl)
25
+ if (key == null) return
26
+ const driver = useStorageDriver()
27
+ if (await driver.exists?.(key)) return
28
+ // Dev is the diagnosis environment, so surface a real derive failure (missing original, sharp reject)
29
+ // instead of silently 404ing — a broken preview <img> with no log violates the fail-loud convention.
30
+ const result = await deriveOnDemand(useDb(), driver, cfg.imagePolicy ?? DEFAULT_IMAGE_POLICY, key).catch((error) => {
31
+ console.warn(`[kestrel] on-demand variant derive failed for ${key}:`, (error as Error)?.message ?? error)
32
+ return null
33
+ })
34
+ if (!result) return
35
+ setResponseHeader(event, 'content-type', result.mime)
36
+ return result.bytes
37
+ })
@@ -0,0 +1,19 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import { media as mediaTable } from '../collections/media'
3
+ import { registerFieldPopulator } from '../../../core/server/utils/populate'
4
+ import { buildMediaFieldPopulator } from '../utils/populate'
5
+ import { resolveMedia } from '../utils/resolve'
6
+ import { useStorageDriver } from '../../../core/server/utils/storage'
7
+ import { mediaCollectionEnabled } from '../utils/media-enabled'
8
+
9
+ export default defineNitroPlugin(() => {
10
+ // A consumer who turned the built-in off has no `media` table, so a row that still carries a media id
11
+ // must stay unpopulated rather than blow up the read path.
12
+ if (!mediaCollectionEnabled()) return
13
+ const cols = getTableColumns(mediaTable) as Record<string, never>
14
+ registerFieldPopulator('media', buildMediaFieldPopulator((id, locale) => {
15
+ const driver = useStorageDriver()
16
+ const row = useDb().select().from(mediaTable).where(eq(cols.id, id)).get()
17
+ return row ? resolveMedia(row as never, locale, (k) => driver.publicUrl(k)) : null
18
+ }))
19
+ })
@@ -0,0 +1,20 @@
1
+ import { registerWriteListener } from '../../../core/server/utils/write-events'
2
+ import { planMediaDeletion } from '../utils/media-cleanup'
3
+ import { useStorageDriver } from '../../../core/server/utils/storage'
4
+
5
+ /**
6
+ * Storage GC for media-record deletes. The media library's own `deleteAffected` deletes storage inline
7
+ * (and bypasses the write bus); this covers EVERY OTHER delete path — the generic CRUD `remove()`, which
8
+ * emits a delete event — by removing the row's original + all derivatives from storage. So a media record
9
+ * is never removed while leaving orphaned files: principle 4 (output ≡ DB) for media, on every path.
10
+ * Fire-and-forget (best-effort, like the publisher); the driver's delete is idempotent, errors are logged.
11
+ */
12
+ export default defineNitroPlugin(() => {
13
+ registerWriteListener((event) => {
14
+ const keys = planMediaDeletion(event)
15
+ if (!keys.length) return
16
+ const driver = useStorageDriver()
17
+ void Promise.all(keys.map((key) => driver.delete(key)))
18
+ .catch((error) => console.error('[kestrel] media storage cleanup failed:', error))
19
+ })
20
+ })
@@ -0,0 +1,23 @@
1
+ import { recordVariants, saveDiscoveredVariants } from '../utils/variant-capture'
2
+
3
+ /**
4
+ * Auto-discovery capture. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
5
+ * `event.context.kestrelVariants` during SSR. Renders count when they are either a PUBLISH render (the
6
+ * incremental publisher marks them with the `x-kestrel-publish` header, see publisher.ts `renderRoute`) OR
7
+ * a build-time PRERENDER render (the classic `nuxt generate` topology) — so live preview / stray runtime
8
+ * renders never pollute the scan. For a publish, `publishFull` reconciles the drained accumulator once at
9
+ * the end; the generate topology has no such orchestrator, so each prerendered route reconciles inline
10
+ * WITHOUT draining and the growing accumulator converges to the full used set by the last route.
11
+ *
12
+ * NOTE: relies on `beforeResponse` firing for the publisher's `localFetch` renders / the prerenderer, and on
13
+ * the app→server `event.context` bridge — verified by a real publish/generate, not the headless unit suite.
14
+ */
15
+ export default defineNitroPlugin((nitroApp) => {
16
+ nitroApp.hooks.hook('beforeResponse', (event) => {
17
+ const isPrerender = import.meta.prerender === true
18
+ if (getRequestHeader(event, 'x-kestrel-publish') !== '1' && !isPrerender) return
19
+ const specs = (event.context as { kestrelVariants?: unknown[] }).kestrelVariants
20
+ if (Array.isArray(specs) && specs.length) recordVariants(specs as never)
21
+ if (isPrerender) saveDiscoveredVariants(useDb(), { clear: false })
22
+ })
23
+ })
@@ -0,0 +1,4 @@
1
+ import { folders } from '../database/folders'
2
+
3
+ // Registers the standalone folders table with the schema engine (the `#kestrel/schema-tables` virtual).
4
+ export default folders
@@ -0,0 +1,18 @@
1
+ import { runBackfill } from '../../utils/backfill'
2
+ import { useStorageDriver, mediaRuntimeConfig } from '../../../../core/server/utils/storage'
3
+ import { DEFAULT_IMAGE_POLICY } from '../../../../core/server/utils/kestrel-config'
4
+
5
+ /**
6
+ * Reconcile every media row's derivatives to the active variant registry: generate the missing sizes/formats
7
+ * (from the stored original) and prune the deregistered ones. `{check:true}` is a dry-run (report only).
8
+ * Trigger (like `db:migrate` — no `nuxi task run`): dev `GET /_nitro/tasks/media:backfill`; prod `runTask(
9
+ * 'media:backfill', { payload })` from the admin endpoint (`POST /api/media/backfill`) or a cron scheduledTask.
10
+ */
11
+ export default defineTask({
12
+ meta: { name: 'media:backfill', description: 'Generate missing image variants + prune deregistered ones; {check:true}=dry-run' },
13
+ async run({ payload }) {
14
+ const policy = mediaRuntimeConfig().imagePolicy ?? DEFAULT_IMAGE_POLICY
15
+ const result = await runBackfill(useDb(), useStorageDriver(), policy, { check: !!(payload as { check?: boolean })?.check })
16
+ return { result }
17
+ },
18
+ })