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.
- package/bun.lock +34 -0
- package/dist/admin/api-client.d.ts +8 -0
- package/dist/admin/auth-client.d.ts +940 -0
- package/dist/admin/custom-field.d.ts +71 -0
- package/dist/admin/index.d.ts +11 -0
- package/dist/admin/react.d.ts +3 -0
- package/dist/admin/router.d.ts +7 -0
- package/dist/admin/stores/admin-queries.d.ts +32 -0
- package/dist/admin/stores/auth.d.ts +33 -0
- package/dist/admin/stores/column-visibility.d.ts +21 -0
- package/dist/admin/stores/config.d.ts +7 -0
- package/dist/admin/stores/media.d.ts +44 -0
- package/dist/admin/stores/query.d.ts +4 -0
- package/dist/admin/stores/ui.d.ts +11 -0
- package/dist/admin/ui/admin-client.d.ts +7 -0
- package/dist/admin/ui/admin-layout.d.ts +14 -0
- package/dist/admin/ui/components/ColumnVisibilityToggle.d.ts +10 -0
- package/dist/admin/ui/components/DataDetailSheet.d.ts +13 -0
- package/dist/admin/ui/components/DataDetailView.d.ts +9 -0
- package/dist/admin/ui/components/Table.d.ts +10 -0
- package/dist/admin/ui/components/fields/ArrayField.d.ts +13 -0
- package/dist/admin/ui/components/fields/BlocksField.d.ts +17 -0
- package/dist/admin/ui/components/fields/BooleanField.d.ts +13 -0
- package/dist/admin/ui/components/fields/CollapsibleField.d.ts +16 -0
- package/dist/admin/ui/components/fields/DateField.d.ts +13 -0
- package/dist/admin/ui/components/fields/FileField.d.ts +23 -0
- package/dist/admin/ui/components/fields/GroupField.d.ts +13 -0
- package/dist/admin/ui/components/fields/JoinField.d.ts +15 -0
- package/dist/admin/ui/components/fields/NumberField.d.ts +14 -0
- package/dist/admin/ui/components/fields/RadioField.d.ts +17 -0
- package/dist/admin/ui/components/fields/RelationshipField.d.ts +16 -0
- package/dist/admin/ui/components/fields/RowField.d.ts +12 -0
- package/dist/admin/ui/components/fields/SelectField.d.ts +18 -0
- package/dist/admin/ui/components/fields/TabsField.d.ts +15 -0
- package/dist/admin/ui/components/fields/TextAreaField.d.ts +14 -0
- package/dist/admin/ui/components/fields/TextField.d.ts +14 -0
- package/dist/admin/ui/components/fields/VirtualField.d.ts +8 -0
- package/dist/admin/ui/components/fields/index.d.ts +28 -0
- package/dist/admin/ui/components/fields/richtext-editor/index.d.ts +10 -0
- package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.d.ts +7 -0
- package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageNode.d.ts +27 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.d.ts +1 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.d.ts +5 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.d.ts +1 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.d.ts +1 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.d.ts +5 -0
- package/dist/admin/ui/components/fields/utils.d.ts +1 -0
- package/dist/admin/ui/components/link.d.ts +8 -0
- package/dist/admin/ui/components/media/AssetManagerModal.d.ts +17 -0
- package/dist/admin/ui/components/toast.d.ts +10 -0
- package/dist/admin/ui/components/ui/accordion.d.ts +11 -0
- package/dist/admin/ui/components/ui/alert-dialog.d.ts +12 -0
- package/dist/admin/ui/components/ui/blocks.d.ts +5 -0
- package/dist/admin/ui/components/ui/breadcrumbs.d.ts +7 -0
- package/dist/admin/ui/components/ui/button.d.ts +7 -0
- package/dist/admin/ui/components/ui/collapsible.d.ts +16 -0
- package/dist/admin/ui/components/ui/dialog.d.ts +27 -0
- package/dist/admin/ui/components/ui/group.d.ts +6 -0
- package/dist/admin/ui/components/ui/index.d.ts +17 -0
- package/dist/admin/ui/components/ui/input.d.ts +5 -0
- package/dist/admin/ui/components/ui/join.d.ts +7 -0
- package/dist/admin/ui/components/ui/label.d.ts +3 -0
- package/dist/admin/ui/components/ui/radio-group.d.ts +13 -0
- package/dist/admin/ui/components/ui/relationship-detail-sheet.d.ts +9 -0
- package/dist/admin/ui/components/ui/relationship.d.ts +8 -0
- package/dist/admin/ui/components/ui/scroll-area.d.ts +7 -0
- package/dist/admin/ui/components/ui/select.d.ts +37 -0
- package/dist/admin/ui/components/ui/separator.d.ts +8 -0
- package/dist/admin/ui/components/ui/sheet.d.ts +28 -0
- package/dist/admin/ui/components/ui/tabs.d.ts +17 -0
- package/dist/admin/ui/components/ui/utils.d.ts +1 -0
- package/dist/admin/ui/hooks/use-debounce.d.ts +1 -0
- package/dist/admin/ui/views/collection-list-view.d.ts +5 -0
- package/dist/admin/ui/views/dashboard-view.d.ts +10 -0
- package/dist/admin/ui/views/document-edit-view.d.ts +7 -0
- package/dist/admin/ui/views/global-edit-view.d.ts +19 -0
- package/dist/admin/ui/views/init-view.d.ts +4 -0
- package/dist/admin/ui/views/login-view.d.ts +4 -0
- package/dist/admin/ui/views/media-registry-view.d.ts +7 -0
- package/dist/admin/ui/views/settings-view.d.ts +7 -0
- package/dist/admin/webcomponent.d.ts +1 -0
- package/dist/api.d.ts +6 -0
- package/dist/auth/index.d.ts +2107 -0
- package/dist/auth/migrations.d.ts +5 -0
- package/dist/auth/premissions.d.ts +6 -0
- package/dist/chunk-16vgcf3k.js +88 -0
- package/dist/chunk-2zm8cy1w.js +9482 -0
- package/dist/chunk-5gvbp2qa.js +167 -0
- package/dist/chunk-62ev8gnc.js +41 -0
- package/dist/chunk-6dhs73zq.js +126 -0
- package/dist/chunk-6ew02s0c.js +472 -0
- package/dist/chunk-7a9kn0np.js +116 -0
- package/dist/chunk-8gkhn1d4.js +309 -0
- package/dist/chunk-8sqjbsgt.js +42 -0
- package/dist/chunk-9kxpbcb1.js +85 -0
- package/dist/chunk-cvdd4eqh.js +110 -0
- package/dist/chunk-d3ffeqp9.js +87 -0
- package/dist/chunk-dy5t83hr.js +261 -0
- package/dist/chunk-f3nvxn63.js +17 -0
- package/dist/chunk-hmhcense.js +1352 -0
- package/dist/chunk-j4d50hrx.js +20 -0
- package/dist/chunk-jwjk85ze.js +15 -0
- package/dist/chunk-kwp83w8b.js +250 -0
- package/dist/chunk-s8mqwnm1.js +14 -0
- package/dist/chunk-srsac177.js +85 -0
- package/dist/chunk-v521d72w.js +10 -0
- package/dist/chunk-xa7rjsn2.js +20 -0
- package/dist/chunk-xg35h5a3.js +15 -0
- package/dist/chunk-ybbbqj63.js +130 -0
- package/dist/chunk-zvwb67nd.js +332 -0
- package/dist/cli/commands/generate-types.d.ts +1 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/migrate-commands.d.ts +5 -0
- package/dist/cli/commands/seed-command.d.ts +2 -0
- package/dist/cli/d1-mock.d.ts +30 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.test.d.ts +1 -0
- package/dist/cli/r2-mock.d.ts +46 -0
- package/dist/cli/seeding.d.ts +17 -0
- package/dist/client.d.ts +51 -0
- package/dist/config-utils.d.ts +6 -0
- package/dist/config.d.ts +10 -0
- package/dist/db/adapter.d.ts +34 -0
- package/dist/db/better-sqlite.d.ts +40 -0
- package/dist/db/bun-sqlite.d.ts +40 -0
- package/dist/db/d1.d.ts +42 -0
- package/dist/db/kysely/data-mapper.d.ts +6 -0
- package/dist/db/kysely/field-mapper.d.ts +22 -0
- package/dist/db/kysely/migration-generator.d.ts +9 -0
- package/dist/db/kysely/query-builder.d.ts +9 -0
- package/dist/db/kysely/schema-builder.d.ts +15 -0
- package/dist/db/kysely/sql-utils.d.ts +1 -0
- package/dist/db/postgres.d.ts +51 -0
- package/dist/db/sqlite.d.ts +41 -0
- package/dist/db/system-schema.d.ts +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/runtimes/bun.d.ts +17 -0
- package/dist/runtimes/cloudflare-workers.d.ts +10 -0
- package/dist/runtimes/next.d.ts +16 -0
- package/dist/runtimes/node.d.ts +18 -0
- package/dist/schema/collection.d.ts +100 -0
- package/dist/schema/fields/base.d.ts +83 -0
- package/dist/schema/fields/index.d.ts +135 -0
- package/dist/schema/global.d.ts +82 -0
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/infer.d.ts +55 -0
- package/dist/server/admin-router.d.ts +9 -0
- package/dist/server/admin.d.ts +18 -0
- package/dist/server/assets.d.ts +47 -0
- package/dist/server/collection-router.d.ts +14 -0
- package/dist/server/handlers.d.ts +76 -0
- package/dist/server/middlewares/admin.d.ts +6 -0
- package/dist/server/middlewares/auth.d.ts +16 -0
- package/dist/server/middlewares/context.d.ts +9 -0
- package/dist/server/middlewares/cors.d.ts +3 -0
- package/dist/server/middlewares/database-init.d.ts +11 -0
- package/dist/server/middlewares/rate-limit.d.ts +3 -0
- package/dist/server/router.d.ts +7 -0
- package/dist/server/setup-middlewares.d.ts +17 -0
- package/dist/server/system-router.d.ts +9 -0
- package/dist/server.d.ts +6 -0
- package/dist/src/admin/index.css +47 -0
- package/dist/src/admin/index.js +176 -0
- package/dist/src/admin/webcomponent.js +19 -0
- package/dist/src/api.js +27 -0
- package/dist/src/cli/index.js +157 -0
- package/dist/src/client.js +9 -0
- package/dist/src/db/bun-sqlite.js +523 -0
- package/dist/src/db/d1.js +568 -0
- package/dist/src/db/postgres.js +520 -0
- package/dist/src/db/sqlite.js +534 -0
- package/dist/src/index.js +20 -0
- package/dist/src/runtimes/bun.js +36 -0
- package/dist/src/runtimes/cloudflare-workers.js +29 -0
- package/dist/src/runtimes/next.js +26 -0
- package/dist/src/runtimes/node.js +38 -0
- package/dist/src/server.js +27 -0
- package/dist/src/storage/index.js +355 -0
- package/dist/storage/adapters/cloudflare-r2.d.ts +6 -0
- package/dist/storage/adapters/local.d.ts +6 -0
- package/dist/storage/adapters/s3.d.ts +13 -0
- package/dist/storage/errors.d.ts +12 -0
- package/dist/storage/index.d.ts +5 -0
- package/dist/storage/types.d.ts +31 -0
- package/dist/types.d.ts +484 -0
- package/dist/utils/lexical.d.ts +5 -0
- package/dist/utils/logger.d.ts +35 -0
- package/dist/validation.d.ts +300 -0
- package/dist/validator.d.ts +9 -0
- package/global.d.ts +11 -0
- package/package.json +151 -0
- package/src/admin/api-client.ts +63 -0
- package/src/admin/auth-client.ts +40 -0
- package/src/admin/custom-field.ts +179 -0
- package/src/admin/index.ts +15 -0
- package/src/admin/react.tsx +72 -0
- package/src/admin/router.ts +9 -0
- package/src/admin/stores/admin-queries.ts +121 -0
- package/src/admin/stores/auth.ts +61 -0
- package/src/admin/stores/column-visibility.ts +67 -0
- package/src/admin/stores/config.ts +15 -0
- package/src/admin/stores/media.ts +95 -0
- package/src/admin/stores/query.ts +13 -0
- package/src/admin/stores/ui.ts +29 -0
- package/src/admin/ui/admin-client.tsx +283 -0
- package/src/admin/ui/admin-layout.tsx +276 -0
- package/src/admin/ui/components/ColumnVisibilityToggle.tsx +141 -0
- package/src/admin/ui/components/DataDetailSheet.tsx +141 -0
- package/src/admin/ui/components/DataDetailView.tsx +175 -0
- package/src/admin/ui/components/Table.tsx +67 -0
- package/src/admin/ui/components/fields/ArrayField.tsx +166 -0
- package/src/admin/ui/components/fields/BlocksField.tsx +202 -0
- package/src/admin/ui/components/fields/BooleanField.tsx +50 -0
- package/src/admin/ui/components/fields/CollapsibleField.tsx +75 -0
- package/src/admin/ui/components/fields/DateField.tsx +45 -0
- package/src/admin/ui/components/fields/FileField.tsx +322 -0
- package/src/admin/ui/components/fields/GroupField.tsx +50 -0
- package/src/admin/ui/components/fields/JoinField.tsx +23 -0
- package/src/admin/ui/components/fields/NumberField.tsx +46 -0
- package/src/admin/ui/components/fields/RadioField.tsx +62 -0
- package/src/admin/ui/components/fields/RelationshipField.tsx +278 -0
- package/src/admin/ui/components/fields/RowField.tsx +40 -0
- package/src/admin/ui/components/fields/SelectField.tsx +59 -0
- package/src/admin/ui/components/fields/TabsField.tsx +101 -0
- package/src/admin/ui/components/fields/TextAreaField.tsx +54 -0
- package/src/admin/ui/components/fields/TextField.tsx +49 -0
- package/src/admin/ui/components/fields/VirtualField.tsx +53 -0
- package/src/admin/ui/components/fields/index.tsx +371 -0
- package/src/admin/ui/components/fields/richtext-editor/index.tsx +211 -0
- package/src/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.tsx +142 -0
- package/src/admin/ui/components/fields/richtext-editor/nodes/ImageNode.tsx +95 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.tsx +226 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.tsx +16 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.tsx +184 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.tsx +240 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.tsx +40 -0
- package/src/admin/ui/components/fields/utils.ts +1 -0
- package/src/admin/ui/components/link.tsx +41 -0
- package/src/admin/ui/components/media/AssetManagerModal.tsx +334 -0
- package/src/admin/ui/components/toast.tsx +72 -0
- package/src/admin/ui/components/ui/accordion.tsx +51 -0
- package/src/admin/ui/components/ui/alert-dialog.tsx +98 -0
- package/src/admin/ui/components/ui/blocks.tsx +32 -0
- package/src/admin/ui/components/ui/breadcrumbs.tsx +59 -0
- package/src/admin/ui/components/ui/button.tsx +26 -0
- package/src/admin/ui/components/ui/collapsible.tsx +124 -0
- package/src/admin/ui/components/ui/dialog.tsx +79 -0
- package/src/admin/ui/components/ui/group.tsx +20 -0
- package/src/admin/ui/components/ui/index.ts +17 -0
- package/src/admin/ui/components/ui/input.tsx +12 -0
- package/src/admin/ui/components/ui/join.tsx +53 -0
- package/src/admin/ui/components/ui/label.tsx +11 -0
- package/src/admin/ui/components/ui/radio-group.tsx +75 -0
- package/src/admin/ui/components/ui/relationship-detail-sheet.tsx +122 -0
- package/src/admin/ui/components/ui/relationship.tsx +58 -0
- package/src/admin/ui/components/ui/scroll-area.tsx +19 -0
- package/src/admin/ui/components/ui/select.tsx +187 -0
- package/src/admin/ui/components/ui/separator.tsx +21 -0
- package/src/admin/ui/components/ui/sheet.tsx +106 -0
- package/src/admin/ui/components/ui/tabs.tsx +116 -0
- package/src/admin/ui/components/ui/utils.ts +3 -0
- package/src/admin/ui/hooks/use-debounce.ts +15 -0
- package/src/admin/ui/styles/_locale-switcher.scss +33 -0
- package/src/admin/ui/styles/accordion.scss +60 -0
- package/src/admin/ui/styles/animations.scss +41 -0
- package/src/admin/ui/styles/asset-manager.scss +547 -0
- package/src/admin/ui/styles/badge.scss +13 -0
- package/src/admin/ui/styles/base.scss +22 -0
- package/src/admin/ui/styles/button.scss +161 -0
- package/src/admin/ui/styles/card.scss +13 -0
- package/src/admin/ui/styles/collapsible.scss +75 -0
- package/src/admin/ui/styles/data-detail.scss +92 -0
- package/src/admin/ui/styles/dialog.scss +102 -0
- package/src/admin/ui/styles/empty-state.scss +22 -0
- package/src/admin/ui/styles/group.scss +19 -0
- package/src/admin/ui/styles/index.scss +33 -0
- package/src/admin/ui/styles/input.scss +80 -0
- package/src/admin/ui/styles/label.scss +12 -0
- package/src/admin/ui/styles/layout.scss +56 -0
- package/src/admin/ui/styles/lexical.scss +469 -0
- package/src/admin/ui/styles/loading.scss +102 -0
- package/src/admin/ui/styles/media-registry.scss +597 -0
- package/src/admin/ui/styles/pagination.scss +20 -0
- package/src/admin/ui/styles/radio-group.scss +66 -0
- package/src/admin/ui/styles/row.scss +17 -0
- package/src/admin/ui/styles/scrollbar.scss +36 -0
- package/src/admin/ui/styles/select.scss +121 -0
- package/src/admin/ui/styles/separator.scss +14 -0
- package/src/admin/ui/styles/sheet.scss +152 -0
- package/src/admin/ui/styles/sidebar.scss +148 -0
- package/src/admin/ui/styles/switch.scss +59 -0
- package/src/admin/ui/styles/table.scss +207 -0
- package/src/admin/ui/styles/tabs.scss +62 -0
- package/src/admin/ui/styles/toast.scss +45 -0
- package/src/admin/ui/styles/variables.scss +24 -0
- package/src/admin/ui/views/collection-list-view.tsx +720 -0
- package/src/admin/ui/views/dashboard-view.tsx +263 -0
- package/src/admin/ui/views/document-edit-view.tsx +384 -0
- package/src/admin/ui/views/global-edit-view.tsx +226 -0
- package/src/admin/ui/views/init-view.tsx +182 -0
- package/src/admin/ui/views/login-view.tsx +123 -0
- package/src/admin/ui/views/media-registry-view.tsx +1104 -0
- package/src/admin/ui/views/settings-view.tsx +729 -0
- package/src/admin/webcomponent.tsx +15 -0
- package/src/api.ts +9 -0
- package/src/auth/index.ts +194 -0
- package/src/auth/migrations.ts +87 -0
- package/src/auth/premissions.ts +46 -0
- package/src/cli/commands/generate-types.ts +116 -0
- package/src/cli/commands/init.ts +95 -0
- package/src/cli/commands/migrate-commands.ts +160 -0
- package/src/cli/commands/seed-command.ts +11 -0
- package/src/cli/d1-mock.ts +101 -0
- package/src/cli/index.test.ts +84 -0
- package/src/cli/index.ts +183 -0
- package/src/cli/r2-mock.ts +217 -0
- package/src/cli/seeding.ts +405 -0
- package/src/client.ts +181 -0
- package/src/config-utils.ts +102 -0
- package/src/config.ts +49 -0
- package/src/db/adapter.ts +53 -0
- package/src/db/better-sqlite.ts +630 -0
- package/src/db/bun-sqlite.ts +646 -0
- package/src/db/d1.ts +711 -0
- package/src/db/kysely/data-mapper.ts +142 -0
- package/src/db/kysely/field-mapper.ts +148 -0
- package/src/db/kysely/migration-generator.ts +223 -0
- package/src/db/kysely/query-builder.ts +92 -0
- package/src/db/kysely/schema-builder.ts +439 -0
- package/src/db/kysely/sql-utils.ts +13 -0
- package/src/db/postgres.ts +621 -0
- package/src/db/sqlite.ts +658 -0
- package/src/db/system-schema.ts +121 -0
- package/src/index.ts +13 -0
- package/src/runtimes/README.md +59 -0
- package/src/runtimes/bun.ts +49 -0
- package/src/runtimes/cloudflare-workers.ts +38 -0
- package/src/runtimes/next.ts +26 -0
- package/src/runtimes/node.ts +52 -0
- package/src/schema/collection.ts +184 -0
- package/src/schema/fields/base.ts +164 -0
- package/src/schema/fields/index.ts +427 -0
- package/src/schema/global.ts +145 -0
- package/src/schema/index.ts +4 -0
- package/src/schema/infer.ts +72 -0
- package/src/server/admin-router.ts +20 -0
- package/src/server/admin.ts +142 -0
- package/src/server/assets.ts +306 -0
- package/src/server/collection-router.ts +55 -0
- package/src/server/handlers.ts +722 -0
- package/src/server/middlewares/admin.ts +27 -0
- package/src/server/middlewares/auth.ts +89 -0
- package/src/server/middlewares/context.ts +17 -0
- package/src/server/middlewares/cors.ts +24 -0
- package/src/server/middlewares/database-init.ts +74 -0
- package/src/server/middlewares/rate-limit.ts +71 -0
- package/src/server/router.ts +47 -0
- package/src/server/setup-middlewares.ts +58 -0
- package/src/server/system-router.ts +35 -0
- package/src/server.ts +9 -0
- package/src/storage/adapters/cloudflare-r2.ts +136 -0
- package/src/storage/adapters/local.ts +146 -0
- package/src/storage/adapters/s3.ts +186 -0
- package/src/storage/errors.ts +46 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/types.ts +39 -0
- package/src/types.ts +577 -0
- package/src/utils/lexical.ts +37 -0
- package/src/utils/logger.ts +73 -0
- package/src/validation.ts +429 -0
- package/src/validator.ts +179 -0
- package/test/admin-custom-field.test.ts +162 -0
- package/test/admin-react-field.test.tsx +134 -0
- package/test/api-features.test.ts +78 -0
- package/test/api.test.ts +178 -0
- package/test/auth.test.ts +62 -0
- package/test/cli-integration.test.ts +146 -0
- package/test/cli.test.ts +25 -0
- package/test/db/postgres.test.ts +95 -0
- package/test/db/sqlite-filter.test.ts +53 -0
- package/test/db/sqlite.test.ts +82 -0
- package/test/engine-features.test.ts +79 -0
- package/test/globals.test.ts +74 -0
- package/test/integration-tmp/db-app/opacacms.config.ts +15 -0
- package/test/integration-tmp/my-sqlite-app/opacacms.config.ts +25 -0
- package/test/integration-tmp/my-test-app/index.ts +8 -0
- package/test/integration-tmp/my-test-app/opacacms.config.ts +16 -0
- package/test/integration-tmp/my-test-app/package.json +12 -0
- package/test/populate.test.ts +79 -0
- package/test/runtimes.test.ts +43 -0
- package/test/schema-builder.test.ts +107 -0
- package/test/schema-features.test.ts +63 -0
- package/test/seeding.test.ts +68 -0
- package/test/storage/local.test.ts +72 -0
- package/test/storage/s3.test.ts +60 -0
- package/test/structural-data.test.ts +100 -0
- package/test/test-setup.ts +11 -0
- package/test/validation.test.ts +162 -0
- 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,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
|
+
}
|