opacacms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (399) hide show
  1. package/bun.lock +34 -0
  2. package/dist/admin/api-client.d.ts +8 -0
  3. package/dist/admin/auth-client.d.ts +940 -0
  4. package/dist/admin/custom-field.d.ts +71 -0
  5. package/dist/admin/index.d.ts +11 -0
  6. package/dist/admin/react.d.ts +3 -0
  7. package/dist/admin/router.d.ts +7 -0
  8. package/dist/admin/stores/admin-queries.d.ts +32 -0
  9. package/dist/admin/stores/auth.d.ts +33 -0
  10. package/dist/admin/stores/column-visibility.d.ts +21 -0
  11. package/dist/admin/stores/config.d.ts +7 -0
  12. package/dist/admin/stores/media.d.ts +44 -0
  13. package/dist/admin/stores/query.d.ts +4 -0
  14. package/dist/admin/stores/ui.d.ts +11 -0
  15. package/dist/admin/ui/admin-client.d.ts +7 -0
  16. package/dist/admin/ui/admin-layout.d.ts +14 -0
  17. package/dist/admin/ui/components/ColumnVisibilityToggle.d.ts +10 -0
  18. package/dist/admin/ui/components/DataDetailSheet.d.ts +13 -0
  19. package/dist/admin/ui/components/DataDetailView.d.ts +9 -0
  20. package/dist/admin/ui/components/Table.d.ts +10 -0
  21. package/dist/admin/ui/components/fields/ArrayField.d.ts +13 -0
  22. package/dist/admin/ui/components/fields/BlocksField.d.ts +17 -0
  23. package/dist/admin/ui/components/fields/BooleanField.d.ts +13 -0
  24. package/dist/admin/ui/components/fields/CollapsibleField.d.ts +16 -0
  25. package/dist/admin/ui/components/fields/DateField.d.ts +13 -0
  26. package/dist/admin/ui/components/fields/FileField.d.ts +23 -0
  27. package/dist/admin/ui/components/fields/GroupField.d.ts +13 -0
  28. package/dist/admin/ui/components/fields/JoinField.d.ts +15 -0
  29. package/dist/admin/ui/components/fields/NumberField.d.ts +14 -0
  30. package/dist/admin/ui/components/fields/RadioField.d.ts +17 -0
  31. package/dist/admin/ui/components/fields/RelationshipField.d.ts +16 -0
  32. package/dist/admin/ui/components/fields/RowField.d.ts +12 -0
  33. package/dist/admin/ui/components/fields/SelectField.d.ts +18 -0
  34. package/dist/admin/ui/components/fields/TabsField.d.ts +15 -0
  35. package/dist/admin/ui/components/fields/TextAreaField.d.ts +14 -0
  36. package/dist/admin/ui/components/fields/TextField.d.ts +14 -0
  37. package/dist/admin/ui/components/fields/VirtualField.d.ts +8 -0
  38. package/dist/admin/ui/components/fields/index.d.ts +28 -0
  39. package/dist/admin/ui/components/fields/richtext-editor/index.d.ts +10 -0
  40. package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.d.ts +7 -0
  41. package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageNode.d.ts +27 -0
  42. package/dist/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.d.ts +1 -0
  43. package/dist/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.d.ts +5 -0
  44. package/dist/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.d.ts +1 -0
  45. package/dist/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.d.ts +1 -0
  46. package/dist/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.d.ts +5 -0
  47. package/dist/admin/ui/components/fields/utils.d.ts +1 -0
  48. package/dist/admin/ui/components/link.d.ts +8 -0
  49. package/dist/admin/ui/components/media/AssetManagerModal.d.ts +17 -0
  50. package/dist/admin/ui/components/toast.d.ts +10 -0
  51. package/dist/admin/ui/components/ui/accordion.d.ts +11 -0
  52. package/dist/admin/ui/components/ui/alert-dialog.d.ts +12 -0
  53. package/dist/admin/ui/components/ui/blocks.d.ts +5 -0
  54. package/dist/admin/ui/components/ui/breadcrumbs.d.ts +7 -0
  55. package/dist/admin/ui/components/ui/button.d.ts +7 -0
  56. package/dist/admin/ui/components/ui/collapsible.d.ts +16 -0
  57. package/dist/admin/ui/components/ui/dialog.d.ts +27 -0
  58. package/dist/admin/ui/components/ui/group.d.ts +6 -0
  59. package/dist/admin/ui/components/ui/index.d.ts +17 -0
  60. package/dist/admin/ui/components/ui/input.d.ts +5 -0
  61. package/dist/admin/ui/components/ui/join.d.ts +7 -0
  62. package/dist/admin/ui/components/ui/label.d.ts +3 -0
  63. package/dist/admin/ui/components/ui/radio-group.d.ts +13 -0
  64. package/dist/admin/ui/components/ui/relationship-detail-sheet.d.ts +9 -0
  65. package/dist/admin/ui/components/ui/relationship.d.ts +8 -0
  66. package/dist/admin/ui/components/ui/scroll-area.d.ts +7 -0
  67. package/dist/admin/ui/components/ui/select.d.ts +37 -0
  68. package/dist/admin/ui/components/ui/separator.d.ts +8 -0
  69. package/dist/admin/ui/components/ui/sheet.d.ts +28 -0
  70. package/dist/admin/ui/components/ui/tabs.d.ts +17 -0
  71. package/dist/admin/ui/components/ui/utils.d.ts +1 -0
  72. package/dist/admin/ui/hooks/use-debounce.d.ts +1 -0
  73. package/dist/admin/ui/views/collection-list-view.d.ts +5 -0
  74. package/dist/admin/ui/views/dashboard-view.d.ts +10 -0
  75. package/dist/admin/ui/views/document-edit-view.d.ts +7 -0
  76. package/dist/admin/ui/views/global-edit-view.d.ts +19 -0
  77. package/dist/admin/ui/views/init-view.d.ts +4 -0
  78. package/dist/admin/ui/views/login-view.d.ts +4 -0
  79. package/dist/admin/ui/views/media-registry-view.d.ts +7 -0
  80. package/dist/admin/ui/views/settings-view.d.ts +7 -0
  81. package/dist/admin/webcomponent.d.ts +1 -0
  82. package/dist/api.d.ts +6 -0
  83. package/dist/auth/index.d.ts +2107 -0
  84. package/dist/auth/migrations.d.ts +5 -0
  85. package/dist/auth/premissions.d.ts +6 -0
  86. package/dist/chunk-16vgcf3k.js +88 -0
  87. package/dist/chunk-2zm8cy1w.js +9482 -0
  88. package/dist/chunk-5gvbp2qa.js +167 -0
  89. package/dist/chunk-62ev8gnc.js +41 -0
  90. package/dist/chunk-6dhs73zq.js +126 -0
  91. package/dist/chunk-6ew02s0c.js +472 -0
  92. package/dist/chunk-7a9kn0np.js +116 -0
  93. package/dist/chunk-8gkhn1d4.js +309 -0
  94. package/dist/chunk-8sqjbsgt.js +42 -0
  95. package/dist/chunk-9kxpbcb1.js +85 -0
  96. package/dist/chunk-cvdd4eqh.js +110 -0
  97. package/dist/chunk-d3ffeqp9.js +87 -0
  98. package/dist/chunk-dy5t83hr.js +261 -0
  99. package/dist/chunk-f3nvxn63.js +17 -0
  100. package/dist/chunk-hmhcense.js +1352 -0
  101. package/dist/chunk-j4d50hrx.js +20 -0
  102. package/dist/chunk-jwjk85ze.js +15 -0
  103. package/dist/chunk-kwp83w8b.js +250 -0
  104. package/dist/chunk-s8mqwnm1.js +14 -0
  105. package/dist/chunk-srsac177.js +85 -0
  106. package/dist/chunk-v521d72w.js +10 -0
  107. package/dist/chunk-xa7rjsn2.js +20 -0
  108. package/dist/chunk-xg35h5a3.js +15 -0
  109. package/dist/chunk-ybbbqj63.js +130 -0
  110. package/dist/chunk-zvwb67nd.js +332 -0
  111. package/dist/cli/commands/generate-types.d.ts +1 -0
  112. package/dist/cli/commands/init.d.ts +1 -0
  113. package/dist/cli/commands/migrate-commands.d.ts +5 -0
  114. package/dist/cli/commands/seed-command.d.ts +2 -0
  115. package/dist/cli/d1-mock.d.ts +30 -0
  116. package/dist/cli/index.d.ts +5 -0
  117. package/dist/cli/index.test.d.ts +1 -0
  118. package/dist/cli/r2-mock.d.ts +46 -0
  119. package/dist/cli/seeding.d.ts +17 -0
  120. package/dist/client.d.ts +51 -0
  121. package/dist/config-utils.d.ts +6 -0
  122. package/dist/config.d.ts +10 -0
  123. package/dist/db/adapter.d.ts +34 -0
  124. package/dist/db/better-sqlite.d.ts +40 -0
  125. package/dist/db/bun-sqlite.d.ts +40 -0
  126. package/dist/db/d1.d.ts +42 -0
  127. package/dist/db/kysely/data-mapper.d.ts +6 -0
  128. package/dist/db/kysely/field-mapper.d.ts +22 -0
  129. package/dist/db/kysely/migration-generator.d.ts +9 -0
  130. package/dist/db/kysely/query-builder.d.ts +9 -0
  131. package/dist/db/kysely/schema-builder.d.ts +15 -0
  132. package/dist/db/kysely/sql-utils.d.ts +1 -0
  133. package/dist/db/postgres.d.ts +51 -0
  134. package/dist/db/sqlite.d.ts +41 -0
  135. package/dist/db/system-schema.d.ts +2 -0
  136. package/dist/index.d.ts +6 -0
  137. package/dist/runtimes/bun.d.ts +17 -0
  138. package/dist/runtimes/cloudflare-workers.d.ts +10 -0
  139. package/dist/runtimes/next.d.ts +16 -0
  140. package/dist/runtimes/node.d.ts +18 -0
  141. package/dist/schema/collection.d.ts +100 -0
  142. package/dist/schema/fields/base.d.ts +83 -0
  143. package/dist/schema/fields/index.d.ts +135 -0
  144. package/dist/schema/global.d.ts +82 -0
  145. package/dist/schema/index.d.ts +4 -0
  146. package/dist/schema/infer.d.ts +55 -0
  147. package/dist/server/admin-router.d.ts +9 -0
  148. package/dist/server/admin.d.ts +18 -0
  149. package/dist/server/assets.d.ts +47 -0
  150. package/dist/server/collection-router.d.ts +14 -0
  151. package/dist/server/handlers.d.ts +76 -0
  152. package/dist/server/middlewares/admin.d.ts +6 -0
  153. package/dist/server/middlewares/auth.d.ts +16 -0
  154. package/dist/server/middlewares/context.d.ts +9 -0
  155. package/dist/server/middlewares/cors.d.ts +3 -0
  156. package/dist/server/middlewares/database-init.d.ts +11 -0
  157. package/dist/server/middlewares/rate-limit.d.ts +3 -0
  158. package/dist/server/router.d.ts +7 -0
  159. package/dist/server/setup-middlewares.d.ts +17 -0
  160. package/dist/server/system-router.d.ts +9 -0
  161. package/dist/server.d.ts +6 -0
  162. package/dist/src/admin/index.css +47 -0
  163. package/dist/src/admin/index.js +176 -0
  164. package/dist/src/admin/webcomponent.js +19 -0
  165. package/dist/src/api.js +27 -0
  166. package/dist/src/cli/index.js +157 -0
  167. package/dist/src/client.js +9 -0
  168. package/dist/src/db/bun-sqlite.js +523 -0
  169. package/dist/src/db/d1.js +568 -0
  170. package/dist/src/db/postgres.js +520 -0
  171. package/dist/src/db/sqlite.js +534 -0
  172. package/dist/src/index.js +20 -0
  173. package/dist/src/runtimes/bun.js +36 -0
  174. package/dist/src/runtimes/cloudflare-workers.js +29 -0
  175. package/dist/src/runtimes/next.js +26 -0
  176. package/dist/src/runtimes/node.js +38 -0
  177. package/dist/src/server.js +27 -0
  178. package/dist/src/storage/index.js +355 -0
  179. package/dist/storage/adapters/cloudflare-r2.d.ts +6 -0
  180. package/dist/storage/adapters/local.d.ts +6 -0
  181. package/dist/storage/adapters/s3.d.ts +13 -0
  182. package/dist/storage/errors.d.ts +12 -0
  183. package/dist/storage/index.d.ts +5 -0
  184. package/dist/storage/types.d.ts +31 -0
  185. package/dist/types.d.ts +484 -0
  186. package/dist/utils/lexical.d.ts +5 -0
  187. package/dist/utils/logger.d.ts +35 -0
  188. package/dist/validation.d.ts +300 -0
  189. package/dist/validator.d.ts +9 -0
  190. package/global.d.ts +11 -0
  191. package/package.json +151 -0
  192. package/src/admin/api-client.ts +63 -0
  193. package/src/admin/auth-client.ts +40 -0
  194. package/src/admin/custom-field.ts +179 -0
  195. package/src/admin/index.ts +15 -0
  196. package/src/admin/react.tsx +72 -0
  197. package/src/admin/router.ts +9 -0
  198. package/src/admin/stores/admin-queries.ts +121 -0
  199. package/src/admin/stores/auth.ts +61 -0
  200. package/src/admin/stores/column-visibility.ts +67 -0
  201. package/src/admin/stores/config.ts +15 -0
  202. package/src/admin/stores/media.ts +95 -0
  203. package/src/admin/stores/query.ts +13 -0
  204. package/src/admin/stores/ui.ts +29 -0
  205. package/src/admin/ui/admin-client.tsx +283 -0
  206. package/src/admin/ui/admin-layout.tsx +276 -0
  207. package/src/admin/ui/components/ColumnVisibilityToggle.tsx +141 -0
  208. package/src/admin/ui/components/DataDetailSheet.tsx +141 -0
  209. package/src/admin/ui/components/DataDetailView.tsx +175 -0
  210. package/src/admin/ui/components/Table.tsx +67 -0
  211. package/src/admin/ui/components/fields/ArrayField.tsx +166 -0
  212. package/src/admin/ui/components/fields/BlocksField.tsx +202 -0
  213. package/src/admin/ui/components/fields/BooleanField.tsx +50 -0
  214. package/src/admin/ui/components/fields/CollapsibleField.tsx +75 -0
  215. package/src/admin/ui/components/fields/DateField.tsx +45 -0
  216. package/src/admin/ui/components/fields/FileField.tsx +322 -0
  217. package/src/admin/ui/components/fields/GroupField.tsx +50 -0
  218. package/src/admin/ui/components/fields/JoinField.tsx +23 -0
  219. package/src/admin/ui/components/fields/NumberField.tsx +46 -0
  220. package/src/admin/ui/components/fields/RadioField.tsx +62 -0
  221. package/src/admin/ui/components/fields/RelationshipField.tsx +278 -0
  222. package/src/admin/ui/components/fields/RowField.tsx +40 -0
  223. package/src/admin/ui/components/fields/SelectField.tsx +59 -0
  224. package/src/admin/ui/components/fields/TabsField.tsx +101 -0
  225. package/src/admin/ui/components/fields/TextAreaField.tsx +54 -0
  226. package/src/admin/ui/components/fields/TextField.tsx +49 -0
  227. package/src/admin/ui/components/fields/VirtualField.tsx +53 -0
  228. package/src/admin/ui/components/fields/index.tsx +371 -0
  229. package/src/admin/ui/components/fields/richtext-editor/index.tsx +211 -0
  230. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.tsx +142 -0
  231. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageNode.tsx +95 -0
  232. package/src/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.tsx +226 -0
  233. package/src/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.tsx +16 -0
  234. package/src/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.tsx +184 -0
  235. package/src/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.tsx +240 -0
  236. package/src/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.tsx +40 -0
  237. package/src/admin/ui/components/fields/utils.ts +1 -0
  238. package/src/admin/ui/components/link.tsx +41 -0
  239. package/src/admin/ui/components/media/AssetManagerModal.tsx +334 -0
  240. package/src/admin/ui/components/toast.tsx +72 -0
  241. package/src/admin/ui/components/ui/accordion.tsx +51 -0
  242. package/src/admin/ui/components/ui/alert-dialog.tsx +98 -0
  243. package/src/admin/ui/components/ui/blocks.tsx +32 -0
  244. package/src/admin/ui/components/ui/breadcrumbs.tsx +59 -0
  245. package/src/admin/ui/components/ui/button.tsx +26 -0
  246. package/src/admin/ui/components/ui/collapsible.tsx +124 -0
  247. package/src/admin/ui/components/ui/dialog.tsx +79 -0
  248. package/src/admin/ui/components/ui/group.tsx +20 -0
  249. package/src/admin/ui/components/ui/index.ts +17 -0
  250. package/src/admin/ui/components/ui/input.tsx +12 -0
  251. package/src/admin/ui/components/ui/join.tsx +53 -0
  252. package/src/admin/ui/components/ui/label.tsx +11 -0
  253. package/src/admin/ui/components/ui/radio-group.tsx +75 -0
  254. package/src/admin/ui/components/ui/relationship-detail-sheet.tsx +122 -0
  255. package/src/admin/ui/components/ui/relationship.tsx +58 -0
  256. package/src/admin/ui/components/ui/scroll-area.tsx +19 -0
  257. package/src/admin/ui/components/ui/select.tsx +187 -0
  258. package/src/admin/ui/components/ui/separator.tsx +21 -0
  259. package/src/admin/ui/components/ui/sheet.tsx +106 -0
  260. package/src/admin/ui/components/ui/tabs.tsx +116 -0
  261. package/src/admin/ui/components/ui/utils.ts +3 -0
  262. package/src/admin/ui/hooks/use-debounce.ts +15 -0
  263. package/src/admin/ui/styles/_locale-switcher.scss +33 -0
  264. package/src/admin/ui/styles/accordion.scss +60 -0
  265. package/src/admin/ui/styles/animations.scss +41 -0
  266. package/src/admin/ui/styles/asset-manager.scss +547 -0
  267. package/src/admin/ui/styles/badge.scss +13 -0
  268. package/src/admin/ui/styles/base.scss +22 -0
  269. package/src/admin/ui/styles/button.scss +161 -0
  270. package/src/admin/ui/styles/card.scss +13 -0
  271. package/src/admin/ui/styles/collapsible.scss +75 -0
  272. package/src/admin/ui/styles/data-detail.scss +92 -0
  273. package/src/admin/ui/styles/dialog.scss +102 -0
  274. package/src/admin/ui/styles/empty-state.scss +22 -0
  275. package/src/admin/ui/styles/group.scss +19 -0
  276. package/src/admin/ui/styles/index.scss +33 -0
  277. package/src/admin/ui/styles/input.scss +80 -0
  278. package/src/admin/ui/styles/label.scss +12 -0
  279. package/src/admin/ui/styles/layout.scss +56 -0
  280. package/src/admin/ui/styles/lexical.scss +469 -0
  281. package/src/admin/ui/styles/loading.scss +102 -0
  282. package/src/admin/ui/styles/media-registry.scss +597 -0
  283. package/src/admin/ui/styles/pagination.scss +20 -0
  284. package/src/admin/ui/styles/radio-group.scss +66 -0
  285. package/src/admin/ui/styles/row.scss +17 -0
  286. package/src/admin/ui/styles/scrollbar.scss +36 -0
  287. package/src/admin/ui/styles/select.scss +121 -0
  288. package/src/admin/ui/styles/separator.scss +14 -0
  289. package/src/admin/ui/styles/sheet.scss +152 -0
  290. package/src/admin/ui/styles/sidebar.scss +148 -0
  291. package/src/admin/ui/styles/switch.scss +59 -0
  292. package/src/admin/ui/styles/table.scss +207 -0
  293. package/src/admin/ui/styles/tabs.scss +62 -0
  294. package/src/admin/ui/styles/toast.scss +45 -0
  295. package/src/admin/ui/styles/variables.scss +24 -0
  296. package/src/admin/ui/views/collection-list-view.tsx +720 -0
  297. package/src/admin/ui/views/dashboard-view.tsx +263 -0
  298. package/src/admin/ui/views/document-edit-view.tsx +384 -0
  299. package/src/admin/ui/views/global-edit-view.tsx +226 -0
  300. package/src/admin/ui/views/init-view.tsx +182 -0
  301. package/src/admin/ui/views/login-view.tsx +123 -0
  302. package/src/admin/ui/views/media-registry-view.tsx +1104 -0
  303. package/src/admin/ui/views/settings-view.tsx +729 -0
  304. package/src/admin/webcomponent.tsx +15 -0
  305. package/src/api.ts +9 -0
  306. package/src/auth/index.ts +194 -0
  307. package/src/auth/migrations.ts +87 -0
  308. package/src/auth/premissions.ts +46 -0
  309. package/src/cli/commands/generate-types.ts +116 -0
  310. package/src/cli/commands/init.ts +95 -0
  311. package/src/cli/commands/migrate-commands.ts +160 -0
  312. package/src/cli/commands/seed-command.ts +11 -0
  313. package/src/cli/d1-mock.ts +101 -0
  314. package/src/cli/index.test.ts +84 -0
  315. package/src/cli/index.ts +183 -0
  316. package/src/cli/r2-mock.ts +217 -0
  317. package/src/cli/seeding.ts +405 -0
  318. package/src/client.ts +181 -0
  319. package/src/config-utils.ts +102 -0
  320. package/src/config.ts +49 -0
  321. package/src/db/adapter.ts +53 -0
  322. package/src/db/better-sqlite.ts +630 -0
  323. package/src/db/bun-sqlite.ts +646 -0
  324. package/src/db/d1.ts +711 -0
  325. package/src/db/kysely/data-mapper.ts +142 -0
  326. package/src/db/kysely/field-mapper.ts +148 -0
  327. package/src/db/kysely/migration-generator.ts +223 -0
  328. package/src/db/kysely/query-builder.ts +92 -0
  329. package/src/db/kysely/schema-builder.ts +439 -0
  330. package/src/db/kysely/sql-utils.ts +13 -0
  331. package/src/db/postgres.ts +621 -0
  332. package/src/db/sqlite.ts +658 -0
  333. package/src/db/system-schema.ts +121 -0
  334. package/src/index.ts +13 -0
  335. package/src/runtimes/README.md +59 -0
  336. package/src/runtimes/bun.ts +49 -0
  337. package/src/runtimes/cloudflare-workers.ts +38 -0
  338. package/src/runtimes/next.ts +26 -0
  339. package/src/runtimes/node.ts +52 -0
  340. package/src/schema/collection.ts +184 -0
  341. package/src/schema/fields/base.ts +164 -0
  342. package/src/schema/fields/index.ts +427 -0
  343. package/src/schema/global.ts +145 -0
  344. package/src/schema/index.ts +4 -0
  345. package/src/schema/infer.ts +72 -0
  346. package/src/server/admin-router.ts +20 -0
  347. package/src/server/admin.ts +142 -0
  348. package/src/server/assets.ts +306 -0
  349. package/src/server/collection-router.ts +55 -0
  350. package/src/server/handlers.ts +722 -0
  351. package/src/server/middlewares/admin.ts +27 -0
  352. package/src/server/middlewares/auth.ts +89 -0
  353. package/src/server/middlewares/context.ts +17 -0
  354. package/src/server/middlewares/cors.ts +24 -0
  355. package/src/server/middlewares/database-init.ts +74 -0
  356. package/src/server/middlewares/rate-limit.ts +71 -0
  357. package/src/server/router.ts +47 -0
  358. package/src/server/setup-middlewares.ts +58 -0
  359. package/src/server/system-router.ts +35 -0
  360. package/src/server.ts +9 -0
  361. package/src/storage/adapters/cloudflare-r2.ts +136 -0
  362. package/src/storage/adapters/local.ts +146 -0
  363. package/src/storage/adapters/s3.ts +186 -0
  364. package/src/storage/errors.ts +46 -0
  365. package/src/storage/index.ts +5 -0
  366. package/src/storage/types.ts +39 -0
  367. package/src/types.ts +577 -0
  368. package/src/utils/lexical.ts +37 -0
  369. package/src/utils/logger.ts +73 -0
  370. package/src/validation.ts +429 -0
  371. package/src/validator.ts +179 -0
  372. package/test/admin-custom-field.test.ts +162 -0
  373. package/test/admin-react-field.test.tsx +134 -0
  374. package/test/api-features.test.ts +78 -0
  375. package/test/api.test.ts +178 -0
  376. package/test/auth.test.ts +62 -0
  377. package/test/cli-integration.test.ts +146 -0
  378. package/test/cli.test.ts +25 -0
  379. package/test/db/postgres.test.ts +95 -0
  380. package/test/db/sqlite-filter.test.ts +53 -0
  381. package/test/db/sqlite.test.ts +82 -0
  382. package/test/engine-features.test.ts +79 -0
  383. package/test/globals.test.ts +74 -0
  384. package/test/integration-tmp/db-app/opacacms.config.ts +15 -0
  385. package/test/integration-tmp/my-sqlite-app/opacacms.config.ts +25 -0
  386. package/test/integration-tmp/my-test-app/index.ts +8 -0
  387. package/test/integration-tmp/my-test-app/opacacms.config.ts +16 -0
  388. package/test/integration-tmp/my-test-app/package.json +12 -0
  389. package/test/populate.test.ts +79 -0
  390. package/test/runtimes.test.ts +43 -0
  391. package/test/schema-builder.test.ts +107 -0
  392. package/test/schema-features.test.ts +63 -0
  393. package/test/seeding.test.ts +68 -0
  394. package/test/storage/local.test.ts +72 -0
  395. package/test/storage/s3.test.ts +60 -0
  396. package/test/structural-data.test.ts +100 -0
  397. package/test/test-setup.ts +11 -0
  398. package/test/validation.test.ts +162 -0
  399. package/tsconfig.json +42 -0
