@actuate-media/cms-core 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/dist/__tests__/actions/document-crud.test.d.ts +2 -0
- package/dist/__tests__/actions/document-crud.test.d.ts.map +1 -0
- package/dist/__tests__/actions/document-crud.test.js +156 -0
- package/dist/__tests__/actions/document-crud.test.js.map +1 -0
- package/dist/__tests__/auth/password.test.d.ts +2 -0
- package/dist/__tests__/auth/password.test.d.ts.map +1 -0
- package/dist/__tests__/auth/password.test.js +102 -0
- package/dist/__tests__/auth/password.test.js.map +1 -0
- package/dist/__tests__/auth/session.test.d.ts +2 -0
- package/dist/__tests__/auth/session.test.d.ts.map +1 -0
- package/dist/__tests__/auth/session.test.js +66 -0
- package/dist/__tests__/auth/session.test.js.map +1 -0
- package/dist/__tests__/codegen/generate-types.test.d.ts +2 -0
- package/dist/__tests__/codegen/generate-types.test.d.ts.map +1 -0
- package/dist/__tests__/codegen/generate-types.test.js +173 -0
- package/dist/__tests__/codegen/generate-types.test.js.map +1 -0
- package/dist/__tests__/scheduling/scheduling.test.d.ts +2 -0
- package/dist/__tests__/scheduling/scheduling.test.d.ts.map +1 -0
- package/dist/__tests__/scheduling/scheduling.test.js +84 -0
- package/dist/__tests__/scheduling/scheduling.test.js.map +1 -0
- package/dist/__tests__/security/access.test.d.ts +2 -0
- package/dist/__tests__/security/access.test.d.ts.map +1 -0
- package/dist/__tests__/security/access.test.js +181 -0
- package/dist/__tests__/security/access.test.js.map +1 -0
- package/dist/__tests__/security/csrf.test.d.ts +2 -0
- package/dist/__tests__/security/csrf.test.d.ts.map +1 -0
- package/dist/__tests__/security/csrf.test.js +40 -0
- package/dist/__tests__/security/csrf.test.js.map +1 -0
- package/dist/__tests__/security/rate-limit.test.d.ts +2 -0
- package/dist/__tests__/security/rate-limit.test.d.ts.map +1 -0
- package/dist/__tests__/security/rate-limit.test.js +62 -0
- package/dist/__tests__/security/rate-limit.test.js.map +1 -0
- package/dist/__tests__/security/reauth.test.d.ts +2 -0
- package/dist/__tests__/security/reauth.test.d.ts.map +1 -0
- package/dist/__tests__/security/reauth.test.js +30 -0
- package/dist/__tests__/security/reauth.test.js.map +1 -0
- package/dist/__tests__/security/sanitize.test.d.ts +2 -0
- package/dist/__tests__/security/sanitize.test.d.ts.map +1 -0
- package/dist/__tests__/security/sanitize.test.js +75 -0
- package/dist/__tests__/security/sanitize.test.js.map +1 -0
- package/dist/__tests__/webhooks/webhooks.test.d.ts +2 -0
- package/dist/__tests__/webhooks/webhooks.test.d.ts.map +1 -0
- package/dist/__tests__/webhooks/webhooks.test.js +96 -0
- package/dist/__tests__/webhooks/webhooks.test.js.map +1 -0
- package/dist/a11y/index.d.ts +25 -0
- package/dist/a11y/index.d.ts.map +1 -0
- package/dist/a11y/index.js +88 -0
- package/dist/a11y/index.js.map +1 -0
- package/dist/actions.d.ts +42 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +391 -0
- package/dist/actions.js.map +1 -0
- package/dist/api/handler-factory.d.ts +7 -0
- package/dist/api/handler-factory.d.ts.map +1 -0
- package/dist/api/handler-factory.js +120 -0
- package/dist/api/handler-factory.js.map +1 -0
- package/dist/api/handlers.d.ts +4 -0
- package/dist/api/handlers.d.ts.map +1 -0
- package/dist/api/handlers.js +2119 -0
- package/dist/api/handlers.js.map +1 -0
- package/dist/api/index.d.ts +23 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +57 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/openapi.d.ts +3 -0
- package/dist/api/openapi.d.ts.map +1 -0
- package/dist/api/openapi.js +348 -0
- package/dist/api/openapi.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oauth.d.ts +84 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +201 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/password.d.ts +13 -0
- package/dist/auth/password.d.ts.map +1 -0
- package/dist/auth/password.js +47 -0
- package/dist/auth/password.js.map +1 -0
- package/dist/auth/providers/github.d.ts +9 -0
- package/dist/auth/providers/github.d.ts.map +1 -0
- package/dist/auth/providers/github.js +10 -0
- package/dist/auth/providers/github.js.map +1 -0
- package/dist/auth/providers/google.d.ts +9 -0
- package/dist/auth/providers/google.d.ts.map +1 -0
- package/dist/auth/providers/google.js +10 -0
- package/dist/auth/providers/google.js.map +1 -0
- package/dist/auth/providers/microsoft.d.ts +9 -0
- package/dist/auth/providers/microsoft.d.ts.map +1 -0
- package/dist/auth/providers/microsoft.js +11 -0
- package/dist/auth/providers/microsoft.js.map +1 -0
- package/dist/auth/session.d.ts +21 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +35 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/totp.d.ts +5 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/totp.js +86 -0
- package/dist/auth/totp.js.map +1 -0
- package/dist/backup/index.d.ts +19 -0
- package/dist/backup/index.d.ts.map +1 -0
- package/dist/backup/index.js +22 -0
- package/dist/backup/index.js.map +1 -0
- package/dist/cache/index.d.ts +15 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +32 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/client.d.ts +30 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +50 -0
- package/dist/client.js.map +1 -0
- package/dist/codegen/index.d.ts +4 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +370 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/collections/index.d.ts +17 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/collections/index.js +29 -0
- package/dist/collections/index.js.map +1 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +74 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +307 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +3 -0
- package/dist/config/types.js.map +1 -0
- package/dist/content/ai-api.d.ts +21 -0
- package/dist/content/ai-api.d.ts.map +1 -0
- package/dist/content/ai-api.js +19 -0
- package/dist/content/ai-api.js.map +1 -0
- package/dist/content/content-graph.d.ts +25 -0
- package/dist/content/content-graph.d.ts.map +1 -0
- package/dist/content/content-graph.js +40 -0
- package/dist/content/content-graph.js.map +1 -0
- package/dist/content/extract.d.ts +7 -0
- package/dist/content/extract.d.ts.map +1 -0
- package/dist/content/extract.js +33 -0
- package/dist/content/extract.js.map +1 -0
- package/dist/content/index.d.ts +8 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +5 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/structured-data.d.ts +80 -0
- package/dist/content/structured-data.d.ts.map +1 -0
- package/dist/content/structured-data.js +295 -0
- package/dist/content/structured-data.js.map +1 -0
- package/dist/db/adapters/mysql.d.ts +5 -0
- package/dist/db/adapters/mysql.d.ts.map +1 -0
- package/dist/db/adapters/mysql.js +18 -0
- package/dist/db/adapters/mysql.js.map +1 -0
- package/dist/db/adapters/postgres.d.ts +7 -0
- package/dist/db/adapters/postgres.d.ts.map +1 -0
- package/dist/db/adapters/postgres.js +20 -0
- package/dist/db/adapters/postgres.js.map +1 -0
- package/dist/db/adapters/sqlite.d.ts +5 -0
- package/dist/db/adapters/sqlite.d.ts.map +1 -0
- package/dist/db/adapters/sqlite.js +19 -0
- package/dist/db/adapters/sqlite.js.map +1 -0
- package/dist/db/create-adapter.d.ts +11 -0
- package/dist/db/create-adapter.d.ts.map +1 -0
- package/dist/db/create-adapter.js +43 -0
- package/dist/db/create-adapter.js.map +1 -0
- package/dist/db/index.d.ts +9 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +5 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db.d.ts +20 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +35 -0
- package/dist/db.js.map +1 -0
- package/dist/fields/index.d.ts +15 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +87 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/forms/analytics.d.ts +62 -0
- package/dist/forms/analytics.d.ts.map +1 -0
- package/dist/forms/analytics.js +95 -0
- package/dist/forms/analytics.js.map +1 -0
- package/dist/forms/attribution.d.ts +29 -0
- package/dist/forms/attribution.d.ts.map +1 -0
- package/dist/forms/attribution.js +216 -0
- package/dist/forms/attribution.js.map +1 -0
- package/dist/forms/index.d.ts +5 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +3 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/graphql/index.d.ts +11 -0
- package/dist/graphql/index.d.ts.map +1 -0
- package/dist/graphql/index.js +58 -0
- package/dist/graphql/index.js.map +1 -0
- package/dist/graphql/resolvers.d.ts +8 -0
- package/dist/graphql/resolvers.d.ts.map +1 -0
- package/dist/graphql/resolvers.js +93 -0
- package/dist/graphql/resolvers.js.map +1 -0
- package/dist/graphql/schema-builder.d.ts +3 -0
- package/dist/graphql/schema-builder.d.ts.map +1 -0
- package/dist/graphql/schema-builder.js +103 -0
- package/dist/graphql/schema-builder.js.map +1 -0
- package/dist/health/index.d.ts +27 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +43 -0
- package/dist/health/index.js.map +1 -0
- package/dist/i18n/index.d.ts +22 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +37 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +3 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +2 -0
- package/dist/media/index.js.map +1 -0
- package/dist/media/optimize.d.ts +40 -0
- package/dist/media/optimize.d.ts.map +1 -0
- package/dist/media/optimize.js +137 -0
- package/dist/media/optimize.js.map +1 -0
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +86 -0
- package/dist/middleware.js.map +1 -0
- package/dist/multisite/index.d.ts +20 -0
- package/dist/multisite/index.d.ts.map +1 -0
- package/dist/multisite/index.js +26 -0
- package/dist/multisite/index.js.map +1 -0
- package/dist/next/preview.d.ts +10 -0
- package/dist/next/preview.d.ts.map +1 -0
- package/dist/next/preview.js +17 -0
- package/dist/next/preview.js.map +1 -0
- package/dist/next.d.ts +9 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +35 -0
- package/dist/next.js.map +1 -0
- package/dist/notifications/index.d.ts +20 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +22 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/presence/index.d.ts +24 -0
- package/dist/presence/index.d.ts.map +1 -0
- package/dist/presence/index.js +99 -0
- package/dist/presence/index.js.map +1 -0
- package/dist/preview/index.d.ts +14 -0
- package/dist/preview/index.d.ts.map +1 -0
- package/dist/preview/index.js +45 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/privacy/index.d.ts +33 -0
- package/dist/privacy/index.d.ts.map +1 -0
- package/dist/privacy/index.js +15 -0
- package/dist/privacy/index.js.map +1 -0
- package/dist/relationships/index.d.ts +13 -0
- package/dist/relationships/index.d.ts.map +1 -0
- package/dist/relationships/index.js +12 -0
- package/dist/relationships/index.js.map +1 -0
- package/dist/scheduling/index.d.ts +44 -0
- package/dist/scheduling/index.d.ts.map +1 -0
- package/dist/scheduling/index.js +119 -0
- package/dist/scheduling/index.js.map +1 -0
- package/dist/search/index.d.ts +25 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +168 -0
- package/dist/search/index.js.map +1 -0
- package/dist/security/access.d.ts +26 -0
- package/dist/security/access.d.ts.map +1 -0
- package/dist/security/access.js +92 -0
- package/dist/security/access.js.map +1 -0
- package/dist/security/anomaly-detection.d.ts +17 -0
- package/dist/security/anomaly-detection.d.ts.map +1 -0
- package/dist/security/anomaly-detection.js +17 -0
- package/dist/security/anomaly-detection.js.map +1 -0
- package/dist/security/api-key-enhanced.d.ts +25 -0
- package/dist/security/api-key-enhanced.d.ts.map +1 -0
- package/dist/security/api-key-enhanced.js +25 -0
- package/dist/security/api-key-enhanced.js.map +1 -0
- package/dist/security/audit.d.ts +39 -0
- package/dist/security/audit.d.ts.map +1 -0
- package/dist/security/audit.js +40 -0
- package/dist/security/audit.js.map +1 -0
- package/dist/security/breach-check.d.ts +3 -0
- package/dist/security/breach-check.d.ts.map +1 -0
- package/dist/security/breach-check.js +27 -0
- package/dist/security/breach-check.js.map +1 -0
- package/dist/security/cors.d.ts +11 -0
- package/dist/security/cors.d.ts.map +1 -0
- package/dist/security/cors.js +33 -0
- package/dist/security/cors.js.map +1 -0
- package/dist/security/csp-nonces.d.ts +5 -0
- package/dist/security/csp-nonces.d.ts.map +1 -0
- package/dist/security/csp-nonces.js +24 -0
- package/dist/security/csp-nonces.js.map +1 -0
- package/dist/security/csrf.d.ts +5 -0
- package/dist/security/csrf.d.ts.map +1 -0
- package/dist/security/csrf.js +20 -0
- package/dist/security/csrf.js.map +1 -0
- package/dist/security/encrypted-fields.d.ts +5 -0
- package/dist/security/encrypted-fields.d.ts.map +1 -0
- package/dist/security/encrypted-fields.js +40 -0
- package/dist/security/encrypted-fields.js.map +1 -0
- package/dist/security/headers.d.ts +11 -0
- package/dist/security/headers.d.ts.map +1 -0
- package/dist/security/headers.js +32 -0
- package/dist/security/headers.js.map +1 -0
- package/dist/security/index.d.ts +31 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +20 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/ip-allowlist.d.ts +3 -0
- package/dist/security/ip-allowlist.d.ts.map +1 -0
- package/dist/security/ip-allowlist.js +35 -0
- package/dist/security/ip-allowlist.js.map +1 -0
- package/dist/security/middleware.d.ts +20 -0
- package/dist/security/middleware.d.ts.map +1 -0
- package/dist/security/middleware.js +45 -0
- package/dist/security/middleware.js.map +1 -0
- package/dist/security/rate-limit.d.ts +24 -0
- package/dist/security/rate-limit.d.ts.map +1 -0
- package/dist/security/rate-limit.js +84 -0
- package/dist/security/rate-limit.js.map +1 -0
- package/dist/security/reauth.d.ts +15 -0
- package/dist/security/reauth.d.ts.map +1 -0
- package/dist/security/reauth.js +38 -0
- package/dist/security/reauth.js.map +1 -0
- package/dist/security/sanitize.d.ts +13 -0
- package/dist/security/sanitize.d.ts.map +1 -0
- package/dist/security/sanitize.js +34 -0
- package/dist/security/sanitize.js.map +1 -0
- package/dist/security/security-txt.d.ts +12 -0
- package/dist/security/security-txt.d.ts.map +1 -0
- package/dist/security/security-txt.js +19 -0
- package/dist/security/security-txt.js.map +1 -0
- package/dist/security/session-limits.d.ts +17 -0
- package/dist/security/session-limits.d.ts.map +1 -0
- package/dist/security/session-limits.js +14 -0
- package/dist/security/session-limits.js.map +1 -0
- package/dist/security/upload.d.ts +13 -0
- package/dist/security/upload.d.ts.map +1 -0
- package/dist/security/upload.js +34 -0
- package/dist/security/upload.js.map +1 -0
- package/dist/security/webhook.d.ts +12 -0
- package/dist/security/webhook.d.ts.map +1 -0
- package/dist/security/webhook.js +38 -0
- package/dist/security/webhook.js.map +1 -0
- package/dist/seo/analysis.d.ts +66 -0
- package/dist/seo/analysis.d.ts.map +1 -0
- package/dist/seo/analysis.js +594 -0
- package/dist/seo/analysis.js.map +1 -0
- package/dist/seo/index.d.ts +9 -0
- package/dist/seo/index.d.ts.map +1 -0
- package/dist/seo/index.js +5 -0
- package/dist/seo/index.js.map +1 -0
- package/dist/seo/llms-txt.d.ts +16 -0
- package/dist/seo/llms-txt.d.ts.map +1 -0
- package/dist/seo/llms-txt.js +70 -0
- package/dist/seo/llms-txt.js.map +1 -0
- package/dist/seo/meta-tags.d.ts +33 -0
- package/dist/seo/meta-tags.d.ts.map +1 -0
- package/dist/seo/meta-tags.js +159 -0
- package/dist/seo/meta-tags.js.map +1 -0
- package/dist/seo/title-templates.d.ts +17 -0
- package/dist/seo/title-templates.d.ts.map +1 -0
- package/dist/seo/title-templates.js +28 -0
- package/dist/seo/title-templates.js.map +1 -0
- package/dist/setup/index.d.ts +38 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +77 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/storage/index.d.ts +11 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +23 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/upgrade/changelog.d.ts +13 -0
- package/dist/upgrade/changelog.d.ts.map +1 -0
- package/dist/upgrade/changelog.js +54 -0
- package/dist/upgrade/changelog.js.map +1 -0
- package/dist/upgrade/index.d.ts +7 -0
- package/dist/upgrade/index.d.ts.map +1 -0
- package/dist/upgrade/index.js +4 -0
- package/dist/upgrade/index.js.map +1 -0
- package/dist/upgrade/upgrade-pr.d.ts +16 -0
- package/dist/upgrade/upgrade-pr.d.ts.map +1 -0
- package/dist/upgrade/upgrade-pr.js +38 -0
- package/dist/upgrade/upgrade-pr.js.map +1 -0
- package/dist/upgrade/version-check.d.ts +17 -0
- package/dist/upgrade/version-check.d.ts.map +1 -0
- package/dist/upgrade/version-check.js +30 -0
- package/dist/upgrade/version-check.js.map +1 -0
- package/dist/webhooks/index.d.ts +46 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +245 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/workflow/index.d.ts +8 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +56 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflows/index.d.ts +30 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +14 -0
- package/dist/workflows/index.js.map +1 -0
- package/generated/browser.ts +109 -0
- package/generated/client.ts +133 -0
- package/generated/commonInputTypes.ts +709 -0
- package/generated/enums.ts +125 -0
- package/generated/internal/class.ts +376 -0
- package/generated/internal/prismaNamespace.ts +2617 -0
- package/generated/internal/prismaNamespaceBrowser.ts +611 -0
- package/generated/models/ApiKey.ts +1550 -0
- package/generated/models/AuditLog.ts +1206 -0
- package/generated/models/BackupRecord.ts +1250 -0
- package/generated/models/ContentLock.ts +1472 -0
- package/generated/models/ContentTemplate.ts +1416 -0
- package/generated/models/Document.ts +3005 -0
- package/generated/models/Folder.ts +1904 -0
- package/generated/models/FormSubmission.ts +1200 -0
- package/generated/models/InAppNotification.ts +1457 -0
- package/generated/models/Media.ts +2340 -0
- package/generated/models/MediaUsage.ts +1472 -0
- package/generated/models/OAuthAccount.ts +1463 -0
- package/generated/models/Redirect.ts +1284 -0
- package/generated/models/Session.ts +1492 -0
- package/generated/models/Site.ts +1206 -0
- package/generated/models/User.ts +3513 -0
- package/generated/models/Version.ts +1511 -0
- package/generated/models/WorkflowState.ts +1514 -0
- package/generated/models.ts +29 -0
- package/package.json +83 -0
|
@@ -0,0 +1,2119 @@
|
|
|
1
|
+
import { listDocuments, getDocument, createDocument, updateDocument, deleteDocument, getGlobal, updateGlobal, } from '../actions';
|
|
2
|
+
import { verifyPassword } from '../auth/password';
|
|
3
|
+
import { createSession, verifySession, revokeSession } from '../auth/session';
|
|
4
|
+
import { checkSetupRequired, createInitialAdmin } from '../setup/index';
|
|
5
|
+
import { getDB } from '../db';
|
|
6
|
+
import { generateCodeVerifier, generateCodeChallenge, generateState, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth';
|
|
7
|
+
import { optimizeImage, formatBytes } from '../media/optimize';
|
|
8
|
+
import { generateToken as generateCsrfToken } from '../security/csrf';
|
|
9
|
+
import { logEvent } from '../security/audit';
|
|
10
|
+
import { applyFieldAccess } from '../security/access';
|
|
11
|
+
import { createPreviewAdapter } from '../preview/index';
|
|
12
|
+
import { schedulingCronHandler } from '../scheduling/index';
|
|
13
|
+
import { createRateLimiter } from '../security/rate-limit';
|
|
14
|
+
import { generateOpenAPISpec } from './openapi';
|
|
15
|
+
import { createSSEPresenceAdapter } from '../presence/index';
|
|
16
|
+
const SECURITY_HEADERS = {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
'X-Content-Type-Options': 'nosniff',
|
|
19
|
+
'X-Frame-Options': 'DENY',
|
|
20
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
21
|
+
};
|
|
22
|
+
function json(data, status = 200) {
|
|
23
|
+
return new Response(JSON.stringify(data), {
|
|
24
|
+
status,
|
|
25
|
+
headers: { ...SECURITY_HEADERS },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function errorResponse(message, status) {
|
|
29
|
+
return json({ error: message }, status);
|
|
30
|
+
}
|
|
31
|
+
function internalError(err, context) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
+
console.error(`[actuate][api]${context ? ` ${context}:` : ''} ${msg}`);
|
|
34
|
+
return errorResponse('Internal server error', 500);
|
|
35
|
+
}
|
|
36
|
+
function clampPageSize(raw, max = 100, fallback = 20) {
|
|
37
|
+
const n = Number(raw) || fallback;
|
|
38
|
+
return Math.min(Math.max(1, n), max);
|
|
39
|
+
}
|
|
40
|
+
function isAllowedStorageUrl(url) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(url);
|
|
43
|
+
if (!['https:', 'http:'].includes(parsed.protocol))
|
|
44
|
+
return false;
|
|
45
|
+
const h = parsed.hostname;
|
|
46
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
|
|
47
|
+
return false;
|
|
48
|
+
if (h.startsWith('10.') || h.startsWith('192.168.'))
|
|
49
|
+
return false;
|
|
50
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
|
|
51
|
+
return false;
|
|
52
|
+
if (h === '169.254.169.254')
|
|
53
|
+
return false;
|
|
54
|
+
if (h.endsWith('.internal') || h.endsWith('.local'))
|
|
55
|
+
return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const ALLOWED_SORT_FIELDS = new Set([
|
|
63
|
+
'createdAt', 'updatedAt', 'publishedAt', 'status', 'collection',
|
|
64
|
+
]);
|
|
65
|
+
function getSessionSecret() {
|
|
66
|
+
const secret = process.env.CMS_SECRET;
|
|
67
|
+
if (!secret)
|
|
68
|
+
throw new Error('CMS_SECRET environment variable is not set');
|
|
69
|
+
if (secret.length < 32)
|
|
70
|
+
throw new Error('CMS_SECRET must be at least 32 characters');
|
|
71
|
+
return secret;
|
|
72
|
+
}
|
|
73
|
+
async function extractSession(request) {
|
|
74
|
+
let token;
|
|
75
|
+
const authHeader = request.headers.get('Authorization');
|
|
76
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
77
|
+
token = authHeader.slice(7);
|
|
78
|
+
}
|
|
79
|
+
if (!token) {
|
|
80
|
+
const cookieHeader = request.headers.get('cookie') ?? '';
|
|
81
|
+
const cookies = parseCookieHeader(cookieHeader);
|
|
82
|
+
token = cookies['actuate_session'];
|
|
83
|
+
}
|
|
84
|
+
if (!token)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
const payload = await verifySession(token, { secret: getSessionSecret() });
|
|
88
|
+
// Verify session is not revoked in DB
|
|
89
|
+
const dbSession = await getDB().session.findUnique({
|
|
90
|
+
where: { id: payload.sessionId },
|
|
91
|
+
select: { revokedAt: true },
|
|
92
|
+
});
|
|
93
|
+
if (!dbSession || dbSession.revokedAt)
|
|
94
|
+
return null;
|
|
95
|
+
return payload;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function parseCookieHeader(cookieHeader) {
|
|
102
|
+
const cookies = {};
|
|
103
|
+
for (const pair of cookieHeader.split(';')) {
|
|
104
|
+
const [key, ...rest] = pair.split('=');
|
|
105
|
+
if (key) {
|
|
106
|
+
cookies[key.trim()] = rest.join('=').trim();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return cookies;
|
|
110
|
+
}
|
|
111
|
+
async function requireAuth(request) {
|
|
112
|
+
const session = await extractSession(request);
|
|
113
|
+
if (!session) {
|
|
114
|
+
return { error: errorResponse('Unauthorized', 401) };
|
|
115
|
+
}
|
|
116
|
+
return { session };
|
|
117
|
+
}
|
|
118
|
+
function buildActionContext(session, db, locale) {
|
|
119
|
+
return {
|
|
120
|
+
userId: session.userId,
|
|
121
|
+
role: session.role,
|
|
122
|
+
locale,
|
|
123
|
+
db,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const WRITE_ROLES = new Set(['ADMIN', 'EDITOR', 'AUTHOR']);
|
|
127
|
+
const ADMIN_ROLES = new Set(['ADMIN']);
|
|
128
|
+
function requireRole(role, allowedRoles) {
|
|
129
|
+
if (!allowedRoles.has(role)) {
|
|
130
|
+
return errorResponse('Insufficient permissions', 403);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
|
|
135
|
+
const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
|
|
136
|
+
async function checkRateLimitAsync(limiter, key) {
|
|
137
|
+
const result = await limiter.check(key);
|
|
138
|
+
return result.allowed;
|
|
139
|
+
}
|
|
140
|
+
export function registerCMSRoutes(router) {
|
|
141
|
+
const db = () => getDB();
|
|
142
|
+
const presenceAdapter = createSSEPresenceAdapter();
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// CSRF token endpoint
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
router.get('/auth/csrf', async () => {
|
|
147
|
+
const token = await generateCsrfToken();
|
|
148
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
149
|
+
const cookieFlags = [
|
|
150
|
+
`actuate_csrf=${token}`,
|
|
151
|
+
'Path=/',
|
|
152
|
+
'SameSite=Lax',
|
|
153
|
+
'Max-Age=86400',
|
|
154
|
+
];
|
|
155
|
+
if (isProduction)
|
|
156
|
+
cookieFlags.push('Secure');
|
|
157
|
+
const response = json({ data: { token } });
|
|
158
|
+
response.headers.set('Set-Cookie', cookieFlags.join('; '));
|
|
159
|
+
return response;
|
|
160
|
+
});
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// OpenAPI spec
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
router.get('/openapi.json', async () => {
|
|
165
|
+
const config = globalThis.__actuateConfig;
|
|
166
|
+
if (!config)
|
|
167
|
+
return errorResponse('CMS not configured', 500);
|
|
168
|
+
const spec = generateOpenAPISpec(config);
|
|
169
|
+
return new Response(JSON.stringify(spec, null, 2), {
|
|
170
|
+
status: 200,
|
|
171
|
+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
router.get('/docs', async () => {
|
|
175
|
+
const html = `<!DOCTYPE html>
|
|
176
|
+
<html>
|
|
177
|
+
<head>
|
|
178
|
+
<title>Actuate CMS API Reference</title>
|
|
179
|
+
<meta charset="utf-8" />
|
|
180
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
181
|
+
</head>
|
|
182
|
+
<body>
|
|
183
|
+
<script id="api-reference" data-url="/api/cms/openapi.json"></script>
|
|
184
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
185
|
+
</body>
|
|
186
|
+
</html>`;
|
|
187
|
+
return new Response(html, {
|
|
188
|
+
status: 200,
|
|
189
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Auth routes
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
router.post('/auth/login', async (request) => {
|
|
196
|
+
try {
|
|
197
|
+
const body = await request.json();
|
|
198
|
+
const { email, password } = body;
|
|
199
|
+
if (!email || !password) {
|
|
200
|
+
return errorResponse('Email and password are required', 400);
|
|
201
|
+
}
|
|
202
|
+
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
|
203
|
+
if (!(await checkRateLimitAsync(loginLimiter, `login:${clientIp}`))) {
|
|
204
|
+
return errorResponse('Too many login attempts. Please try again later.', 429);
|
|
205
|
+
}
|
|
206
|
+
const user = await db().user.findFirst({
|
|
207
|
+
where: { email: email.toLowerCase().trim() },
|
|
208
|
+
});
|
|
209
|
+
if (!user) {
|
|
210
|
+
return errorResponse('Invalid email or password', 401);
|
|
211
|
+
}
|
|
212
|
+
if (!user.passwordHash) {
|
|
213
|
+
return errorResponse('This account uses social login. Please sign in with your OAuth provider.', 400);
|
|
214
|
+
}
|
|
215
|
+
const passwordValid = await verifyPassword(password, user.passwordHash);
|
|
216
|
+
if (!passwordValid) {
|
|
217
|
+
await logEvent({
|
|
218
|
+
event: 'login_failed',
|
|
219
|
+
userId: user.id,
|
|
220
|
+
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
|
|
221
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
222
|
+
});
|
|
223
|
+
return errorResponse('Invalid email or password', 401);
|
|
224
|
+
}
|
|
225
|
+
if (!user.isActive) {
|
|
226
|
+
return errorResponse('Account is deactivated', 403);
|
|
227
|
+
}
|
|
228
|
+
if (user.totpEnabled) {
|
|
229
|
+
return json({ data: { requiresTOTP: true, userId: user.id } });
|
|
230
|
+
}
|
|
231
|
+
const tempSessionId = crypto.randomUUID();
|
|
232
|
+
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
233
|
+
await db().session.create({
|
|
234
|
+
data: {
|
|
235
|
+
id: tempSessionId,
|
|
236
|
+
userId: user.id,
|
|
237
|
+
token,
|
|
238
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
239
|
+
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
|
|
240
|
+
userAgent: request.headers.get('user-agent') ?? null,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const response = json({
|
|
244
|
+
data: {
|
|
245
|
+
token,
|
|
246
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
250
|
+
const sessionCookie = [
|
|
251
|
+
`actuate_session=${token}`,
|
|
252
|
+
'Path=/',
|
|
253
|
+
'HttpOnly',
|
|
254
|
+
'SameSite=Lax',
|
|
255
|
+
'Max-Age=604800',
|
|
256
|
+
];
|
|
257
|
+
if (isProduction)
|
|
258
|
+
sessionCookie.push('Secure');
|
|
259
|
+
const csrfToken = await generateCsrfToken();
|
|
260
|
+
const csrfCookie = [
|
|
261
|
+
`actuate_csrf=${csrfToken}`,
|
|
262
|
+
'Path=/',
|
|
263
|
+
'SameSite=Lax',
|
|
264
|
+
'Max-Age=86400',
|
|
265
|
+
];
|
|
266
|
+
if (isProduction)
|
|
267
|
+
csrfCookie.push('Secure');
|
|
268
|
+
response.headers.append('Set-Cookie', sessionCookie.join('; '));
|
|
269
|
+
response.headers.append('Set-Cookie', csrfCookie.join('; '));
|
|
270
|
+
await logEvent({
|
|
271
|
+
event: 'login_success',
|
|
272
|
+
userId: user.id,
|
|
273
|
+
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
|
|
274
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
275
|
+
});
|
|
276
|
+
return response;
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return errorResponse('Login failed', 500);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
router.post('/auth/logout', async (request) => {
|
|
283
|
+
try {
|
|
284
|
+
const auth = await requireAuth(request);
|
|
285
|
+
if (auth.error)
|
|
286
|
+
return auth.error;
|
|
287
|
+
await revokeSession(auth.session.sessionId, db());
|
|
288
|
+
await logEvent({
|
|
289
|
+
event: 'logout',
|
|
290
|
+
userId: auth.session.userId,
|
|
291
|
+
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
|
|
292
|
+
});
|
|
293
|
+
const response = json({ data: { success: true } });
|
|
294
|
+
response.headers.set('Set-Cookie', 'actuate_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
295
|
+
return response;
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
return errorResponse('Logout failed', 500);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
router.get('/auth/me', async (request) => {
|
|
302
|
+
try {
|
|
303
|
+
const auth = await requireAuth(request);
|
|
304
|
+
if (auth.error)
|
|
305
|
+
return auth.error;
|
|
306
|
+
const user = await db().user.findUnique({
|
|
307
|
+
where: { id: auth.session.userId },
|
|
308
|
+
select: { id: true, email: true, name: true, role: true, isActive: true },
|
|
309
|
+
});
|
|
310
|
+
if (!user) {
|
|
311
|
+
return errorResponse('User not found', 404);
|
|
312
|
+
}
|
|
313
|
+
return json({ data: user });
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
return errorResponse('Failed to fetch user', 500);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// TOTP routes
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
router.post('/auth/totp/setup', async (request) => {
|
|
323
|
+
try {
|
|
324
|
+
const auth = await requireAuth(request);
|
|
325
|
+
if (auth.error)
|
|
326
|
+
return auth.error;
|
|
327
|
+
const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp');
|
|
328
|
+
const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { email: true, totpEnabled: true } });
|
|
329
|
+
if (!user)
|
|
330
|
+
return errorResponse('User not found', 404);
|
|
331
|
+
if (user.totpEnabled)
|
|
332
|
+
return errorResponse('TOTP already enabled', 400);
|
|
333
|
+
const secret = generateTOTPSecret();
|
|
334
|
+
const uri = generateTOTPUri(secret, user.email);
|
|
335
|
+
const backups = generateBackupCodes();
|
|
336
|
+
await db().user.update({ where: { id: auth.session.userId }, data: { totpSecret: secret, backupCodes: backups } });
|
|
337
|
+
return json({ data: { secret, uri, backupCodes: backups } });
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
return internalError(err);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
router.post('/auth/totp/verify', async (request) => {
|
|
344
|
+
try {
|
|
345
|
+
const auth = await requireAuth(request);
|
|
346
|
+
if (auth.error)
|
|
347
|
+
return auth.error;
|
|
348
|
+
const body = await request.json();
|
|
349
|
+
if (!body.code)
|
|
350
|
+
return errorResponse('Code is required', 400);
|
|
351
|
+
const { verifyTOTP } = await import('../auth/totp');
|
|
352
|
+
const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { totpSecret: true } });
|
|
353
|
+
if (!user?.totpSecret)
|
|
354
|
+
return errorResponse('TOTP not set up', 400);
|
|
355
|
+
const valid = verifyTOTP(body.code, user.totpSecret);
|
|
356
|
+
if (!valid)
|
|
357
|
+
return errorResponse('Invalid code', 400);
|
|
358
|
+
await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: true } });
|
|
359
|
+
return json({ data: { enabled: true } });
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
return internalError(err);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
router.post('/auth/totp/disable', async (request) => {
|
|
366
|
+
try {
|
|
367
|
+
const auth = await requireAuth(request);
|
|
368
|
+
if (auth.error)
|
|
369
|
+
return auth.error;
|
|
370
|
+
await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: false, totpSecret: null, backupCodes: null } });
|
|
371
|
+
return json({ data: { enabled: false } });
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
return internalError(err);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
router.post('/auth/totp/login', async (request) => {
|
|
378
|
+
try {
|
|
379
|
+
const body = await request.json();
|
|
380
|
+
if (!body.userId || !body.code)
|
|
381
|
+
return errorResponse('userId and code are required', 400);
|
|
382
|
+
const { verifyTOTP } = await import('../auth/totp');
|
|
383
|
+
const user = await db().user.findUnique({ where: { id: body.userId }, select: { id: true, email: true, role: true, totpSecret: true, totpEnabled: true, isActive: true } });
|
|
384
|
+
if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret)
|
|
385
|
+
return errorResponse('Invalid request', 400);
|
|
386
|
+
const valid = verifyTOTP(body.code, user.totpSecret);
|
|
387
|
+
if (!valid)
|
|
388
|
+
return errorResponse('Invalid code', 401);
|
|
389
|
+
const tempSessionId = crypto.randomUUID();
|
|
390
|
+
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
391
|
+
await db().session.create({ data: { id: tempSessionId, userId: user.id, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) } });
|
|
392
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
393
|
+
const sessionCookie = [`actuate_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${7 * 24 * 3600}`, ...(isProduction ? ['Secure'] : [])].join('; ');
|
|
394
|
+
return new Response(JSON.stringify({ data: { token, user: { id: user.id, email: user.email, role: user.role } } }), { status: 200, headers: { ...SECURITY_HEADERS, 'Set-Cookie': sessionCookie } });
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
return internalError(err);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// OAuth routes
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
router.get('/auth/oauth/:provider', async (request, params) => {
|
|
404
|
+
try {
|
|
405
|
+
const provider = params.provider;
|
|
406
|
+
if (!['google', 'github', 'microsoft'].includes(provider)) {
|
|
407
|
+
return errorResponse('Unsupported OAuth provider', 400);
|
|
408
|
+
}
|
|
409
|
+
const secret = getSessionSecret();
|
|
410
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
|
411
|
+
const oauthProviders = {};
|
|
412
|
+
const envPrefix = provider.toUpperCase();
|
|
413
|
+
const clientId = process.env[`OAUTH_${envPrefix}_CLIENT_ID`] ?? '';
|
|
414
|
+
const clientSecret = process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '';
|
|
415
|
+
if (!clientId || !clientSecret) {
|
|
416
|
+
return errorResponse(`OAuth provider "${provider}" is not configured`, 400);
|
|
417
|
+
}
|
|
418
|
+
oauthProviders[provider] = {
|
|
419
|
+
clientId,
|
|
420
|
+
clientSecret,
|
|
421
|
+
redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
|
|
422
|
+
};
|
|
423
|
+
const codeVerifier = generateCodeVerifier();
|
|
424
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
425
|
+
const state = await generateState(provider, codeVerifier, '/admin', secret);
|
|
426
|
+
const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
|
|
427
|
+
return json({ data: { url } });
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
return internalError(err);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
router.get('/auth/oauth/:provider/callback', async (request, params) => {
|
|
434
|
+
try {
|
|
435
|
+
const provider = params.provider;
|
|
436
|
+
if (!['google', 'github', 'microsoft'].includes(provider)) {
|
|
437
|
+
return errorResponse('Unsupported OAuth provider', 400);
|
|
438
|
+
}
|
|
439
|
+
const url = new URL(request.url);
|
|
440
|
+
const code = url.searchParams.get('code');
|
|
441
|
+
const stateToken = url.searchParams.get('state');
|
|
442
|
+
const errorParam = url.searchParams.get('error');
|
|
443
|
+
if (errorParam) {
|
|
444
|
+
const desc = url.searchParams.get('error_description') ?? errorParam;
|
|
445
|
+
return new Response(null, {
|
|
446
|
+
status: 302,
|
|
447
|
+
headers: { Location: `/admin/login?error=${encodeURIComponent(desc)}` },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (!code || !stateToken) {
|
|
451
|
+
return errorResponse('Missing code or state parameter', 400);
|
|
452
|
+
}
|
|
453
|
+
const secret = getSessionSecret();
|
|
454
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? url.origin;
|
|
455
|
+
const oauthProviders = {};
|
|
456
|
+
const envPrefix = provider.toUpperCase();
|
|
457
|
+
oauthProviders[provider] = {
|
|
458
|
+
clientId: process.env[`OAUTH_${envPrefix}_CLIENT_ID`] ?? '',
|
|
459
|
+
clientSecret: process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '',
|
|
460
|
+
redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
|
|
461
|
+
};
|
|
462
|
+
const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db());
|
|
463
|
+
const cookieFlags = [
|
|
464
|
+
`actuate_session=${result.token}`,
|
|
465
|
+
'Path=/',
|
|
466
|
+
'HttpOnly',
|
|
467
|
+
'SameSite=Lax',
|
|
468
|
+
'Max-Age=604800',
|
|
469
|
+
];
|
|
470
|
+
if (siteUrl.startsWith('https')) {
|
|
471
|
+
cookieFlags.push('Secure');
|
|
472
|
+
}
|
|
473
|
+
return new Response(null, {
|
|
474
|
+
status: 302,
|
|
475
|
+
headers: {
|
|
476
|
+
Location: '/admin',
|
|
477
|
+
'Set-Cookie': cookieFlags.join('; '),
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
const message = err instanceof Error ? err.message : 'OAuth callback failed';
|
|
483
|
+
return new Response(null, {
|
|
484
|
+
status: 302,
|
|
485
|
+
headers: { Location: `/admin/login?error=${encodeURIComponent(message)}` },
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Document routes
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
router.get('/collections/:slug', async (request, params) => {
|
|
493
|
+
try {
|
|
494
|
+
const auth = await requireAuth(request);
|
|
495
|
+
if (auth.error)
|
|
496
|
+
return auth.error;
|
|
497
|
+
const url = new URL(request.url);
|
|
498
|
+
const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
|
|
499
|
+
const result = await listDocuments({
|
|
500
|
+
collection: params.slug,
|
|
501
|
+
page: Number(url.searchParams.get('page')) || 1,
|
|
502
|
+
pageSize: clampPageSize(url.searchParams.get('pageSize')),
|
|
503
|
+
sort: url.searchParams.get('sort') ?? undefined,
|
|
504
|
+
order: url.searchParams.get('order') ?? undefined,
|
|
505
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
506
|
+
locale: url.searchParams.get('locale') ?? undefined,
|
|
507
|
+
folderId: url.searchParams.get('folderId') ?? undefined,
|
|
508
|
+
}, ctx);
|
|
509
|
+
const collectionConfig = globalThis.__actuateConfig?.collections?.[params.slug];
|
|
510
|
+
const fields = collectionConfig?.fields;
|
|
511
|
+
if (fields && result.docs.length > 0) {
|
|
512
|
+
const user = { id: auth.session.userId, role: auth.session.role };
|
|
513
|
+
result.docs = await Promise.all(result.docs.map(async (doc) => {
|
|
514
|
+
if (doc.data && typeof doc.data === 'object') {
|
|
515
|
+
return {
|
|
516
|
+
...doc,
|
|
517
|
+
data: await applyFieldAccess('read', fields, doc.data, user),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return doc;
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
return json({ data: result });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
return internalError(err);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
router.get('/collections/:slug/:id', async (request, params) => {
|
|
530
|
+
try {
|
|
531
|
+
const auth = await requireAuth(request);
|
|
532
|
+
if (auth.error)
|
|
533
|
+
return auth.error;
|
|
534
|
+
const ctx = buildActionContext(auth.session, db());
|
|
535
|
+
const doc = await getDocument(params.slug, params.id, ctx);
|
|
536
|
+
if (!doc) {
|
|
537
|
+
return errorResponse('Document not found', 404);
|
|
538
|
+
}
|
|
539
|
+
const collectionConfig = globalThis.__actuateConfig?.collections?.[params.slug];
|
|
540
|
+
const fields = collectionConfig?.fields;
|
|
541
|
+
if (fields && doc.data && typeof doc.data === 'object') {
|
|
542
|
+
const user = { id: auth.session.userId, role: auth.session.role };
|
|
543
|
+
return json({
|
|
544
|
+
data: {
|
|
545
|
+
...doc,
|
|
546
|
+
data: await applyFieldAccess('read', fields, doc.data, user),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return json({ data: doc });
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
return internalError(err);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
router.post('/collections/:slug', async (request, params) => {
|
|
557
|
+
try {
|
|
558
|
+
const auth = await requireAuth(request);
|
|
559
|
+
if (auth.error)
|
|
560
|
+
return auth.error;
|
|
561
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
562
|
+
if (roleErr)
|
|
563
|
+
return roleErr;
|
|
564
|
+
const body = await request.json();
|
|
565
|
+
const ctx = buildActionContext(auth.session, db());
|
|
566
|
+
const doc = await createDocument(params.slug, body, ctx);
|
|
567
|
+
await logEvent({
|
|
568
|
+
event: 'document_created',
|
|
569
|
+
userId: auth.session.userId,
|
|
570
|
+
details: { collection: params.slug, documentId: doc?.id },
|
|
571
|
+
});
|
|
572
|
+
return json({ data: doc }, 201);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
return errorResponse('Failed to create document', 500);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
router.put('/collections/:slug/:id', async (request, params) => {
|
|
579
|
+
try {
|
|
580
|
+
const auth = await requireAuth(request);
|
|
581
|
+
if (auth.error)
|
|
582
|
+
return auth.error;
|
|
583
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
584
|
+
if (roleErr)
|
|
585
|
+
return roleErr;
|
|
586
|
+
const body = await request.json();
|
|
587
|
+
const ctx = buildActionContext(auth.session, db());
|
|
588
|
+
const doc = await updateDocument(params.slug, params.id, body, ctx);
|
|
589
|
+
await logEvent({
|
|
590
|
+
event: 'document_updated',
|
|
591
|
+
userId: auth.session.userId,
|
|
592
|
+
details: { collection: params.slug, documentId: params.id },
|
|
593
|
+
});
|
|
594
|
+
return json({ data: doc });
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
return errorResponse('Failed to update document', 500);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
router.delete('/collections/:slug/:id', async (request, params) => {
|
|
601
|
+
try {
|
|
602
|
+
const auth = await requireAuth(request);
|
|
603
|
+
if (auth.error)
|
|
604
|
+
return auth.error;
|
|
605
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
606
|
+
if (roleErr)
|
|
607
|
+
return roleErr;
|
|
608
|
+
const ctx = buildActionContext(auth.session, db());
|
|
609
|
+
await deleteDocument(params.slug, params.id, ctx);
|
|
610
|
+
await logEvent({
|
|
611
|
+
event: 'document_deleted',
|
|
612
|
+
userId: auth.session.userId,
|
|
613
|
+
details: { collection: params.slug, documentId: params.id },
|
|
614
|
+
});
|
|
615
|
+
return json({ data: { success: true } });
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
return errorResponse('Failed to delete document', 500);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// Media routes
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
router.get('/media', async (request) => {
|
|
625
|
+
try {
|
|
626
|
+
const auth = await requireAuth(request);
|
|
627
|
+
if (auth.error)
|
|
628
|
+
return auth.error;
|
|
629
|
+
const url = new URL(request.url);
|
|
630
|
+
const page = Number(url.searchParams.get('page')) || 1;
|
|
631
|
+
const pageSize = clampPageSize(url.searchParams.get('pageSize'));
|
|
632
|
+
const skip = (page - 1) * pageSize;
|
|
633
|
+
const folderParam = url.searchParams.get('folderId');
|
|
634
|
+
const where = {};
|
|
635
|
+
if (folderParam === 'none') {
|
|
636
|
+
where.folderId = null;
|
|
637
|
+
}
|
|
638
|
+
else if (folderParam) {
|
|
639
|
+
where.folderId = folderParam;
|
|
640
|
+
}
|
|
641
|
+
const [items, total] = await Promise.all([
|
|
642
|
+
db().media.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' } }),
|
|
643
|
+
db().media.count({ where }),
|
|
644
|
+
]);
|
|
645
|
+
return json({
|
|
646
|
+
data: { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
return internalError(err);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
router.post('/media/presign', async (request) => {
|
|
654
|
+
try {
|
|
655
|
+
const auth = await requireAuth(request);
|
|
656
|
+
if (auth.error)
|
|
657
|
+
return auth.error;
|
|
658
|
+
const body = await request.json();
|
|
659
|
+
if (!body.filename || !body.contentType) {
|
|
660
|
+
return errorResponse('filename and contentType are required', 400);
|
|
661
|
+
}
|
|
662
|
+
const storageKey = `actuate/media/${Date.now()}-${body.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
663
|
+
return json({
|
|
664
|
+
data: {
|
|
665
|
+
storageKey,
|
|
666
|
+
uploadUrl: `/api/cms/media/upload`,
|
|
667
|
+
fields: { storageKey, contentType: body.contentType },
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
return internalError(err);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
676
|
+
router.post('/media/upload', async (request) => {
|
|
677
|
+
try {
|
|
678
|
+
const auth = await requireAuth(request);
|
|
679
|
+
if (auth.error)
|
|
680
|
+
return auth.error;
|
|
681
|
+
const contentLength = parseInt(request.headers.get('content-length') ?? '0', 10);
|
|
682
|
+
if (contentLength > MAX_UPLOAD_BYTES) {
|
|
683
|
+
return errorResponse('File exceeds maximum size of 50MB', 413);
|
|
684
|
+
}
|
|
685
|
+
const formData = await request.formData();
|
|
686
|
+
const file = formData.get('file');
|
|
687
|
+
const skipOptimize = formData.get('skipOptimize') === 'true';
|
|
688
|
+
if (!file) {
|
|
689
|
+
return errorResponse('No file provided', 400);
|
|
690
|
+
}
|
|
691
|
+
const originalFilename = file.name;
|
|
692
|
+
const contentType = file.type;
|
|
693
|
+
const originalSize = file.size;
|
|
694
|
+
if (originalSize > 50 * 1024 * 1024) {
|
|
695
|
+
return errorResponse('File exceeds maximum size of 50MB', 413);
|
|
696
|
+
}
|
|
697
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
698
|
+
let uploadBuffer;
|
|
699
|
+
let finalFilename = originalFilename;
|
|
700
|
+
let finalMimeType = contentType;
|
|
701
|
+
let finalSize = originalSize;
|
|
702
|
+
let width = null;
|
|
703
|
+
let height = null;
|
|
704
|
+
let blurHash = null;
|
|
705
|
+
let savings = 0;
|
|
706
|
+
if (!skipOptimize && contentType.startsWith('image/')) {
|
|
707
|
+
const result = await optimizeImage(arrayBuffer, originalFilename, contentType);
|
|
708
|
+
uploadBuffer = result.buffer;
|
|
709
|
+
finalFilename = result.filename;
|
|
710
|
+
finalMimeType = result.mimeType;
|
|
711
|
+
finalSize = result.optimizedSize;
|
|
712
|
+
width = result.width || null;
|
|
713
|
+
height = result.height || null;
|
|
714
|
+
blurHash = result.blurHash ?? null;
|
|
715
|
+
savings = result.savings;
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
uploadBuffer = Buffer.from(arrayBuffer);
|
|
719
|
+
}
|
|
720
|
+
const sanitizedName = finalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
721
|
+
const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
|
|
722
|
+
let publicUrl = '';
|
|
723
|
+
try {
|
|
724
|
+
// @ts-ignore -- @vercel/blob is an optional peer dependency
|
|
725
|
+
const blob = await import('@vercel/blob');
|
|
726
|
+
const result = await blob.put(storageKey, uploadBuffer, {
|
|
727
|
+
access: 'public',
|
|
728
|
+
contentType: finalMimeType,
|
|
729
|
+
});
|
|
730
|
+
publicUrl = result.url;
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
publicUrl = `/api/cms/media/file/${storageKey}`;
|
|
734
|
+
}
|
|
735
|
+
const media = await db().media.create({
|
|
736
|
+
data: {
|
|
737
|
+
filename: finalFilename,
|
|
738
|
+
storageKey: publicUrl || storageKey,
|
|
739
|
+
mimeType: finalMimeType,
|
|
740
|
+
fileSize: finalSize,
|
|
741
|
+
width,
|
|
742
|
+
height,
|
|
743
|
+
blurHash,
|
|
744
|
+
uploadedById: auth.session.userId,
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
await logEvent({
|
|
748
|
+
event: 'media_uploaded',
|
|
749
|
+
userId: auth.session.userId,
|
|
750
|
+
details: { mediaId: media.id, filename: finalFilename },
|
|
751
|
+
});
|
|
752
|
+
return json({
|
|
753
|
+
data: {
|
|
754
|
+
...media,
|
|
755
|
+
optimization: {
|
|
756
|
+
originalSize,
|
|
757
|
+
optimizedSize: finalSize,
|
|
758
|
+
savings,
|
|
759
|
+
originalFormat: contentType,
|
|
760
|
+
outputFormat: finalMimeType,
|
|
761
|
+
originalSizeFormatted: formatBytes(originalSize),
|
|
762
|
+
optimizedSizeFormatted: formatBytes(finalSize),
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
}, 201);
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
return internalError(err);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
router.post('/media/:id/optimize', async (request, params) => {
|
|
772
|
+
try {
|
|
773
|
+
const auth = await requireAuth(request);
|
|
774
|
+
if (auth.error)
|
|
775
|
+
return auth.error;
|
|
776
|
+
const media = await db().media.findUnique({ where: { id: params.id } });
|
|
777
|
+
if (!media)
|
|
778
|
+
return errorResponse('Media not found', 404);
|
|
779
|
+
if (!media.mimeType.startsWith('image/')) {
|
|
780
|
+
return errorResponse('Only images can be optimized', 400);
|
|
781
|
+
}
|
|
782
|
+
if (media.mimeType === 'image/webp') {
|
|
783
|
+
return json({
|
|
784
|
+
data: { ...media, optimization: { alreadyOptimized: true, savings: 0 } },
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
if (!isAllowedStorageUrl(media.storageKey)) {
|
|
788
|
+
return errorResponse('Invalid media storage URL', 400);
|
|
789
|
+
}
|
|
790
|
+
let fileBuffer;
|
|
791
|
+
try {
|
|
792
|
+
const response = await fetch(media.storageKey);
|
|
793
|
+
if (!response.ok)
|
|
794
|
+
throw new Error('Failed to fetch media file');
|
|
795
|
+
fileBuffer = Buffer.from(await response.arrayBuffer());
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return errorResponse('Could not retrieve the original file for optimization', 500);
|
|
799
|
+
}
|
|
800
|
+
const result = await optimizeImage(fileBuffer, media.filename, media.mimeType);
|
|
801
|
+
if (result.savings <= 0) {
|
|
802
|
+
return json({
|
|
803
|
+
data: { ...media, optimization: { alreadyOptimized: true, savings: 0 } },
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Upload the optimized version
|
|
807
|
+
const sanitizedName = result.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
808
|
+
const newStorageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
|
|
809
|
+
let newPublicUrl = '';
|
|
810
|
+
try {
|
|
811
|
+
// @ts-ignore -- @vercel/blob is an optional peer dependency
|
|
812
|
+
const blob = await import('@vercel/blob');
|
|
813
|
+
const uploadResult = await blob.put(newStorageKey, result.buffer, {
|
|
814
|
+
access: 'public',
|
|
815
|
+
contentType: result.mimeType,
|
|
816
|
+
});
|
|
817
|
+
newPublicUrl = uploadResult.url;
|
|
818
|
+
// Delete the old blob
|
|
819
|
+
try {
|
|
820
|
+
await blob.del(media.storageKey);
|
|
821
|
+
}
|
|
822
|
+
catch { /* best-effort */ }
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
newPublicUrl = `/api/cms/media/file/${newStorageKey}`;
|
|
826
|
+
}
|
|
827
|
+
const updated = await db().media.update({
|
|
828
|
+
where: { id: params.id },
|
|
829
|
+
data: {
|
|
830
|
+
filename: result.filename,
|
|
831
|
+
storageKey: newPublicUrl || newStorageKey,
|
|
832
|
+
mimeType: result.mimeType,
|
|
833
|
+
fileSize: result.optimizedSize,
|
|
834
|
+
width: result.width || undefined,
|
|
835
|
+
height: result.height || undefined,
|
|
836
|
+
blurHash: result.blurHash ?? undefined,
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
return json({
|
|
840
|
+
data: {
|
|
841
|
+
...updated,
|
|
842
|
+
optimization: {
|
|
843
|
+
originalSize: result.originalSize,
|
|
844
|
+
optimizedSize: result.optimizedSize,
|
|
845
|
+
savings: result.savings,
|
|
846
|
+
originalFormat: media.mimeType,
|
|
847
|
+
outputFormat: result.mimeType,
|
|
848
|
+
originalSizeFormatted: formatBytes(result.originalSize),
|
|
849
|
+
optimizedSizeFormatted: formatBytes(result.optimizedSize),
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
return internalError(err);
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
router.put('/media/:id', async (request, params) => {
|
|
859
|
+
try {
|
|
860
|
+
const auth = await requireAuth(request);
|
|
861
|
+
if (auth.error)
|
|
862
|
+
return auth.error;
|
|
863
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
864
|
+
if (roleErr)
|
|
865
|
+
return roleErr;
|
|
866
|
+
const body = await request.json();
|
|
867
|
+
const updated = await db().media.update({
|
|
868
|
+
where: { id: params.id },
|
|
869
|
+
data: {
|
|
870
|
+
...(body.alt !== undefined ? { altText: body.alt } : {}),
|
|
871
|
+
...(body.title !== undefined ? { title: body.title } : {}),
|
|
872
|
+
...(body.filename !== undefined ? { filename: body.filename } : {}),
|
|
873
|
+
...(body.focalX !== undefined ? { focalPointX: body.focalX } : {}),
|
|
874
|
+
...(body.focalY !== undefined ? { focalPointY: body.focalY } : {}),
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
return json({ data: updated });
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
return internalError(err);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
router.delete('/media/:id', async (request, params) => {
|
|
884
|
+
try {
|
|
885
|
+
const auth = await requireAuth(request);
|
|
886
|
+
if (auth.error)
|
|
887
|
+
return auth.error;
|
|
888
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
889
|
+
if (roleErr)
|
|
890
|
+
return roleErr;
|
|
891
|
+
const media = await db().media.findUnique({ where: { id: params.id } });
|
|
892
|
+
if (!media) {
|
|
893
|
+
return errorResponse('Media not found', 404);
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
// @ts-ignore -- @vercel/blob is an optional peer dependency
|
|
897
|
+
const blob = await import('@vercel/blob');
|
|
898
|
+
await blob.del(media.storageKey);
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
// Storage delete failed or not available
|
|
902
|
+
}
|
|
903
|
+
await db().media.delete({ where: { id: params.id } });
|
|
904
|
+
await logEvent({
|
|
905
|
+
event: 'media_deleted',
|
|
906
|
+
userId: auth.session.userId,
|
|
907
|
+
details: { mediaId: params.id, filename: media.filename },
|
|
908
|
+
});
|
|
909
|
+
return json({ data: { success: true } });
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
return internalError(err);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
// Setup routes
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
router.get('/setup/status', async () => {
|
|
919
|
+
try {
|
|
920
|
+
const status = await checkSetupRequired(db());
|
|
921
|
+
return json({ data: status });
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
return internalError(err);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
router.post('/setup/create-admin', async (request) => {
|
|
928
|
+
try {
|
|
929
|
+
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
|
930
|
+
if (!(await checkRateLimitAsync(loginLimiter, `setup:${clientIp}`))) {
|
|
931
|
+
return errorResponse('Too many setup attempts', 429);
|
|
932
|
+
}
|
|
933
|
+
const body = await request.json();
|
|
934
|
+
if (!body.name || !body.email || !body.password) {
|
|
935
|
+
return errorResponse('Name, email, and password are required', 400);
|
|
936
|
+
}
|
|
937
|
+
const result = await createInitialAdmin(db(), {
|
|
938
|
+
name: body.name,
|
|
939
|
+
email: body.email,
|
|
940
|
+
password: body.password,
|
|
941
|
+
});
|
|
942
|
+
if (!result.success) {
|
|
943
|
+
return errorResponse(result.error ?? 'Setup failed', 400);
|
|
944
|
+
}
|
|
945
|
+
return json({ data: result }, 201);
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
return internalError(err);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
// ---------------------------------------------------------------------------
|
|
952
|
+
// Stats route
|
|
953
|
+
// ---------------------------------------------------------------------------
|
|
954
|
+
router.get('/stats', async (request) => {
|
|
955
|
+
try {
|
|
956
|
+
const auth = await requireAuth(request);
|
|
957
|
+
if (auth.error)
|
|
958
|
+
return auth.error;
|
|
959
|
+
const [totalDocuments, totalMedia, totalUsers, recentDocuments] = await Promise.all([
|
|
960
|
+
db().document.count({ where: { deletedAt: null } }),
|
|
961
|
+
db().media.count(),
|
|
962
|
+
db().user.count(),
|
|
963
|
+
db().document.findMany({
|
|
964
|
+
where: { deletedAt: null },
|
|
965
|
+
orderBy: { updatedAt: 'desc' },
|
|
966
|
+
take: 10,
|
|
967
|
+
}),
|
|
968
|
+
]);
|
|
969
|
+
return json({ data: { totalDocuments, totalMedia, totalUsers, recentDocuments } });
|
|
970
|
+
}
|
|
971
|
+
catch (err) {
|
|
972
|
+
return internalError(err);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
// Global search
|
|
977
|
+
// ---------------------------------------------------------------------------
|
|
978
|
+
router.get('/search/global', async (request) => {
|
|
979
|
+
try {
|
|
980
|
+
const auth = await requireAuth(request);
|
|
981
|
+
if (auth.error)
|
|
982
|
+
return auth.error;
|
|
983
|
+
const url = new URL(request.url, 'http://localhost');
|
|
984
|
+
const q = (url.searchParams.get('q') ?? '').trim();
|
|
985
|
+
if (!q)
|
|
986
|
+
return json({ data: { documents: [], media: [], users: [] } });
|
|
987
|
+
const [documents, media, users] = await Promise.all([
|
|
988
|
+
db().document.findMany({
|
|
989
|
+
where: { deletedAt: null, OR: [{ title: { contains: q, mode: 'insensitive' } }, { plainText: { contains: q, mode: 'insensitive' } }] },
|
|
990
|
+
take: 10,
|
|
991
|
+
orderBy: { updatedAt: 'desc' },
|
|
992
|
+
select: { id: true, title: true, slug: true, collection: true, status: true, updatedAt: true },
|
|
993
|
+
}),
|
|
994
|
+
db().media.findMany({
|
|
995
|
+
where: { OR: [{ filename: { contains: q, mode: 'insensitive' } }, { altText: { contains: q, mode: 'insensitive' } }] },
|
|
996
|
+
take: 5,
|
|
997
|
+
orderBy: { createdAt: 'desc' },
|
|
998
|
+
select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
|
|
999
|
+
}),
|
|
1000
|
+
db().user.findMany({
|
|
1001
|
+
where: { isActive: true, OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] },
|
|
1002
|
+
take: 5,
|
|
1003
|
+
select: { id: true, name: true, email: true, role: true },
|
|
1004
|
+
}),
|
|
1005
|
+
]);
|
|
1006
|
+
return json({ data: { documents, media, users } });
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
return internalError(err);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
// Users route
|
|
1014
|
+
// ---------------------------------------------------------------------------
|
|
1015
|
+
router.get('/users', async (request) => {
|
|
1016
|
+
try {
|
|
1017
|
+
const auth = await requireAuth(request);
|
|
1018
|
+
if (auth.error)
|
|
1019
|
+
return auth.error;
|
|
1020
|
+
if (auth.session.role !== 'ADMIN') {
|
|
1021
|
+
return errorResponse('Forbidden', 403);
|
|
1022
|
+
}
|
|
1023
|
+
const users = await db().user.findMany({
|
|
1024
|
+
select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
|
|
1025
|
+
});
|
|
1026
|
+
return json({ data: users });
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
return internalError(err);
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
router.delete('/users/:id', async (request, params) => {
|
|
1033
|
+
try {
|
|
1034
|
+
const auth = await requireAuth(request);
|
|
1035
|
+
if (auth.error)
|
|
1036
|
+
return auth.error;
|
|
1037
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1038
|
+
if (roleErr)
|
|
1039
|
+
return roleErr;
|
|
1040
|
+
if (auth.session.userId === params.id) {
|
|
1041
|
+
return errorResponse('Cannot delete your own account', 400);
|
|
1042
|
+
}
|
|
1043
|
+
const user = await db().user.findUnique({ where: { id: params.id } });
|
|
1044
|
+
if (!user)
|
|
1045
|
+
return errorResponse('User not found', 404);
|
|
1046
|
+
await db().user.update({
|
|
1047
|
+
where: { id: params.id },
|
|
1048
|
+
data: { isActive: false },
|
|
1049
|
+
});
|
|
1050
|
+
await db().session.updateMany({
|
|
1051
|
+
where: { userId: params.id },
|
|
1052
|
+
data: { revokedAt: new Date() },
|
|
1053
|
+
});
|
|
1054
|
+
await logEvent({
|
|
1055
|
+
event: 'user_deactivated',
|
|
1056
|
+
userId: auth.session.userId,
|
|
1057
|
+
details: { targetUserId: params.id, targetEmail: user.email },
|
|
1058
|
+
});
|
|
1059
|
+
return json({ data: { success: true } });
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
return internalError(err);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
// Forms routes
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
router.get('/forms', async (request) => {
|
|
1069
|
+
try {
|
|
1070
|
+
const auth = await requireAuth(request);
|
|
1071
|
+
if (auth.error)
|
|
1072
|
+
return auth.error;
|
|
1073
|
+
const forms = await db().document.findMany({
|
|
1074
|
+
where: { collection: 'forms', deletedAt: null },
|
|
1075
|
+
orderBy: { createdAt: 'desc' },
|
|
1076
|
+
});
|
|
1077
|
+
return json({ data: forms });
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
return internalError(err);
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
router.get('/forms/:id/submissions', async (request, params) => {
|
|
1084
|
+
try {
|
|
1085
|
+
const auth = await requireAuth(request);
|
|
1086
|
+
if (auth.error)
|
|
1087
|
+
return auth.error;
|
|
1088
|
+
const url = new URL(request.url);
|
|
1089
|
+
const page = Number(url.searchParams.get('page')) || 1;
|
|
1090
|
+
const pageSize = clampPageSize(url.searchParams.get('pageSize'));
|
|
1091
|
+
const skip = (page - 1) * pageSize;
|
|
1092
|
+
const [submissions, total] = await Promise.all([
|
|
1093
|
+
db().formSubmission.findMany({
|
|
1094
|
+
where: { formId: params.id },
|
|
1095
|
+
skip,
|
|
1096
|
+
take: pageSize,
|
|
1097
|
+
orderBy: { createdAt: 'desc' },
|
|
1098
|
+
}),
|
|
1099
|
+
db().formSubmission.count({ where: { formId: params.id } }),
|
|
1100
|
+
]);
|
|
1101
|
+
return json({
|
|
1102
|
+
data: { submissions, total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
return internalError(err);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
router.post('/forms/:id/submit', async (request, params) => {
|
|
1110
|
+
try {
|
|
1111
|
+
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
|
1112
|
+
if (!(await checkRateLimitAsync(formLimiterGlobal, `form:${clientIp}`))) {
|
|
1113
|
+
return errorResponse('Too many submissions. Please try again later.', 429);
|
|
1114
|
+
}
|
|
1115
|
+
const formId = params.id;
|
|
1116
|
+
const form = await db().document.findFirst({
|
|
1117
|
+
where: { id: formId, collection: 'forms', deletedAt: null },
|
|
1118
|
+
});
|
|
1119
|
+
if (!form) {
|
|
1120
|
+
return errorResponse('Form not found', 404);
|
|
1121
|
+
}
|
|
1122
|
+
const formData = (form.data ?? {});
|
|
1123
|
+
const body = await request.json();
|
|
1124
|
+
if (!body.fields || typeof body.fields !== 'object') {
|
|
1125
|
+
return errorResponse('Missing or invalid "fields" in request body', 400);
|
|
1126
|
+
}
|
|
1127
|
+
const submission = await db().formSubmission.create({
|
|
1128
|
+
data: {
|
|
1129
|
+
formId,
|
|
1130
|
+
data: body.fields,
|
|
1131
|
+
attribution: body.attribution ?? null,
|
|
1132
|
+
submittedAt: new Date(),
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
// Fire form hooks asynchronously (email notification, webhooks)
|
|
1136
|
+
(async () => {
|
|
1137
|
+
try {
|
|
1138
|
+
const config = globalThis.__actuateConfig;
|
|
1139
|
+
const hooks = [
|
|
1140
|
+
...(config?.plugins?.forms?.hooks ?? []),
|
|
1141
|
+
...(config?._pluginHooks ?? []),
|
|
1142
|
+
];
|
|
1143
|
+
const formHooks = hooks.filter((h) => h.event === 'afterCreate:form-submissions');
|
|
1144
|
+
for (const hook of formHooks) {
|
|
1145
|
+
await hook.handler({ formId, data: body.fields });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
catch (hookErr) {
|
|
1149
|
+
console.error('[cms] Form hook error:', hookErr);
|
|
1150
|
+
}
|
|
1151
|
+
})();
|
|
1152
|
+
const confirmation = formData.confirmation ?? {
|
|
1153
|
+
type: 'message',
|
|
1154
|
+
message: 'Thank you! Your submission has been received.',
|
|
1155
|
+
};
|
|
1156
|
+
const analytics = formData.analytics ?? null;
|
|
1157
|
+
return json({
|
|
1158
|
+
data: {
|
|
1159
|
+
success: true,
|
|
1160
|
+
submissionId: submission.id,
|
|
1161
|
+
confirmation,
|
|
1162
|
+
analytics,
|
|
1163
|
+
},
|
|
1164
|
+
}, 201);
|
|
1165
|
+
}
|
|
1166
|
+
catch (err) {
|
|
1167
|
+
return internalError(err);
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
// ---------------------------------------------------------------------------
|
|
1171
|
+
// Redirects routes
|
|
1172
|
+
// ---------------------------------------------------------------------------
|
|
1173
|
+
router.get('/redirects', async (request) => {
|
|
1174
|
+
try {
|
|
1175
|
+
const auth = await requireAuth(request);
|
|
1176
|
+
if (auth.error)
|
|
1177
|
+
return auth.error;
|
|
1178
|
+
const redirects = await db().redirect.findMany({ orderBy: { createdAt: 'desc' } });
|
|
1179
|
+
return json({ data: redirects });
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
return internalError(err);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
router.post('/redirects', async (request) => {
|
|
1186
|
+
try {
|
|
1187
|
+
const auth = await requireAuth(request);
|
|
1188
|
+
if (auth.error)
|
|
1189
|
+
return auth.error;
|
|
1190
|
+
if (auth.session.role !== 'ADMIN')
|
|
1191
|
+
return errorResponse('Admin access required', 403);
|
|
1192
|
+
const body = await request.json();
|
|
1193
|
+
const source = String(body.source ?? '').trim();
|
|
1194
|
+
const destination = String(body.destination ?? '').trim();
|
|
1195
|
+
if (!source || !destination) {
|
|
1196
|
+
return errorResponse('source and destination are required', 400);
|
|
1197
|
+
}
|
|
1198
|
+
if (destination.startsWith('http') && !destination.startsWith(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://')) {
|
|
1199
|
+
try {
|
|
1200
|
+
const destUrl = new URL(destination);
|
|
1201
|
+
if (!['http:', 'https:'].includes(destUrl.protocol)) {
|
|
1202
|
+
return errorResponse('Invalid destination URL', 400);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
return errorResponse('Invalid destination URL', 400);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const redirect = await db().redirect.create({
|
|
1210
|
+
data: {
|
|
1211
|
+
source,
|
|
1212
|
+
destination,
|
|
1213
|
+
statusCode: [301, 302, 307, 308].includes(Number(body.statusCode)) ? Number(body.statusCode) : 301,
|
|
1214
|
+
isRegex: body.isRegex === true,
|
|
1215
|
+
notes: typeof body.notes === 'string' ? body.notes : null,
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
return json({ data: redirect }, 201);
|
|
1219
|
+
}
|
|
1220
|
+
catch (err) {
|
|
1221
|
+
return errorResponse('Failed to create redirect', 500);
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
router.delete('/redirects/:id', async (request, params) => {
|
|
1225
|
+
try {
|
|
1226
|
+
const auth = await requireAuth(request);
|
|
1227
|
+
if (auth.error)
|
|
1228
|
+
return auth.error;
|
|
1229
|
+
if (auth.session.role !== 'ADMIN')
|
|
1230
|
+
return errorResponse('Admin access required', 403);
|
|
1231
|
+
await db().redirect.delete({ where: { id: params.id } });
|
|
1232
|
+
return json({ data: { success: true } });
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
return errorResponse('Failed to delete redirect', 500);
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
// ---------------------------------------------------------------------------
|
|
1239
|
+
// SEO routes
|
|
1240
|
+
// ---------------------------------------------------------------------------
|
|
1241
|
+
router.get('/seo/pages', async (request) => {
|
|
1242
|
+
try {
|
|
1243
|
+
const auth = await requireAuth(request);
|
|
1244
|
+
if (auth.error)
|
|
1245
|
+
return auth.error;
|
|
1246
|
+
const pages = await db().document.findMany({
|
|
1247
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
1248
|
+
select: {
|
|
1249
|
+
id: true,
|
|
1250
|
+
collection: true,
|
|
1251
|
+
data: true,
|
|
1252
|
+
updatedAt: true,
|
|
1253
|
+
structuredData: true,
|
|
1254
|
+
},
|
|
1255
|
+
orderBy: { updatedAt: 'desc' },
|
|
1256
|
+
});
|
|
1257
|
+
return json({ data: pages });
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
return internalError(err);
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
router.get('/seo/link-health', async (request) => {
|
|
1264
|
+
try {
|
|
1265
|
+
const auth = await requireAuth(request);
|
|
1266
|
+
if (auth.error)
|
|
1267
|
+
return auth.error;
|
|
1268
|
+
const docs = await db().document.findMany({
|
|
1269
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
1270
|
+
select: { id: true, title: true, data: true, collection: true },
|
|
1271
|
+
});
|
|
1272
|
+
const linkResults = [];
|
|
1273
|
+
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
|
|
1274
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
|
|
1275
|
+
for (const doc of docs) {
|
|
1276
|
+
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
1277
|
+
const content = JSON.stringify(doc.data ?? {});
|
|
1278
|
+
const urls = content.match(urlRegex) ?? [];
|
|
1279
|
+
const seen = new Set();
|
|
1280
|
+
for (const url of urls) {
|
|
1281
|
+
const clean = url.replace(/[",;)}\]]+$/, '');
|
|
1282
|
+
if (seen.has(clean))
|
|
1283
|
+
continue;
|
|
1284
|
+
seen.add(clean);
|
|
1285
|
+
const isInternal = siteUrl && clean.startsWith(siteUrl);
|
|
1286
|
+
try {
|
|
1287
|
+
const resp = await fetch(clean, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) });
|
|
1288
|
+
if (resp.status >= 400 || (resp.status >= 300 && resp.status < 400)) {
|
|
1289
|
+
linkResults.push({
|
|
1290
|
+
id: `${doc.id}-${linkResults.length}`,
|
|
1291
|
+
page: pageTitle,
|
|
1292
|
+
url: clean,
|
|
1293
|
+
status: resp.status,
|
|
1294
|
+
type: isInternal ? 'internal' : 'external',
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
linkResults.push({
|
|
1300
|
+
id: `${doc.id}-${linkResults.length}`,
|
|
1301
|
+
page: pageTitle,
|
|
1302
|
+
url: clean,
|
|
1303
|
+
status: 0,
|
|
1304
|
+
type: isInternal ? 'internal' : 'external',
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return json({ data: linkResults });
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
return internalError(err);
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
router.post('/seo/scan', async (request) => {
|
|
1316
|
+
try {
|
|
1317
|
+
const auth = await requireAuth(request);
|
|
1318
|
+
if (auth.error)
|
|
1319
|
+
return auth.error;
|
|
1320
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1321
|
+
if (roleErr)
|
|
1322
|
+
return roleErr;
|
|
1323
|
+
const documents = await db().document.findMany({
|
|
1324
|
+
where: { status: 'PUBLISHED', deletedAt: null },
|
|
1325
|
+
select: { id: true, title: true, slug: true, collection: true, data: true, plainText: true },
|
|
1326
|
+
});
|
|
1327
|
+
const issues = [];
|
|
1328
|
+
for (const doc of documents) {
|
|
1329
|
+
const data = (doc.data ?? {});
|
|
1330
|
+
const problems = [];
|
|
1331
|
+
if (!data.metaTitle && !data.seoTitle)
|
|
1332
|
+
problems.push('Missing meta title');
|
|
1333
|
+
if (!data.metaDescription && !data.seoDescription)
|
|
1334
|
+
problems.push('Missing meta description');
|
|
1335
|
+
if (!data.canonical)
|
|
1336
|
+
problems.push('No canonical URL set');
|
|
1337
|
+
if (!data.schemaType)
|
|
1338
|
+
problems.push('No Schema.org type');
|
|
1339
|
+
const plainText = (doc.plainText ?? '');
|
|
1340
|
+
if (plainText.length > 0 && plainText.length < 300)
|
|
1341
|
+
problems.push('Content is too short (< 300 characters)');
|
|
1342
|
+
const content = typeof data.body === 'string' ? data.body : typeof data.content === 'string' ? data.content : '';
|
|
1343
|
+
if (content) {
|
|
1344
|
+
const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
1345
|
+
const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
|
|
1346
|
+
if (missingAlt > 0)
|
|
1347
|
+
problems.push(`${missingAlt} image(s) missing alt text`);
|
|
1348
|
+
if (!content.includes('<h1') && !content.includes('<h1>'))
|
|
1349
|
+
problems.push('No H1 heading found in content');
|
|
1350
|
+
}
|
|
1351
|
+
if (problems.length > 0) {
|
|
1352
|
+
issues.push({ documentId: doc.id, title: doc.title ?? 'Untitled', slug: doc.slug ?? '', problems });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
const total = documents.length;
|
|
1356
|
+
const pagesWithIssues = issues.length;
|
|
1357
|
+
const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
|
|
1358
|
+
return json({ data: { total, pagesWithIssues, totalProblems, issues } });
|
|
1359
|
+
}
|
|
1360
|
+
catch (err) {
|
|
1361
|
+
return internalError(err);
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
// ---------------------------------------------------------------------------
|
|
1365
|
+
// SEO analysis, readability, internal links, schema, meta
|
|
1366
|
+
// ---------------------------------------------------------------------------
|
|
1367
|
+
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
1368
|
+
try {
|
|
1369
|
+
const doc = await db().document.findUnique({ where: { id: params.documentId } });
|
|
1370
|
+
if (!doc)
|
|
1371
|
+
return errorResponse('Not found', 404);
|
|
1372
|
+
const { analyzeContent } = await import('../seo/analysis');
|
|
1373
|
+
const data = doc.data || {};
|
|
1374
|
+
const result = analyzeContent({
|
|
1375
|
+
title: doc.title || data.title || '',
|
|
1376
|
+
slug: doc.slug || data.slug || '',
|
|
1377
|
+
content: data.content || data.body || '',
|
|
1378
|
+
metaTitle: data.metaTitle || data.seoTitle,
|
|
1379
|
+
metaDescription: data.metaDescription || data.seoDescription,
|
|
1380
|
+
focusKeyphrase: data.focusKeyphrase,
|
|
1381
|
+
canonical: data.canonical,
|
|
1382
|
+
ogTitle: data.ogTitle,
|
|
1383
|
+
ogDescription: data.ogDescription,
|
|
1384
|
+
ogImage: data.ogImage,
|
|
1385
|
+
isCornerstone: data.isCornerstone,
|
|
1386
|
+
});
|
|
1387
|
+
return new Response(JSON.stringify(result), {
|
|
1388
|
+
status: 200,
|
|
1389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
catch (err) {
|
|
1393
|
+
return internalError(err, 'seo/analysis');
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
router.get('/seo/readability/:documentId', async (request, params) => {
|
|
1397
|
+
try {
|
|
1398
|
+
const doc = await db().document.findUnique({ where: { id: params.documentId } });
|
|
1399
|
+
if (!doc)
|
|
1400
|
+
return errorResponse('Not found', 404);
|
|
1401
|
+
const { calculateReadability, stripHtmlTags } = await import('../seo/analysis');
|
|
1402
|
+
const data = doc.data || {};
|
|
1403
|
+
const text = stripHtmlTags(data.content || data.body || '');
|
|
1404
|
+
const result = calculateReadability(text);
|
|
1405
|
+
return new Response(JSON.stringify(result), {
|
|
1406
|
+
status: 200,
|
|
1407
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
catch (err) {
|
|
1411
|
+
return internalError(err, 'seo/readability');
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
router.get('/seo/internal-links/:documentId', async (request, params) => {
|
|
1415
|
+
try {
|
|
1416
|
+
const doc = await db().document.findUnique({ where: { id: params.documentId } });
|
|
1417
|
+
if (!doc)
|
|
1418
|
+
return errorResponse('Not found', 404);
|
|
1419
|
+
const data = doc.data || {};
|
|
1420
|
+
const title = doc.title || data.title || '';
|
|
1421
|
+
const keywords = title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5);
|
|
1422
|
+
if (keywords.length === 0) {
|
|
1423
|
+
return new Response(JSON.stringify({ suggestions: [] }), {
|
|
1424
|
+
status: 200,
|
|
1425
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
const suggestions = await db().document.findMany({
|
|
1429
|
+
where: {
|
|
1430
|
+
id: { not: params.documentId },
|
|
1431
|
+
status: 'PUBLISHED',
|
|
1432
|
+
OR: keywords.map((kw) => ({
|
|
1433
|
+
title: { contains: kw, mode: 'insensitive' },
|
|
1434
|
+
})),
|
|
1435
|
+
},
|
|
1436
|
+
select: { id: true, title: true, slug: true, collection: true },
|
|
1437
|
+
take: 10,
|
|
1438
|
+
orderBy: { updatedAt: 'desc' },
|
|
1439
|
+
});
|
|
1440
|
+
return new Response(JSON.stringify({ suggestions }), {
|
|
1441
|
+
status: 200,
|
|
1442
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
return internalError(err, 'seo/internal-links');
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
router.get('/llms.txt', async () => {
|
|
1450
|
+
try {
|
|
1451
|
+
const docs = await db().document.findMany({
|
|
1452
|
+
where: { status: 'PUBLISHED' },
|
|
1453
|
+
select: { title: true, slug: true, collection: true, updatedAt: true, data: true },
|
|
1454
|
+
orderBy: { updatedAt: 'desc' },
|
|
1455
|
+
take: 50,
|
|
1456
|
+
});
|
|
1457
|
+
const { generateLlmsTxt } = await import('../seo/llms-txt');
|
|
1458
|
+
const pages = docs.map((d) => {
|
|
1459
|
+
const data = d.data || {};
|
|
1460
|
+
return {
|
|
1461
|
+
title: d.title || data.title || 'Untitled',
|
|
1462
|
+
url: `/${d.collection}/${d.slug || d.title?.toLowerCase().replace(/\s+/g, '-')}`,
|
|
1463
|
+
description: data.metaDescription || data.seoDescription || '',
|
|
1464
|
+
collection: d.collection,
|
|
1465
|
+
updatedAt: d.updatedAt?.toISOString(),
|
|
1466
|
+
isCornerstone: data.isCornerstone === true,
|
|
1467
|
+
};
|
|
1468
|
+
});
|
|
1469
|
+
const siteUrl = '';
|
|
1470
|
+
const txt = generateLlmsTxt({
|
|
1471
|
+
siteName: 'Actuate CMS',
|
|
1472
|
+
siteUrl,
|
|
1473
|
+
siteDescription: 'Content managed by Actuate CMS',
|
|
1474
|
+
maxPages: 50,
|
|
1475
|
+
}, pages);
|
|
1476
|
+
return new Response(txt, {
|
|
1477
|
+
status: 200,
|
|
1478
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
catch (err) {
|
|
1482
|
+
return internalError(err, 'llms.txt');
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
router.get('/seo/schema/:documentId', async (request, params) => {
|
|
1486
|
+
try {
|
|
1487
|
+
const doc = await db().document.findUnique({ where: { id: params.documentId } });
|
|
1488
|
+
if (!doc)
|
|
1489
|
+
return errorResponse('Not found', 404);
|
|
1490
|
+
const { buildSchemaGraph } = await import('../content/structured-data');
|
|
1491
|
+
const data = doc.data || {};
|
|
1492
|
+
const graph = buildSchemaGraph({
|
|
1493
|
+
siteName: 'Actuate CMS',
|
|
1494
|
+
siteUrl: '',
|
|
1495
|
+
}, {
|
|
1496
|
+
url: `/${doc.collection}/${doc.slug || ''}`,
|
|
1497
|
+
title: doc.title || data.title || '',
|
|
1498
|
+
description: data.metaDescription || data.seoDescription || '',
|
|
1499
|
+
datePublished: doc.createdAt?.toISOString(),
|
|
1500
|
+
dateModified: doc.updatedAt?.toISOString(),
|
|
1501
|
+
type: doc.collection === 'posts' ? 'post' : 'page',
|
|
1502
|
+
keywords: data.tags || data.keywords,
|
|
1503
|
+
featuredImage: data.featuredImage ? { url: data.featuredImage } : undefined,
|
|
1504
|
+
});
|
|
1505
|
+
return new Response(JSON.stringify(graph), {
|
|
1506
|
+
status: 200,
|
|
1507
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
catch (err) {
|
|
1511
|
+
return internalError(err, 'seo/schema');
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
router.get('/seo/meta/:documentId', async (request, params) => {
|
|
1515
|
+
try {
|
|
1516
|
+
const doc = await db().document.findUnique({ where: { id: params.documentId } });
|
|
1517
|
+
if (!doc)
|
|
1518
|
+
return errorResponse('Not found', 404);
|
|
1519
|
+
const { generateMetaTags } = await import('../seo/meta-tags');
|
|
1520
|
+
const data = doc.data || {};
|
|
1521
|
+
const tags = generateMetaTags({
|
|
1522
|
+
title: data.metaTitle || doc.title || data.title || '',
|
|
1523
|
+
description: data.metaDescription || data.seoDescription || '',
|
|
1524
|
+
url: `/${doc.collection}/${doc.slug || ''}`,
|
|
1525
|
+
siteName: 'Actuate CMS',
|
|
1526
|
+
type: doc.collection === 'posts' ? 'article' : 'website',
|
|
1527
|
+
ogTitle: data.ogTitle,
|
|
1528
|
+
ogDescription: data.ogDescription,
|
|
1529
|
+
ogImage: data.ogImage ? { url: data.ogImage } : undefined,
|
|
1530
|
+
twitterTitle: data.twitterTitle,
|
|
1531
|
+
twitterDescription: data.twitterDescription,
|
|
1532
|
+
twitterImage: data.twitterImage,
|
|
1533
|
+
publishedTime: doc.createdAt?.toISOString(),
|
|
1534
|
+
modifiedTime: doc.updatedAt?.toISOString(),
|
|
1535
|
+
noIndex: data.noIndex,
|
|
1536
|
+
noFollow: data.noFollow,
|
|
1537
|
+
canonical: data.canonical,
|
|
1538
|
+
});
|
|
1539
|
+
return new Response(JSON.stringify(tags), {
|
|
1540
|
+
status: 200,
|
|
1541
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
catch (err) {
|
|
1545
|
+
return internalError(err, 'seo/meta');
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
// ---------------------------------------------------------------------------
|
|
1549
|
+
// URL Resolution — maps a public URL path to its document
|
|
1550
|
+
// ---------------------------------------------------------------------------
|
|
1551
|
+
router.get('/resolve', async (request) => {
|
|
1552
|
+
try {
|
|
1553
|
+
const url = new URL(request.url);
|
|
1554
|
+
const pathParam = url.searchParams.get('path');
|
|
1555
|
+
if (!pathParam) {
|
|
1556
|
+
return errorResponse('Missing required "path" query parameter', 400);
|
|
1557
|
+
}
|
|
1558
|
+
const segments = pathParam.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
|
|
1559
|
+
if (segments.length === 0) {
|
|
1560
|
+
return errorResponse('Empty path', 400);
|
|
1561
|
+
}
|
|
1562
|
+
const configCollections = globalThis.__actuateConfig?.collections ?? {};
|
|
1563
|
+
const collectionDefs = Object.values(configCollections);
|
|
1564
|
+
let matchedCollection = null;
|
|
1565
|
+
let docSlug = null;
|
|
1566
|
+
for (const col of collectionDefs) {
|
|
1567
|
+
const prefix = (col.urlPrefix ?? col.slug ?? '').replace(/^\/|\/$/g, '');
|
|
1568
|
+
if (prefix && segments.length >= 2) {
|
|
1569
|
+
const prefixParts = prefix.split('/');
|
|
1570
|
+
const pathPrefix = segments.slice(0, prefixParts.length).join('/');
|
|
1571
|
+
if (pathPrefix === prefix) {
|
|
1572
|
+
matchedCollection = col.slug;
|
|
1573
|
+
docSlug = segments.slice(prefixParts.length).join('/');
|
|
1574
|
+
break;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
else if (!prefix && col.type === 'page') {
|
|
1578
|
+
matchedCollection = col.slug;
|
|
1579
|
+
docSlug = segments.join('/');
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (!matchedCollection && segments.length === 1) {
|
|
1583
|
+
matchedCollection = 'pages';
|
|
1584
|
+
docSlug = segments[0];
|
|
1585
|
+
}
|
|
1586
|
+
if (!matchedCollection && segments.length >= 2) {
|
|
1587
|
+
matchedCollection = segments[0];
|
|
1588
|
+
docSlug = segments.slice(1).join('/');
|
|
1589
|
+
}
|
|
1590
|
+
if (!matchedCollection || !docSlug) {
|
|
1591
|
+
return errorResponse('Could not resolve path', 404);
|
|
1592
|
+
}
|
|
1593
|
+
const doc = await db().document.findFirst({
|
|
1594
|
+
where: {
|
|
1595
|
+
collection: matchedCollection,
|
|
1596
|
+
deletedAt: null,
|
|
1597
|
+
status: 'PUBLISHED',
|
|
1598
|
+
OR: [
|
|
1599
|
+
{ data: { path: ['slug'], equals: docSlug } },
|
|
1600
|
+
{ plainText: { contains: docSlug } },
|
|
1601
|
+
],
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
if (!doc) {
|
|
1605
|
+
return errorResponse('Document not found', 404);
|
|
1606
|
+
}
|
|
1607
|
+
return json({
|
|
1608
|
+
data: {
|
|
1609
|
+
id: doc.id,
|
|
1610
|
+
collection: doc.collection,
|
|
1611
|
+
data: doc.data,
|
|
1612
|
+
status: doc.status,
|
|
1613
|
+
publishedAt: doc.publishedAt,
|
|
1614
|
+
structuredData: doc.structuredData,
|
|
1615
|
+
},
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
catch (err) {
|
|
1619
|
+
return internalError(err);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
// ---------------------------------------------------------------------------
|
|
1623
|
+
// Folder routes
|
|
1624
|
+
// ---------------------------------------------------------------------------
|
|
1625
|
+
router.get('/folders', async (request) => {
|
|
1626
|
+
try {
|
|
1627
|
+
const auth = await requireAuth(request);
|
|
1628
|
+
if (auth.error)
|
|
1629
|
+
return auth.error;
|
|
1630
|
+
const url = new URL(request.url);
|
|
1631
|
+
const scope = url.searchParams.get('scope');
|
|
1632
|
+
if (!scope)
|
|
1633
|
+
return errorResponse('scope query parameter is required', 400);
|
|
1634
|
+
const folders = await db().folder.findMany({
|
|
1635
|
+
where: { scope },
|
|
1636
|
+
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
|
1637
|
+
});
|
|
1638
|
+
const buildTree = (parentId) => folders
|
|
1639
|
+
.filter((f) => f.parentId === parentId)
|
|
1640
|
+
.map((f) => ({ ...f, children: buildTree(f.id) }));
|
|
1641
|
+
return json({ data: buildTree(null) });
|
|
1642
|
+
}
|
|
1643
|
+
catch (err) {
|
|
1644
|
+
return internalError(err);
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
router.post('/folders', async (request) => {
|
|
1648
|
+
try {
|
|
1649
|
+
const auth = await requireAuth(request);
|
|
1650
|
+
if (auth.error)
|
|
1651
|
+
return auth.error;
|
|
1652
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1653
|
+
if (roleErr)
|
|
1654
|
+
return roleErr;
|
|
1655
|
+
const body = await request.json();
|
|
1656
|
+
if (!body.name || !body.scope)
|
|
1657
|
+
return errorResponse('name and scope are required', 400);
|
|
1658
|
+
const folder = await db().folder.create({
|
|
1659
|
+
data: {
|
|
1660
|
+
name: body.name,
|
|
1661
|
+
scope: body.scope,
|
|
1662
|
+
parentId: body.parentId ?? null,
|
|
1663
|
+
},
|
|
1664
|
+
});
|
|
1665
|
+
return json({ data: folder }, 201);
|
|
1666
|
+
}
|
|
1667
|
+
catch (err) {
|
|
1668
|
+
return internalError(err);
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
router.put('/folders/:id', async (request, params) => {
|
|
1672
|
+
try {
|
|
1673
|
+
const auth = await requireAuth(request);
|
|
1674
|
+
if (auth.error)
|
|
1675
|
+
return auth.error;
|
|
1676
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1677
|
+
if (roleErr)
|
|
1678
|
+
return roleErr;
|
|
1679
|
+
const body = await request.json();
|
|
1680
|
+
const data = {};
|
|
1681
|
+
if (body.name !== undefined)
|
|
1682
|
+
data.name = body.name;
|
|
1683
|
+
if (body.parentId !== undefined)
|
|
1684
|
+
data.parentId = body.parentId;
|
|
1685
|
+
if (body.position !== undefined)
|
|
1686
|
+
data.position = body.position;
|
|
1687
|
+
const folder = await db().folder.update({
|
|
1688
|
+
where: { id: params.id },
|
|
1689
|
+
data,
|
|
1690
|
+
});
|
|
1691
|
+
return json({ data: folder });
|
|
1692
|
+
}
|
|
1693
|
+
catch (err) {
|
|
1694
|
+
return internalError(err);
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
router.delete('/folders/:id', async (request, params) => {
|
|
1698
|
+
try {
|
|
1699
|
+
const auth = await requireAuth(request);
|
|
1700
|
+
if (auth.error)
|
|
1701
|
+
return auth.error;
|
|
1702
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1703
|
+
if (roleErr)
|
|
1704
|
+
return roleErr;
|
|
1705
|
+
await db().folder.delete({ where: { id: params.id } });
|
|
1706
|
+
return json({ data: { success: true } });
|
|
1707
|
+
}
|
|
1708
|
+
catch (err) {
|
|
1709
|
+
return internalError(err);
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
router.put('/documents/:id/folder', async (request, params) => {
|
|
1713
|
+
try {
|
|
1714
|
+
const auth = await requireAuth(request);
|
|
1715
|
+
if (auth.error)
|
|
1716
|
+
return auth.error;
|
|
1717
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1718
|
+
if (roleErr)
|
|
1719
|
+
return roleErr;
|
|
1720
|
+
const body = await request.json();
|
|
1721
|
+
await db().document.update({
|
|
1722
|
+
where: { id: params.id },
|
|
1723
|
+
data: { folderId: body.folderId ?? null },
|
|
1724
|
+
});
|
|
1725
|
+
return json({ data: { success: true } });
|
|
1726
|
+
}
|
|
1727
|
+
catch (err) {
|
|
1728
|
+
return internalError(err);
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
router.put('/media/:id/folder', async (request, params) => {
|
|
1732
|
+
try {
|
|
1733
|
+
const auth = await requireAuth(request);
|
|
1734
|
+
if (auth.error)
|
|
1735
|
+
return auth.error;
|
|
1736
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1737
|
+
if (roleErr)
|
|
1738
|
+
return roleErr;
|
|
1739
|
+
const body = await request.json();
|
|
1740
|
+
await db().media.update({
|
|
1741
|
+
where: { id: params.id },
|
|
1742
|
+
data: { folderId: body.folderId ?? null },
|
|
1743
|
+
});
|
|
1744
|
+
return json({ data: { success: true } });
|
|
1745
|
+
}
|
|
1746
|
+
catch (err) {
|
|
1747
|
+
return internalError(err);
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
// ---------------------------------------------------------------------------
|
|
1751
|
+
// Preview routes
|
|
1752
|
+
// ---------------------------------------------------------------------------
|
|
1753
|
+
router.post('/preview/token', async (request) => {
|
|
1754
|
+
try {
|
|
1755
|
+
const auth = await requireAuth(request);
|
|
1756
|
+
if (auth.error)
|
|
1757
|
+
return auth.error;
|
|
1758
|
+
const body = await request.json();
|
|
1759
|
+
if (!body.collection || !body.documentId) {
|
|
1760
|
+
return errorResponse('collection and documentId are required', 400);
|
|
1761
|
+
}
|
|
1762
|
+
const preview = createPreviewAdapter(getSessionSecret(), db());
|
|
1763
|
+
const session = await preview.createPreviewSession(body.collection, body.documentId);
|
|
1764
|
+
return json({ data: { token: session.token, expiresAt: session.expiresAt } });
|
|
1765
|
+
}
|
|
1766
|
+
catch (err) {
|
|
1767
|
+
return errorResponse('Failed to create preview token', 500);
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
router.get('/preview/:collection/:id', async (request, params) => {
|
|
1771
|
+
try {
|
|
1772
|
+
const url = new URL(request.url);
|
|
1773
|
+
const token = url.searchParams.get('token');
|
|
1774
|
+
if (!token)
|
|
1775
|
+
return errorResponse('Preview token required', 401);
|
|
1776
|
+
const preview = createPreviewAdapter(getSessionSecret(), db());
|
|
1777
|
+
const session = await preview.validatePreviewToken(token);
|
|
1778
|
+
if (!session)
|
|
1779
|
+
return errorResponse('Invalid or expired preview token', 401);
|
|
1780
|
+
if (session.collection !== params.collection || session.documentId !== params.id) {
|
|
1781
|
+
return errorResponse('Token does not match requested resource', 403);
|
|
1782
|
+
}
|
|
1783
|
+
const data = await preview.getPreviewData(session);
|
|
1784
|
+
if (!data)
|
|
1785
|
+
return errorResponse('Document not found', 404);
|
|
1786
|
+
return json({ data });
|
|
1787
|
+
}
|
|
1788
|
+
catch (err) {
|
|
1789
|
+
return errorResponse('Failed to fetch preview data', 500);
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
// ---------------------------------------------------------------------------
|
|
1793
|
+
// Workflow routes
|
|
1794
|
+
// ---------------------------------------------------------------------------
|
|
1795
|
+
router.post('/collections/:slug/:id/workflow', async (request, params) => {
|
|
1796
|
+
try {
|
|
1797
|
+
const auth = await requireAuth(request);
|
|
1798
|
+
if (auth.error)
|
|
1799
|
+
return auth.error;
|
|
1800
|
+
const body = await request.json();
|
|
1801
|
+
if (!body.stage)
|
|
1802
|
+
return errorResponse('Stage is required', 400);
|
|
1803
|
+
const { transitionDocument } = await import('../workflow/index');
|
|
1804
|
+
const result = await transitionDocument(params.id, body.stage, auth.session.userId, auth.session.role, body.note);
|
|
1805
|
+
if (!result.success)
|
|
1806
|
+
return errorResponse(result.error ?? 'Transition failed', 400);
|
|
1807
|
+
return json({ data: { success: true } });
|
|
1808
|
+
}
|
|
1809
|
+
catch (err) {
|
|
1810
|
+
return internalError(err);
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
router.get('/collections/:slug/:id/workflow', async (request, params) => {
|
|
1814
|
+
try {
|
|
1815
|
+
const auth = await requireAuth(request);
|
|
1816
|
+
if (auth.error)
|
|
1817
|
+
return auth.error;
|
|
1818
|
+
const { getAvailableTransitions } = await import('../workflow/index');
|
|
1819
|
+
const doc = await db().document.findFirst({
|
|
1820
|
+
where: { id: params.id, deletedAt: null },
|
|
1821
|
+
select: { workflowStage: true, reviewerId: true, reviewNote: true },
|
|
1822
|
+
});
|
|
1823
|
+
if (!doc)
|
|
1824
|
+
return errorResponse('Document not found', 404);
|
|
1825
|
+
const stage = (doc.workflowStage ?? 'DRAFT');
|
|
1826
|
+
const transitions = getAvailableTransitions(stage, auth.session.role);
|
|
1827
|
+
return json({ data: { stage, transitions, reviewerId: doc.reviewerId, reviewNote: doc.reviewNote } });
|
|
1828
|
+
}
|
|
1829
|
+
catch (err) {
|
|
1830
|
+
return internalError(err);
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
// ---------------------------------------------------------------------------
|
|
1834
|
+
// Version history routes
|
|
1835
|
+
// ---------------------------------------------------------------------------
|
|
1836
|
+
router.get('/collections/:slug/:id/versions', async (request, params) => {
|
|
1837
|
+
try {
|
|
1838
|
+
const auth = await requireAuth(request);
|
|
1839
|
+
if (auth.error)
|
|
1840
|
+
return auth.error;
|
|
1841
|
+
const url = new URL(request.url);
|
|
1842
|
+
const page = Number(url.searchParams.get('page')) || 1;
|
|
1843
|
+
const pageSize = clampPageSize(url.searchParams.get('pageSize'), 50, 20);
|
|
1844
|
+
const skip = (page - 1) * pageSize;
|
|
1845
|
+
const doc = await db().document.findFirst({
|
|
1846
|
+
where: { id: params.id, collection: params.slug },
|
|
1847
|
+
});
|
|
1848
|
+
if (!doc)
|
|
1849
|
+
return errorResponse('Document not found', 404);
|
|
1850
|
+
const [versions, total] = await Promise.all([
|
|
1851
|
+
db().version.findMany({
|
|
1852
|
+
where: { documentId: params.id },
|
|
1853
|
+
orderBy: { createdAt: 'desc' },
|
|
1854
|
+
skip,
|
|
1855
|
+
take: pageSize,
|
|
1856
|
+
include: {
|
|
1857
|
+
changedBy: { select: { id: true, name: true, email: true } },
|
|
1858
|
+
},
|
|
1859
|
+
}),
|
|
1860
|
+
db().version.count({ where: { documentId: params.id } }),
|
|
1861
|
+
]);
|
|
1862
|
+
return json({
|
|
1863
|
+
data: { versions, total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
catch (err) {
|
|
1867
|
+
return internalError(err);
|
|
1868
|
+
}
|
|
1869
|
+
});
|
|
1870
|
+
router.post('/collections/:slug/:id/versions/:versionId/restore', async (request, params) => {
|
|
1871
|
+
try {
|
|
1872
|
+
const auth = await requireAuth(request);
|
|
1873
|
+
if (auth.error)
|
|
1874
|
+
return auth.error;
|
|
1875
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1876
|
+
if (roleErr)
|
|
1877
|
+
return roleErr;
|
|
1878
|
+
const version = await db().version.findFirst({
|
|
1879
|
+
where: { id: params.versionId, documentId: params.id },
|
|
1880
|
+
});
|
|
1881
|
+
if (!version)
|
|
1882
|
+
return errorResponse('Version not found', 404);
|
|
1883
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1884
|
+
const doc = await updateDocument(params.slug, params.id, version.data, ctx);
|
|
1885
|
+
await logEvent({
|
|
1886
|
+
event: 'version_restored',
|
|
1887
|
+
userId: auth.session.userId,
|
|
1888
|
+
details: { collection: params.slug, documentId: params.id, versionId: params.versionId },
|
|
1889
|
+
});
|
|
1890
|
+
return json({ data: doc });
|
|
1891
|
+
}
|
|
1892
|
+
catch (err) {
|
|
1893
|
+
return errorResponse('Failed to restore version', 500);
|
|
1894
|
+
}
|
|
1895
|
+
});
|
|
1896
|
+
// ---------------------------------------------------------------------------
|
|
1897
|
+
// Scheduling routes
|
|
1898
|
+
// ---------------------------------------------------------------------------
|
|
1899
|
+
router.post('/scheduling/run', async (request) => {
|
|
1900
|
+
try {
|
|
1901
|
+
const auth = await requireAuth(request);
|
|
1902
|
+
if (auth.error)
|
|
1903
|
+
return auth.error;
|
|
1904
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1905
|
+
if (roleErr)
|
|
1906
|
+
return roleErr;
|
|
1907
|
+
const result = await schedulingCronHandler(db());
|
|
1908
|
+
return json({ data: result });
|
|
1909
|
+
}
|
|
1910
|
+
catch (err) {
|
|
1911
|
+
return errorResponse('Scheduling run failed', 500);
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
router.get('/scheduling/calendar', async (request) => {
|
|
1915
|
+
try {
|
|
1916
|
+
const auth = await requireAuth(request);
|
|
1917
|
+
if (auth.error)
|
|
1918
|
+
return auth.error;
|
|
1919
|
+
const url = new URL(request.url);
|
|
1920
|
+
const fromStr = url.searchParams.get('from');
|
|
1921
|
+
const toStr = url.searchParams.get('to');
|
|
1922
|
+
const from = fromStr ? new Date(fromStr) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1923
|
+
const to = toStr ? new Date(toStr) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
|
1924
|
+
const { getScheduleCalendar } = await import('../scheduling/index');
|
|
1925
|
+
const entries = await getScheduleCalendar(from, to, db());
|
|
1926
|
+
return json({ data: entries });
|
|
1927
|
+
}
|
|
1928
|
+
catch (err) {
|
|
1929
|
+
return internalError(err);
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
// ---------------------------------------------------------------------------
|
|
1933
|
+
// Globals routes
|
|
1934
|
+
// ---------------------------------------------------------------------------
|
|
1935
|
+
router.get('/globals/:slug', async (request, params) => {
|
|
1936
|
+
try {
|
|
1937
|
+
const auth = await requireAuth(request);
|
|
1938
|
+
if (auth.error)
|
|
1939
|
+
return auth.error;
|
|
1940
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1941
|
+
const global = await getGlobal(params.slug, ctx);
|
|
1942
|
+
if (!global) {
|
|
1943
|
+
return errorResponse('Global not found', 404);
|
|
1944
|
+
}
|
|
1945
|
+
return json({ data: global });
|
|
1946
|
+
}
|
|
1947
|
+
catch (err) {
|
|
1948
|
+
return internalError(err);
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
router.put('/globals/:slug', async (request, params) => {
|
|
1952
|
+
try {
|
|
1953
|
+
const auth = await requireAuth(request);
|
|
1954
|
+
if (auth.error)
|
|
1955
|
+
return auth.error;
|
|
1956
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1957
|
+
if (roleErr)
|
|
1958
|
+
return roleErr;
|
|
1959
|
+
const body = await request.json();
|
|
1960
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1961
|
+
const global = await updateGlobal(params.slug, body, ctx);
|
|
1962
|
+
return json({ data: global });
|
|
1963
|
+
}
|
|
1964
|
+
catch (err) {
|
|
1965
|
+
return internalError(err);
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
// ---------------------------------------------------------------------------
|
|
1969
|
+
// Webhook management routes
|
|
1970
|
+
// ---------------------------------------------------------------------------
|
|
1971
|
+
router.get('/webhooks', async (request) => {
|
|
1972
|
+
try {
|
|
1973
|
+
const auth = await requireAuth(request);
|
|
1974
|
+
if (auth.error)
|
|
1975
|
+
return auth.error;
|
|
1976
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1977
|
+
if (roleErr)
|
|
1978
|
+
return roleErr;
|
|
1979
|
+
const { listEndpoints } = await import('../webhooks/index');
|
|
1980
|
+
const endpoints = await listEndpoints();
|
|
1981
|
+
return json({ data: endpoints });
|
|
1982
|
+
}
|
|
1983
|
+
catch (err) {
|
|
1984
|
+
return internalError(err);
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
router.post('/webhooks', async (request) => {
|
|
1988
|
+
try {
|
|
1989
|
+
const auth = await requireAuth(request);
|
|
1990
|
+
if (auth.error)
|
|
1991
|
+
return auth.error;
|
|
1992
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1993
|
+
if (roleErr)
|
|
1994
|
+
return roleErr;
|
|
1995
|
+
const body = await request.json();
|
|
1996
|
+
if (!body.url || !body.events?.length) {
|
|
1997
|
+
return errorResponse('url and events are required', 400);
|
|
1998
|
+
}
|
|
1999
|
+
const { createEndpoint } = await import('../webhooks/index');
|
|
2000
|
+
const secret = body.secret || crypto.randomUUID();
|
|
2001
|
+
const endpoint = await createEndpoint({
|
|
2002
|
+
url: body.url,
|
|
2003
|
+
events: body.events,
|
|
2004
|
+
secret,
|
|
2005
|
+
name: body.name,
|
|
2006
|
+
active: body.active ?? true,
|
|
2007
|
+
});
|
|
2008
|
+
await logEvent({
|
|
2009
|
+
event: 'settings_changed',
|
|
2010
|
+
userId: auth.session.userId,
|
|
2011
|
+
details: { action: 'webhook_created', endpointId: endpoint.id, url: body.url },
|
|
2012
|
+
});
|
|
2013
|
+
return json({ data: endpoint }, 201);
|
|
2014
|
+
}
|
|
2015
|
+
catch (err) {
|
|
2016
|
+
return internalError(err);
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
router.put('/webhooks/:id', async (request, params) => {
|
|
2020
|
+
try {
|
|
2021
|
+
const auth = await requireAuth(request);
|
|
2022
|
+
if (auth.error)
|
|
2023
|
+
return auth.error;
|
|
2024
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2025
|
+
if (roleErr)
|
|
2026
|
+
return roleErr;
|
|
2027
|
+
const body = await request.json();
|
|
2028
|
+
const { updateEndpoint } = await import('../webhooks/index');
|
|
2029
|
+
const updated = await updateEndpoint(params.id, body);
|
|
2030
|
+
return json({ data: updated });
|
|
2031
|
+
}
|
|
2032
|
+
catch (err) {
|
|
2033
|
+
return internalError(err);
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
router.delete('/webhooks/:id', async (request, params) => {
|
|
2037
|
+
try {
|
|
2038
|
+
const auth = await requireAuth(request);
|
|
2039
|
+
if (auth.error)
|
|
2040
|
+
return auth.error;
|
|
2041
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2042
|
+
if (roleErr)
|
|
2043
|
+
return roleErr;
|
|
2044
|
+
const { deleteEndpoint } = await import('../webhooks/index');
|
|
2045
|
+
await deleteEndpoint(params.id);
|
|
2046
|
+
await logEvent({
|
|
2047
|
+
event: 'settings_changed',
|
|
2048
|
+
userId: auth.session.userId,
|
|
2049
|
+
details: { action: 'webhook_deleted', endpointId: params.id },
|
|
2050
|
+
});
|
|
2051
|
+
return json({ data: { success: true } });
|
|
2052
|
+
}
|
|
2053
|
+
catch (err) {
|
|
2054
|
+
return internalError(err);
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
router.get('/webhooks/:id/deliveries', async (request, params) => {
|
|
2058
|
+
try {
|
|
2059
|
+
const auth = await requireAuth(request);
|
|
2060
|
+
if (auth.error)
|
|
2061
|
+
return auth.error;
|
|
2062
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2063
|
+
if (roleErr)
|
|
2064
|
+
return roleErr;
|
|
2065
|
+
const { getDeliveries } = await import('../webhooks/index');
|
|
2066
|
+
const deliveries = await getDeliveries(params.id);
|
|
2067
|
+
return json({ data: deliveries });
|
|
2068
|
+
}
|
|
2069
|
+
catch (err) {
|
|
2070
|
+
return internalError(err);
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
router.post('/webhooks/retry', async (request) => {
|
|
2074
|
+
try {
|
|
2075
|
+
const auth = await requireAuth(request);
|
|
2076
|
+
if (auth.error)
|
|
2077
|
+
return auth.error;
|
|
2078
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2079
|
+
if (roleErr)
|
|
2080
|
+
return roleErr;
|
|
2081
|
+
const { processRetries } = await import('../webhooks/index');
|
|
2082
|
+
const result = await processRetries();
|
|
2083
|
+
return json({ data: result });
|
|
2084
|
+
}
|
|
2085
|
+
catch (err) {
|
|
2086
|
+
return internalError(err);
|
|
2087
|
+
}
|
|
2088
|
+
});
|
|
2089
|
+
// ---------------------------------------------------------------------------
|
|
2090
|
+
// Presence SSE
|
|
2091
|
+
// ---------------------------------------------------------------------------
|
|
2092
|
+
router.get('/presence/:documentId', async (request, params) => {
|
|
2093
|
+
try {
|
|
2094
|
+
const auth = await requireAuth(request);
|
|
2095
|
+
if (auth.error)
|
|
2096
|
+
return auth.error;
|
|
2097
|
+
return presenceAdapter.handleSSE(request, params.documentId, {
|
|
2098
|
+
userId: auth.session.userId,
|
|
2099
|
+
name: auth.session.role,
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
catch (err) {
|
|
2103
|
+
return internalError(err);
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
router.post('/presence/:documentId/heartbeat', async (request, params) => {
|
|
2107
|
+
try {
|
|
2108
|
+
const auth = await requireAuth(request);
|
|
2109
|
+
if (auth.error)
|
|
2110
|
+
return auth.error;
|
|
2111
|
+
await presenceAdapter.provider.heartbeat(params.documentId, auth.session.userId);
|
|
2112
|
+
return json({ data: { ok: true } });
|
|
2113
|
+
}
|
|
2114
|
+
catch (err) {
|
|
2115
|
+
return internalError(err);
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
//# sourceMappingURL=handlers.js.map
|