@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,62 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import { media } from '../collections/media'
4
+ import { derivativeKey, withPreservedTranslations, type DerivativeManifest } from './record'
5
+ import type { DerivedImage } from './derive'
6
+ import type { StorageDriver } from '../../../core/server/utils/storage'
7
+ import type { Translations } from './translations'
8
+
9
+ // Long-lived (non-immutable, so a replace isn't pinned) cache for originals + derivatives — effective on
10
+ // the S3 driver, ignored by the local driver (NGINX owns its cache). Mirrors the header in backfill.ts.
11
+ export const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
12
+
13
+ export interface PersistArgs {
14
+ storageKey: string
15
+ bytes: Buffer
16
+ mime: string
17
+ derived?: DerivedImage
18
+ values: Record<string, unknown>
19
+ existing?: { id: number; derivatives?: DerivativeManifest; translations?: Translations }
20
+ overwrite: boolean
21
+ }
22
+
23
+ /**
24
+ * Write the original + derivative objects and insert/update the media row. On the CREATE path (no
25
+ * pre-existing row) a failure at ANY step removes every blob written during the attempt — otherwise a
26
+ * partial write (an S3 hiccup mid-derivative, a lost INSERT race) strands orphan blobs that permanently
27
+ * 409-block the filename (overwrite can't reclaim a row-less key, and no reconcile removes them). The
28
+ * OVERWRITE path leaves the (pre-existing) original in place on failure — it was there before this upload.
29
+ */
30
+ export async function persistUpload(db: BetterSQLite3Database, driver: StorageDriver, args: PersistArgs): Promise<Record<string, unknown>> {
31
+ const { storageKey, bytes, mime, derived, values, existing, overwrite } = args
32
+ const cols = getTableColumns(media) as Record<string, never>
33
+ const isOverwrite = !!(existing && overwrite)
34
+ const derivKeys = (derived?.variants ?? []).map((v) => derivativeKey(storageKey, v.name, v.format))
35
+
36
+ try {
37
+ await driver.put(storageKey, bytes, mime, { cacheControl: MEDIA_CACHE_CONTROL })
38
+ for (const v of derived?.variants ?? []) {
39
+ await driver.put(derivativeKey(storageKey, v.name, v.format), v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
40
+ }
41
+ if (isOverwrite) {
42
+ // Drop any old derivative objects the new manifest no longer references, then update the row.
43
+ const newKeys = new Set(Object.values(values.derivatives as DerivativeManifest).map((d) => d.key))
44
+ for (const old of Object.values(existing!.derivatives ?? {})) {
45
+ if (!newKeys.has(old.key)) await driver.delete(old.key)
46
+ }
47
+ // Re-read translations fresh, right before the merge — `existing` was captured by the caller BEFORE
48
+ // the (potentially slow) driver.put calls above; a concurrent alt-text PATCH landing during that
49
+ // window would otherwise be silently reverted by merging the pre-I/O snapshot.
50
+ const fresh = db.select({ translations: cols.translations }).from(media)
51
+ .where(eq(cols.storageKey, storageKey)).get() as { translations?: Translations } | undefined
52
+ return db.update(media).set({ ...withPreservedTranslations(values, fresh ?? existing), updatedAt: new Date() })
53
+ .where(eq(cols.storageKey, storageKey)).returning().get() as Record<string, unknown>
54
+ }
55
+ return db.insert(media).values(values as never).returning().get() as Record<string, unknown>
56
+ } catch (error) {
57
+ if (!isOverwrite) {
58
+ for (const k of [storageKey, ...derivKeys]) await driver.delete(k).catch(() => {})
59
+ }
60
+ throw error
61
+ }
62
+ }
@@ -0,0 +1,47 @@
1
+ import { fieldIs } from '../../../core/server/utils/defineCollection'
2
+ import type { FieldPopulator } from '../../../core/server/utils/populate'
3
+ import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
4
+ import { memoResolver } from '../../../core/server/utils/resolve-scope'
5
+ import { captureRead } from '../../../core/server/utils/read-capture'
6
+ import type { ResolvedMedia } from './resolve'
7
+
8
+ export type ResolveById = (id: number, locale: string) => ResolvedMedia | null
9
+
10
+ function attach(target: Record<string, unknown>, key: string, value: unknown): void {
11
+ // Clone the existing $media bag rather than mutate it in place: if `target` is a shallow clone that
12
+ // happens to share a pre-existing $media sidecar with the stored row, in-place mutation would leak into
13
+ // the original. No current caller feeds a $media-bearing bag into the walker, but this keeps the
14
+ // populator unconditionally non-destructive (future-proof against a double-populate / pre-seeded bag).
15
+ target.$media = { ...((target.$media as Record<string, unknown> | undefined) ?? {}), [key]: value }
16
+ }
17
+
18
+ /**
19
+ * The `media` field populator: resolves a media id (single, key `${name}Id` in columns / bare `name` in
20
+ * props) or id array (multiple, always bare) to a `ResolvedMedia` under the row/entry's `$media` bag,
21
+ * leaving the raw id column untouched. Registered per-type via `registerFieldPopulator('media', …)`; the
22
+ * shared field-tree walker drives it over top-level fields, block props, and (now) repeater entries.
23
+ */
24
+ export function buildMediaFieldPopulator(resolveById: ResolveById): FieldPopulator {
25
+ // The same media id+locale is resolved once — build-wide during a generate run, request-/publish-run-
26
+ // wide via the resolve scope (which also budgets one live request's distinct fan-out).
27
+ // memoResolver OUTERMOST so a per-scope budget-skip null is never cached build-wide by the prerender memo.
28
+ const key = (id: number, locale: string) => `media:${id}:${locale}`
29
+ const resolve = memoResolver(memoDuringPrerender(resolveById, key), key)
30
+ return (bag, key, field, ctx, keyMode) => {
31
+ if (!fieldIs(field, 'media')) return
32
+ // captureRead runs even on a memo hit (each embedding page must record the dep), so it sits here, not
33
+ // inside `resolve`: it durably ties this render's route to `media:<id>`, so a later media change
34
+ // (rename/move/alt-edit) re-renders the page instead of leaving a stale/broken image in the static output.
35
+ if (!field.options?.multiple) {
36
+ const id = bag[keyMode === 'columns' ? `${key}Id` : key]
37
+ if (typeof id === 'number') { captureRead('media', id); attach(bag, key, resolve(id, ctx.locale)) }
38
+ } else {
39
+ const ids = bag[key]
40
+ if (Array.isArray(ids)) {
41
+ const nums = ids.filter((x): x is number => typeof x === 'number')
42
+ for (const id of nums) captureRead('media', id)
43
+ attach(bag, key, nums.map((id) => resolve(id, ctx.locale)))
44
+ }
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,63 @@
1
+ import type { DerivedImage } from './derive'
2
+ import { mergeTranslations, type Translations } from './translations'
3
+
4
+ export interface DerivativeEntry { key: string; width: number; height: number; mime: string }
5
+ export type DerivativeManifest = Record<string, DerivativeEntry>
6
+
7
+ export interface MediaInput {
8
+ storageKey: string
9
+ folder: string
10
+ filename: string
11
+ mime: string
12
+ ext: string
13
+ size: number
14
+ checksum: string
15
+ derived?: DerivedImage
16
+ translations?: Record<string, { alt?: string; title?: string; description?: string }>
17
+ }
18
+
19
+ /**
20
+ * Object key for a resized/reformatted variant of an original. The stem is the FULL original key
21
+ * (extension included), so two same-stem uploads that differ only in extension — `logo.png` and
22
+ * `logo.jpg`, a designer exporting both formats — never derive the same key and overwrite/cross-delete
23
+ * each other's variants. `storageKey` is unique, so this stays injective per original.
24
+ */
25
+ export function derivativeKey(originalKey: string, name: string, format: string): string {
26
+ return `${originalKey}-${name}.${format}`
27
+ }
28
+
29
+ export function buildMediaValues(input: MediaInput): Record<string, unknown> {
30
+ const manifest: DerivativeManifest = {}
31
+ const src = input.derived
32
+ for (const v of src?.variants ?? []) {
33
+ // Name-keyed (`<name>.<format>`); height is the variant's REAL output dim (crops break the aspect ratio).
34
+ manifest[`${v.name}.${v.format}`] = { key: derivativeKey(input.storageKey, v.name, v.format), width: v.width, height: v.height, mime: v.mime }
35
+ }
36
+ return {
37
+ storageKey: input.storageKey,
38
+ folder: input.folder || null,
39
+ filename: input.filename,
40
+ mime: input.mime,
41
+ ext: input.ext,
42
+ size: input.size,
43
+ width: input.derived?.width ?? null,
44
+ height: input.derived?.height ?? null,
45
+ checksum: input.checksum,
46
+ thumbhash: input.derived?.thumbhash ?? null,
47
+ derivatives: manifest,
48
+ translations: input.translations ?? {},
49
+ }
50
+ }
51
+
52
+ /**
53
+ * On overwrite (re-upload of an existing storageKey) the multipart request rarely re-sends
54
+ * alt/title/description — the conflict-dialog overwrite path never does — so a bare re-upload must not
55
+ * wipe the per-locale metadata editors maintain via the viewer. Merge any incoming fields over the
56
+ * existing map (same semantics as the PATCH endpoint); an empty incoming map leaves the existing intact.
57
+ */
58
+ export function withPreservedTranslations(
59
+ values: Record<string, unknown>,
60
+ existing: { translations?: Translations } | undefined,
61
+ ): Record<string, unknown> {
62
+ return { ...values, translations: mergeTranslations(existing?.translations, (values.translations ?? {}) as Translations) }
63
+ }
@@ -0,0 +1,341 @@
1
+ import { eq, inArray, getTableColumns, sql } from 'drizzle-orm'
2
+ import { createError, type H3Event } from 'h3'
3
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
+ import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
5
+ import { media } from '../collections/media'
6
+ import { folders } from '../database/folders'
7
+ import { isUnder, parentOf, childName, rewritePrefix } from './folder-paths'
8
+ import { sanitizeFolder, buildKey, suggestFreeName } from './naming'
9
+ import { planRelocateInto, planRename, type ItemPlan } from './relocate-plan'
10
+ import { relocateMedia, duplicateMedia } from './storage-relocate'
11
+ import { ensureFolder } from './folders'
12
+ import { collectAffected, deleteAffected, type OpItem, type AffectedSet } from './media-ops'
13
+ import { useStorageDriver, type StorageDriver } from '../../../core/server/utils/storage'
14
+ import { escapeLike } from '../../../core/server/utils/sql'
15
+
16
+ export type OpType = 'move' | 'copy' | 'rename'
17
+ export interface MediaOp { type: OpType; items: OpItem[]; dest?: string; name?: string }
18
+ export interface Conflict { item: OpItem; targetPath: string; type: 'file-exists' | 'folder-exists' }
19
+ export interface RelocationReport { summary: { files: number; folders: number; totalBytes: number }; conflicts: Conflict[] }
20
+
21
+ /** Parse untrusted request input into well-formed op items (drops malformed entries). */
22
+ export function coerceOpItems(raw: unknown): OpItem[] {
23
+ if (!Array.isArray(raw)) return []
24
+ const out: OpItem[] = []
25
+ for (const it of raw) {
26
+ if (!it || typeof it !== 'object') continue
27
+ const o = it as Record<string, unknown>
28
+ if (o.type === 'file' && Number.isInteger(o.id)) out.push({ type: 'file', id: o.id as number })
29
+ else if (o.type === 'folder' && typeof o.path === 'string') {
30
+ // Sanitize at the boundary so the disk cascade (removeDir) targets the same normalized path
31
+ // the DB cascade matches — a raw `..` would diverge them. Empty == root, not a valid item.
32
+ const path = sanitizeFolder(o.path)
33
+ if (path) out.push({ type: 'folder', path })
34
+ }
35
+ }
36
+ return out
37
+ }
38
+
39
+ /** Resolve one op item into its affected set + relocation plan (shared by preview and execute). */
40
+ export function planItem(db: BetterSQLite3Database, op: MediaOp, item: OpItem): { affected: AffectedSet; plan: ItemPlan } {
41
+ const affected = collectAffected(db, [item])
42
+ const plan = op.type === 'rename'
43
+ ? planRename(item, affected, op.name ?? '')
44
+ : planRelocateInto(item, affected, op.dest ?? '')
45
+ return { affected, plan }
46
+ }
47
+
48
+ /** Reject malformed ops before any work: empty items, bad rename args, folder-into-itself moves. */
49
+ export function validateOp(op: MediaOp): void {
50
+ if (!op.items.length) throw createError({ statusCode: 400, statusMessage: 'No items' })
51
+ if (op.type === 'rename') {
52
+ if (op.items.length !== 1 || !op.name) throw createError({ statusCode: 400, statusMessage: 'Rename needs exactly one item and a name' })
53
+ return
54
+ }
55
+ const dest = sanitizeFolder(op.dest ?? '')
56
+ for (const item of op.items) {
57
+ if (item.type === 'folder' && isUnder(item.path, dest)) {
58
+ throw createError({ statusCode: 400, statusMessage: 'Cannot move a folder into itself' })
59
+ }
60
+ }
61
+ }
62
+
63
+ /** Wave 1 (dryRun): plan the op, then report impact + target-occupancy conflicts. Mutates nothing. */
64
+ export function previewRelocation(db: BetterSQLite3Database, op: MediaOp): RelocationReport {
65
+ validateOp(op)
66
+ const planned = op.items.map((item) => ({ item, ...planItem(db, op, item) }))
67
+
68
+ const summary = {
69
+ files: planned.reduce((n, p) => n + p.affected.media.length, 0),
70
+ folders: planned.reduce((n, p) => n + p.affected.folders.length, 0),
71
+ totalBytes: planned.reduce((n, p) => n + p.affected.media.reduce((s, m) => s + (m.size || 0), 0), 0),
72
+ }
73
+
74
+ // Bounded index lookups instead of materializing the whole media/folders tables: this runs once per
75
+ // item during execute for any onConflict other than 'abort' (applyStrategy re-previews in isolation),
76
+ // so a full scan here is O(items × table size) on a bulk move/copy. Both probed columns are uniquely
77
+ // indexed, so only the keys/paths THIS op's own items could possibly land on ever need checking —
78
+ // the loop below never queries either Set outside that set.
79
+ const probeKeys = new Set<string>()
80
+ const probeRoots = new Set<string>()
81
+ for (const { item, plan } of planned) {
82
+ if (item.type === 'file') {
83
+ const k = plan.media[0]?.toKey
84
+ if (k) probeKeys.add(k)
85
+ } else {
86
+ probeRoots.add(plan.toRoot)
87
+ probeKeys.add(plan.toRoot)
88
+ for (const m of plan.media) probeKeys.add(m.toKey)
89
+ }
90
+ }
91
+ const allKeys = probeKeys.size
92
+ ? new Set((db.select({ k: getTableColumns(media).storageKey }).from(media as AnySQLiteTable)
93
+ .where(inArray(getTableColumns(media).storageKey, [...probeKeys])).all() as { k: string }[]).map((r) => r.k))
94
+ : new Set<string>()
95
+ const allFolderPaths = probeRoots.size
96
+ ? new Set(db.select({ path: folders.path }).from(folders).where(inArray(folders.path, [...probeRoots])).all().map((f) => f.path))
97
+ : new Set<string>()
98
+
99
+ // Preview frees all source keys set-wise (order-independent), but execute vacates them one at a time — so a key-rotation batch (move A onto B's key while moving B) can pass preview yet hit relocateMedia's per-key 409 mid-execute; acceptable under the per-item compensating model.
100
+ const frees = op.type === 'copy' ? { keys: new Set<string>(), folders: new Set<string>() } : {
101
+ keys: new Set(planned.flatMap((p) => p.plan.media.map((m) => m.fromKey))),
102
+ folders: new Set(planned.flatMap((p) => p.affected.folders)),
103
+ }
104
+ const isOccupiedKey = (key: string) => allKeys.has(key) && !frees.keys.has(key)
105
+ const isOccupiedFolder = (path: string) => allFolderPaths.has(path) && !frees.folders.has(path)
106
+
107
+ // Targets claimed by earlier items in this same op, so two items whose new
108
+ // targets land on the same slot collide (the later one is flagged).
109
+ const claimedKeys = new Set<string>()
110
+ const claimedRoots = new Set<string>()
111
+
112
+ const conflicts: Conflict[] = []
113
+ for (const { item, plan } of planned) {
114
+ if (item.type === 'file') {
115
+ const toKey = plan.media[0]?.toKey
116
+ if (toKey && (isOccupiedKey(toKey) || claimedKeys.has(toKey))) conflicts.push({ item, targetPath: toKey, type: 'file-exists' })
117
+ // folder-exists ⇒ the destination subtree is occupied: an existing folder row, a FILE whose key equals the
118
+ // target root (a folder onto a file would mkdir over a file → 500 / DB-vs-disk desync), a descendant file
119
+ // key, or an intra-batch claim.
120
+ } else if (isOccupiedFolder(plan.toRoot) || isOccupiedKey(plan.toRoot) || claimedRoots.has(plan.toRoot) || claimedKeys.has(plan.toRoot) || plan.media.some((m) => isOccupiedKey(m.toKey) || claimedKeys.has(m.toKey))) {
121
+ conflicts.push({ item, targetPath: plan.toRoot, type: 'folder-exists' })
122
+ }
123
+ if (item.type === 'folder') claimedRoots.add(plan.toRoot)
124
+ for (const m of plan.media) claimedKeys.add(m.toKey)
125
+ }
126
+ return { summary, conflicts }
127
+ }
128
+
129
+ export interface OpResult {
130
+ item: OpItem
131
+ status: 'moved' | 'copied' | 'renamed' | 'skipped' | 'overwritten' | 'renamed-auto'
132
+ newPath?: string
133
+ }
134
+
135
+ /** Move/rename folder-item branch: repoint the folder rows in one transaction. */
136
+ function relocateFolderRows(db: BetterSQLite3Database, plan: ItemPlan): void {
137
+ db.transaction(() => {
138
+ // Ensure the destination's PARENT chain (not toRoot) first: toRoot is created by re-pathing
139
+ // the source root row, so pre-inserting it would clash on the folders UNIQUE(path).
140
+ ensureFolder(db, parentOf(plan.toRoot) ?? '')
141
+ for (const f of plan.folders) db.update(folders).set({ path: f.to, updatedAt: new Date() }).where(eq(folders.path, f.from)).run()
142
+ })
143
+ }
144
+
145
+ /** Copy folder-item branch: create the destination folder rows. */
146
+ function createCopiedFolderRows(db: BetterSQLite3Database, plan: ItemPlan): void {
147
+ for (const f of plan.folders) ensureFolder(db, f.to)
148
+ }
149
+
150
+ /** An item's result plus the media ids it actually LANDED on the target: for a copy those are the rows
151
+ * `duplicateMedia` created (the source rows never move), for a move/rename the source rows themselves.
152
+ * Internal to the batch loop — the wire shape stays `OpResult`. */
153
+ type ExecutedItem = OpResult & { landedIds: number[] }
154
+
155
+ /** Execute one already-planned item: relocate/duplicate its media rows and repoint/create its
156
+ * folder rows. No-op items short-circuit to 'skipped'. */
157
+ async function executeItem(
158
+ db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp, item: OpItem, plan: ItemPlan,
159
+ ): Promise<ExecutedItem> {
160
+ if (plan.media.every((m) => m.toKey === m.fromKey) && plan.folders.every((f) => f.from === f.to)) {
161
+ return { item, status: 'skipped', landedIds: [] }
162
+ }
163
+
164
+ if (op.type === 'copy') {
165
+ const landedIds: number[] = []
166
+ for (const m of plan.media) landedIds.push((await duplicateMedia(db, driver, m.id, { folder: m.toFolder, filename: m.toFilename })).id)
167
+ if (item.type === 'folder') {
168
+ createCopiedFolderRows(db, plan)
169
+ for (const f of plan.folders) await driver.ensureDir?.(f.to)
170
+ } else ensureFolder(db, plan.toRoot)
171
+ return { item, status: 'copied', newPath: item.type === 'folder' ? plan.toRoot : plan.media[0].toKey, landedIds }
172
+ }
173
+
174
+ for (const m of plan.media) await relocateMedia(db, driver, m.id, { folder: m.toFolder, filename: m.toFilename })
175
+ if (item.type === 'folder') {
176
+ relocateFolderRows(db, plan)
177
+ for (const f of plan.folders) await driver.ensureDir?.(f.to) // dest dirs incl. empty subfolders
178
+ // Clean the vacated source subtree ONLY when nothing unmanaged remains — removeDir is a recursive wipe,
179
+ // so a blind call would destroy non-media objects (extension/consumer blobs) living under the same path.
180
+ const remaining = driver.listPrefix ? await driver.listPrefix(item.path) : ['guard']
181
+ if (remaining.length === 0) await driver.removeDir?.(item.path)
182
+ } else ensureFolder(db, plan.toRoot)
183
+ return {
184
+ item, status: op.type === 'rename' ? 'renamed' : 'moved',
185
+ newPath: item.type === 'folder' ? plan.toRoot : plan.media[0].toKey,
186
+ landedIds: plan.media.map((m) => m.id),
187
+ }
188
+ }
189
+
190
+ /** Wave 2 (execute): re-validate (TOCTOU), then move/copy/rename each item in order, re-planning
191
+ * per item against the now-mutated db. `abort` rejects 409 up front if any conflict exists. */
192
+ export async function executeRelocation(
193
+ db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp,
194
+ onConflict: 'abort' | 'skip' | 'overwrite' | 'rename' = 'abort',
195
+ ): Promise<OpResult[]> {
196
+ validateOp(op)
197
+ const report = previewRelocation(db, op)
198
+ if (onConflict === 'abort' && report.conflicts.length) {
199
+ throw createError({ statusCode: 409, statusMessage: 'Conflicts prevent the operation', data: report })
200
+ }
201
+
202
+ // Ids this batch has already relocated ONTO their target — so a later item's 'overwrite' never treats
203
+ // an earlier item's freshly-landed row as a stale occupant to destroy (see deleteOccupant below).
204
+ const relocatedIds = new Set<number>()
205
+ const results: OpResult[] = []
206
+ for (const item of op.items) {
207
+ const { landedIds, ...result } = await applyStrategy(db, driver, op, item, onConflict, relocatedIds)
208
+ results.push(result)
209
+ for (const id of landedIds) relocatedIds.add(id)
210
+ }
211
+ return results
212
+ }
213
+
214
+ /**
215
+ * Shared body for the move/copy/rename POST handlers (they differ only in the op shape): auth backstop,
216
+ * parse + non-empty-check the items, build the op via `buildOp`, parse `onConflict`, then dispatch a
217
+ * dry-run preview vs a real execute. Each route becomes a one-liner supplying only its op.
218
+ */
219
+ export async function runRelocation(event: H3Event, buildOp: (items: OpItem[], body: Record<string, unknown>) => MediaOp) {
220
+ requireAdmin(event)
221
+ const body = (await readBody(event)) as Record<string, unknown> | null
222
+ const items = coerceOpItems(body?.items)
223
+ if (!items.length) throw createError({ statusCode: 400, statusMessage: 'No items' })
224
+ const op = buildOp(items, body ?? {})
225
+ const oc = body?.onConflict
226
+ const onConflict = oc === 'skip' || oc === 'overwrite' || oc === 'rename' ? oc : 'abort'
227
+ const db = useDb()
228
+ if (body?.dryRun === true) return previewRelocation(db, op)
229
+ return executeRelocation(db, useStorageDriver(), op, onConflict)
230
+ }
231
+
232
+ /** Per-item conflict dispatch. Re-checks this item's status against the live db (earlier items in
233
+ * the batch are already committed), then resolves per `onConflict`. No-Merge: a folder conflict is
234
+ * handled at the subtree root (skip/overwrite/rename the whole subtree, never per file). */
235
+ async function applyStrategy(
236
+ db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp, item: OpItem,
237
+ onConflict: 'abort' | 'skip' | 'overwrite' | 'rename', relocatedIds: Set<number>,
238
+ ): Promise<ExecutedItem> {
239
+ const conflicts = onConflict === 'abort' ? [] : previewRelocation(db, { ...op, items: [item] }).conflicts
240
+ const { plan } = planItem(db, op, item)
241
+
242
+ if (conflicts.length) {
243
+ if (onConflict === 'skip') return { item, status: 'skipped', landedIds: [] }
244
+ if (onConflict === 'overwrite') {
245
+ // Copy/paste into the file's own folder: the "occupant" of the target key IS the source.
246
+ // Overwriting a file with a copy of itself is a no-op — deleting the occupant here would
247
+ // destroy the only copy (executeItem then short-circuits as a no-op and copies nothing).
248
+ const isNoop = plan.media.every((m) => m.toKey === m.fromKey) && plan.folders.every((f) => f.from === f.to)
249
+ if (isNoop) return { item, status: 'skipped', landedIds: [] }
250
+ // The per-item re-preview above sees only the LIVE db, so it can't tell a genuine pre-existing
251
+ // occupant from an earlier item in THIS SAME batch that already landed there — deleting the latter
252
+ // would silently destroy real data this very operation just placed, one item after reporting it
253
+ // as a success. Refuse and skip this item instead (its source is left untouched).
254
+ if (!(await deleteOccupant(db, driver, item, plan, relocatedIds))) return { item, status: 'skipped', landedIds: [] }
255
+ const r = await executeItem(db, driver, op, item, plan)
256
+ return { ...r, status: 'overwritten' }
257
+ }
258
+ const freePlan = freeRenamePlan(db, item, plan)
259
+ const r = await executeItem(db, driver, op, item, freePlan)
260
+ const newPath = item.type === 'file' ? freePlan.media[0]?.toKey : freePlan.toRoot
261
+ return { ...r, status: 'renamed-auto', newPath }
262
+ }
263
+
264
+ return executeItem(db, driver, op, item, plan)
265
+ }
266
+
267
+ /** True when no media row currently occupies this storage key. */
268
+ function keyIsFree(db: BetterSQLite3Database, key: string): boolean {
269
+ const cols = getTableColumns(media)
270
+ const row = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, key)).get()
271
+ return !row
272
+ }
273
+
274
+ /** True when no folders row has this path, no FILE sits exactly at it, AND no media key sits under it. */
275
+ function folderRootIsFree(db: BetterSQLite3Database, path: string): boolean {
276
+ const cols = getTableColumns(media)
277
+ const folderRow = db.select({ path: folders.path }).from(folders).where(eq(folders.path, path)).get()
278
+ if (folderRow) return false
279
+ // A file whose storageKey EQUALS this path blocks the folder root too — a folder can't live where a file
280
+ // already is (mkdir over a file 500s; the per-media guard only checks descendant keys, never the root).
281
+ const fileAtRoot = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, path)).get()
282
+ if (fileAtRoot) return false
283
+ const descendant = db.select({ id: cols.id }).from(media as AnySQLiteTable)
284
+ .where(sql`${cols.storageKey} like ${`${escapeLike(path)}/%`} escape '\\'`).get()
285
+ return !descendant
286
+ }
287
+
288
+ /** Delete whatever currently occupies the item's target so the move/copy can proceed. No-op if free.
289
+ * Returns false (refuses to delete) when the occupant is itself a media id THIS SAME BATCH already
290
+ * relocated onto that target — an intra-batch name collision, not a stale pre-existing occupant. */
291
+ async function deleteOccupant(
292
+ db: BetterSQLite3Database, driver: StorageDriver, item: OpItem, plan: ItemPlan, relocatedIds: Set<number>,
293
+ ): Promise<boolean> {
294
+ // Occupant is deleted before the move commits, so a runtime fault mid-move loses it — inherent to overwrite.
295
+ if (item.type === 'file') {
296
+ const key = plan.media[0]?.toKey
297
+ if (!key) return true
298
+ const cols = getTableColumns(media)
299
+ const row = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, key)).get() as { id: number } | undefined
300
+ if (row) {
301
+ if (relocatedIds.has(row.id)) return false
302
+ await deleteAffected(db, driver, [{ type: 'file', id: row.id }], false)
303
+ }
304
+ return true
305
+ }
306
+ const occupant = collectAffected(db, [{ type: 'folder', path: plan.toRoot }])
307
+ if (occupant.media.some((m) => relocatedIds.has(m.id))) return false
308
+ await deleteAffected(db, driver, [{ type: 'folder', path: plan.toRoot }], false)
309
+ return true
310
+ }
311
+
312
+ /** Build a plan whose target is free, for the rename strategy (file → freed filename; folder → freed root). */
313
+ function freeRenamePlan(db: BetterSQLite3Database, item: OpItem, plan: ItemPlan): ItemPlan {
314
+ if (item.type === 'file') {
315
+ const m = plan.media[0]
316
+ const name = suggestFreeName(m.toFilename, (n) => keyIsFree(db, buildKey(m.toFolder, n)))
317
+ return { ...plan, media: [{ ...m, toFilename: name, toKey: buildKey(m.toFolder, name) }] }
318
+ }
319
+ const freeRoot = freeFolderRoot(db, plan.toRoot)
320
+ return rebasePlan(plan, plan.toRoot, freeRoot)
321
+ }
322
+
323
+ /** Pick a fresh sibling segment for a colliding folder root: `archive/sub` → `archive/sub-2`. */
324
+ function freeFolderRoot(db: BetterSQLite3Database, toRoot: string): string {
325
+ const parent = parentOf(toRoot) ?? ''
326
+ const seg = childName(toRoot)
327
+ const freeSeg = suggestFreeName(seg, (s) => folderRootIsFree(db, parent === '' ? s : `${parent}/${s}`))
328
+ return parent === '' ? freeSeg : `${parent}/${freeSeg}`
329
+ }
330
+
331
+ /** Re-base every target in a folder plan from `from` onto `to` (the whole subtree moves under the new root). */
332
+ function rebasePlan(plan: ItemPlan, from: string, to: string): ItemPlan {
333
+ return {
334
+ toRoot: to,
335
+ media: plan.media.map((m) => {
336
+ const toFolder = rewritePrefix(m.toFolder, from, to)
337
+ return { ...m, toFolder, toKey: buildKey(toFolder, m.toFilename) }
338
+ }),
339
+ folders: plan.folders.map((f) => ({ from: f.from, to: rewritePrefix(f.to, from, to) })),
340
+ }
341
+ }
@@ -0,0 +1,63 @@
1
+ import { createError } from 'h3'
2
+ import { sanitizeFolder, buildKey, withExtension, extensionOf } from './naming'
3
+ import { childName, parentOf, rewritePrefix } from './folder-paths'
4
+ import type { OpItem, AffectedSet, AffectedMedia } from './media-ops'
5
+
6
+ export interface PlannedMedia {
7
+ id: number
8
+ fromKey: string
9
+ toFolder: string
10
+ toFilename: string
11
+ toKey: string
12
+ }
13
+ export interface PlannedFolder { from: string; to: string }
14
+ export interface ItemPlan { toRoot: string; media: PlannedMedia[]; folders: PlannedFolder[] }
15
+
16
+ /** A rename must not introduce a path separator: take the last clean segment, never empty. */
17
+ function sanitizeSegment(name: string): string {
18
+ const seg = childName(sanitizeFolder(name))
19
+ if (!seg) throw createError({ statusCode: 400, statusMessage: 'Invalid name' })
20
+ return seg
21
+ }
22
+
23
+ const planMedia = (m: AffectedMedia, toFolder: string): PlannedMedia => ({
24
+ id: m.id, fromKey: m.storageKey, toFolder, toFilename: m.filename, toKey: buildKey(toFolder, m.filename),
25
+ })
26
+
27
+ /** Re-base every affected media row + folder path from the `from` subtree onto `to`. */
28
+ function cascade(affected: AffectedSet, from: string, to: string): Pick<ItemPlan, 'media' | 'folders'> {
29
+ return {
30
+ media: affected.media.map((m) => planMedia(m, rewritePrefix(m.folder ?? '', from, to))),
31
+ folders: affected.folders.map((fp) => ({ from: fp, to: rewritePrefix(fp, from, to) })),
32
+ }
33
+ }
34
+
35
+ /** Targets for moving/copying an item into `dest` (file → dest unchanged; folder → dest/<name>). */
36
+ export function planRelocateInto(item: OpItem, affected: AffectedSet, dest: string): ItemPlan {
37
+ const to = sanitizeFolder(dest)
38
+ if (item.type === 'file') {
39
+ const m = affected.media[0]
40
+ if (!m) throw createError({ statusCode: 404, statusMessage: 'Media not found' })
41
+ return { toRoot: to, media: [planMedia(m, to)], folders: [] }
42
+ }
43
+ const toRoot = to === '' ? childName(item.path) : `${to}/${childName(item.path)}`
44
+ return { toRoot, ...cascade(affected, item.path, toRoot) }
45
+ }
46
+
47
+ /** Targets for renaming an item in place (file → new filename; folder → new last segment). */
48
+ export function planRename(item: OpItem, affected: AffectedSet, name: string): ItemPlan {
49
+ if (item.type === 'file') {
50
+ const m = affected.media[0]
51
+ if (!m) throw createError({ statusCode: 404, statusMessage: 'Media not found' })
52
+ const toFolder = m.folder ?? ''
53
+ const toFilename = withExtension(name, extensionOf(m.filename))
54
+ return {
55
+ toRoot: toFolder, folders: [],
56
+ media: [{ id: m.id, fromKey: m.storageKey, toFolder, toFilename, toKey: buildKey(toFolder, toFilename) }],
57
+ }
58
+ }
59
+ const parent = parentOf(item.path) ?? ''
60
+ const seg = sanitizeSegment(name)
61
+ const toRoot = parent === '' ? seg : `${parent}/${seg}`
62
+ return { toRoot, ...cascade(affected, item.path, toRoot) }
63
+ }
@@ -0,0 +1,86 @@
1
+ import { inArray, getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import { primaryLocale } from '../../../core/server/utils/locale'
4
+ import { media } from '../collections/media'
5
+ import type { DerivativeManifest } from './record'
6
+
7
+ /** One derivative, tagged with its parsed name + format so `<picture>` rendering can group by format. */
8
+ export interface MediaVariant { name: string; format: string; url: string; width: number; height: number }
9
+
10
+ export interface ResolvedMedia {
11
+ id: number
12
+ /** The media's storage folder ('' at the media root) — lets the field open the picker there. */
13
+ folder: string
14
+ alt: string | null
15
+ title: string | null
16
+ description: string | null
17
+ mime: string
18
+ width: number | null
19
+ height: number | null
20
+ thumbhash: string | null
21
+ src: string
22
+ /** WebP-only responsive srcset (kept for the admin thumb/grid/field/viewer `<img>`s). */
23
+ srcset: { url: string; width: number }[]
24
+ /** Every derivative, name+format-tagged — the source `KestrelImg`/`useMediaVariant` build `<picture>` from. */
25
+ variants: MediaVariant[]
26
+ }
27
+
28
+ interface MediaRow {
29
+ id: number
30
+ folder: string | null
31
+ storageKey: string
32
+ mime: string
33
+ width: number | null
34
+ height: number | null
35
+ thumbhash: string | null
36
+ derivatives: DerivativeManifest | null
37
+ translations: Record<string, { alt?: string; title?: string; description?: string }> | null
38
+ }
39
+
40
+ export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: string) => string): ResolvedMedia {
41
+ // Per-FIELD locale fallback, not whole-locale: a locale entry that carries e.g. only `title` (the PATCH
42
+ // API creates partial per-locale objects) must not suppress the primary's `alt` — that silently drops
43
+ // accessibility text on that locale's pages. Each field falls back to the primary locale independently.
44
+ const loc = row.translations?.[locale] ?? {}
45
+ const prim = row.translations?.[primaryLocale()] ?? {}
46
+ const t = { alt: loc.alt ?? prim.alt, title: loc.title ?? prim.title, description: loc.description ?? prim.description }
47
+ // Every derivative, name+format-tagged (the manifest key is `<name>.<format>`; names are dot-free so the
48
+ // last dot splits cleanly). `srcset` is the webp slice, kept for the admin `<img>`s.
49
+ const variants = Object.entries(row.derivatives ?? {}).map(([mk, d]) => {
50
+ const dot = mk.lastIndexOf('.')
51
+ const name = dot > 0 ? mk.slice(0, dot) : mk
52
+ const format = dot > 0 ? mk.slice(dot + 1) : (d.mime.split('/').pop() ?? '')
53
+ return { name, format, url: publicUrl(d.key), width: d.width, height: d.height }
54
+ })
55
+ const srcset = variants
56
+ .filter((v) => v.format === 'webp')
57
+ .map((v) => ({ url: v.url, width: v.width }))
58
+ .sort((a, b) => a.width - b.width)
59
+ return {
60
+ id: row.id,
61
+ folder: row.folder ?? '', // root files store folder NULL → ''
62
+ alt: t.alt ?? null,
63
+ title: t.title ?? null,
64
+ description: t.description ?? null,
65
+ mime: row.mime,
66
+ width: row.width,
67
+ height: row.height,
68
+ thumbhash: row.thumbhash,
69
+ src: publicUrl(row.storageKey),
70
+ srcset,
71
+ variants,
72
+ }
73
+ }
74
+
75
+ /** Reorder rows to match the requested id order, dropping ids that resolved to nothing. */
76
+ export function orderById<T extends { id: number }>(ids: number[], rows: T[]): T[] {
77
+ const byId = new Map(rows.map((r) => [r.id, r]))
78
+ return ids.map((id) => byId.get(id)).filter((r): r is T => !!r)
79
+ }
80
+
81
+ export function resolveManyByIds(db: BetterSQLite3Database, ids: number[], locale: string, publicUrl: (key: string) => string): ResolvedMedia[] {
82
+ if (!ids.length) return []
83
+ const cols = getTableColumns(media) as Record<string, never>
84
+ const rows = db.select().from(media).where(inArray(cols.id, ids)).all() as MediaRow[]
85
+ return orderById(ids, rows.map((r) => resolveMedia(r, locale, publicUrl)))
86
+ }