@@ -0,0 +1,145 @@
1
+ import type { ApiKey } from "@better-auth/api-key";
2
+ import type { Session, User } from "better-auth";
3
+ import { icons } from "lucide-react";
4
+ import type { AccessConfig, Global as GlobalType, IconName } from "../types";
5
+ import { type FieldBuilder, VirtualFieldBuilder } from "./fields";
6
+ import type { InferFields } from "./infer";
7
+
8
+ export type Global = GlobalType;
9
+
10
+ /**
11
+ * A Fluent API Builder for defining OpacaCMS Globals.
12
+ */
13
+ export class GlobalBuilder<TFields extends Record<string, any> = {}> {
14
+ protected config: Partial<Global> = {};
15
+ protected _fields: FieldBuilder<any, any>[] = [];
16
+
17
+ constructor(slug: string) {
18
+ this.config.slug = slug;
19
+ this.config.fields = [];
20
+ this.config.timestamps = true;
21
+ }
22
+
23
+ /**
24
+ * Enables or disables timestamps (createdAt, updatedAt) for this global.
25
+ * Defaults to true.
26
+ */
27
+ public timestamps(enabled: boolean | { createdAt?: string; updatedAt?: string } = true): this {
28
+ this.config.timestamps = enabled;
29
+ return this;
30
+ }
31
+
32
+ /**
33
+ * Sets the label for this global in the Admin UI.
34
+ */
35
+ public label(label: string): this {
36
+ this.config.label = label;
37
+ return this;
38
+ }
39
+
40
+ /**
41
+ * Sets the Lucide icon for this global.
42
+ */
43
+ public icon(iconName: IconName): this {
44
+ this.config.icon = iconName;
45
+ return this;
46
+ }
47
+
48
+ /**
49
+ * Defines the fields for this global schema.
50
+ */
51
+ public fields<T extends readonly any[]>(fields: [...T]): GlobalBuilder<InferFields<T>> {
52
+ const nextBuilder = new GlobalBuilder<InferFields<T>>(this.config.slug!);
53
+ nextBuilder.config = { ...this.config };
54
+ nextBuilder._fields = [...fields];
55
+ nextBuilder.config.fields = fields.map((f) => f.build());
56
+ return nextBuilder as any; // Cast safely due to generic transformation
57
+ }
58
+
59
+ /**
60
+ * Configuration for who can read and update this global.
61
+ */
62
+ public access(rules: AccessConfig): this {
63
+ this.config.access = rules;
64
+ return this;
65
+ }
66
+
67
+ /**
68
+ * Defines a virtual/computed field with full type inference of the current global data.
69
+ */
70
+ public virtual<TName extends string, TReturn = any>(
71
+ name: TName,
72
+ options: {
73
+ label?: string;
74
+ resolve: (args: {
75
+ data: TFields;
76
+ req: any;
77
+ user: any;
78
+ session: any;
79
+ apiKey?: any;
80
+ }) => TReturn | Promise<TReturn>;
81
+ returnType?: "string" | "number" | "boolean" | "json";
82
+ },
83
+ ): GlobalBuilder<TFields & { [K in TName]: TReturn }> {
84
+ const builder = new VirtualFieldBuilder<TName, TFields>(name);
85
+ if (options.label) builder.label(options.label);
86
+ if (options.resolve) builder.resolve(options.resolve as any);
87
+ if (options.returnType) builder.returnType(options.returnType);
88
+
89
+ const nextBuilder = new GlobalBuilder<TFields & { [K in TName]: TReturn }>(this.config.slug!);
90
+ nextBuilder.config = { ...this.config };
91
+ nextBuilder._fields = [...this._fields, builder];
92
+ // Update raw fields array
93
+ nextBuilder.config.fields = nextBuilder._fields.map((f) => f.build());
94
+ return nextBuilder;
95
+ }
96
+
97
+ /**
98
+ * Alias for .virtual()
99
+ */
100
+ public computed<TName extends string, TReturn = any>(
101
+ name: TName,
102
+ options: {
103
+ label?: string;
104
+ resolve: (args: {
105
+ data: TFields;
106
+ req: any;
107
+ user: any;
108
+ session: any;
109
+ apiKey?: any;
110
+ }) => TReturn | Promise<TReturn>;
111
+ returnType?: "string" | "number" | "boolean" | "json";
112
+ },
113
+ ): GlobalBuilder<TFields & { [K in TName]: TReturn }> {
114
+ return this.virtual<TName, TReturn>(name, options);
115
+ }
116
+
117
+ /**
118
+ * Extends the global with arbitrary configuration.
119
+ */
120
+ public extend(opts: Record<string, any>): this {
121
+ this.config = { ...this.config, ...opts };
122
+ return this;
123
+ }
124
+
125
+ /**
126
+ * Compiles the builder down into the raw Global object expected by OpacaCMS.
127
+ */
128
+ public build(): Global {
129
+ if (!this.config.slug) {
130
+ throw new Error("Globals must have a slug.");
131
+ }
132
+ if (!this.config.fields || this.config.fields.length === 0) {
133
+ throw new Error(`Global "${this.config.slug}" must have at least one field.`);
134
+ }
135
+
136
+ return this.config as Global;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Singleton factory for creating Globals.
142
+ */
143
+ export const Global = {
144
+ create: (slug: string) => new GlobalBuilder(slug),
145
+ };
@@ -0,0 +1,4 @@
1
+ export { Collection, CollectionBuilder } from "./collection";
2
+ export { Field, FieldBuilder } from "./fields/index";
3
+ export { Global, GlobalBuilder } from "./global";
4
+ export type { AccessRules, InferFields } from "./infer";
@@ -0,0 +1,72 @@
1
+ import type { FieldBuilder } from "./fields/base";
2
+
3
+ /**
4
+ * Helper to convert a union of types into an intersection.
5
+ */
6
+ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
7
+ ? I
8
+ : never;
9
+
10
+ type Flatten<T> = { [K in keyof T]: T[K] } & {};
11
+
12
+ /**
13
+ * Extracts the TName and TValue from a single FieldBuilder.
14
+ */
15
+ export type InferField<T extends FieldBuilder<any, any, any, any>> =
16
+ T extends FieldBuilder<any, any, infer TValue, any> ? TValue : any;
17
+
18
+ /**
19
+ * Internal helper to distribute over a union of FieldBuilders.
20
+ * Matches against the class generics for maximum reliability.
21
+ */
22
+ type ProcessField<F> =
23
+ F extends FieldBuilder<"group", infer N, any, infer Sub>
24
+ ? N extends string
25
+ ? { [K in N]: Sub extends readonly any[] ? InferFields<Sub> : unknown }
26
+ : {}
27
+ : F extends FieldBuilder<"array", infer N, any, infer Sub>
28
+ ? N extends string
29
+ ? { [K in N]: Sub extends readonly any[] ? InferFields<Sub>[] : unknown[] }
30
+ : {}
31
+ : F extends FieldBuilder<"row" | "collapsible", any, any, infer Sub>
32
+ ? Sub extends readonly any[]
33
+ ? InferFields<Sub>
34
+ : {}
35
+ : F extends FieldBuilder<"tabs", any, any, infer Sub>
36
+ ? Sub extends readonly any[]
37
+ ? UnionToIntersection<
38
+ Sub[number] extends { fields: infer S }
39
+ ? S extends readonly any[]
40
+ ? InferFields<S>
41
+ : {}
42
+ : {}
43
+ >
44
+ : {}
45
+ : F extends FieldBuilder<any, infer N, infer V, any>
46
+ ? N extends string
47
+ ? N extends ""
48
+ ? {}
49
+ : { [K in N]: V }
50
+ : {}
51
+ : {};
52
+
53
+ /**
54
+ * Magically maps an array of FieldBuilders into a recursive object type representation.
55
+ *
56
+ * Performance Optimized:
57
+ * - Uses ProcessField helper for guaranteed union distribution.
58
+ * - UnionToIntersection merges individual field objects into a clean intersection.
59
+ */
60
+ export type InferFields<T extends readonly any[]> = Flatten<
61
+ UnionToIntersection<ProcessField<T[number]>>
62
+ >;
63
+
64
+ /**
65
+ * Access rules with strongly typed hooks.
66
+ */
67
+ export interface AccessRules<TFields extends Record<string, any>> {
68
+ read?: (args: { user: any; data?: TFields }) => boolean | Promise<boolean>;
69
+ create?: (args: { user: any; data: TFields }) => boolean | Promise<boolean>;
70
+ update?: (args: { user: any; data: TFields }) => boolean | Promise<boolean>;
71
+ delete?: (args: { user: any; data?: TFields }) => boolean | Promise<boolean>;
72
+ }
@@ -0,0 +1,20 @@
1
+ import { Hono } from "hono";
2
+ import type { Auth } from "../auth";
3
+ import { getSystemCollections } from "../db/system-schema";
4
+ import type { OpacaConfig } from "../types";
5
+ import { createAdminHandlers } from "./admin";
6
+ import { adminMiddleware } from "./middlewares/admin";
7
+ import type { ApiContextVariables } from "./router";
8
+
9
+ export function createAdminRouter(config: OpacaConfig, state: { auth: Auth | undefined }) {
10
+ const adminRouter = new Hono<{ Variables: ApiContextVariables }>();
11
+ const adminHandlers = createAdminHandlers(config, () => state.auth);
12
+
13
+ adminRouter.get("/collections", adminMiddleware, adminHandlers.getCollections);
14
+ adminRouter.get("/metadata", adminHandlers.getMetadata);
15
+ adminRouter.get("/config", adminMiddleware, adminHandlers.getConfig);
16
+ adminRouter.get("/setup", adminHandlers.getSetupStatus);
17
+ adminRouter.post("/api-key/create", adminMiddleware, adminHandlers.createApiKey);
18
+
19
+ return adminRouter;
20
+ }
@@ -0,0 +1,142 @@
1
+ import type { Context } from "hono";
2
+ import type { Auth } from "../auth";
3
+ import { sanitizeConfig } from "../config-utils";
4
+ import type { OpacaConfig } from "../types";
5
+
6
+ type AdminHandlers = {
7
+ getMetadata: (c: Context) => Response;
8
+ getCollections: (c: Context) => Response;
9
+ getConfig: (c: Context) => Promise<Response>;
10
+ getSetupStatus: (c: Context) => Promise<Response>;
11
+ createApiKey: (c: Context) => Promise<Response>;
12
+ };
13
+
14
+ /**
15
+ * Creates the admin handlers for the OpacaCMS.
16
+ * @param config The OpacaCMS configuration.
17
+ * @param getAuth A function that returns the authentication instance.
18
+ * @returns An object containing the admin handlers for hono integration.
19
+ */
20
+ export function createAdminHandlers(
21
+ config: OpacaConfig,
22
+ getAuth: () => Auth | undefined,
23
+ ): AdminHandlers {
24
+ const getMetadata = (c: Context) => {
25
+ return c.json(sanitizeConfig(config));
26
+ };
27
+
28
+ const getCollections = (c: Context) => {
29
+ // Return collections with field details simplified if needed, or full config
30
+ // For now returning config.collections directly.
31
+ // Ideally we might strip internal server-only properties if any.
32
+
33
+ const collections = [...config.collections];
34
+
35
+ // Auto-inject system collections (auth + assets) if relevant features are enabled
36
+ const supportsAuth =
37
+ config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
38
+
39
+ const { getSystemCollections } = require("../db/system-schema");
40
+ const systemCollections = getSystemCollections();
41
+
42
+ for (const systemCol of systemCollections) {
43
+ const isAsset = systemCol.slug === "_opaca_assets";
44
+ const isAuth = ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(
45
+ systemCol.slug,
46
+ );
47
+
48
+ if ((isAsset && config.storages) || (isAuth && supportsAuth)) {
49
+ if (!collections.find((col) => col.slug === systemCol.slug)) {
50
+ collections.push({
51
+ ...systemCol,
52
+ admin: true, // Mark as system/admin collection
53
+ });
54
+ }
55
+ }
56
+ }
57
+
58
+ // Filter collections to returned only what's allowed in the UI
59
+ const filteredCollections = collections.filter((c) => !c.hidden);
60
+
61
+ return c.json({
62
+ collections: filteredCollections,
63
+ globals: config.globals,
64
+ });
65
+ };
66
+
67
+ const getConfig = async (c: Context) => {
68
+ return c.json({
69
+ serverURL: config.serverURL,
70
+ admin: config.admin,
71
+ });
72
+ };
73
+
74
+ const getSetupStatus = async (c: Context) => {
75
+ try {
76
+ let userCount = 0;
77
+ try {
78
+ userCount = await config.db.count("_users");
79
+ } catch (_e) {
80
+ // Fallback: the "_users" collection might not be in the OpacaCMS schema
81
+ const result = (await config.db.unsafe("SELECT COUNT(*) as count FROM _users")) as any;
82
+ const rows = result?.results || result || [];
83
+ userCount = Number(rows[0]?.count || rows[0]?.["count(*)"] || 0);
84
+ }
85
+
86
+ return c.json({
87
+ initialized: userCount > 0,
88
+ });
89
+ } catch (e) {
90
+ console.error("[OpacaCMS] Failed to check setup status:", e);
91
+
92
+ return c.json({
93
+ initialized: false,
94
+ });
95
+ }
96
+ };
97
+
98
+ const createApiKey = async (c: Context) => {
99
+ const auth = getAuth();
100
+ if (!auth) {
101
+ return c.json({ message: "Auth not initialized" }, 503);
102
+ }
103
+
104
+ const user = c.get("user");
105
+ if (!user) {
106
+ return c.json({ message: "Unauthorized" }, 401);
107
+ }
108
+
109
+ try {
110
+ const { name, expiresIn, permissions } = await c.req.json();
111
+
112
+ if (!name || typeof name !== "string") {
113
+ return c.json({ message: "Invalid or missing 'name'" }, 400);
114
+ }
115
+
116
+ const res = await auth.api.createApiKey({
117
+ body: {
118
+ name,
119
+ expiresIn: expiresIn ? Number(expiresIn) : undefined,
120
+ permissions,
121
+ userId: user.id,
122
+ },
123
+ });
124
+
125
+ return c.json(res);
126
+ // biome-ignore lint/suspicious/noExplicitAny: error shoulb be typed as any
127
+ } catch (err: any) {
128
+ console.error("[OpacaCMS] Failed to create API key:", err);
129
+ // Return full error details if possible
130
+ const message = err?.message || (typeof err === "string" ? err : JSON.stringify(err));
131
+ return c.json({ message, detail: err }, 400);
132
+ }
133
+ };
134
+
135
+ return {
136
+ getMetadata,
137
+ getCollections,
138
+ getConfig,
139
+ getSetupStatus,
140
+ createApiKey,
141
+ };
142
+ }
@@ -0,0 +1,306 @@
1
+ import type { Context } from "hono";
2
+ import type { FileRecord } from "../storage/types";
3
+ import type { OpacaConfig } from "../types";
4
+
5
+ export function createAssetsHandlers(config: OpacaConfig) {
6
+ return {
7
+ async upload(c: Context) {
8
+ const user = c.get("user");
9
+ // Security: Only allow authenticated users to upload to the global registry
10
+ if (!user) return c.json({ error: "Unauthorized" }, 401);
11
+
12
+ const bucket = c.req.query("bucket") || "default";
13
+ if (!config.storages) return c.json({ error: "Storage not configured" }, 500);
14
+
15
+ const storageAdapter = config.storages[bucket];
16
+ if (!storageAdapter) {
17
+ return c.json({ error: `Bucket '${bucket}' not found` }, 404);
18
+ }
19
+
20
+ try {
21
+ // Auto-patch missing columns for DX
22
+ try {
23
+ if (config.db.name === "sqlite" || config.db.name === "d1") {
24
+ const tableInfo = (await config.db.unsafe(`PRAGMA table_info(_opaca_assets)`)) as any[];
25
+ const columns = tableInfo.map((c) => c.name);
26
+ if (!columns.includes("folder"))
27
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
28
+ if (!columns.includes("alt_text"))
29
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN alt_text TEXT`);
30
+ if (!columns.includes("caption"))
31
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
32
+ } else if (config.db.name === "postgres") {
33
+ const checkCols = (await config.db.unsafe(`
34
+ SELECT column_name FROM information_schema.columns
35
+ WHERE table_name = '_opaca_assets' AND column_name IN ('folder', 'alt_text', 'caption')
36
+ `)) as any[];
37
+ const existing = checkCols.map((c) => c.column_name);
38
+ if (!existing.includes("folder"))
39
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
40
+ if (!existing.includes("alt_text"))
41
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN "alt_text" TEXT`);
42
+ if (!existing.includes("caption"))
43
+ await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
44
+ }
45
+ } catch (e) {
46
+ console.error("Auto-patch columns failed", e);
47
+ }
48
+
49
+ // Read the folder before upload so we can prefix the storage key
50
+ const folder = c.req.query("folder") || null;
51
+ const keyPrefix = folder ? `${folder}/` : "";
52
+ const now = new Date().toISOString();
53
+
54
+ const formData = await c.req.parseBody({ all: true });
55
+ const fileRaw = formData["file"];
56
+ const file = (Array.isArray(fileRaw) ? fileRaw[0] : fileRaw) as any;
57
+
58
+ if (!file || (typeof file !== "object" && typeof file !== "string")) {
59
+ return c.json({ error: "No file provided" }, 400);
60
+ }
61
+
62
+ const fileName = file.name || "unnamed";
63
+ const fileType = file.type || "application/octet-stream";
64
+ const fileSize = file.size || 0;
65
+
66
+ const fileRecord: FileRecord = {
67
+ filename: fileName,
68
+ original_filename: fileName,
69
+ mime_type: fileType,
70
+ filesize: fileSize,
71
+ stream: typeof file.stream === "function" ? file.stream() : new Response(file).body!,
72
+ };
73
+
74
+ // Pipe stream directly to adapter, passing the folder as a key prefix
75
+ const uploadedFileData = await storageAdapter.upload(fileRecord, {
76
+ generateUniqueName: true,
77
+ keyPrefix,
78
+ });
79
+
80
+ // Ensure the stored key in the DB reflects the folder prefix
81
+ const storedKey = keyPrefix + uploadedFileData.filename;
82
+
83
+ try {
84
+ // Insert into hidden registry table
85
+ const assetId = (
86
+ globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)
87
+ ).replace(/-/g, "");
88
+
89
+ await config.db.create("_opaca_assets", {
90
+ id: assetId,
91
+ key: storedKey,
92
+ filename: fileName,
93
+ originalFilename: fileName,
94
+ mimeType: uploadedFileData.mime_type,
95
+ filesize: uploadedFileData.filesize,
96
+ bucket,
97
+ folder,
98
+ altText: null,
99
+ caption: null,
100
+ uploadedBy: user.id || null,
101
+ });
102
+
103
+ // Return the standardized JSON payload for embedded file fields
104
+ return c.json(
105
+ {
106
+ assetId,
107
+ ...uploadedFileData,
108
+ key: storedKey,
109
+ },
110
+ 201,
111
+ );
112
+ } catch (dbError: any) {
113
+ // Rollback mechanism
114
+ console.error(
115
+ `[OpacaCMS] Registry insert failed, rolling back physical file upload: ${storedKey}`,
116
+ );
117
+ storageAdapter.delete(storedKey).catch((cleanupError: any) => {
118
+ console.error(
119
+ `[OpacaCMS] CRITICAL: Failed to clean up orphaned file ${storedKey}!`,
120
+ cleanupError,
121
+ );
122
+ });
123
+ throw dbError;
124
+ }
125
+ } catch (error: any) {
126
+ return c.json({ error: error.message }, 400);
127
+ }
128
+ },
129
+
130
+ async list(c: Context) {
131
+ const user = c.get("user");
132
+ if (!user || (user.role !== "admin" && !user.role?.includes("admin"))) {
133
+ return c.json({ error: "Unauthorized" }, 401);
134
+ }
135
+
136
+ const bucket = c.req.query("bucket") || "all";
137
+ const page = parseInt(c.req.query("page") || "1", 10);
138
+ const limit = parseInt(c.req.query("limit") || "20", 10);
139
+ const offset = (page - 1) * limit;
140
+ const folder = c.req.query("folder") || null;
141
+
142
+ try {
143
+ let query: any = {};
144
+ if (bucket !== "all") query.bucket = bucket;
145
+
146
+ if (folder !== null && folder !== "") {
147
+ query.folder = folder;
148
+ } else {
149
+ // Handle root folder (NULL or empty string)
150
+ // If we have a bucket filter, we should combine it
151
+ if (bucket !== "all") {
152
+ query = {
153
+ and: [{ bucket: bucket }, { or: [{ folder: null }, { folder: "" }] }],
154
+ };
155
+ } else {
156
+ query = { or: [{ folder: null }, { folder: "" }] };
157
+ }
158
+ }
159
+
160
+ const result = await config.db.find<any>("_opaca_assets", query, {
161
+ page,
162
+ limit,
163
+ sort: "created_at:desc",
164
+ });
165
+
166
+ const rows = result.docs;
167
+ const total = result.totalDocs;
168
+
169
+ // Discover subfolders (isolated by bucket)
170
+ let folderRows: any[] = [];
171
+ const bucketFilter = bucket !== "all" ? `AND bucket = ?` : "";
172
+ const bucketParam = bucket !== "all" ? [bucket] : [];
173
+
174
+ if (config.db.name === "postgres") {
175
+ const pgBucketFilter = bucketFilter.replace("?", "$1");
176
+ if (folder === null || folder === "") {
177
+ folderRows = (await config.db.unsafe(
178
+ `SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${pgBucketFilter}`,
179
+ bucketParam,
180
+ )) as any[];
181
+ } else {
182
+ folderRows = (await config.db.unsafe(
183
+ `SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder LIKE $2 ${bucket !== "all" ? "AND bucket = $3" : ""}`,
184
+ [folder, `${folder}/%`, ...bucketParam],
185
+ )) as any[];
186
+ }
187
+ } else {
188
+ if (folder === null || folder === "") {
189
+ folderRows = (await config.db.unsafe(
190
+ `
191
+ SELECT DISTINCT
192
+ CASE
193
+ WHEN INSTR(folder, '/') > 0 THEN SUBSTR(folder, 1, INSTR(folder, '/') - 1)
194
+ ELSE folder
195
+ END as subfolder,
196
+ bucket
197
+ FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${bucketFilter}
198
+ `,
199
+ bucketParam,
200
+ )) as any[];
201
+ } else {
202
+ const skipLen = folder.length + 2;
203
+ folderRows = (await config.db.unsafe(
204
+ `
205
+ SELECT DISTINCT
206
+ CASE
207
+ WHEN INSTR(SUBSTR(folder, ?), '/') > 0 THEN SUBSTR(SUBSTR(folder, ?), 1, INSTR(SUBSTR(folder, ?), '/') - 1)
208
+ ELSE SUBSTR(folder, ?)
209
+ END as subfolder,
210
+ bucket
211
+ FROM _opaca_assets WHERE folder LIKE ? ${bucketFilter}
212
+ `,
213
+ [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, ...bucketParam],
214
+ )) as any[];
215
+ }
216
+ }
217
+
218
+ // Group shared folders by name
219
+ const folderMap: Record<string, string[]> = {};
220
+ for (const row of folderRows) {
221
+ if (!row.subfolder) continue;
222
+ if (!folderMap[row.subfolder]) folderMap[row.subfolder] = [];
223
+ if (!folderMap[row.subfolder]?.includes(row.bucket)) {
224
+ folderMap[row.subfolder]?.push(row.bucket);
225
+ }
226
+ }
227
+
228
+ const folders = Object.entries(folderMap).map(([name, buckets]) => ({
229
+ name,
230
+ buckets,
231
+ }));
232
+
233
+ return c.json({
234
+ docs: rows,
235
+ folders,
236
+ totalDocs: total,
237
+ limit,
238
+ page,
239
+ totalPages: Math.ceil(total / limit),
240
+ });
241
+ } catch (e: any) {
242
+ return c.json({ error: e.message }, 500);
243
+ }
244
+ },
245
+
246
+ async presign(c: Context) {
247
+ const user = c.get("user");
248
+ if (!user) return c.json({ error: "Unauthorized" }, 401);
249
+
250
+ const { filename, bucket = "default", operation = "write" } = await c.req.json();
251
+ if (!config.storages || !config.storages[bucket]) {
252
+ return c.json({ error: "Bucket not found" }, 404);
253
+ }
254
+
255
+ const adapter = config.storages[bucket];
256
+ if (!adapter.generatePresignedUrl) {
257
+ return c.json({ error: "Adapter does not support presigned URLs" }, 400);
258
+ }
259
+
260
+ try {
261
+ const url = await adapter.generatePresignedUrl(
262
+ filename,
263
+ operation as "read" | "write",
264
+ 3600,
265
+ );
266
+ return c.json({ uploadUrl: url, filename });
267
+ } catch (e: any) {
268
+ return c.json({ error: e.message }, 500);
269
+ }
270
+ },
271
+
272
+ async serve(c: Context) {
273
+ const id = c.req.param("id");
274
+
275
+ try {
276
+ const asset = await config.db.findOne<any>("_opaca_assets", { id });
277
+
278
+ if (!asset) {
279
+ return c.json({ error: "Asset not found" }, 404);
280
+ }
281
+
282
+ const bucket = asset.bucket || "default";
283
+ if (!config.storages || !config.storages[bucket]) {
284
+ return c.json({ error: "Storage bucket not configured" }, 500);
285
+ }
286
+
287
+ const adapter = config.storages[bucket];
288
+ if (!adapter.download) {
289
+ return c.json({ error: "Storage adapter does not support direct downloads" }, 400);
290
+ }
291
+
292
+ const stream = await adapter.download(asset.key || asset.filename);
293
+
294
+ c.header("Content-Type", asset.mimeType || "application/octet-stream");
295
+ c.header("Content-Length", asset.filesize.toString());
296
+ // Cache for 1 day
297
+ c.header("Cache-Control", "public, max-age=86400");
298
+
299
+ return c.body(stream as any);
300
+ } catch (e: any) {
301
+ console.error(`[OpacaCMS] Failed to serve asset ${id}:`, e);
302
+ return c.json({ error: e.message }, 500);
303
+ }
304
+ },
305
+ };
306
+ }