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,1352 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAuth,
|
|
3
|
+
sanitizeConfig
|
|
4
|
+
} from "./chunk-dy5t83hr.js";
|
|
5
|
+
import {
|
|
6
|
+
logger
|
|
7
|
+
} from "./chunk-62ev8gnc.js";
|
|
8
|
+
import {
|
|
9
|
+
toSnakeCase
|
|
10
|
+
} from "./chunk-cvdd4eqh.js";
|
|
11
|
+
import {
|
|
12
|
+
exports_system_schema,
|
|
13
|
+
getSystemCollections,
|
|
14
|
+
init_system_schema
|
|
15
|
+
} from "./chunk-ybbbqj63.js";
|
|
16
|
+
import {
|
|
17
|
+
__toCommonJS
|
|
18
|
+
} from "./chunk-8sqjbsgt.js";
|
|
19
|
+
|
|
20
|
+
// src/server/admin.ts
|
|
21
|
+
function createAdminHandlers(config, getAuth) {
|
|
22
|
+
const getMetadata = (c) => {
|
|
23
|
+
return c.json(sanitizeConfig(config));
|
|
24
|
+
};
|
|
25
|
+
const getCollections = (c) => {
|
|
26
|
+
const collections = [...config.collections];
|
|
27
|
+
const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
|
|
28
|
+
const { getSystemCollections: getSystemCollections2 } = (init_system_schema(), __toCommonJS(exports_system_schema));
|
|
29
|
+
const systemCollections = getSystemCollections2();
|
|
30
|
+
for (const systemCol of systemCollections) {
|
|
31
|
+
const isAsset = systemCol.slug === "_opaca_assets";
|
|
32
|
+
const isAuth = ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(systemCol.slug);
|
|
33
|
+
if (isAsset && config.storages || isAuth && supportsAuth) {
|
|
34
|
+
if (!collections.find((col) => col.slug === systemCol.slug)) {
|
|
35
|
+
collections.push({
|
|
36
|
+
...systemCol,
|
|
37
|
+
admin: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const filteredCollections = collections.filter((c2) => !c2.hidden);
|
|
43
|
+
return c.json({
|
|
44
|
+
collections: filteredCollections,
|
|
45
|
+
globals: config.globals
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const getConfig = async (c) => {
|
|
49
|
+
return c.json({
|
|
50
|
+
serverURL: config.serverURL,
|
|
51
|
+
admin: config.admin
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const getSetupStatus = async (c) => {
|
|
55
|
+
try {
|
|
56
|
+
let userCount = 0;
|
|
57
|
+
try {
|
|
58
|
+
userCount = await config.db.count("_users");
|
|
59
|
+
} catch (_e) {
|
|
60
|
+
const result = await config.db.unsafe("SELECT COUNT(*) as count FROM _users");
|
|
61
|
+
const rows = result?.results || result || [];
|
|
62
|
+
userCount = Number(rows[0]?.count || rows[0]?.["count(*)"] || 0);
|
|
63
|
+
}
|
|
64
|
+
return c.json({
|
|
65
|
+
initialized: userCount > 0
|
|
66
|
+
});
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error("[OpacaCMS] Failed to check setup status:", e);
|
|
69
|
+
return c.json({
|
|
70
|
+
initialized: false
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const createApiKey = async (c) => {
|
|
75
|
+
const auth = getAuth();
|
|
76
|
+
if (!auth) {
|
|
77
|
+
return c.json({ message: "Auth not initialized" }, 503);
|
|
78
|
+
}
|
|
79
|
+
const user = c.get("user");
|
|
80
|
+
if (!user) {
|
|
81
|
+
return c.json({ message: "Unauthorized" }, 401);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const { name, expiresIn, permissions } = await c.req.json();
|
|
85
|
+
if (!name || typeof name !== "string") {
|
|
86
|
+
return c.json({ message: "Invalid or missing 'name'" }, 400);
|
|
87
|
+
}
|
|
88
|
+
const res = await auth.api.createApiKey({
|
|
89
|
+
body: {
|
|
90
|
+
name,
|
|
91
|
+
expiresIn: expiresIn ? Number(expiresIn) : undefined,
|
|
92
|
+
permissions,
|
|
93
|
+
userId: user.id
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return c.json(res);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error("[OpacaCMS] Failed to create API key:", err);
|
|
99
|
+
const message = err?.message || (typeof err === "string" ? err : JSON.stringify(err));
|
|
100
|
+
return c.json({ message, detail: err }, 400);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
getMetadata,
|
|
105
|
+
getCollections,
|
|
106
|
+
getConfig,
|
|
107
|
+
getSetupStatus,
|
|
108
|
+
createApiKey
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/validator.ts
|
|
113
|
+
import { z } from "zod";
|
|
114
|
+
function generateSchemaForCollection(collection, isUpdate = false) {
|
|
115
|
+
const shape = mapFieldsToShape(collection.fields, isUpdate);
|
|
116
|
+
if (collection.versions) {
|
|
117
|
+
shape._status = z.enum(["draft", "published"]).optional().nullable();
|
|
118
|
+
}
|
|
119
|
+
const ts = collection.timestamps;
|
|
120
|
+
if (ts !== false && ts !== undefined) {
|
|
121
|
+
const config = typeof ts === "object" ? ts : {};
|
|
122
|
+
const createdField = config.createdAt || "createdAt";
|
|
123
|
+
const updatedField = config.updatedAt || "updatedAt";
|
|
124
|
+
shape[createdField] = z.union([z.string(), z.date()]).optional().nullable();
|
|
125
|
+
shape[updatedField] = z.union([z.string(), z.date()]).optional().nullable();
|
|
126
|
+
}
|
|
127
|
+
return z.object(shape);
|
|
128
|
+
}
|
|
129
|
+
function mapFieldsToShape(fields, isUpdate = false) {
|
|
130
|
+
const shape = {};
|
|
131
|
+
for (const field of fields) {
|
|
132
|
+
if (!field.name) {
|
|
133
|
+
if (field.type === "tabs" && field.tabs) {
|
|
134
|
+
for (const tab of field.tabs) {
|
|
135
|
+
Object.assign(shape, mapFieldsToShape(tab.fields, isUpdate));
|
|
136
|
+
}
|
|
137
|
+
} else if (field.type === "group" && field.fields) {
|
|
138
|
+
Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
|
|
139
|
+
} else if (field.type === "row" && field.fields) {
|
|
140
|
+
Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
|
|
141
|
+
} else if (field.type === "collapsible" && field.fields) {
|
|
142
|
+
Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const fieldName = field.name;
|
|
147
|
+
let schema;
|
|
148
|
+
if (field.type === "group" && field.fields) {
|
|
149
|
+
schema = z.object(mapFieldsToShape(field.fields, isUpdate));
|
|
150
|
+
} else if (field.type === "blocks" && field.blocks) {
|
|
151
|
+
const blockSchemas = field.blocks.map((block) => z.object({
|
|
152
|
+
blockType: z.literal(block.slug),
|
|
153
|
+
...mapFieldsToShape(block.fields, isUpdate)
|
|
154
|
+
}));
|
|
155
|
+
schema = z.array(z.union(blockSchemas));
|
|
156
|
+
} else if (field.type === "row" || field.type === "collapsible") {
|
|
157
|
+
if (field.fields) {
|
|
158
|
+
schema = z.object(mapFieldsToShape(field.fields, isUpdate));
|
|
159
|
+
} else {
|
|
160
|
+
schema = z.any();
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
schema = mapFieldToZod(field);
|
|
164
|
+
}
|
|
165
|
+
if (field.localized) {
|
|
166
|
+
schema = z.union([z.record(z.string(), schema), schema]);
|
|
167
|
+
}
|
|
168
|
+
if (field.required && !isUpdate) {
|
|
169
|
+
if (field.type === "slug" && field.from) {
|
|
170
|
+
schema = schema.optional().nullable();
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
schema = schema.optional().nullable();
|
|
174
|
+
}
|
|
175
|
+
if (field.defaultValue !== undefined && !isUpdate) {
|
|
176
|
+
schema = schema.default(field.defaultValue);
|
|
177
|
+
}
|
|
178
|
+
if (field.validate) {
|
|
179
|
+
schema = schema.superRefine((val, ctx) => {
|
|
180
|
+
if (val === undefined || val === null)
|
|
181
|
+
return;
|
|
182
|
+
const result = field.validate(val);
|
|
183
|
+
if (result !== true) {
|
|
184
|
+
ctx.addIssue({
|
|
185
|
+
code: z.ZodIssueCode.custom,
|
|
186
|
+
message: typeof result === "string" ? result : "Invalid field"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
shape[fieldName] = schema;
|
|
192
|
+
}
|
|
193
|
+
return shape;
|
|
194
|
+
}
|
|
195
|
+
function mapFieldToZod(field) {
|
|
196
|
+
switch (field.type) {
|
|
197
|
+
case "text":
|
|
198
|
+
case "slug":
|
|
199
|
+
case "richtext":
|
|
200
|
+
case "textarea":
|
|
201
|
+
return z.string();
|
|
202
|
+
case "relationship": {
|
|
203
|
+
const isHasMany = "hasMany" in field && field.hasMany;
|
|
204
|
+
const base = z.union([z.string(), z.number(), z.undefined(), z.null()]);
|
|
205
|
+
const schema = isHasMany ? z.array(base.optional().nullable()) : base;
|
|
206
|
+
if (isHasMany) {
|
|
207
|
+
return z.preprocess((val) => {
|
|
208
|
+
if (val === undefined || val === null || val === "")
|
|
209
|
+
return [];
|
|
210
|
+
if (Array.isArray(val))
|
|
211
|
+
return val;
|
|
212
|
+
return [val];
|
|
213
|
+
}, schema);
|
|
214
|
+
}
|
|
215
|
+
return schema;
|
|
216
|
+
}
|
|
217
|
+
case "number":
|
|
218
|
+
return z.preprocess((val) => {
|
|
219
|
+
if (val === "" || val === undefined || val === null)
|
|
220
|
+
return;
|
|
221
|
+
if (typeof val === "string") {
|
|
222
|
+
const num = Number(val);
|
|
223
|
+
return Number.isNaN(num) ? undefined : num;
|
|
224
|
+
}
|
|
225
|
+
return val;
|
|
226
|
+
}, z.union([z.number(), z.undefined(), z.null()]));
|
|
227
|
+
case "boolean":
|
|
228
|
+
return z.preprocess((val) => {
|
|
229
|
+
if (typeof val === "string")
|
|
230
|
+
return val === "true";
|
|
231
|
+
return val;
|
|
232
|
+
}, z.boolean());
|
|
233
|
+
case "date":
|
|
234
|
+
return z.preprocess((val) => {
|
|
235
|
+
if (val === "" || val === undefined || val === null)
|
|
236
|
+
return;
|
|
237
|
+
return val;
|
|
238
|
+
}, z.union([
|
|
239
|
+
z.string().regex(/^\d{4}-\d{2}-\d{2}/, "Invalid date format"),
|
|
240
|
+
z.date(),
|
|
241
|
+
z.undefined(),
|
|
242
|
+
z.null()
|
|
243
|
+
]));
|
|
244
|
+
case "select":
|
|
245
|
+
return z.string();
|
|
246
|
+
case "array":
|
|
247
|
+
return z.preprocess((val) => {
|
|
248
|
+
if (val === undefined || val === null || val === "")
|
|
249
|
+
return [];
|
|
250
|
+
if (Array.isArray(val))
|
|
251
|
+
return val;
|
|
252
|
+
return [val];
|
|
253
|
+
}, z.array(z.any()));
|
|
254
|
+
default:
|
|
255
|
+
return z.any();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/server/handlers.ts
|
|
260
|
+
var hydrateDoc = async (doc, fields, c, config) => {
|
|
261
|
+
if (!doc)
|
|
262
|
+
return doc;
|
|
263
|
+
const user = c.get("user");
|
|
264
|
+
const session = c.get("session");
|
|
265
|
+
const apiKey = c.get("apiKey");
|
|
266
|
+
const hydratePromises = fields.map(async (field) => {
|
|
267
|
+
if (field.type === "virtual" && typeof field.resolve === "function") {
|
|
268
|
+
try {
|
|
269
|
+
doc[field.name] = await field.resolve({
|
|
270
|
+
data: doc,
|
|
271
|
+
req: c,
|
|
272
|
+
user,
|
|
273
|
+
session,
|
|
274
|
+
apiKey
|
|
275
|
+
});
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.error(`[OpacaCMS] Failed to resolve virtual field ${field.name} `, err);
|
|
278
|
+
doc[field.name] = null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
282
|
+
await hydrateDoc(doc, field.fields, c, config);
|
|
283
|
+
}
|
|
284
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
285
|
+
for (const tab of field.tabs) {
|
|
286
|
+
if (tab.fields && Array.isArray(tab.fields)) {
|
|
287
|
+
await hydrateDoc(doc, tab.fields, c, config);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (field.type === "group" && field.fields && doc[field.name]) {
|
|
292
|
+
await hydrateDoc(doc[field.name], field.fields, c, config);
|
|
293
|
+
}
|
|
294
|
+
if (field.type === "array" && field.fields && Array.isArray(doc[field.name])) {
|
|
295
|
+
await Promise.all(doc[field.name].map((item) => hydrateDoc(item, field.fields, c, config)));
|
|
296
|
+
}
|
|
297
|
+
if (field.type === "blocks" && field.blocks && Array.isArray(doc[field.name])) {
|
|
298
|
+
await Promise.all(doc[field.name].map((item) => {
|
|
299
|
+
const block = field.blocks.find((b) => b.slug === item.block_type);
|
|
300
|
+
if (block && block.fields) {
|
|
301
|
+
return hydrateDoc(item, block.fields, c, config);
|
|
302
|
+
}
|
|
303
|
+
return Promise.resolve();
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
if (field.localized && doc[field.name] && typeof doc[field.name] === "object") {
|
|
307
|
+
const i18nConfig = config.i18n;
|
|
308
|
+
const requestedLocale = c.req.header("x-opaca-locale") || c.req.query("locale");
|
|
309
|
+
const targetLocale = requestedLocale || i18nConfig?.defaultLocale || "en";
|
|
310
|
+
if (targetLocale !== "all") {
|
|
311
|
+
const localizedValue = doc[field.name][targetLocale] ?? doc[field.name][i18nConfig?.defaultLocale || "en"] ?? "";
|
|
312
|
+
doc[field.name] = localizedValue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
await Promise.all(hydratePromises);
|
|
317
|
+
return doc;
|
|
318
|
+
};
|
|
319
|
+
var populateDoc = async (db, fields, doc, populateKeys) => {
|
|
320
|
+
if (!doc)
|
|
321
|
+
return doc;
|
|
322
|
+
const populatePromises = fields.filter((f) => {
|
|
323
|
+
const field = f;
|
|
324
|
+
return field.type === "relationship" && field.relationTo && populateKeys.includes(field.name) && doc[field.name];
|
|
325
|
+
}).map(async (f) => {
|
|
326
|
+
const field = f;
|
|
327
|
+
try {
|
|
328
|
+
const relatedDoc = await db.findOne(field.relationTo, { id: doc[field.name] });
|
|
329
|
+
if (relatedDoc) {
|
|
330
|
+
doc[field.name] = relatedDoc;
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
console.error(`[OpacaCMS] Failed to populate relationship ${field.name} `, err);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
await Promise.all(populatePromises);
|
|
337
|
+
return doc;
|
|
338
|
+
};
|
|
339
|
+
function createHandlers(config, collection, getAuth) {
|
|
340
|
+
const { db } = config;
|
|
341
|
+
const checkAccess = async (c, action, data) => {
|
|
342
|
+
const access = collection.access?.[action];
|
|
343
|
+
const apiKey = c.get("apiKey");
|
|
344
|
+
if (collection.access?.requireApiKey && !apiKey) {
|
|
345
|
+
const user2 = c.get("user");
|
|
346
|
+
if (user2?.role === "admin" || Array.isArray(user2?.role) && user2.role.includes("admin")) {} else {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (apiKey) {
|
|
351
|
+
if (apiKey.permissions) {
|
|
352
|
+
const collectionPermissions = apiKey.permissions[collection.slug];
|
|
353
|
+
if (collectionPermissions) {
|
|
354
|
+
if (!collectionPermissions.includes(action)) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (access === undefined)
|
|
362
|
+
return true;
|
|
363
|
+
if (typeof access === "boolean")
|
|
364
|
+
return access;
|
|
365
|
+
const user = c.get("user");
|
|
366
|
+
const session = c.get("session");
|
|
367
|
+
return await access({
|
|
368
|
+
req: c,
|
|
369
|
+
user,
|
|
370
|
+
session,
|
|
371
|
+
apiKey,
|
|
372
|
+
data
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
const getFieldAccessPermissions = async (c, operation, fields, data) => {
|
|
376
|
+
const permissions = {};
|
|
377
|
+
const user = c.get("user");
|
|
378
|
+
const session = c.get("session");
|
|
379
|
+
const apiKey = c.get("apiKey");
|
|
380
|
+
const accessArgs = {
|
|
381
|
+
req: c,
|
|
382
|
+
user,
|
|
383
|
+
session,
|
|
384
|
+
apiKey,
|
|
385
|
+
data,
|
|
386
|
+
operation
|
|
387
|
+
};
|
|
388
|
+
for (const field of fields) {
|
|
389
|
+
if (field.name) {
|
|
390
|
+
if (!field.access) {
|
|
391
|
+
permissions[field.name] = { hidden: false, readOnly: false, disabled: false };
|
|
392
|
+
} else {
|
|
393
|
+
const hidden = typeof field.access.hidden === "function" ? await field.access.hidden(accessArgs) : !!field.access.hidden;
|
|
394
|
+
const readOnly = typeof field.access.readOnly === "function" ? await field.access.readOnly(accessArgs) : !!field.access.readOnly;
|
|
395
|
+
const disabled = typeof field.access.disabled === "function" ? await field.access.disabled(accessArgs) : !!field.access.disabled;
|
|
396
|
+
permissions[field.name] = { hidden, readOnly, disabled };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
400
|
+
const nestedPermissions = await getFieldAccessPermissions(c, operation, field.fields, data);
|
|
401
|
+
Object.assign(permissions, nestedPermissions);
|
|
402
|
+
}
|
|
403
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
404
|
+
for (const tab of field.tabs) {
|
|
405
|
+
if (tab.fields && Array.isArray(tab.fields)) {
|
|
406
|
+
const nestedPermissions = await getFieldAccessPermissions(c, operation, tab.fields, data);
|
|
407
|
+
Object.assign(permissions, nestedPermissions);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return permissions;
|
|
413
|
+
};
|
|
414
|
+
const saveVersion = async (doc, status) => {
|
|
415
|
+
if (!collection.versions)
|
|
416
|
+
return;
|
|
417
|
+
const versionsTable = `${collection.slug}_versions`.toLowerCase();
|
|
418
|
+
const versionDoc = {
|
|
419
|
+
id: crypto.randomUUID(),
|
|
420
|
+
_parent_id: doc.id,
|
|
421
|
+
_version_data: JSON.stringify(doc),
|
|
422
|
+
_status: status || doc._status || "published",
|
|
423
|
+
created_at: new Date().toISOString()
|
|
424
|
+
};
|
|
425
|
+
try {
|
|
426
|
+
await db.unsafe(`INSERT INTO ${versionsTable} (id, _parent_id, _version_data, _status, created_at) VALUES(?, ?, ?, ?, ?)`, [
|
|
427
|
+
versionDoc.id,
|
|
428
|
+
versionDoc._parent_id,
|
|
429
|
+
versionDoc._version_data,
|
|
430
|
+
versionDoc._status,
|
|
431
|
+
versionDoc.created_at
|
|
432
|
+
]);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
console.error(`[OpacaCMS] Failed to save version for ${collection.slug}: `, err);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
return {
|
|
438
|
+
async find(c) {
|
|
439
|
+
if (!await checkAccess(c, "read")) {
|
|
440
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
441
|
+
}
|
|
442
|
+
const queries = c.req.query();
|
|
443
|
+
const maxLimit = config.api?.maxLimit ?? 100;
|
|
444
|
+
const page = queries.page ? parseInt(queries.page, 10) : 1;
|
|
445
|
+
let limit = queries.limit ? parseInt(queries.limit, 10) : 10;
|
|
446
|
+
if (limit > maxLimit)
|
|
447
|
+
limit = maxLimit;
|
|
448
|
+
const sort = queries.sort;
|
|
449
|
+
const populate = queries.populate ? queries.populate.split(",") : [];
|
|
450
|
+
const filter = {};
|
|
451
|
+
for (const [key, value] of Object.entries(queries)) {
|
|
452
|
+
if (["page", "limit", "sort", "populate", "locale", "draft"].includes(key))
|
|
453
|
+
continue;
|
|
454
|
+
const match = key.match(/^([^[]+)\[([^\]]+)\]$/);
|
|
455
|
+
if (match) {
|
|
456
|
+
const field = match[1];
|
|
457
|
+
const op = match[2];
|
|
458
|
+
if (!filter[field])
|
|
459
|
+
filter[field] = {};
|
|
460
|
+
filter[field][op] = value;
|
|
461
|
+
} else {
|
|
462
|
+
filter[key] = value;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const results = await db.find(collection.slug, filter, { page, limit, sort });
|
|
466
|
+
if (populate.length > 0) {
|
|
467
|
+
results.docs = await Promise.all(results.docs.map((doc) => populateDoc(db, collection.fields, doc, populate)));
|
|
468
|
+
}
|
|
469
|
+
results.docs = await Promise.all(results.docs.map((doc) => hydrateDoc(doc, collection.fields, c, config)));
|
|
470
|
+
return c.json(results);
|
|
471
|
+
},
|
|
472
|
+
async findOne(c) {
|
|
473
|
+
if (!await checkAccess(c, "read")) {
|
|
474
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
475
|
+
}
|
|
476
|
+
const queries = c.req.query();
|
|
477
|
+
const populate = queries.populate ? queries.populate.split(",") : [];
|
|
478
|
+
const id = c.req.param("id");
|
|
479
|
+
let doc = await db.findOne(collection.slug, { id });
|
|
480
|
+
if (!doc)
|
|
481
|
+
return c.json({ message: "Not found" }, 404);
|
|
482
|
+
if (populate.length > 0) {
|
|
483
|
+
doc = await populateDoc(db, collection.fields, doc, populate);
|
|
484
|
+
}
|
|
485
|
+
doc = await hydrateDoc(doc, collection.fields, c, config);
|
|
486
|
+
const permissions = await getFieldAccessPermissions(c, "read", collection.fields, doc);
|
|
487
|
+
const cleanDoc = { ...doc };
|
|
488
|
+
for (const [field, perm] of Object.entries(permissions)) {
|
|
489
|
+
if (perm.hidden) {
|
|
490
|
+
delete cleanDoc[field];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return c.json(cleanDoc);
|
|
494
|
+
},
|
|
495
|
+
async create(c) {
|
|
496
|
+
const body = await c.req.json();
|
|
497
|
+
if (!await checkAccess(c, "create", body)) {
|
|
498
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
499
|
+
}
|
|
500
|
+
for (const field of collection.fields) {
|
|
501
|
+
if (field.type === "slug" && !body[field.name]) {
|
|
502
|
+
const fromValue = body[field.from];
|
|
503
|
+
if (fromValue && typeof fromValue === "string") {
|
|
504
|
+
const formatted = field.format ? field.format(fromValue) : fromValue.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
505
|
+
body[field.name] = formatted;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const schema = generateSchemaForCollection(collection);
|
|
510
|
+
const validation = schema.safeParse(body);
|
|
511
|
+
if (!validation.success) {
|
|
512
|
+
return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
|
|
513
|
+
}
|
|
514
|
+
let data = validation.data;
|
|
515
|
+
const locale = c.req.header("x-opaca-locale");
|
|
516
|
+
if (locale && locale !== "all") {
|
|
517
|
+
for (const field of collection.fields) {
|
|
518
|
+
if (field.name && field.localized && data[field.name] !== undefined) {
|
|
519
|
+
data[field.name] = { [locale]: data[field.name] };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (collection.hooks?.beforeCreate) {
|
|
524
|
+
data = await collection.hooks.beforeCreate(data);
|
|
525
|
+
}
|
|
526
|
+
const doc = await db.create(collection.slug, data);
|
|
527
|
+
if (collection.hooks?.afterCreate) {
|
|
528
|
+
await collection.hooks.afterCreate(doc);
|
|
529
|
+
}
|
|
530
|
+
if (collection.webhooks) {
|
|
531
|
+
const afterCreateWebhooks = collection.webhooks.filter((w) => w.events.includes("afterCreate"));
|
|
532
|
+
for (const webhook of afterCreateWebhooks) {
|
|
533
|
+
const hookPromise = fetch(webhook.url, {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers: { "Content-Type": "application/json", ...webhook.headers },
|
|
536
|
+
body: JSON.stringify(doc)
|
|
537
|
+
}).catch((e) => logger.error(`Webhook afterCreate failed for ${collection.slug}: `, e));
|
|
538
|
+
if (c.executionCtx?.waitUntil) {
|
|
539
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (collection.versions) {
|
|
544
|
+
await saveVersion(doc, body._status);
|
|
545
|
+
}
|
|
546
|
+
const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
|
|
547
|
+
return c.json(hydratedDoc, 201);
|
|
548
|
+
},
|
|
549
|
+
async update(c) {
|
|
550
|
+
const id = c.req.param("id");
|
|
551
|
+
const body = await c.req.json();
|
|
552
|
+
if (!await checkAccess(c, "update", body)) {
|
|
553
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
554
|
+
}
|
|
555
|
+
for (const field of collection.fields) {
|
|
556
|
+
if (field.type === "slug" && !body[field.name]) {
|
|
557
|
+
const fromValue = body[field.from];
|
|
558
|
+
if (fromValue && typeof fromValue === "string") {
|
|
559
|
+
const formatted = field.format ? field.format(fromValue) : fromValue.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
560
|
+
body[field.name] = formatted;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const schema = generateSchemaForCollection(collection, true);
|
|
565
|
+
const validation = schema.safeParse(body);
|
|
566
|
+
if (!validation.success) {
|
|
567
|
+
return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
|
|
568
|
+
}
|
|
569
|
+
let data = validation.data;
|
|
570
|
+
const locale = c.req.header("x-opaca-locale");
|
|
571
|
+
if (locale && locale !== "all") {
|
|
572
|
+
let existing = null;
|
|
573
|
+
for (const field of collection.fields) {
|
|
574
|
+
if (field.name && field.localized && data[field.name] !== undefined) {
|
|
575
|
+
if (!existing) {
|
|
576
|
+
existing = await db.findOne(collection.slug, { id });
|
|
577
|
+
}
|
|
578
|
+
const currentObj = existing?.[field.name] || {};
|
|
579
|
+
data[field.name] = { ...currentObj, [locale]: data[field.name] };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (collection.hooks?.beforeUpdate) {
|
|
584
|
+
data = await collection.hooks.beforeUpdate(data);
|
|
585
|
+
}
|
|
586
|
+
const doc = await db.update(collection.slug, { id }, data);
|
|
587
|
+
if (collection.hooks?.afterUpdate) {
|
|
588
|
+
await collection.hooks.afterUpdate(doc);
|
|
589
|
+
}
|
|
590
|
+
if (collection.webhooks) {
|
|
591
|
+
const afterUpdateWebhooks = collection.webhooks.filter((w) => w.events.includes("afterUpdate"));
|
|
592
|
+
for (const webhook of afterUpdateWebhooks) {
|
|
593
|
+
const hookPromise = fetch(webhook.url, {
|
|
594
|
+
method: "POST",
|
|
595
|
+
headers: { "Content-Type": "application/json", ...webhook.headers },
|
|
596
|
+
body: JSON.stringify(doc)
|
|
597
|
+
}).catch((e) => logger.error(`Webhook afterUpdate failed for ${collection.slug}: `, e));
|
|
598
|
+
if (c.executionCtx?.waitUntil) {
|
|
599
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (collection.versions) {
|
|
604
|
+
await saveVersion(doc, body._status);
|
|
605
|
+
}
|
|
606
|
+
const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
|
|
607
|
+
return c.json(hydratedDoc);
|
|
608
|
+
},
|
|
609
|
+
async delete(c) {
|
|
610
|
+
if (!await checkAccess(c, "delete")) {
|
|
611
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
612
|
+
}
|
|
613
|
+
const id = c.req.param("id");
|
|
614
|
+
let docToPass = { id };
|
|
615
|
+
try {
|
|
616
|
+
if (collection.webhooks && collection.webhooks.some((w) => w.events.includes("afterDelete"))) {
|
|
617
|
+
docToPass = await db.findOne(collection.slug, { id });
|
|
618
|
+
}
|
|
619
|
+
} catch (e) {}
|
|
620
|
+
if (collection.hooks?.beforeDelete) {
|
|
621
|
+
await collection.hooks.beforeDelete(id);
|
|
622
|
+
}
|
|
623
|
+
await db.delete(collection.slug, { id });
|
|
624
|
+
if (collection.hooks?.afterDelete) {
|
|
625
|
+
await collection.hooks.afterDelete(id);
|
|
626
|
+
}
|
|
627
|
+
if (collection.webhooks) {
|
|
628
|
+
const afterDeleteWebhooks = collection.webhooks.filter((w) => w.events.includes("afterDelete"));
|
|
629
|
+
for (const webhook of afterDeleteWebhooks) {
|
|
630
|
+
const hookPromise = fetch(webhook.url, {
|
|
631
|
+
method: "POST",
|
|
632
|
+
headers: { "Content-Type": "application/json", ...webhook.headers },
|
|
633
|
+
body: JSON.stringify(docToPass || { id })
|
|
634
|
+
}).catch((e) => logger.error(`Webhook afterDelete failed for ${collection.slug}: `, e));
|
|
635
|
+
if (c.executionCtx?.waitUntil) {
|
|
636
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return c.json({ message: "Deleted" });
|
|
641
|
+
},
|
|
642
|
+
async findVersions(c) {
|
|
643
|
+
if (!await checkAccess(c, "read")) {
|
|
644
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
645
|
+
}
|
|
646
|
+
const parentId = c.req.query("parentId");
|
|
647
|
+
const versionsTable = `${collection.slug} _versions`.toLowerCase();
|
|
648
|
+
const query = parentId ? `SELECT * FROM ${versionsTable} WHERE _parent_id = ? ORDER BY created_at DESC` : `SELECT * FROM ${versionsTable} ORDER BY created_at DESC`;
|
|
649
|
+
const params = parentId ? [parentId] : [];
|
|
650
|
+
try {
|
|
651
|
+
const rows = await db.unsafe(query, params);
|
|
652
|
+
return c.json({ docs: rows });
|
|
653
|
+
} catch (err) {
|
|
654
|
+
return c.json({ message: "Failed to fetch versions", error: err.message }, 500);
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
async restoreVersion(c) {
|
|
658
|
+
if (!await checkAccess(c, "update")) {
|
|
659
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
660
|
+
}
|
|
661
|
+
const versionId = c.req.param("versionId");
|
|
662
|
+
const versionsTable = `${collection.slug} _versions`.toLowerCase();
|
|
663
|
+
try {
|
|
664
|
+
const versionRows = await db.unsafe(`SELECT * FROM ${versionsTable} WHERE id = ? `, [versionId]);
|
|
665
|
+
if (versionRows.length === 0)
|
|
666
|
+
return c.json({ message: "Version not found" }, 404);
|
|
667
|
+
const version = versionRows[0];
|
|
668
|
+
const data = JSON.parse(version._version_data);
|
|
669
|
+
const id = version._parent_id;
|
|
670
|
+
delete data.id;
|
|
671
|
+
delete data.created_at;
|
|
672
|
+
delete data.updated_at;
|
|
673
|
+
const doc = await db.update(collection.slug, { id }, data);
|
|
674
|
+
await saveVersion(doc, "published");
|
|
675
|
+
return c.json(doc);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
return c.json({ message: "Failed to restore version", error: err.message }, 500);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function createGlobalHandlers(config, globalConfig, getAuth) {
|
|
683
|
+
const { db } = config;
|
|
684
|
+
const checkAccess = async (c, action, data) => {
|
|
685
|
+
const access = globalConfig.access?.[action];
|
|
686
|
+
if (access === undefined)
|
|
687
|
+
return true;
|
|
688
|
+
if (typeof access === "boolean")
|
|
689
|
+
return access;
|
|
690
|
+
const user = c.get("user");
|
|
691
|
+
const session = c.get("session");
|
|
692
|
+
const apiKey = c.get("apiKey");
|
|
693
|
+
return await access({
|
|
694
|
+
req: c,
|
|
695
|
+
user,
|
|
696
|
+
session,
|
|
697
|
+
apiKey,
|
|
698
|
+
data
|
|
699
|
+
});
|
|
700
|
+
};
|
|
701
|
+
return {
|
|
702
|
+
async find(c) {
|
|
703
|
+
if (!await checkAccess(c, "read")) {
|
|
704
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
705
|
+
}
|
|
706
|
+
if (!db.findGlobal) {
|
|
707
|
+
return c.json({ message: "Globals are not supported by this database adapter" }, 501);
|
|
708
|
+
}
|
|
709
|
+
const doc = await db.findGlobal(globalConfig.slug);
|
|
710
|
+
const hydratedDoc = await hydrateDoc(doc || {}, globalConfig.fields, c, config);
|
|
711
|
+
return c.json(hydratedDoc);
|
|
712
|
+
},
|
|
713
|
+
async update(c) {
|
|
714
|
+
const body = await c.req.json();
|
|
715
|
+
if (!await checkAccess(c, "update", body)) {
|
|
716
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
717
|
+
}
|
|
718
|
+
if (!db.updateGlobal) {
|
|
719
|
+
return c.json({ message: "Globals are not supported by this database adapter" }, 501);
|
|
720
|
+
}
|
|
721
|
+
const schema = generateSchemaForCollection(globalConfig, true);
|
|
722
|
+
const validation = schema.safeParse(body);
|
|
723
|
+
if (!validation.success) {
|
|
724
|
+
logger.error(`Validation Error on Global ${globalConfig.slug}: `, validation.error.format());
|
|
725
|
+
return c.json({
|
|
726
|
+
message: "Validation Error",
|
|
727
|
+
errors: validation.error.format()
|
|
728
|
+
}, 400);
|
|
729
|
+
}
|
|
730
|
+
const doc = await db.updateGlobal(globalConfig.slug, validation.data);
|
|
731
|
+
const hydratedDoc = await hydrateDoc(doc, globalConfig.fields, c, config);
|
|
732
|
+
return c.json(hydratedDoc);
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/server/router.ts
|
|
738
|
+
import { Hono as Hono3 } from "hono";
|
|
739
|
+
|
|
740
|
+
// src/server/admin-router.ts
|
|
741
|
+
import { Hono } from "hono";
|
|
742
|
+
|
|
743
|
+
// src/server/middlewares/admin.ts
|
|
744
|
+
var adminMiddleware = async (c, next) => {
|
|
745
|
+
const user = c.get("user");
|
|
746
|
+
const isPublicAdmin = c.req.path.endsWith("/__admin/metadata") || c.req.path.endsWith("/__admin/setup");
|
|
747
|
+
if (!user && !isPublicAdmin) {
|
|
748
|
+
return c.json({ message: "Unauthorized" }, 401);
|
|
749
|
+
}
|
|
750
|
+
if (isPublicAdmin) {
|
|
751
|
+
await next();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (user.role === "admin" || user.role?.includes("admin")) {
|
|
755
|
+
await next();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// src/server/admin-router.ts
|
|
762
|
+
function createAdminRouter(config, state) {
|
|
763
|
+
const adminRouter = new Hono;
|
|
764
|
+
const adminHandlers = createAdminHandlers(config, () => state.auth);
|
|
765
|
+
adminRouter.get("/collections", adminMiddleware, adminHandlers.getCollections);
|
|
766
|
+
adminRouter.get("/metadata", adminHandlers.getMetadata);
|
|
767
|
+
adminRouter.get("/config", adminMiddleware, adminHandlers.getConfig);
|
|
768
|
+
adminRouter.get("/setup", adminHandlers.getSetupStatus);
|
|
769
|
+
adminRouter.post("/api-key/create", adminMiddleware, adminHandlers.createApiKey);
|
|
770
|
+
return adminRouter;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/server/collection-router.ts
|
|
774
|
+
init_system_schema();
|
|
775
|
+
function mountCollectionRoutes(router, config, state) {
|
|
776
|
+
const combinedCollections = [...config.collections];
|
|
777
|
+
for (const systemCol of getSystemCollections()) {
|
|
778
|
+
if (!combinedCollections.find((c) => c.slug === systemCol.slug)) {
|
|
779
|
+
combinedCollections.push(systemCol);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const exposedCollections = combinedCollections.filter((c) => !c.hidden);
|
|
783
|
+
for (const collection of exposedCollections) {
|
|
784
|
+
const handlers = createHandlers(config, collection, () => state.auth);
|
|
785
|
+
const path = `/${collection.apiPath || collection.slug}`;
|
|
786
|
+
router.get(path, handlers.find);
|
|
787
|
+
router.get(`${path}/versions`, handlers.findVersions);
|
|
788
|
+
router.get(`${path}/:id`, handlers.findOne);
|
|
789
|
+
router.post(path, handlers.create);
|
|
790
|
+
router.patch(`${path}/:id`, handlers.update);
|
|
791
|
+
router.post(`${path}/versions/:versionId/restore`, handlers.restoreVersion);
|
|
792
|
+
router.delete(`${path}/:id`, handlers.delete);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function mountGlobalRoutes(router, config, state) {
|
|
796
|
+
if (config.globals) {
|
|
797
|
+
for (const globalConfig of config.globals) {
|
|
798
|
+
const handlers = createGlobalHandlers(config, globalConfig, () => state.auth);
|
|
799
|
+
const path = `/globals/${globalConfig.slug}`;
|
|
800
|
+
router.get(path, handlers.find);
|
|
801
|
+
router.post(path, handlers.update);
|
|
802
|
+
router.patch(path, handlers.update);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/server/middlewares/auth.ts
|
|
808
|
+
function createAuthMiddleware(getAuth) {
|
|
809
|
+
return async (c, next) => {
|
|
810
|
+
const auth = getAuth();
|
|
811
|
+
if (!auth) {
|
|
812
|
+
c.set("user", null);
|
|
813
|
+
c.set("session", null);
|
|
814
|
+
c.set("apiKey", null);
|
|
815
|
+
await next();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
819
|
+
if (session) {
|
|
820
|
+
c.set("user", session.user);
|
|
821
|
+
c.set("session", session.session);
|
|
822
|
+
c.set("apiKey", null);
|
|
823
|
+
await next();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const authHeader = c.req.header("Authorization");
|
|
827
|
+
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
828
|
+
const token = authHeader.split(" ")[1];
|
|
829
|
+
if (token) {
|
|
830
|
+
try {
|
|
831
|
+
const result = await auth.api.verifyApiKey({
|
|
832
|
+
headers: c.req.raw.headers,
|
|
833
|
+
body: { key: token }
|
|
834
|
+
});
|
|
835
|
+
if (result && result.valid && result.key) {
|
|
836
|
+
c.set("apiKey", {
|
|
837
|
+
id: result.key.id,
|
|
838
|
+
name: result.key.name,
|
|
839
|
+
permissions: result.key.permissions,
|
|
840
|
+
referenceId: result.key.referenceId
|
|
841
|
+
});
|
|
842
|
+
try {
|
|
843
|
+
const ownerResult = await auth.options.database?.findOne?.("_users", {
|
|
844
|
+
id: result.key.referenceId
|
|
845
|
+
});
|
|
846
|
+
c.set("user", ownerResult || null);
|
|
847
|
+
} catch (e) {
|
|
848
|
+
logger.warn("Failed to fetch API key owner from database:", e);
|
|
849
|
+
c.set("user", null);
|
|
850
|
+
}
|
|
851
|
+
c.set("session", null);
|
|
852
|
+
await next();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
} catch (err) {
|
|
856
|
+
logger.warn("API Key verification failed:", err);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
c.set("user", null);
|
|
861
|
+
c.set("session", null);
|
|
862
|
+
c.set("apiKey", null);
|
|
863
|
+
await next();
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/server/middlewares/context.ts
|
|
868
|
+
function createContextMiddleware(config) {
|
|
869
|
+
return async (c, next) => {
|
|
870
|
+
c.set("config", config);
|
|
871
|
+
c.set("db", config.db);
|
|
872
|
+
await next();
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// src/server/middlewares/cors.ts
|
|
877
|
+
import { cors } from "hono/cors";
|
|
878
|
+
function createCorsMiddleware(config) {
|
|
879
|
+
const trustedOrigins = config.trustedOrigins || [];
|
|
880
|
+
return cors({
|
|
881
|
+
origin: async (origin, _c) => {
|
|
882
|
+
const allowed = typeof trustedOrigins === "function" ? await trustedOrigins(_c.req.raw) : trustedOrigins;
|
|
883
|
+
if (Array.isArray(allowed) && allowed.includes(origin)) {
|
|
884
|
+
return origin;
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
},
|
|
888
|
+
allowMethods: ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
889
|
+
exposeHeaders: ["Content-Length"],
|
|
890
|
+
credentials: true
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/auth/migrations.ts
|
|
895
|
+
init_system_schema();
|
|
896
|
+
async function runAuthMigrations(db) {
|
|
897
|
+
const rawDb = db.raw;
|
|
898
|
+
if (!rawDb) {
|
|
899
|
+
logger.error("Database not connected yet. Skipping auth migrations.");
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const isPostgres = db.name === "postgres";
|
|
903
|
+
const authCollections = getSystemCollections().filter((c) => ["_users", "_sessions", "_accounts", "_verifications", "_api_keys"].includes(c.slug));
|
|
904
|
+
try {
|
|
905
|
+
for (const collection of authCollections) {
|
|
906
|
+
const columnDefs = [];
|
|
907
|
+
columnDefs.push(`"id" TEXT PRIMARY KEY`);
|
|
908
|
+
for (const field of collection.fields) {
|
|
909
|
+
if (field.name === "id")
|
|
910
|
+
continue;
|
|
911
|
+
let type = "TEXT";
|
|
912
|
+
if (field.type === "number")
|
|
913
|
+
type = "INTEGER";
|
|
914
|
+
if (field.type === "boolean")
|
|
915
|
+
type = isPostgres ? "BOOLEAN" : "INTEGER";
|
|
916
|
+
if (field.type === "date")
|
|
917
|
+
type = isPostgres ? "TIMESTAMPTZ" : "TEXT";
|
|
918
|
+
let definition = `"${toSnakeCase(field.name)}" ${type}`;
|
|
919
|
+
if (field.required)
|
|
920
|
+
definition += " NOT NULL";
|
|
921
|
+
if (field.unique)
|
|
922
|
+
definition += " UNIQUE";
|
|
923
|
+
if (field.defaultValue !== undefined) {
|
|
924
|
+
const val = typeof field.defaultValue === "string" ? `'${field.defaultValue}'` : field.defaultValue;
|
|
925
|
+
definition += ` DEFAULT ${val}`;
|
|
926
|
+
}
|
|
927
|
+
if (field.references) {
|
|
928
|
+
definition += ` REFERENCES "${field.references.table}"("${toSnakeCase(field.references.column)}")`;
|
|
929
|
+
if (field.references.onDelete) {
|
|
930
|
+
definition += ` ON DELETE ${field.references.onDelete.toUpperCase()}`;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
columnDefs.push(definition);
|
|
934
|
+
}
|
|
935
|
+
const ts = collection.timestamps;
|
|
936
|
+
if (ts !== false && ts !== undefined) {
|
|
937
|
+
const config = typeof ts === "object" ? ts : {};
|
|
938
|
+
const createdField = toSnakeCase(config.createdAt || "createdAt");
|
|
939
|
+
const updatedField = toSnakeCase(config.updatedAt || "updatedAt");
|
|
940
|
+
const fieldNames = new Set(collection.fields.map((f) => toSnakeCase(f.name)));
|
|
941
|
+
if (!fieldNames.has(createdField)) {
|
|
942
|
+
columnDefs.push(`"${createdField}" ${isPostgres ? "TIMESTAMPTZ" : "TEXT"} DEFAULT CURRENT_TIMESTAMP`);
|
|
943
|
+
}
|
|
944
|
+
if (!fieldNames.has(updatedField)) {
|
|
945
|
+
columnDefs.push(`"${updatedField}" ${isPostgres ? "TIMESTAMPTZ" : "TEXT"} DEFAULT CURRENT_TIMESTAMP`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
logger.info(` -> Verifying table: ${logger.format("green", `"${collection.slug}"`)}`);
|
|
949
|
+
await db.unsafe(`CREATE TABLE IF NOT EXISTS "${collection.slug}" (${columnDefs.join(", ")})`);
|
|
950
|
+
}
|
|
951
|
+
logger.success("Auth tables verified/created successfully.");
|
|
952
|
+
} catch (error) {
|
|
953
|
+
logger.error("Failed to create auth tables:", error);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/server/middlewares/database-init.ts
|
|
958
|
+
init_system_schema();
|
|
959
|
+
function createDatabaseInitMiddleware(config, state) {
|
|
960
|
+
const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
|
|
961
|
+
return async (_c, next) => {
|
|
962
|
+
if (!state.migrated) {
|
|
963
|
+
const isDev = typeof process !== "undefined" && true;
|
|
964
|
+
if (isDev) {
|
|
965
|
+
logger.info(`Connecting to database: ${logger.format("yellow", config.db.name)}...`);
|
|
966
|
+
} else {
|
|
967
|
+
logger.debug(`Connecting to database: ${config.db.name}...`);
|
|
968
|
+
}
|
|
969
|
+
await config.db.connect();
|
|
970
|
+
if (isDev) {
|
|
971
|
+
logger.debug("Synchronizing database schema...");
|
|
972
|
+
}
|
|
973
|
+
const allCollections = [...config.collections];
|
|
974
|
+
for (const systemCol of getSystemCollections()) {
|
|
975
|
+
if (!allCollections.find((c) => c.slug === systemCol.slug)) {
|
|
976
|
+
allCollections.push(systemCol);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
await config.db.migrate(allCollections, config.globals);
|
|
980
|
+
if (isDev) {
|
|
981
|
+
logger.success("Database schema synchronized.");
|
|
982
|
+
}
|
|
983
|
+
const shouldMigrate = config.runMigrationsOnStartup || isDev;
|
|
984
|
+
if (shouldMigrate) {
|
|
985
|
+
if (config.runMigrationsOnStartup && config.db.runMigrations) {
|
|
986
|
+
logger.info("Running file-based migrations on startup...");
|
|
987
|
+
await config.db.runMigrations();
|
|
988
|
+
}
|
|
989
|
+
await runAuthMigrations(config.db);
|
|
990
|
+
} else {
|
|
991
|
+
logger.debug("Automatic schema migrations skipped (Production).");
|
|
992
|
+
}
|
|
993
|
+
if (supportsAuth && !state.auth) {
|
|
994
|
+
state.auth = await createAuth(config);
|
|
995
|
+
}
|
|
996
|
+
state.migrated = true;
|
|
997
|
+
}
|
|
998
|
+
await next();
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/server/middlewares/rate-limit.ts
|
|
1003
|
+
import { WorkersKVStore } from "@hono-rate-limiter/cloudflare";
|
|
1004
|
+
import { rateLimiter } from "hono-rate-limiter";
|
|
1005
|
+
function createRateLimitMiddleware(config) {
|
|
1006
|
+
const rateLimitConfig = config.api?.rateLimit;
|
|
1007
|
+
if (rateLimitConfig?.enabled === false) {
|
|
1008
|
+
return async (_c, next) => await next();
|
|
1009
|
+
}
|
|
1010
|
+
const windowMs = rateLimitConfig?.windowMs || 60000;
|
|
1011
|
+
const limit = rateLimitConfig?.limit || 100;
|
|
1012
|
+
return async (c, next) => {
|
|
1013
|
+
let provider = rateLimitConfig?.provider?.(c);
|
|
1014
|
+
if (!provider && !rateLimitConfig?.store && c.env) {
|
|
1015
|
+
const rateLimitKey = Object.keys(c.env).find((key) => c.env[key]?.limit && typeof c.env[key].limit === "function");
|
|
1016
|
+
if (rateLimitKey) {
|
|
1017
|
+
provider = c.env[rateLimitKey];
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (provider) {
|
|
1021
|
+
const limiter2 = rateLimiter({
|
|
1022
|
+
binding: () => provider,
|
|
1023
|
+
keyGenerator: rateLimitConfig?.keyGenerator || ((c2) => c2.req.header("cf-connecting-ip") || c2.req.header("x-forwarded-for") || "anonymous")
|
|
1024
|
+
});
|
|
1025
|
+
return limiter2(c, next);
|
|
1026
|
+
}
|
|
1027
|
+
let resolvedStore = rateLimitConfig?.store;
|
|
1028
|
+
if (!resolvedStore && c.env) {
|
|
1029
|
+
const kvBindingKey = Object.keys(c.env).find((key) => key.startsWith("OPACA_") && c.env[key]?.put && c.env[key]?.get);
|
|
1030
|
+
if (kvBindingKey) {
|
|
1031
|
+
resolvedStore = new WorkersKVStore({ namespace: c.env[kvBindingKey] });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const limiter = rateLimiter({
|
|
1035
|
+
windowMs,
|
|
1036
|
+
limit,
|
|
1037
|
+
standardHeaders: "draft-6",
|
|
1038
|
+
store: resolvedStore,
|
|
1039
|
+
keyGenerator: rateLimitConfig?.keyGenerator || ((c2) => c2.req.header("cf-connecting-ip") || c2.req.header("x-forwarded-for") || "anonymous"),
|
|
1040
|
+
message: "Too many requests from this IP, please try again after a minute."
|
|
1041
|
+
});
|
|
1042
|
+
return limiter(c, next);
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/server/setup-middlewares.ts
|
|
1047
|
+
function setupMiddlewares(router, config, state) {
|
|
1048
|
+
router.use("*", async (c, next) => {
|
|
1049
|
+
await next();
|
|
1050
|
+
c.res.headers.set("X-Powered-By", "OpacaCMS");
|
|
1051
|
+
});
|
|
1052
|
+
router.use("*", createContextMiddleware(config));
|
|
1053
|
+
router.use("*", createRateLimitMiddleware(config));
|
|
1054
|
+
router.use("*", createCorsMiddleware(config));
|
|
1055
|
+
router.use("*", createDatabaseInitMiddleware(config, state));
|
|
1056
|
+
router.onError((err, c) => {
|
|
1057
|
+
logger.error(`API Error: ${err.message}`, err);
|
|
1058
|
+
return c.json({ message: "Internal Server Error", error: err.message }, 500);
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
function setupAuthMiddlewares(router, config, state) {
|
|
1062
|
+
const supportsAuth = config.db.name === "sqlite" || config.db.name === "postgres" || config.db.name === "d1";
|
|
1063
|
+
if (supportsAuth) {
|
|
1064
|
+
router.use("*", createAuthMiddleware(() => state.auth));
|
|
1065
|
+
router.on(["POST", "GET"], ["/auth/*"], async (c) => {
|
|
1066
|
+
if (!state.auth) {
|
|
1067
|
+
return c.json({ message: "Auth not initialized" }, 503);
|
|
1068
|
+
}
|
|
1069
|
+
return await state.auth.handler(c.req.raw);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/server/system-router.ts
|
|
1075
|
+
init_system_schema();
|
|
1076
|
+
import { Hono as Hono2 } from "hono";
|
|
1077
|
+
|
|
1078
|
+
// src/server/assets.ts
|
|
1079
|
+
function createAssetsHandlers(config) {
|
|
1080
|
+
return {
|
|
1081
|
+
async upload(c) {
|
|
1082
|
+
const user = c.get("user");
|
|
1083
|
+
if (!user)
|
|
1084
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1085
|
+
const bucket = c.req.query("bucket") || "default";
|
|
1086
|
+
if (!config.storages)
|
|
1087
|
+
return c.json({ error: "Storage not configured" }, 500);
|
|
1088
|
+
const storageAdapter = config.storages[bucket];
|
|
1089
|
+
if (!storageAdapter) {
|
|
1090
|
+
return c.json({ error: `Bucket '${bucket}' not found` }, 404);
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
try {
|
|
1094
|
+
if (config.db.name === "sqlite" || config.db.name === "d1") {
|
|
1095
|
+
const tableInfo = await config.db.unsafe(`PRAGMA table_info(_opaca_assets)`);
|
|
1096
|
+
const columns = tableInfo.map((c2) => c2.name);
|
|
1097
|
+
if (!columns.includes("folder"))
|
|
1098
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
|
|
1099
|
+
if (!columns.includes("alt_text"))
|
|
1100
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN alt_text TEXT`);
|
|
1101
|
+
if (!columns.includes("caption"))
|
|
1102
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
|
|
1103
|
+
} else if (config.db.name === "postgres") {
|
|
1104
|
+
const checkCols = await config.db.unsafe(`
|
|
1105
|
+
SELECT column_name FROM information_schema.columns
|
|
1106
|
+
WHERE table_name = '_opaca_assets' AND column_name IN ('folder', 'alt_text', 'caption')
|
|
1107
|
+
`);
|
|
1108
|
+
const existing = checkCols.map((c2) => c2.column_name);
|
|
1109
|
+
if (!existing.includes("folder"))
|
|
1110
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
|
|
1111
|
+
if (!existing.includes("alt_text"))
|
|
1112
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN "alt_text" TEXT`);
|
|
1113
|
+
if (!existing.includes("caption"))
|
|
1114
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
|
|
1115
|
+
}
|
|
1116
|
+
} catch (e) {
|
|
1117
|
+
console.error("Auto-patch columns failed", e);
|
|
1118
|
+
}
|
|
1119
|
+
const folder = c.req.query("folder") || null;
|
|
1120
|
+
const keyPrefix = folder ? `${folder}/` : "";
|
|
1121
|
+
const now = new Date().toISOString();
|
|
1122
|
+
const formData = await c.req.parseBody({ all: true });
|
|
1123
|
+
const fileRaw = formData["file"];
|
|
1124
|
+
const file = Array.isArray(fileRaw) ? fileRaw[0] : fileRaw;
|
|
1125
|
+
if (!file || typeof file !== "object" && typeof file !== "string") {
|
|
1126
|
+
return c.json({ error: "No file provided" }, 400);
|
|
1127
|
+
}
|
|
1128
|
+
const fileName = file.name || "unnamed";
|
|
1129
|
+
const fileType = file.type || "application/octet-stream";
|
|
1130
|
+
const fileSize = file.size || 0;
|
|
1131
|
+
const fileRecord = {
|
|
1132
|
+
filename: fileName,
|
|
1133
|
+
original_filename: fileName,
|
|
1134
|
+
mime_type: fileType,
|
|
1135
|
+
filesize: fileSize,
|
|
1136
|
+
stream: typeof file.stream === "function" ? file.stream() : new Response(file).body
|
|
1137
|
+
};
|
|
1138
|
+
const uploadedFileData = await storageAdapter.upload(fileRecord, {
|
|
1139
|
+
generateUniqueName: true,
|
|
1140
|
+
keyPrefix
|
|
1141
|
+
});
|
|
1142
|
+
const storedKey = keyPrefix + uploadedFileData.filename;
|
|
1143
|
+
try {
|
|
1144
|
+
const assetId = (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "");
|
|
1145
|
+
await config.db.create("_opaca_assets", {
|
|
1146
|
+
id: assetId,
|
|
1147
|
+
key: storedKey,
|
|
1148
|
+
filename: fileName,
|
|
1149
|
+
originalFilename: fileName,
|
|
1150
|
+
mimeType: uploadedFileData.mime_type,
|
|
1151
|
+
filesize: uploadedFileData.filesize,
|
|
1152
|
+
bucket,
|
|
1153
|
+
folder,
|
|
1154
|
+
altText: null,
|
|
1155
|
+
caption: null,
|
|
1156
|
+
uploadedBy: user.id || null
|
|
1157
|
+
});
|
|
1158
|
+
return c.json({
|
|
1159
|
+
assetId,
|
|
1160
|
+
...uploadedFileData,
|
|
1161
|
+
key: storedKey
|
|
1162
|
+
}, 201);
|
|
1163
|
+
} catch (dbError) {
|
|
1164
|
+
console.error(`[OpacaCMS] Registry insert failed, rolling back physical file upload: ${storedKey}`);
|
|
1165
|
+
storageAdapter.delete(storedKey).catch((cleanupError) => {
|
|
1166
|
+
console.error(`[OpacaCMS] CRITICAL: Failed to clean up orphaned file ${storedKey}!`, cleanupError);
|
|
1167
|
+
});
|
|
1168
|
+
throw dbError;
|
|
1169
|
+
}
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
return c.json({ error: error.message }, 400);
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
async list(c) {
|
|
1175
|
+
const user = c.get("user");
|
|
1176
|
+
if (!user || user.role !== "admin" && !user.role?.includes("admin")) {
|
|
1177
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1178
|
+
}
|
|
1179
|
+
const bucket = c.req.query("bucket") || "all";
|
|
1180
|
+
const page = parseInt(c.req.query("page") || "1", 10);
|
|
1181
|
+
const limit = parseInt(c.req.query("limit") || "20", 10);
|
|
1182
|
+
const offset = (page - 1) * limit;
|
|
1183
|
+
const folder = c.req.query("folder") || null;
|
|
1184
|
+
try {
|
|
1185
|
+
let query = {};
|
|
1186
|
+
if (bucket !== "all")
|
|
1187
|
+
query.bucket = bucket;
|
|
1188
|
+
if (folder !== null && folder !== "") {
|
|
1189
|
+
query.folder = folder;
|
|
1190
|
+
} else {
|
|
1191
|
+
if (bucket !== "all") {
|
|
1192
|
+
query = {
|
|
1193
|
+
and: [{ bucket }, { or: [{ folder: null }, { folder: "" }] }]
|
|
1194
|
+
};
|
|
1195
|
+
} else {
|
|
1196
|
+
query = { or: [{ folder: null }, { folder: "" }] };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const result = await config.db.find("_opaca_assets", query, {
|
|
1200
|
+
page,
|
|
1201
|
+
limit,
|
|
1202
|
+
sort: "created_at:desc"
|
|
1203
|
+
});
|
|
1204
|
+
const rows = result.docs;
|
|
1205
|
+
const total = result.totalDocs;
|
|
1206
|
+
let folderRows = [];
|
|
1207
|
+
const bucketFilter = bucket !== "all" ? `AND bucket = ?` : "";
|
|
1208
|
+
const bucketParam = bucket !== "all" ? [bucket] : [];
|
|
1209
|
+
if (config.db.name === "postgres") {
|
|
1210
|
+
const pgBucketFilter = bucketFilter.replace("?", "$1");
|
|
1211
|
+
if (folder === null || folder === "") {
|
|
1212
|
+
folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${pgBucketFilter}`, bucketParam);
|
|
1213
|
+
} else {
|
|
1214
|
+
folderRows = await config.db.unsafe(`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" : ""}`, [folder, `${folder}/%`, ...bucketParam]);
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
if (folder === null || folder === "") {
|
|
1218
|
+
folderRows = await config.db.unsafe(`
|
|
1219
|
+
SELECT DISTINCT
|
|
1220
|
+
CASE
|
|
1221
|
+
WHEN INSTR(folder, '/') > 0 THEN SUBSTR(folder, 1, INSTR(folder, '/') - 1)
|
|
1222
|
+
ELSE folder
|
|
1223
|
+
END as subfolder,
|
|
1224
|
+
bucket
|
|
1225
|
+
FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${bucketFilter}
|
|
1226
|
+
`, bucketParam);
|
|
1227
|
+
} else {
|
|
1228
|
+
const skipLen = folder.length + 2;
|
|
1229
|
+
folderRows = await config.db.unsafe(`
|
|
1230
|
+
SELECT DISTINCT
|
|
1231
|
+
CASE
|
|
1232
|
+
WHEN INSTR(SUBSTR(folder, ?), '/') > 0 THEN SUBSTR(SUBSTR(folder, ?), 1, INSTR(SUBSTR(folder, ?), '/') - 1)
|
|
1233
|
+
ELSE SUBSTR(folder, ?)
|
|
1234
|
+
END as subfolder,
|
|
1235
|
+
bucket
|
|
1236
|
+
FROM _opaca_assets WHERE folder LIKE ? ${bucketFilter}
|
|
1237
|
+
`, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, ...bucketParam]);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const folderMap = {};
|
|
1241
|
+
for (const row of folderRows) {
|
|
1242
|
+
if (!row.subfolder)
|
|
1243
|
+
continue;
|
|
1244
|
+
if (!folderMap[row.subfolder])
|
|
1245
|
+
folderMap[row.subfolder] = [];
|
|
1246
|
+
if (!folderMap[row.subfolder]?.includes(row.bucket)) {
|
|
1247
|
+
folderMap[row.subfolder]?.push(row.bucket);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const folders = Object.entries(folderMap).map(([name, buckets]) => ({
|
|
1251
|
+
name,
|
|
1252
|
+
buckets
|
|
1253
|
+
}));
|
|
1254
|
+
return c.json({
|
|
1255
|
+
docs: rows,
|
|
1256
|
+
folders,
|
|
1257
|
+
totalDocs: total,
|
|
1258
|
+
limit,
|
|
1259
|
+
page,
|
|
1260
|
+
totalPages: Math.ceil(total / limit)
|
|
1261
|
+
});
|
|
1262
|
+
} catch (e) {
|
|
1263
|
+
return c.json({ error: e.message }, 500);
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
1266
|
+
async presign(c) {
|
|
1267
|
+
const user = c.get("user");
|
|
1268
|
+
if (!user)
|
|
1269
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1270
|
+
const { filename, bucket = "default", operation = "write" } = await c.req.json();
|
|
1271
|
+
if (!config.storages || !config.storages[bucket]) {
|
|
1272
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
1273
|
+
}
|
|
1274
|
+
const adapter = config.storages[bucket];
|
|
1275
|
+
if (!adapter.generatePresignedUrl) {
|
|
1276
|
+
return c.json({ error: "Adapter does not support presigned URLs" }, 400);
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
const url = await adapter.generatePresignedUrl(filename, operation, 3600);
|
|
1280
|
+
return c.json({ uploadUrl: url, filename });
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
return c.json({ error: e.message }, 500);
|
|
1283
|
+
}
|
|
1284
|
+
},
|
|
1285
|
+
async serve(c) {
|
|
1286
|
+
const id = c.req.param("id");
|
|
1287
|
+
try {
|
|
1288
|
+
const asset = await config.db.findOne("_opaca_assets", { id });
|
|
1289
|
+
if (!asset) {
|
|
1290
|
+
return c.json({ error: "Asset not found" }, 404);
|
|
1291
|
+
}
|
|
1292
|
+
const bucket = asset.bucket || "default";
|
|
1293
|
+
if (!config.storages || !config.storages[bucket]) {
|
|
1294
|
+
return c.json({ error: "Storage bucket not configured" }, 500);
|
|
1295
|
+
}
|
|
1296
|
+
const adapter = config.storages[bucket];
|
|
1297
|
+
if (!adapter.download) {
|
|
1298
|
+
return c.json({ error: "Storage adapter does not support direct downloads" }, 400);
|
|
1299
|
+
}
|
|
1300
|
+
const stream = await adapter.download(asset.key || asset.filename);
|
|
1301
|
+
c.header("Content-Type", asset.mimeType || "application/octet-stream");
|
|
1302
|
+
c.header("Content-Length", asset.filesize.toString());
|
|
1303
|
+
c.header("Cache-Control", "public, max-age=86400");
|
|
1304
|
+
return c.body(stream);
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
console.error(`[OpacaCMS] Failed to serve asset ${id}:`, e);
|
|
1307
|
+
return c.json({ error: e.message }, 500);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// src/server/system-router.ts
|
|
1314
|
+
function createSystemRouter(config) {
|
|
1315
|
+
const systemRouter = new Hono2;
|
|
1316
|
+
if (config.storages) {
|
|
1317
|
+
const assetsHandlers = createAssetsHandlers(config);
|
|
1318
|
+
systemRouter.post("/assets/upload", adminMiddleware, assetsHandlers.upload);
|
|
1319
|
+
systemRouter.get("/assets", adminMiddleware, assetsHandlers.list);
|
|
1320
|
+
systemRouter.post("/assets/presign-upload", adminMiddleware, assetsHandlers.presign);
|
|
1321
|
+
}
|
|
1322
|
+
return systemRouter;
|
|
1323
|
+
}
|
|
1324
|
+
function createAssetsServingRouter(config) {
|
|
1325
|
+
const assetsServingRouter = new Hono2;
|
|
1326
|
+
if (config.storages) {
|
|
1327
|
+
const assetsHandlers = createAssetsHandlers(config);
|
|
1328
|
+
const assetCol = getSystemCollections().find((c) => c.slug === "_opaca_assets");
|
|
1329
|
+
const assetPath = `/${assetCol?.apiPath || assetCol?.slug || "_opaca_assets"}`;
|
|
1330
|
+
assetsServingRouter.get(`${assetPath}/:id/view`, assetsHandlers.serve);
|
|
1331
|
+
}
|
|
1332
|
+
return assetsServingRouter;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// src/server/router.ts
|
|
1336
|
+
function createAPIRouter(config) {
|
|
1337
|
+
const state = { auth: undefined, migrated: false };
|
|
1338
|
+
const router = new Hono3().basePath("/api");
|
|
1339
|
+
setupMiddlewares(router, config, state);
|
|
1340
|
+
setupAuthMiddlewares(router, config, state);
|
|
1341
|
+
router.get("/", (c) => {
|
|
1342
|
+
return c.json({ status: "ok", version: "1.0.0", appName: config.appName });
|
|
1343
|
+
});
|
|
1344
|
+
router.route("/__admin", createAdminRouter(config, state));
|
|
1345
|
+
router.route("/__system", createSystemRouter(config));
|
|
1346
|
+
router.route("/", createAssetsServingRouter(config));
|
|
1347
|
+
mountCollectionRoutes(router, config, state);
|
|
1348
|
+
mountGlobalRoutes(router, config, state);
|
|
1349
|
+
return router;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
export { createAdminHandlers, hydrateDoc, createHandlers, createGlobalHandlers, createAPIRouter };
|