@actuate-media/cms-core 0.10.4 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/actions/document-crud.test.js +5 -1
- package/dist/__tests__/actions/document-crud.test.js.map +1 -1
- package/dist/__tests__/api/admin-contracts.test.js +1 -0
- package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
- package/dist/__tests__/api/public-globals.test.js +8 -4
- package/dist/__tests__/api/public-globals.test.js.map +1 -1
- package/dist/__tests__/auth/password.test.js.map +1 -1
- package/dist/__tests__/auth/session.test.js.map +1 -1
- package/dist/__tests__/codegen/generate-types.test.js.map +1 -1
- package/dist/__tests__/next.test.js +1 -3
- package/dist/__tests__/next.test.js.map +1 -1
- package/dist/__tests__/scheduling/scheduling.test.js +28 -4
- package/dist/__tests__/scheduling/scheduling.test.js.map +1 -1
- package/dist/__tests__/security/access.test.js +1 -1
- package/dist/__tests__/security/access.test.js.map +1 -1
- package/dist/__tests__/security/audit.test.d.ts +2 -0
- package/dist/__tests__/security/audit.test.d.ts.map +1 -0
- package/dist/__tests__/security/audit.test.js +50 -0
- package/dist/__tests__/security/audit.test.js.map +1 -0
- package/dist/__tests__/security/client-ip.test.d.ts +2 -0
- package/dist/__tests__/security/client-ip.test.d.ts.map +1 -0
- package/dist/__tests__/security/client-ip.test.js +37 -0
- package/dist/__tests__/security/client-ip.test.js.map +1 -0
- package/dist/__tests__/security/csrf.test.js.map +1 -1
- package/dist/__tests__/security/ip-allowlist.test.d.ts +2 -0
- package/dist/__tests__/security/ip-allowlist.test.d.ts.map +1 -0
- package/dist/__tests__/security/ip-allowlist.test.js +40 -0
- package/dist/__tests__/security/ip-allowlist.test.js.map +1 -0
- package/dist/__tests__/security/rate-limit.test.js.map +1 -1
- package/dist/__tests__/security/reauth.test.js.map +1 -1
- package/dist/__tests__/security/redact.test.d.ts +2 -0
- package/dist/__tests__/security/redact.test.d.ts.map +1 -0
- package/dist/__tests__/security/redact.test.js +31 -0
- package/dist/__tests__/security/redact.test.js.map +1 -0
- package/dist/__tests__/security/sanitize.test.js.map +1 -1
- package/dist/__tests__/security/secret-storage.test.d.ts +2 -0
- package/dist/__tests__/security/secret-storage.test.d.ts.map +1 -0
- package/dist/__tests__/security/secret-storage.test.js +42 -0
- package/dist/__tests__/security/secret-storage.test.js.map +1 -0
- package/dist/__tests__/security/upload-magic.test.d.ts +2 -0
- package/dist/__tests__/security/upload-magic.test.d.ts.map +1 -0
- package/dist/__tests__/security/upload-magic.test.js +55 -0
- package/dist/__tests__/security/upload-magic.test.js.map +1 -0
- package/dist/__tests__/server-site.test.d.ts +2 -0
- package/dist/__tests__/server-site.test.d.ts.map +1 -0
- package/dist/__tests__/server-site.test.js +123 -0
- package/dist/__tests__/server-site.test.js.map +1 -0
- package/dist/__tests__/site.test.js +5 -2
- package/dist/__tests__/site.test.js.map +1 -1
- package/dist/__tests__/webhooks/webhooks.test.js.map +1 -1
- package/dist/a11y/index.d.ts +1 -1
- package/dist/a11y/index.d.ts.map +1 -1
- package/dist/a11y/index.js +23 -20
- package/dist/a11y/index.js.map +1 -1
- package/dist/actions.d.ts +1 -1
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +211 -68
- package/dist/actions.js.map +1 -1
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +76 -14
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +952 -220
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/openapi.d.ts.map +1 -1
- package/dist/api/openapi.js +182 -23
- package/dist/api/openapi.js.map +1 -1
- package/dist/api/router.d.ts +6 -6
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +27 -10
- package/dist/api/router.js.map +1 -1
- package/dist/auth/index.d.ts +12 -12
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +9 -9
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/mfa-pending.d.ts +24 -0
- package/dist/auth/mfa-pending.d.ts.map +1 -0
- package/dist/auth/mfa-pending.js +38 -0
- package/dist/auth/mfa-pending.js.map +1 -0
- package/dist/auth/oauth.d.ts +25 -3
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +118 -21
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/password.d.ts +1 -1
- package/dist/auth/password.d.ts.map +1 -1
- package/dist/auth/password.js +14 -14
- package/dist/auth/password.js.map +1 -1
- package/dist/auth/providers/github.d.ts +1 -1
- package/dist/auth/providers/github.d.ts.map +1 -1
- package/dist/auth/providers/github.js +2 -2
- package/dist/auth/providers/github.js.map +1 -1
- package/dist/auth/providers/google.d.ts +1 -1
- package/dist/auth/providers/google.d.ts.map +1 -1
- package/dist/auth/providers/google.js +2 -2
- package/dist/auth/providers/google.js.map +1 -1
- package/dist/auth/providers/microsoft.d.ts +1 -1
- package/dist/auth/providers/microsoft.d.ts.map +1 -1
- package/dist/auth/providers/microsoft.js +2 -2
- package/dist/auth/providers/microsoft.js.map +1 -1
- package/dist/auth/reset-email.d.ts.map +1 -1
- package/dist/auth/reset-email.js +1 -1
- package/dist/auth/reset-email.js.map +1 -1
- package/dist/auth/reset.d.ts.map +1 -1
- package/dist/auth/reset.js +34 -10
- package/dist/auth/reset.js.map +1 -1
- package/dist/auth/session.d.ts +9 -2
- package/dist/auth/session.d.ts.map +1 -1
- package/dist/auth/session.js +26 -8
- package/dist/auth/session.js.map +1 -1
- package/dist/auth/totp.d.ts.map +1 -1
- package/dist/auth/totp.js +8 -2
- package/dist/auth/totp.js.map +1 -1
- package/dist/backup/index.d.ts +2 -2
- package/dist/backup/index.d.ts.map +1 -1
- package/dist/backup/index.js +5 -5
- package/dist/backup/index.js.map +1 -1
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +8 -8
- package/dist/client.js.map +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.d.ts.map +1 -1
- package/dist/codegen/index.js +170 -174
- package/dist/codegen/index.js.map +1 -1
- package/dist/collections/index.d.ts +1 -1
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/collections/index.js.map +1 -1
- package/dist/config/define.d.ts +2 -2
- package/dist/config/define.d.ts.map +1 -1
- package/dist/config/define.js +1 -1
- package/dist/config/define.js.map +1 -1
- package/dist/config/index.d.ts +3 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +32 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +26 -26
- package/dist/config/types.d.ts.map +1 -1
- package/dist/content/ai-api.d.ts.map +1 -1
- package/dist/content/ai-api.js +8 -2
- package/dist/content/ai-api.js.map +1 -1
- package/dist/content/content-graph.d.ts +1 -1
- package/dist/content/content-graph.d.ts.map +1 -1
- package/dist/content/content-graph.js +7 -7
- package/dist/content/content-graph.js.map +1 -1
- package/dist/content/extract.js +13 -13
- package/dist/content/extract.js.map +1 -1
- package/dist/content/index.d.ts +7 -7
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -4
- package/dist/content/index.js.map +1 -1
- package/dist/content/structured-data.d.ts +3 -3
- package/dist/content/structured-data.d.ts.map +1 -1
- package/dist/content/structured-data.js +65 -67
- package/dist/content/structured-data.js.map +1 -1
- package/dist/db/adapters/mysql.d.ts.map +1 -1
- package/dist/db/adapters/mysql.js.map +1 -1
- package/dist/db/adapters/postgres.d.ts.map +1 -1
- package/dist/db/adapters/postgres.js.map +1 -1
- package/dist/db/adapters/sqlite.d.ts.map +1 -1
- package/dist/db/adapters/sqlite.js.map +1 -1
- package/dist/db/create-adapter.d.ts.map +1 -1
- package/dist/db/create-adapter.js.map +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db.d.ts +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1 -1
- package/dist/db.js.map +1 -1
- package/dist/fields/index.d.ts +2 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +51 -47
- package/dist/fields/index.js.map +1 -1
- package/dist/forms/analytics.d.ts.map +1 -1
- package/dist/forms/analytics.js.map +1 -1
- package/dist/forms/attribution.d.ts.map +1 -1
- package/dist/forms/attribution.js +7 -2
- package/dist/forms/attribution.js.map +1 -1
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js.map +1 -1
- package/dist/graphql/index.d.ts.map +1 -1
- package/dist/graphql/index.js.map +1 -1
- package/dist/graphql/resolvers.d.ts.map +1 -1
- package/dist/graphql/resolvers.js +17 -21
- package/dist/graphql/resolvers.js.map +1 -1
- package/dist/graphql/schema-builder.d.ts.map +1 -1
- package/dist/graphql/schema-builder.js.map +1 -1
- package/dist/health/index.d.ts +2 -2
- package/dist/health/index.d.ts.map +1 -1
- package/dist/health/index.js +9 -9
- package/dist/health/index.js.map +1 -1
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/index.js.map +1 -1
- package/dist/index.d.ts +78 -76
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -42
- package/dist/index.js.map +1 -1
- package/dist/media/index.d.ts +2 -2
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/index.js.map +1 -1
- package/dist/media/optimize.d.ts.map +1 -1
- package/dist/media/optimize.js +7 -4
- package/dist/media/optimize.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +21 -34
- package/dist/middleware.js.map +1 -1
- package/dist/multisite/index.d.ts.map +1 -1
- package/dist/multisite/index.js +4 -4
- package/dist/multisite/index.js.map +1 -1
- package/dist/next/preview.d.ts.map +1 -1
- package/dist/next/preview.js.map +1 -1
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +4 -5
- package/dist/next.js.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +5 -5
- package/dist/notifications/index.js.map +1 -1
- package/dist/page-builder/__tests__/a11y-fix.test.js +1 -5
- package/dist/page-builder/__tests__/a11y-fix.test.js.map +1 -1
- package/dist/page-builder/__tests__/blocks.test.js +108 -1
- package/dist/page-builder/__tests__/blocks.test.js.map +1 -1
- package/dist/page-builder/__tests__/design-scorer.test.js +44 -11
- package/dist/page-builder/__tests__/design-scorer.test.js.map +1 -1
- package/dist/page-builder/__tests__/schema.test.js +12 -12
- package/dist/page-builder/__tests__/schema.test.js.map +1 -1
- package/dist/page-builder/__tests__/seo-analyzer.test.js +27 -13
- package/dist/page-builder/__tests__/seo-analyzer.test.js.map +1 -1
- package/dist/page-builder/ai-pipeline.d.ts.map +1 -1
- package/dist/page-builder/ai-pipeline.js +1 -3
- package/dist/page-builder/ai-pipeline.js.map +1 -1
- package/dist/page-builder/blocks.d.ts +18 -1
- package/dist/page-builder/blocks.d.ts.map +1 -1
- package/dist/page-builder/blocks.js +67 -11
- package/dist/page-builder/blocks.js.map +1 -1
- package/dist/page-builder/design-scorer.d.ts.map +1 -1
- package/dist/page-builder/design-scorer.js +249 -41
- package/dist/page-builder/design-scorer.js.map +1 -1
- package/dist/page-builder/index.d.ts +3 -3
- package/dist/page-builder/index.d.ts.map +1 -1
- package/dist/page-builder/index.js +2 -2
- package/dist/page-builder/index.js.map +1 -1
- package/dist/page-builder/seo-analyzer.d.ts.map +1 -1
- package/dist/page-builder/seo-analyzer.js +252 -56
- package/dist/page-builder/seo-analyzer.js.map +1 -1
- package/dist/page-builder/templates.d.ts.map +1 -1
- package/dist/page-builder/templates.js +45 -16
- package/dist/page-builder/templates.js.map +1 -1
- package/dist/page-builder/tree.d.ts.map +1 -1
- package/dist/page-builder/tree.js.map +1 -1
- package/dist/page-builder/validate.js.map +1 -1
- package/dist/presence/index.d.ts.map +1 -1
- package/dist/presence/index.js +2 -2
- package/dist/presence/index.js.map +1 -1
- package/dist/preview/index.d.ts.map +1 -1
- package/dist/preview/index.js.map +1 -1
- package/dist/privacy/index.d.ts +1 -1
- package/dist/privacy/index.d.ts.map +1 -1
- package/dist/privacy/index.js +3 -3
- package/dist/privacy/index.js.map +1 -1
- package/dist/relationships/index.d.ts.map +1 -1
- package/dist/relationships/index.js +1 -1
- package/dist/relationships/index.js.map +1 -1
- package/dist/scheduling/index.d.ts +2 -2
- package/dist/scheduling/index.d.ts.map +1 -1
- package/dist/scheduling/index.js +3 -1
- package/dist/scheduling/index.js.map +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -3
- package/dist/search/index.js.map +1 -1
- package/dist/security/access.d.ts +4 -4
- package/dist/security/access.d.ts.map +1 -1
- package/dist/security/access.js +11 -15
- package/dist/security/access.js.map +1 -1
- package/dist/security/anomaly-detection.d.ts.map +1 -1
- package/dist/security/anomaly-detection.js +5 -5
- package/dist/security/anomaly-detection.js.map +1 -1
- package/dist/security/api-key-enhanced.d.ts +2 -2
- package/dist/security/api-key-enhanced.d.ts.map +1 -1
- package/dist/security/api-key-enhanced.js +5 -5
- package/dist/security/api-key-enhanced.js.map +1 -1
- package/dist/security/audit.d.ts.map +1 -1
- package/dist/security/audit.js +8 -4
- package/dist/security/audit.js.map +1 -1
- package/dist/security/breach-check.js.map +1 -1
- package/dist/security/captcha.d.ts.map +1 -1
- package/dist/security/captcha.js.map +1 -1
- package/dist/security/client-ip.d.ts +33 -0
- package/dist/security/client-ip.d.ts.map +1 -0
- package/dist/security/client-ip.js +42 -0
- package/dist/security/client-ip.js.map +1 -0
- package/dist/security/cors.d.ts +1 -1
- package/dist/security/cors.d.ts.map +1 -1
- package/dist/security/cors.js +12 -12
- package/dist/security/cors.js.map +1 -1
- package/dist/security/csp-nonces.js +11 -11
- package/dist/security/csp-nonces.js.map +1 -1
- package/dist/security/csrf.js +2 -2
- package/dist/security/csrf.js.map +1 -1
- package/dist/security/encrypted-fields.d.ts.map +1 -1
- package/dist/security/encrypted-fields.js +7 -4
- package/dist/security/encrypted-fields.js.map +1 -1
- package/dist/security/headers.d.ts.map +1 -1
- package/dist/security/headers.js +12 -12
- package/dist/security/headers.js.map +1 -1
- package/dist/security/index.d.ts +39 -32
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +25 -20
- package/dist/security/index.js.map +1 -1
- package/dist/security/internal-keys.d.ts +15 -0
- package/dist/security/internal-keys.d.ts.map +1 -0
- package/dist/security/internal-keys.js +33 -0
- package/dist/security/internal-keys.js.map +1 -0
- package/dist/security/ip-allowlist.d.ts +13 -1
- package/dist/security/ip-allowlist.d.ts.map +1 -1
- package/dist/security/ip-allowlist.js +117 -11
- package/dist/security/ip-allowlist.js.map +1 -1
- package/dist/security/middleware.d.ts +2 -2
- package/dist/security/middleware.d.ts.map +1 -1
- package/dist/security/middleware.js +11 -11
- package/dist/security/middleware.js.map +1 -1
- package/dist/security/rate-limit.d.ts.map +1 -1
- package/dist/security/rate-limit.js +50 -18
- package/dist/security/rate-limit.js.map +1 -1
- package/dist/security/reauth.d.ts +1 -1
- package/dist/security/reauth.d.ts.map +1 -1
- package/dist/security/reauth.js.map +1 -1
- package/dist/security/redact.d.ts +12 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +44 -0
- package/dist/security/redact.js.map +1 -0
- package/dist/security/safe-fetch.d.ts +35 -0
- package/dist/security/safe-fetch.d.ts.map +1 -0
- package/dist/security/safe-fetch.js +45 -0
- package/dist/security/safe-fetch.js.map +1 -0
- package/dist/security/sanitize.d.ts.map +1 -1
- package/dist/security/sanitize.js +40 -8
- package/dist/security/sanitize.js.map +1 -1
- package/dist/security/secret-storage.d.ts +22 -0
- package/dist/security/secret-storage.d.ts.map +1 -0
- package/dist/security/secret-storage.js +75 -0
- package/dist/security/secret-storage.js.map +1 -0
- package/dist/security/security-txt.d.ts.map +1 -1
- package/dist/security/security-txt.js +2 -2
- package/dist/security/security-txt.js.map +1 -1
- package/dist/security/session-limits.d.ts +1 -1
- package/dist/security/session-limits.d.ts.map +1 -1
- package/dist/security/session-limits.js +1 -1
- package/dist/security/session-limits.js.map +1 -1
- package/dist/security/upload.d.ts +23 -4
- package/dist/security/upload.d.ts.map +1 -1
- package/dist/security/upload.js +118 -23
- package/dist/security/upload.js.map +1 -1
- package/dist/security/webhook.d.ts.map +1 -1
- package/dist/security/webhook.js +12 -8
- package/dist/security/webhook.js.map +1 -1
- package/dist/seo/analysis.d.ts.map +1 -1
- package/dist/seo/analysis.js +25 -13
- package/dist/seo/analysis.js.map +1 -1
- package/dist/seo/index.d.ts +9 -9
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +4 -4
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/llms-txt.js +1 -3
- package/dist/seo/llms-txt.js.map +1 -1
- package/dist/server-site.d.ts +54 -0
- package/dist/server-site.d.ts.map +1 -0
- package/dist/server-site.js +147 -0
- package/dist/server-site.js.map +1 -0
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js.map +1 -1
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +26 -4
- package/dist/site.js.map +1 -1
- package/dist/storage/index.d.ts +20 -10
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +6 -3
- package/dist/storage/index.js.map +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +3 -3
- package/dist/templates/index.js.map +1 -1
- package/dist/upgrade/changelog.d.ts +1 -1
- package/dist/upgrade/changelog.d.ts.map +1 -1
- package/dist/upgrade/changelog.js +12 -12
- package/dist/upgrade/changelog.js.map +1 -1
- package/dist/upgrade/index.d.ts +6 -6
- package/dist/upgrade/index.d.ts.map +1 -1
- package/dist/upgrade/index.js +3 -3
- package/dist/upgrade/index.js.map +1 -1
- package/dist/upgrade/upgrade-pr.d.ts.map +1 -1
- package/dist/upgrade/upgrade-pr.js +36 -36
- package/dist/upgrade/upgrade-pr.js.map +1 -1
- package/dist/upgrade/version-check.d.ts +1 -1
- package/dist/upgrade/version-check.d.ts.map +1 -1
- package/dist/upgrade/version-check.js +13 -13
- package/dist/upgrade/version-check.js.map +1 -1
- package/dist/webhooks/index.d.ts +1 -1
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +24 -13
- package/dist/webhooks/index.js.map +1 -1
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.d.ts.map +1 -1
- package/dist/workflows/index.js +3 -3
- package/dist/workflows/index.js.map +1 -1
- package/package.json +1 -1
- package/prisma/seed.ts +31 -31
package/dist/api/handlers.js
CHANGED
|
@@ -4,7 +4,8 @@ import { createSession, verifySession, revokeSession } from '../auth/session.js'
|
|
|
4
4
|
import { createPasswordReset, executePasswordReset } from '../auth/reset.js';
|
|
5
5
|
import { checkSetupRequired, createInitialAdmin } from '../setup/index.js';
|
|
6
6
|
import { getDB } from '../db.js';
|
|
7
|
-
import { generateCodeVerifier, generateCodeChallenge, generateState, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
|
|
7
|
+
import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
|
|
8
|
+
import { createMfaPendingToken, verifyMfaPendingToken, computeRequestFingerprint, } from '../auth/mfa-pending.js';
|
|
8
9
|
import { optimizeImage, formatBytes } from '../media/optimize.js';
|
|
9
10
|
import { generateToken as generateCsrfToken } from '../security/csrf.js';
|
|
10
11
|
import { logEvent } from '../security/audit.js';
|
|
@@ -15,12 +16,20 @@ import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
|
|
|
15
16
|
import { checkForUpdates } from '../upgrade/version-check.js';
|
|
16
17
|
import { createUpgradePR } from '../upgrade/upgrade-pr.js';
|
|
17
18
|
import { encryptField, decryptField } from '../security/encrypted-fields.js';
|
|
19
|
+
import { encryptSecret, decryptSecret, encryptStringArray, } from '../security/secret-storage.js';
|
|
18
20
|
import { createRateLimiter } from '../security/rate-limit.js';
|
|
19
21
|
import { generateOpenAPISpec } from './openapi.js';
|
|
20
22
|
import { createSSEPresenceAdapter } from '../presence/index.js';
|
|
21
23
|
import { BUILT_IN_TEMPLATES } from '../page-builder/templates.js';
|
|
22
24
|
import { validateTree } from '../page-builder/validate.js';
|
|
23
25
|
import { auditAccessibility, fixAccessibility } from '../page-builder/a11y-fix.js';
|
|
26
|
+
import { getClientIp, isResolvedIp } from '../security/client-ip.js';
|
|
27
|
+
import { safeFetch, SsrfBlockedError } from '../security/safe-fetch.js';
|
|
28
|
+
import { redactSecrets } from '../security/redact.js';
|
|
29
|
+
import { enforceSessionLimits } from '../security/session-limits.js';
|
|
30
|
+
import { verifyReauth } from '../security/reauth.js';
|
|
31
|
+
import { validateMimeType, checkMagicBytes } from '../security/upload.js';
|
|
32
|
+
import { sanitizeHtml } from '../security/sanitize.js';
|
|
24
33
|
// Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
|
|
25
34
|
// Returns { put, del, ... } from @vercel/blob when available.
|
|
26
35
|
async function importBlobStorage() {
|
|
@@ -62,7 +71,9 @@ function mediaUrl(storageKey) {
|
|
|
62
71
|
const value = String(storageKey ?? '');
|
|
63
72
|
if (!value)
|
|
64
73
|
return '';
|
|
65
|
-
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
|
|
74
|
+
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
|
|
75
|
+
? value
|
|
76
|
+
: '';
|
|
66
77
|
}
|
|
67
78
|
function normalizeMediaItem(media) {
|
|
68
79
|
const width = typeof media.width === 'number' ? media.width : null;
|
|
@@ -158,9 +169,9 @@ function hasModel(d, name) {
|
|
|
158
169
|
}
|
|
159
170
|
}
|
|
160
171
|
function modelNotAvailable(name) {
|
|
161
|
-
return errorResponse(`The "${name}" model is not available in your Prisma schema. `
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
return errorResponse(`The "${name}" model is not available in your Prisma schema. ` +
|
|
173
|
+
'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
|
|
174
|
+
'See https://actuatecms.dev/docs/database-setup for required models.', 501);
|
|
164
175
|
}
|
|
165
176
|
async function safeCount(model, where) {
|
|
166
177
|
try {
|
|
@@ -223,28 +234,34 @@ function isAllowedStorageUrl(url) {
|
|
|
223
234
|
}
|
|
224
235
|
}
|
|
225
236
|
const ALLOWED_SORT_FIELDS = new Set([
|
|
226
|
-
'createdAt',
|
|
237
|
+
'createdAt',
|
|
238
|
+
'updatedAt',
|
|
239
|
+
'publishedAt',
|
|
240
|
+
'status',
|
|
241
|
+
'collection',
|
|
227
242
|
]);
|
|
228
243
|
let _secretMissing = false;
|
|
229
244
|
let _secretWarningLogged = false;
|
|
230
245
|
function getSessionSecret() {
|
|
231
|
-
const secret = process.env.CMS_SECRET
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
const secret = process.env.CMS_SECRET ??
|
|
247
|
+
process.env.CMS_SESSION_SECRET ??
|
|
248
|
+
globalThis.__actuateConfig?.secret;
|
|
234
249
|
if (!secret) {
|
|
235
250
|
_secretMissing = true;
|
|
236
251
|
if (!_secretWarningLogged) {
|
|
237
252
|
_secretWarningLogged = true;
|
|
238
|
-
console.error('[Actuate CMS] Missing CMS secret. Set the CMS_SECRET environment variable (min 32 characters) '
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
console.error('[Actuate CMS] Missing CMS secret. Set the CMS_SECRET environment variable (min 32 characters) ' +
|
|
254
|
+
'or pass `secret` in your actuate.config.ts. ' +
|
|
255
|
+
"Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\" " +
|
|
256
|
+
'-- All authenticated API routes will return 503 until this is configured.');
|
|
242
257
|
}
|
|
243
258
|
throw new Error('CMS secret not configured');
|
|
244
259
|
}
|
|
245
260
|
if (secret.length < 32) {
|
|
246
|
-
throw new Error('[Actuate CMS] CMS secret must be at least 32 characters (got ' +
|
|
247
|
-
+
|
|
261
|
+
throw new Error('[Actuate CMS] CMS secret must be at least 32 characters (got ' +
|
|
262
|
+
secret.length +
|
|
263
|
+
'). ' +
|
|
264
|
+
"Generate a secure value with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"");
|
|
248
265
|
}
|
|
249
266
|
_secretMissing = false;
|
|
250
267
|
return secret;
|
|
@@ -302,6 +319,36 @@ export function parseCookieHeader(cookieHeader) {
|
|
|
302
319
|
}
|
|
303
320
|
return cookies;
|
|
304
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Verify a password (or "current password") posted in the X-Reauth-Password
|
|
324
|
+
* header against the authenticated user. Returns an error Response when the
|
|
325
|
+
* header is missing or the password is wrong; returns null on success.
|
|
326
|
+
*
|
|
327
|
+
* Use this for high-impact account changes (TOTP enable/disable, role change,
|
|
328
|
+
* delete user, etc.) so a stolen session alone cannot perform them.
|
|
329
|
+
*/
|
|
330
|
+
async function requirePasswordReauth(request, userId) {
|
|
331
|
+
const password = request.headers.get('x-reauth-password');
|
|
332
|
+
if (!password) {
|
|
333
|
+
return new Response(JSON.stringify({
|
|
334
|
+
error: 'Re-authentication required for this action.',
|
|
335
|
+
code: 'REAUTH_REQUIRED',
|
|
336
|
+
method: 'password',
|
|
337
|
+
}), { status: 401, headers: { ...SECURITY_HEADERS } });
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const { getDB } = await import('../db.js');
|
|
341
|
+
const ok = await verifyReauth(userId, password, 'password', getDB());
|
|
342
|
+
if (!ok) {
|
|
343
|
+
return errorResponse('Re-authentication failed.', 401);
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
console.error('[actuate][reauth] verify failed:', err instanceof Error ? err.message : err);
|
|
349
|
+
return errorResponse('Re-authentication failed.', 401);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
305
352
|
async function requireAuth(request) {
|
|
306
353
|
if (isSecretMissing()) {
|
|
307
354
|
return {
|
|
@@ -334,15 +381,59 @@ function requireRole(role, allowedRoles) {
|
|
|
334
381
|
return null;
|
|
335
382
|
}
|
|
336
383
|
const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
|
|
384
|
+
const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });
|
|
337
385
|
const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
|
|
386
|
+
const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
|
|
387
|
+
const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
|
|
338
388
|
async function checkRateLimitAsync(limiter, key) {
|
|
389
|
+
// Explicit, environment-gated bypass for test harnesses. Production never
|
|
390
|
+
// sets this — Vercel + the deploy guide both omit it. We deliberately do
|
|
391
|
+
// NOT key off `NODE_ENV === 'test'` because Next.js builds run with
|
|
392
|
+
// NODE_ENV=test in some CI configurations and we want rate limiting
|
|
393
|
+
// exercised by unit tests that explicitly opt in.
|
|
394
|
+
if (process.env.ACTUATE_DISABLE_RATE_LIMIT === '1') {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
339
397
|
const result = await limiter.check(key);
|
|
340
398
|
return result.allowed;
|
|
341
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
|
|
402
|
+
* then x-forwarded-for only when ACTUATE_TRUST_PROXY=1). Returns 'unknown' when
|
|
403
|
+
* no trustworthy source is available — callers MUST treat that case as a hard
|
|
404
|
+
* failure for security-sensitive decisions like rate-limit keys and IP allowlists.
|
|
405
|
+
*/
|
|
406
|
+
function clientIp(request) {
|
|
407
|
+
return getClientIp(request);
|
|
408
|
+
}
|
|
409
|
+
const MAX_CONCURRENT_SESSIONS = 5;
|
|
410
|
+
/**
|
|
411
|
+
* After a successful primary-credential check, prune the user's active sessions
|
|
412
|
+
* down to the configured concurrent maximum (default 5; configurable via
|
|
413
|
+
* `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
|
|
414
|
+
* always sign in.
|
|
415
|
+
*/
|
|
416
|
+
async function enforceSessionLimitsForUser(d, userId) {
|
|
417
|
+
if (!hasModel(d, 'session'))
|
|
418
|
+
return;
|
|
419
|
+
const config = globalThis.__actuateConfig?.auth ?? {};
|
|
420
|
+
const max = typeof config.maxConcurrentSessions === 'number' && config.maxConcurrentSessions > 0
|
|
421
|
+
? config.maxConcurrentSessions
|
|
422
|
+
: MAX_CONCURRENT_SESSIONS;
|
|
423
|
+
const active = await d.session.findMany({
|
|
424
|
+
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
425
|
+
select: { id: true, createdAt: true },
|
|
426
|
+
});
|
|
427
|
+
const decision = enforceSessionLimits(active.map((s) => ({ sessionId: s.id, userId, createdAt: s.createdAt })), { maxConcurrentSessions: max, strategy: 'revoke_oldest' });
|
|
428
|
+
if (decision.sessionsToRevoke.length > 0) {
|
|
429
|
+
await d.session.updateMany({
|
|
430
|
+
where: { id: { in: decision.sessionsToRevoke } },
|
|
431
|
+
data: { revokedAt: new Date() },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
342
435
|
function getAdminPath() {
|
|
343
|
-
return process.env.ACTUATE_ADMIN_PATH
|
|
344
|
-
?? globalThis.__actuateConfig?.admin?.path
|
|
345
|
-
?? '/admin';
|
|
436
|
+
return (process.env.ACTUATE_ADMIN_PATH ?? globalThis.__actuateConfig?.admin?.path ?? '/admin');
|
|
346
437
|
}
|
|
347
438
|
class ModelNotAvailableError extends Error {
|
|
348
439
|
model;
|
|
@@ -365,7 +456,9 @@ export function registerCMSRoutes(router) {
|
|
|
365
456
|
if (typeof prop !== 'string' || prop.startsWith('$') || prop === 'then')
|
|
366
457
|
return undefined;
|
|
367
458
|
return new Proxy({}, {
|
|
368
|
-
get() {
|
|
459
|
+
get() {
|
|
460
|
+
throw new ModelNotAvailableError(String(prop));
|
|
461
|
+
},
|
|
369
462
|
});
|
|
370
463
|
},
|
|
371
464
|
});
|
|
@@ -386,7 +479,9 @@ export function registerCMSRoutes(router) {
|
|
|
386
479
|
if (val !== undefined && val !== null)
|
|
387
480
|
return val;
|
|
388
481
|
return new Proxy({}, {
|
|
389
|
-
get() {
|
|
482
|
+
get() {
|
|
483
|
+
throw new ModelNotAvailableError(String(prop));
|
|
484
|
+
},
|
|
390
485
|
});
|
|
391
486
|
},
|
|
392
487
|
});
|
|
@@ -398,12 +493,7 @@ export function registerCMSRoutes(router) {
|
|
|
398
493
|
router.get('/auth/csrf', async () => {
|
|
399
494
|
const token = await generateCsrfToken();
|
|
400
495
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
401
|
-
const cookieFlags = [
|
|
402
|
-
`actuate_csrf=${token}`,
|
|
403
|
-
'Path=/',
|
|
404
|
-
'SameSite=Lax',
|
|
405
|
-
'Max-Age=86400',
|
|
406
|
-
];
|
|
496
|
+
const cookieFlags = [`actuate_csrf=${token}`, 'Path=/', 'SameSite=Lax', 'Max-Age=86400'];
|
|
407
497
|
if (isProduction)
|
|
408
498
|
cookieFlags.push('Secure');
|
|
409
499
|
const response = json({ data: { token } });
|
|
@@ -446,18 +536,23 @@ export function registerCMSRoutes(router) {
|
|
|
446
536
|
// ---------------------------------------------------------------------------
|
|
447
537
|
router.post('/auth/login', async (request) => {
|
|
448
538
|
try {
|
|
449
|
-
const body = await request.json();
|
|
539
|
+
const body = (await request.json());
|
|
450
540
|
const { email, password } = body;
|
|
451
541
|
if (!email || !password) {
|
|
452
542
|
return errorResponse('Email and password are required', 400);
|
|
453
543
|
}
|
|
454
|
-
const
|
|
455
|
-
|
|
544
|
+
const ip = clientIp(request);
|
|
545
|
+
const userAgent = request.headers.get('user-agent');
|
|
546
|
+
// Bucket by IP when we have a trustworthy one; otherwise fall back to a
|
|
547
|
+
// global bucket. We deliberately avoid keying on the email alone — that
|
|
548
|
+
// would let attackers lock out legitimate users by spamming logins.
|
|
549
|
+
const rateLimitKey = isResolvedIp(ip) ? `login:${ip}` : 'login:unknown';
|
|
550
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
456
551
|
return errorResponse('Too many login attempts. Please try again later.', 429);
|
|
457
552
|
}
|
|
458
553
|
const captchaConfig = getCaptchaConfig();
|
|
459
554
|
if (captchaConfig.provider !== 'none') {
|
|
460
|
-
const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig,
|
|
555
|
+
const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
|
|
461
556
|
if (!captchaResult.success) {
|
|
462
557
|
return errorResponse('CAPTCHA verification failed. Please try again.', 403);
|
|
463
558
|
}
|
|
@@ -479,8 +574,8 @@ export function registerCMSRoutes(router) {
|
|
|
479
574
|
await logEvent({
|
|
480
575
|
event: 'login_failed',
|
|
481
576
|
userId: user.id,
|
|
482
|
-
ipAddress:
|
|
483
|
-
userAgent:
|
|
577
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
578
|
+
userAgent: userAgent ?? undefined,
|
|
484
579
|
});
|
|
485
580
|
return errorResponse('Invalid email or password', 401);
|
|
486
581
|
}
|
|
@@ -488,8 +583,14 @@ export function registerCMSRoutes(router) {
|
|
|
488
583
|
return errorResponse('Account is deactivated', 403);
|
|
489
584
|
}
|
|
490
585
|
if (user.totpEnabled) {
|
|
491
|
-
|
|
586
|
+
// Hand back an opaque short-lived token instead of the raw userId.
|
|
587
|
+
// The /auth/totp/login endpoint will verify both this token and a
|
|
588
|
+
// stable browser fingerprint before checking the TOTP code.
|
|
589
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
590
|
+
const mfaPendingToken = await createMfaPendingToken({ userId: user.id, fingerprint }, getSessionSecret());
|
|
591
|
+
return json({ data: { requiresTOTP: true, mfaPendingToken } });
|
|
492
592
|
}
|
|
593
|
+
await enforceSessionLimitsForUser(d, user.id);
|
|
493
594
|
const tempSessionId = crypto.randomUUID();
|
|
494
595
|
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
495
596
|
await db().session.create({
|
|
@@ -498,8 +599,8 @@ export function registerCMSRoutes(router) {
|
|
|
498
599
|
userId: user.id,
|
|
499
600
|
token,
|
|
500
601
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
501
|
-
ipAddress:
|
|
502
|
-
userAgent:
|
|
602
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
603
|
+
userAgent: userAgent ?? null,
|
|
503
604
|
},
|
|
504
605
|
});
|
|
505
606
|
const response = json({
|
|
@@ -519,12 +620,7 @@ export function registerCMSRoutes(router) {
|
|
|
519
620
|
if (isProduction)
|
|
520
621
|
sessionCookie.push('Secure');
|
|
521
622
|
const csrfToken = await generateCsrfToken();
|
|
522
|
-
const csrfCookie = [
|
|
523
|
-
`actuate_csrf=${csrfToken}`,
|
|
524
|
-
'Path=/',
|
|
525
|
-
'SameSite=Lax',
|
|
526
|
-
'Max-Age=86400',
|
|
527
|
-
];
|
|
623
|
+
const csrfCookie = [`actuate_csrf=${csrfToken}`, 'Path=/', 'SameSite=Lax', 'Max-Age=86400'];
|
|
528
624
|
if (isProduction)
|
|
529
625
|
csrfCookie.push('Secure');
|
|
530
626
|
response.headers.append('Set-Cookie', sessionCookie.join('; '));
|
|
@@ -532,8 +628,8 @@ export function registerCMSRoutes(router) {
|
|
|
532
628
|
await logEvent({
|
|
533
629
|
event: 'login_success',
|
|
534
630
|
userId: user.id,
|
|
535
|
-
ipAddress:
|
|
536
|
-
userAgent:
|
|
631
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
632
|
+
userAgent: userAgent ?? undefined,
|
|
537
633
|
});
|
|
538
634
|
return response;
|
|
539
635
|
}
|
|
@@ -547,10 +643,11 @@ export function registerCMSRoutes(router) {
|
|
|
547
643
|
if (auth.error)
|
|
548
644
|
return auth.error;
|
|
549
645
|
await revokeSession(auth.session.sessionId, db());
|
|
646
|
+
const ip = clientIp(request);
|
|
550
647
|
await logEvent({
|
|
551
648
|
event: 'logout',
|
|
552
649
|
userId: auth.session.userId,
|
|
553
|
-
ipAddress:
|
|
650
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
554
651
|
});
|
|
555
652
|
const response = json({ data: { success: true } });
|
|
556
653
|
response.headers.set('Set-Cookie', 'actuate_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
@@ -562,13 +659,14 @@ export function registerCMSRoutes(router) {
|
|
|
562
659
|
});
|
|
563
660
|
router.post('/auth/forgot-password', async (request) => {
|
|
564
661
|
try {
|
|
565
|
-
const body = await request.json();
|
|
662
|
+
const body = (await request.json());
|
|
566
663
|
const { email } = body;
|
|
567
664
|
if (!email) {
|
|
568
665
|
return errorResponse('Email is required', 400);
|
|
569
666
|
}
|
|
570
|
-
const
|
|
571
|
-
|
|
667
|
+
const ip = clientIp(request);
|
|
668
|
+
const rateLimitKey = isResolvedIp(ip) ? `forgot:${ip}` : 'forgot:unknown';
|
|
669
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
572
670
|
return errorResponse('Too many requests. Please try again later.', 429);
|
|
573
671
|
}
|
|
574
672
|
const d = db();
|
|
@@ -582,7 +680,7 @@ export function registerCMSRoutes(router) {
|
|
|
582
680
|
});
|
|
583
681
|
await logEvent({
|
|
584
682
|
event: 'password_reset_request',
|
|
585
|
-
ipAddress:
|
|
683
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
586
684
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
587
685
|
details: { email: email.toLowerCase().trim() },
|
|
588
686
|
});
|
|
@@ -594,7 +692,7 @@ export function registerCMSRoutes(router) {
|
|
|
594
692
|
});
|
|
595
693
|
router.post('/auth/reset-password', async (request) => {
|
|
596
694
|
try {
|
|
597
|
-
const body = await request.json();
|
|
695
|
+
const body = (await request.json());
|
|
598
696
|
const { token, password } = body;
|
|
599
697
|
if (!token || !password) {
|
|
600
698
|
return errorResponse('Token and new password are required', 400);
|
|
@@ -602,8 +700,9 @@ export function registerCMSRoutes(router) {
|
|
|
602
700
|
if (password.length < 8) {
|
|
603
701
|
return errorResponse('Password must be at least 8 characters', 400);
|
|
604
702
|
}
|
|
605
|
-
const
|
|
606
|
-
|
|
703
|
+
const ip = clientIp(request);
|
|
704
|
+
const rateLimitKey = isResolvedIp(ip) ? `reset:${ip}` : 'reset:unknown';
|
|
705
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
607
706
|
return errorResponse('Too many requests. Please try again later.', 429);
|
|
608
707
|
}
|
|
609
708
|
const d = db();
|
|
@@ -615,7 +714,7 @@ export function registerCMSRoutes(router) {
|
|
|
615
714
|
}
|
|
616
715
|
await logEvent({
|
|
617
716
|
event: 'password_reset_complete',
|
|
618
|
-
ipAddress:
|
|
717
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
619
718
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
620
719
|
});
|
|
621
720
|
return json({ data: { success: true } });
|
|
@@ -636,7 +735,24 @@ export function registerCMSRoutes(router) {
|
|
|
636
735
|
if (!user) {
|
|
637
736
|
return errorResponse('User not found', 404);
|
|
638
737
|
}
|
|
639
|
-
|
|
738
|
+
const response = json({ data: user });
|
|
739
|
+
// Bootstrap (or refresh) the double-submit CSRF cookie. Without this
|
|
740
|
+
// the admin app's first non-GET after a hard reload races to populate
|
|
741
|
+
// it; some users hit "Invalid CSRF token" on the very first save.
|
|
742
|
+
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
743
|
+
if (!cookies['actuate_csrf']) {
|
|
744
|
+
const csrfToken = await generateCsrfToken();
|
|
745
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
746
|
+
const csrfCookie = [
|
|
747
|
+
`actuate_csrf=${csrfToken}`,
|
|
748
|
+
'Path=/',
|
|
749
|
+
'SameSite=Lax',
|
|
750
|
+
'Max-Age=86400',
|
|
751
|
+
...(isProduction ? ['Secure'] : []),
|
|
752
|
+
].join('; ');
|
|
753
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
754
|
+
}
|
|
755
|
+
return response;
|
|
640
756
|
}
|
|
641
757
|
catch (err) {
|
|
642
758
|
return internalError(err, 'auth/me');
|
|
@@ -650,8 +766,16 @@ export function registerCMSRoutes(router) {
|
|
|
650
766
|
const auth = await requireAuth(request);
|
|
651
767
|
if (auth.error)
|
|
652
768
|
return auth.error;
|
|
769
|
+
// Sensitive operation — require recent password reauth so a stolen
|
|
770
|
+
// session can't silently rotate someone's TOTP secret.
|
|
771
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
772
|
+
if (reauthErr)
|
|
773
|
+
return reauthErr;
|
|
653
774
|
const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp.js');
|
|
654
|
-
const user = await db().user.findUnique({
|
|
775
|
+
const user = await db().user.findUnique({
|
|
776
|
+
where: { id: auth.session.userId },
|
|
777
|
+
select: { email: true, totpEnabled: true },
|
|
778
|
+
});
|
|
655
779
|
if (!user)
|
|
656
780
|
return errorResponse('User not found', 404);
|
|
657
781
|
if (user.totpEnabled)
|
|
@@ -659,7 +783,14 @@ export function registerCMSRoutes(router) {
|
|
|
659
783
|
const secret = generateTOTPSecret();
|
|
660
784
|
const uri = generateTOTPUri(secret, user.email);
|
|
661
785
|
const backups = generateBackupCodes();
|
|
662
|
-
|
|
786
|
+
// Persist encrypted; the plaintext is only returned to the user once
|
|
787
|
+
// (so they can scan the QR / save backup codes).
|
|
788
|
+
const encryptedSecret = await encryptSecret(secret);
|
|
789
|
+
const encryptedBackups = await encryptStringArray(backups);
|
|
790
|
+
await db().user.update({
|
|
791
|
+
where: { id: auth.session.userId },
|
|
792
|
+
data: { totpSecret: encryptedSecret, backupCodes: encryptedBackups },
|
|
793
|
+
});
|
|
663
794
|
return json({ data: { secret, uri, backupCodes: backups } });
|
|
664
795
|
}
|
|
665
796
|
catch (err) {
|
|
@@ -671,17 +802,22 @@ export function registerCMSRoutes(router) {
|
|
|
671
802
|
const auth = await requireAuth(request);
|
|
672
803
|
if (auth.error)
|
|
673
804
|
return auth.error;
|
|
674
|
-
const body = await request.json();
|
|
805
|
+
const body = (await request.json());
|
|
675
806
|
if (!body.code)
|
|
676
807
|
return errorResponse('Code is required', 400);
|
|
677
808
|
const { verifyTOTP } = await import('../auth/totp.js');
|
|
678
|
-
const user = await db().user.findUnique({
|
|
809
|
+
const user = await db().user.findUnique({
|
|
810
|
+
where: { id: auth.session.userId },
|
|
811
|
+
select: { totpSecret: true },
|
|
812
|
+
});
|
|
679
813
|
if (!user?.totpSecret)
|
|
680
814
|
return errorResponse('TOTP not set up', 400);
|
|
681
|
-
const
|
|
815
|
+
const secret = await decryptSecret(user.totpSecret);
|
|
816
|
+
const valid = verifyTOTP(body.code, secret);
|
|
682
817
|
if (!valid)
|
|
683
818
|
return errorResponse('Invalid code', 400);
|
|
684
819
|
await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: true } });
|
|
820
|
+
await logEvent({ event: 'totp_enabled', userId: auth.session.userId });
|
|
685
821
|
return json({ data: { enabled: true } });
|
|
686
822
|
}
|
|
687
823
|
catch (err) {
|
|
@@ -693,7 +829,28 @@ export function registerCMSRoutes(router) {
|
|
|
693
829
|
const auth = await requireAuth(request);
|
|
694
830
|
if (auth.error)
|
|
695
831
|
return auth.error;
|
|
696
|
-
|
|
832
|
+
// Disabling MFA is a high-impact security change. Require recent password
|
|
833
|
+
// reauth and revoke every other session so a compromised cookie loses
|
|
834
|
+
// access immediately after MFA goes away.
|
|
835
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
836
|
+
if (reauthErr)
|
|
837
|
+
return reauthErr;
|
|
838
|
+
const d = db();
|
|
839
|
+
await d.user.update({
|
|
840
|
+
where: { id: auth.session.userId },
|
|
841
|
+
data: { totpEnabled: false, totpSecret: null, backupCodes: null },
|
|
842
|
+
});
|
|
843
|
+
if (hasModel(d, 'session')) {
|
|
844
|
+
await d.session.updateMany({
|
|
845
|
+
where: {
|
|
846
|
+
userId: auth.session.userId,
|
|
847
|
+
id: { not: auth.session.sessionId },
|
|
848
|
+
revokedAt: null,
|
|
849
|
+
},
|
|
850
|
+
data: { revokedAt: new Date() },
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
await logEvent({ event: 'totp_disabled', userId: auth.session.userId });
|
|
697
854
|
return json({ data: { enabled: false } });
|
|
698
855
|
}
|
|
699
856
|
catch (err) {
|
|
@@ -702,22 +859,116 @@ export function registerCMSRoutes(router) {
|
|
|
702
859
|
});
|
|
703
860
|
router.post('/auth/totp/login', async (request) => {
|
|
704
861
|
try {
|
|
705
|
-
const body = await request.json();
|
|
706
|
-
if (!body.
|
|
707
|
-
return errorResponse('
|
|
862
|
+
const body = (await request.json());
|
|
863
|
+
if (!body.mfaPendingToken || !body.code) {
|
|
864
|
+
return errorResponse('mfaPendingToken and code are required', 400);
|
|
865
|
+
}
|
|
866
|
+
const ip = clientIp(request);
|
|
867
|
+
const userAgent = request.headers.get('user-agent');
|
|
868
|
+
// Per-IP and per-token rate limits. The per-token limit is the actual
|
|
869
|
+
// brute-force defence: even with IP rotation, an attacker has to obtain
|
|
870
|
+
// a fresh mfaPendingToken (which requires the password) for every
|
|
871
|
+
// window. The per-IP limit guards the IP-aware case and limits log noise.
|
|
872
|
+
const ipBucket = isResolvedIp(ip) ? `totp-ip:${ip}` : 'totp-ip:unknown';
|
|
873
|
+
if (!(await checkRateLimitAsync(totpLimiter, ipBucket))) {
|
|
874
|
+
return errorResponse('Too many TOTP attempts. Please try again later.', 429);
|
|
875
|
+
}
|
|
876
|
+
let pending;
|
|
877
|
+
try {
|
|
878
|
+
pending = await verifyMfaPendingToken(body.mfaPendingToken, getSessionSecret());
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return errorResponse('Session expired. Please sign in again.', 401);
|
|
882
|
+
}
|
|
883
|
+
// Validate the request comes from the same browser that completed the
|
|
884
|
+
// password step. This frustrates attempts to ship a captured pending
|
|
885
|
+
// token to a different host.
|
|
886
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
887
|
+
if (fingerprint !== pending.fingerprint) {
|
|
888
|
+
return errorResponse('Session fingerprint mismatch. Please sign in again.', 401);
|
|
889
|
+
}
|
|
890
|
+
// Per-userId bucket is what actually caps brute-force. With the default
|
|
891
|
+
// 10 attempts / 15 min, an attacker would need ~190 years to cover the
|
|
892
|
+
// 1M code space even with unbounded IPs.
|
|
893
|
+
const userBucket = `totp-user:${pending.userId}`;
|
|
894
|
+
if (!(await checkRateLimitAsync(totpLimiter, userBucket))) {
|
|
895
|
+
return errorResponse('Too many TOTP attempts. Please try again later.', 429);
|
|
896
|
+
}
|
|
708
897
|
const { verifyTOTP } = await import('../auth/totp.js');
|
|
709
|
-
const user = await db().user.findUnique({
|
|
710
|
-
|
|
898
|
+
const user = await db().user.findUnique({
|
|
899
|
+
where: { id: pending.userId },
|
|
900
|
+
select: {
|
|
901
|
+
id: true,
|
|
902
|
+
email: true,
|
|
903
|
+
name: true,
|
|
904
|
+
role: true,
|
|
905
|
+
totpSecret: true,
|
|
906
|
+
totpEnabled: true,
|
|
907
|
+
isActive: true,
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret) {
|
|
711
911
|
return errorResponse('Invalid request', 400);
|
|
712
|
-
|
|
713
|
-
|
|
912
|
+
}
|
|
913
|
+
const decryptedSecret = await decryptSecret(user.totpSecret);
|
|
914
|
+
const valid = verifyTOTP(body.code, decryptedSecret);
|
|
915
|
+
if (!valid) {
|
|
916
|
+
await logEvent({
|
|
917
|
+
event: 'login_failed',
|
|
918
|
+
userId: user.id,
|
|
919
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
920
|
+
userAgent: userAgent ?? undefined,
|
|
921
|
+
details: { reason: 'totp_invalid' },
|
|
922
|
+
});
|
|
714
923
|
return errorResponse('Invalid code', 401);
|
|
924
|
+
}
|
|
925
|
+
const d = db();
|
|
926
|
+
await enforceSessionLimitsForUser(d, user.id);
|
|
715
927
|
const tempSessionId = crypto.randomUUID();
|
|
716
928
|
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
717
|
-
await
|
|
929
|
+
await d.session.create({
|
|
930
|
+
data: {
|
|
931
|
+
id: tempSessionId,
|
|
932
|
+
userId: user.id,
|
|
933
|
+
token,
|
|
934
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
935
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
936
|
+
userAgent: userAgent ?? null,
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
await logEvent({
|
|
940
|
+
event: 'login_success',
|
|
941
|
+
userId: user.id,
|
|
942
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
943
|
+
userAgent: userAgent ?? undefined,
|
|
944
|
+
details: { mfa: 'totp' },
|
|
945
|
+
});
|
|
718
946
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
719
|
-
const sessionCookie = [
|
|
720
|
-
|
|
947
|
+
const sessionCookie = [
|
|
948
|
+
`actuate_session=${token}`,
|
|
949
|
+
'Path=/',
|
|
950
|
+
'HttpOnly',
|
|
951
|
+
'SameSite=Lax',
|
|
952
|
+
`Max-Age=${7 * 24 * 3600}`,
|
|
953
|
+
...(isProduction ? ['Secure'] : []),
|
|
954
|
+
].join('; ');
|
|
955
|
+
const csrfToken = await generateCsrfToken();
|
|
956
|
+
const csrfCookie = [
|
|
957
|
+
`actuate_csrf=${csrfToken}`,
|
|
958
|
+
'Path=/',
|
|
959
|
+
'SameSite=Lax',
|
|
960
|
+
'Max-Age=86400',
|
|
961
|
+
...(isProduction ? ['Secure'] : []),
|
|
962
|
+
].join('; ');
|
|
963
|
+
const response = new Response(JSON.stringify({
|
|
964
|
+
data: {
|
|
965
|
+
token,
|
|
966
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
967
|
+
},
|
|
968
|
+
}), { status: 200, headers: { ...SECURITY_HEADERS } });
|
|
969
|
+
response.headers.append('Set-Cookie', sessionCookie);
|
|
970
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
971
|
+
return response;
|
|
721
972
|
}
|
|
722
973
|
catch (err) {
|
|
723
974
|
return internalError(err);
|
|
@@ -748,9 +999,25 @@ export function registerCMSRoutes(router) {
|
|
|
748
999
|
};
|
|
749
1000
|
const codeVerifier = generateCodeVerifier();
|
|
750
1001
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
751
|
-
|
|
1002
|
+
// Generate a one-time nonce, embed it in the signed state, and also set
|
|
1003
|
+
// it on a host-only cookie. The callback will require both to match —
|
|
1004
|
+
// this binds the OAuth flow to the browser that started it and prevents
|
|
1005
|
+
// an attacker from delivering a state token to a victim's browser.
|
|
1006
|
+
const nonce = generateOAuthNonce();
|
|
1007
|
+
const state = await generateState(provider, codeVerifier, getAdminPath(), secret, nonce);
|
|
752
1008
|
const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
|
|
753
|
-
|
|
1009
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
1010
|
+
const nonceCookie = [
|
|
1011
|
+
`actuate_oauth_nonce=${nonce}`,
|
|
1012
|
+
'Path=/',
|
|
1013
|
+
'HttpOnly',
|
|
1014
|
+
'SameSite=Lax',
|
|
1015
|
+
'Max-Age=600',
|
|
1016
|
+
...(isProduction ? ['Secure'] : []),
|
|
1017
|
+
].join('; ');
|
|
1018
|
+
const response = json({ data: { url } });
|
|
1019
|
+
response.headers.append('Set-Cookie', nonceCookie);
|
|
1020
|
+
return response;
|
|
754
1021
|
}
|
|
755
1022
|
catch (err) {
|
|
756
1023
|
return internalError(err);
|
|
@@ -785,31 +1052,39 @@ export function registerCMSRoutes(router) {
|
|
|
785
1052
|
clientSecret: process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '',
|
|
786
1053
|
redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
|
|
787
1054
|
};
|
|
788
|
-
const
|
|
789
|
-
const
|
|
1055
|
+
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
1056
|
+
const expectedNonce = cookies['actuate_oauth_nonce'] ?? null;
|
|
1057
|
+
const cmsConfig = globalThis.__actuateConfig;
|
|
1058
|
+
const allowSelfSignup = cmsConfig?.auth?.oauth?.allowSelfSignup === true;
|
|
1059
|
+
const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db(), { expectedNonce, allowSelfSignup });
|
|
1060
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
1061
|
+
const sessionCookieFlags = [
|
|
790
1062
|
`actuate_session=${result.token}`,
|
|
791
1063
|
'Path=/',
|
|
792
1064
|
'HttpOnly',
|
|
793
1065
|
'SameSite=Lax',
|
|
794
1066
|
'Max-Age=604800',
|
|
795
1067
|
];
|
|
796
|
-
if (siteUrl.startsWith('https')) {
|
|
797
|
-
|
|
1068
|
+
if (siteUrl.startsWith('https') || isProduction) {
|
|
1069
|
+
sessionCookieFlags.push('Secure');
|
|
798
1070
|
}
|
|
799
|
-
|
|
1071
|
+
const response = new Response(null, {
|
|
800
1072
|
status: 302,
|
|
801
|
-
headers: {
|
|
802
|
-
Location: getAdminPath(),
|
|
803
|
-
'Set-Cookie': cookieFlags.join('; '),
|
|
804
|
-
},
|
|
1073
|
+
headers: { Location: getAdminPath() },
|
|
805
1074
|
});
|
|
1075
|
+
response.headers.append('Set-Cookie', sessionCookieFlags.join('; '));
|
|
1076
|
+
// Clear the one-time nonce cookie regardless of outcome.
|
|
1077
|
+
response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
1078
|
+
return response;
|
|
806
1079
|
}
|
|
807
1080
|
catch (err) {
|
|
808
1081
|
const message = err instanceof Error ? err.message : 'OAuth callback failed';
|
|
809
|
-
|
|
1082
|
+
const response = new Response(null, {
|
|
810
1083
|
status: 302,
|
|
811
1084
|
headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(message)}` },
|
|
812
1085
|
});
|
|
1086
|
+
response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
1087
|
+
return response;
|
|
813
1088
|
}
|
|
814
1089
|
});
|
|
815
1090
|
// ---------------------------------------------------------------------------
|
|
@@ -862,17 +1137,10 @@ export function registerCMSRoutes(router) {
|
|
|
862
1137
|
if (!doc) {
|
|
863
1138
|
return errorResponse('Document not found', 404);
|
|
864
1139
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
return json({
|
|
870
|
-
data: {
|
|
871
|
-
...doc,
|
|
872
|
-
data: await applyFieldAccess('read', fields, doc.data, user),
|
|
873
|
-
},
|
|
874
|
-
});
|
|
875
|
-
}
|
|
1140
|
+
// `getDocument` already lifts `_layout` / `_pageSettings` to the
|
|
1141
|
+
// top-level page builder envelope and strips internal keys from
|
|
1142
|
+
// `data`. Field-level access has been applied as well — we don't
|
|
1143
|
+
// need to re-apply it here.
|
|
876
1144
|
return json({ data: doc });
|
|
877
1145
|
}
|
|
878
1146
|
catch (err) {
|
|
@@ -887,7 +1155,7 @@ export function registerCMSRoutes(router) {
|
|
|
887
1155
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
888
1156
|
if (roleErr)
|
|
889
1157
|
return roleErr;
|
|
890
|
-
const body = await request.json();
|
|
1158
|
+
const body = (await request.json());
|
|
891
1159
|
const ctx = buildActionContext(auth.session, db());
|
|
892
1160
|
const doc = await createDocument(params.slug, body, ctx);
|
|
893
1161
|
await logEvent({
|
|
@@ -909,7 +1177,7 @@ export function registerCMSRoutes(router) {
|
|
|
909
1177
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
910
1178
|
if (roleErr)
|
|
911
1179
|
return roleErr;
|
|
912
|
-
const body = await request.json();
|
|
1180
|
+
const body = (await request.json());
|
|
913
1181
|
const ctx = buildActionContext(auth.session, db());
|
|
914
1182
|
const doc = await updateDocument(params.slug, params.id, body, ctx);
|
|
915
1183
|
await logEvent({
|
|
@@ -984,21 +1252,28 @@ export function registerCMSRoutes(router) {
|
|
|
984
1252
|
return internalError(err);
|
|
985
1253
|
}
|
|
986
1254
|
});
|
|
1255
|
+
// The /media/presign endpoint returns advisory upload metadata; the actual
|
|
1256
|
+
// upload still goes through /media/upload (which performs validation).
|
|
1257
|
+
// Returning a presigned URL pointing at our own non-presigned endpoint was
|
|
1258
|
+
// misleading, so we deprecated this in favour of just using /media/upload
|
|
1259
|
+
// directly. We keep the route for backward compatibility but it now just
|
|
1260
|
+
// returns a hint to use the upload endpoint.
|
|
987
1261
|
router.post('/media/presign', async (request) => {
|
|
988
1262
|
try {
|
|
989
1263
|
const auth = await requireAuth(request);
|
|
990
1264
|
if (auth.error)
|
|
991
1265
|
return auth.error;
|
|
992
|
-
const body = await request.json();
|
|
1266
|
+
const body = (await request.json());
|
|
993
1267
|
if (!body.filename || !body.contentType) {
|
|
994
1268
|
return errorResponse('filename and contentType are required', 400);
|
|
995
1269
|
}
|
|
996
|
-
const storageKey = `actuate/media/${Date.now()}-${body.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
997
1270
|
return json({
|
|
998
1271
|
data: {
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1272
|
+
uploadUrl: '/api/cms/media/upload',
|
|
1273
|
+
method: 'POST',
|
|
1274
|
+
field: 'file',
|
|
1275
|
+
deprecated: true,
|
|
1276
|
+
message: 'Direct multipart upload to /media/upload is preferred; this endpoint will be removed in a future release.',
|
|
1002
1277
|
},
|
|
1003
1278
|
});
|
|
1004
1279
|
}
|
|
@@ -1007,6 +1282,26 @@ export function registerCMSRoutes(router) {
|
|
|
1007
1282
|
}
|
|
1008
1283
|
});
|
|
1009
1284
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
1285
|
+
/**
|
|
1286
|
+
* Allowlist of accepted upload mime types. Storing a file outside this set
|
|
1287
|
+
* would let an attacker upload e.g. `.html`/`.js` and serve it from the same
|
|
1288
|
+
* origin as the admin (cookie-leaking XSS), or `.svg` with embedded
|
|
1289
|
+
* `<script>` (also XSS). Add to this list deliberately.
|
|
1290
|
+
*/
|
|
1291
|
+
const ALLOWED_UPLOAD_MIME_TYPES = [
|
|
1292
|
+
'image/jpeg',
|
|
1293
|
+
'image/png',
|
|
1294
|
+
'image/gif',
|
|
1295
|
+
'image/webp',
|
|
1296
|
+
'image/avif',
|
|
1297
|
+
'image/svg+xml',
|
|
1298
|
+
'application/pdf',
|
|
1299
|
+
'video/mp4',
|
|
1300
|
+
'video/webm',
|
|
1301
|
+
'audio/mpeg',
|
|
1302
|
+
'audio/ogg',
|
|
1303
|
+
'audio/wav',
|
|
1304
|
+
];
|
|
1010
1305
|
router.post('/media/upload', async (request) => {
|
|
1011
1306
|
try {
|
|
1012
1307
|
const auth = await requireAuth(request);
|
|
@@ -1028,7 +1323,21 @@ export function registerCMSRoutes(router) {
|
|
|
1028
1323
|
if (originalSize > 50 * 1024 * 1024) {
|
|
1029
1324
|
return errorResponse('File exceeds maximum size of 50MB', 413);
|
|
1030
1325
|
}
|
|
1326
|
+
// 1. Block file types that aren't on our allowlist outright.
|
|
1327
|
+
if (!validateMimeType(contentType, ALLOWED_UPLOAD_MIME_TYPES)) {
|
|
1328
|
+
return errorResponse(`Unsupported file type "${contentType || 'unknown'}". Allowed types: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`, 415);
|
|
1329
|
+
}
|
|
1031
1330
|
const arrayBuffer = await file.arrayBuffer();
|
|
1331
|
+
// 2. Verify the file's actual bytes match the claimed mime type. Without
|
|
1332
|
+
// this, an attacker can upload a `.exe` with `Content-Type: image/png`
|
|
1333
|
+
// and have it served from our origin.
|
|
1334
|
+
const magicCheck = checkMagicBytes(arrayBuffer, contentType);
|
|
1335
|
+
if (!magicCheck.valid) {
|
|
1336
|
+
return errorResponse(`File contents do not match declared type "${contentType}".`, 415);
|
|
1337
|
+
}
|
|
1338
|
+
// 3. SVGs need an extra pass — even when the mime type is correct, the
|
|
1339
|
+
// XML body can contain `<script>` or event-handler attributes that
|
|
1340
|
+
// execute when an admin previews the file. Sanitize before storing.
|
|
1032
1341
|
let uploadBuffer;
|
|
1033
1342
|
let finalFilename = originalFilename;
|
|
1034
1343
|
let finalMimeType = contentType;
|
|
@@ -1037,7 +1346,105 @@ export function registerCMSRoutes(router) {
|
|
|
1037
1346
|
let height = null;
|
|
1038
1347
|
let blurHash = null;
|
|
1039
1348
|
let savings = 0;
|
|
1040
|
-
if (
|
|
1349
|
+
if (contentType === 'image/svg+xml') {
|
|
1350
|
+
// Strip <script>, on*, javascript: URLs, foreignObject, etc.
|
|
1351
|
+
const xml = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer);
|
|
1352
|
+
const sanitized = sanitizeHtml(xml, {
|
|
1353
|
+
allowedTags: [
|
|
1354
|
+
'svg',
|
|
1355
|
+
'g',
|
|
1356
|
+
'path',
|
|
1357
|
+
'circle',
|
|
1358
|
+
'ellipse',
|
|
1359
|
+
'line',
|
|
1360
|
+
'polygon',
|
|
1361
|
+
'polyline',
|
|
1362
|
+
'rect',
|
|
1363
|
+
'text',
|
|
1364
|
+
'tspan',
|
|
1365
|
+
'defs',
|
|
1366
|
+
'use',
|
|
1367
|
+
'symbol',
|
|
1368
|
+
'title',
|
|
1369
|
+
'desc',
|
|
1370
|
+
'style',
|
|
1371
|
+
'linearGradient',
|
|
1372
|
+
'radialGradient',
|
|
1373
|
+
'stop',
|
|
1374
|
+
'mask',
|
|
1375
|
+
'clipPath',
|
|
1376
|
+
'pattern',
|
|
1377
|
+
'filter',
|
|
1378
|
+
'feGaussianBlur',
|
|
1379
|
+
'feColorMatrix',
|
|
1380
|
+
'feOffset',
|
|
1381
|
+
'feBlend',
|
|
1382
|
+
'feFlood',
|
|
1383
|
+
'feComposite',
|
|
1384
|
+
'feMerge',
|
|
1385
|
+
'feMergeNode',
|
|
1386
|
+
],
|
|
1387
|
+
allowedAttributes: {
|
|
1388
|
+
'*': [
|
|
1389
|
+
'id',
|
|
1390
|
+
'class',
|
|
1391
|
+
'fill',
|
|
1392
|
+
'stroke',
|
|
1393
|
+
'stroke-width',
|
|
1394
|
+
'stroke-linecap',
|
|
1395
|
+
'stroke-linejoin',
|
|
1396
|
+
'opacity',
|
|
1397
|
+
'transform',
|
|
1398
|
+
'd',
|
|
1399
|
+
'cx',
|
|
1400
|
+
'cy',
|
|
1401
|
+
'r',
|
|
1402
|
+
'rx',
|
|
1403
|
+
'ry',
|
|
1404
|
+
'x',
|
|
1405
|
+
'y',
|
|
1406
|
+
'x1',
|
|
1407
|
+
'y1',
|
|
1408
|
+
'x2',
|
|
1409
|
+
'y2',
|
|
1410
|
+
'width',
|
|
1411
|
+
'height',
|
|
1412
|
+
'viewBox',
|
|
1413
|
+
'xmlns',
|
|
1414
|
+
'xmlns:xlink',
|
|
1415
|
+
'preserveAspectRatio',
|
|
1416
|
+
'points',
|
|
1417
|
+
'points',
|
|
1418
|
+
'offset',
|
|
1419
|
+
'stop-color',
|
|
1420
|
+
'stop-opacity',
|
|
1421
|
+
'gradientUnits',
|
|
1422
|
+
'gradientTransform',
|
|
1423
|
+
'href',
|
|
1424
|
+
'xlink:href',
|
|
1425
|
+
'fill-rule',
|
|
1426
|
+
'clip-rule',
|
|
1427
|
+
'mask',
|
|
1428
|
+
'clip-path',
|
|
1429
|
+
'filter',
|
|
1430
|
+
'patternUnits',
|
|
1431
|
+
'patternContentUnits',
|
|
1432
|
+
'in',
|
|
1433
|
+
'in2',
|
|
1434
|
+
'result',
|
|
1435
|
+
'mode',
|
|
1436
|
+
'values',
|
|
1437
|
+
'type',
|
|
1438
|
+
'stdDeviation',
|
|
1439
|
+
'dx',
|
|
1440
|
+
'dy',
|
|
1441
|
+
],
|
|
1442
|
+
},
|
|
1443
|
+
});
|
|
1444
|
+
uploadBuffer = Buffer.from(sanitized, 'utf-8');
|
|
1445
|
+
finalSize = uploadBuffer.byteLength;
|
|
1446
|
+
}
|
|
1447
|
+
else if (!skipOptimize && contentType.startsWith('image/')) {
|
|
1041
1448
|
const result = await optimizeImage(arrayBuffer, originalFilename, contentType);
|
|
1042
1449
|
uploadBuffer = result.buffer;
|
|
1043
1450
|
finalFilename = result.filename;
|
|
@@ -1054,16 +1461,35 @@ export function registerCMSRoutes(router) {
|
|
|
1054
1461
|
const sanitizedName = finalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1055
1462
|
const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
|
|
1056
1463
|
let publicUrl = '';
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1464
|
+
// Prefer the configured platform storage adapter (e.g. platform-vercel,
|
|
1465
|
+
// platform-aws, or a consumer-provided one). Falling through to
|
|
1466
|
+
// @vercel/blob via dynamic import preserves the legacy behavior for
|
|
1467
|
+
// installs that haven't wired up a platform package yet.
|
|
1468
|
+
const { getStorageAdapter } = await import('../storage/index.js');
|
|
1469
|
+
const storage = getStorageAdapter();
|
|
1470
|
+
if (storage) {
|
|
1471
|
+
try {
|
|
1472
|
+
publicUrl = await storage.upload(storageKey, uploadBuffer, finalMimeType);
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
1476
|
+
console.error('[Actuate CMS] Storage adapter upload failed:', err);
|
|
1477
|
+
}
|
|
1478
|
+
return errorResponse('Storage upload failed', 500);
|
|
1479
|
+
}
|
|
1064
1480
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1481
|
+
else {
|
|
1482
|
+
try {
|
|
1483
|
+
const blob = await importBlobStorage();
|
|
1484
|
+
const result = await blob.put(storageKey, uploadBuffer, {
|
|
1485
|
+
access: 'public',
|
|
1486
|
+
contentType: finalMimeType,
|
|
1487
|
+
});
|
|
1488
|
+
publicUrl = result.url;
|
|
1489
|
+
}
|
|
1490
|
+
catch {
|
|
1491
|
+
publicUrl = `/api/cms/media/file/${storageKey}`;
|
|
1492
|
+
}
|
|
1067
1493
|
}
|
|
1068
1494
|
const media = await db().media.create({
|
|
1069
1495
|
data: {
|
|
@@ -1151,7 +1577,9 @@ export function registerCMSRoutes(router) {
|
|
|
1151
1577
|
try {
|
|
1152
1578
|
await blob.del(media.storageKey);
|
|
1153
1579
|
}
|
|
1154
|
-
catch {
|
|
1580
|
+
catch {
|
|
1581
|
+
/* best-effort */
|
|
1582
|
+
}
|
|
1155
1583
|
}
|
|
1156
1584
|
catch {
|
|
1157
1585
|
newPublicUrl = `/api/cms/media/file/${newStorageKey}`;
|
|
@@ -1195,7 +1623,7 @@ export function registerCMSRoutes(router) {
|
|
|
1195
1623
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1196
1624
|
if (roleErr)
|
|
1197
1625
|
return roleErr;
|
|
1198
|
-
const body = await request.json();
|
|
1626
|
+
const body = (await request.json());
|
|
1199
1627
|
const updated = await db().media.update({
|
|
1200
1628
|
where: { id: params.id },
|
|
1201
1629
|
data: {
|
|
@@ -1320,7 +1748,7 @@ export function registerCMSRoutes(router) {
|
|
|
1320
1748
|
if (!encKey) {
|
|
1321
1749
|
return errorResponse('CMS_ENCRYPTION_KEY is required to store encrypted credentials.', 400);
|
|
1322
1750
|
}
|
|
1323
|
-
const body = await request.json();
|
|
1751
|
+
const body = (await request.json());
|
|
1324
1752
|
if (body.githubRepo && !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(body.githubRepo)) {
|
|
1325
1753
|
return errorResponse('Invalid repository format. Use owner/repo.', 400);
|
|
1326
1754
|
}
|
|
@@ -1362,7 +1790,7 @@ export function registerCMSRoutes(router) {
|
|
|
1362
1790
|
if (!session || session.role !== 'admin') {
|
|
1363
1791
|
return errorResponse('Unauthorized — admin only', 403);
|
|
1364
1792
|
}
|
|
1365
|
-
const body = await request.json();
|
|
1793
|
+
const body = (await request.json());
|
|
1366
1794
|
if (!body.targetVersion) {
|
|
1367
1795
|
return errorResponse('targetVersion is required', 400);
|
|
1368
1796
|
}
|
|
@@ -1427,7 +1855,7 @@ export function registerCMSRoutes(router) {
|
|
|
1427
1855
|
if (!(await checkRateLimitAsync(loginLimiter, `setup:${clientIp}`))) {
|
|
1428
1856
|
return errorResponse('Too many setup attempts', 429);
|
|
1429
1857
|
}
|
|
1430
|
-
const body = await request.json();
|
|
1858
|
+
const body = (await request.json());
|
|
1431
1859
|
if (!body.name || !body.email || !body.password) {
|
|
1432
1860
|
return errorResponse('Name, email, and password are required', 400);
|
|
1433
1861
|
}
|
|
@@ -1452,9 +1880,17 @@ export function registerCMSRoutes(router) {
|
|
|
1452
1880
|
// Health endpoint -- reports available models and CMS version
|
|
1453
1881
|
// ---------------------------------------------------------------------------
|
|
1454
1882
|
const CMS_EXPECTED_MODELS = [
|
|
1455
|
-
'document',
|
|
1456
|
-
'
|
|
1457
|
-
'
|
|
1883
|
+
'document',
|
|
1884
|
+
'media',
|
|
1885
|
+
'user',
|
|
1886
|
+
'session',
|
|
1887
|
+
'version',
|
|
1888
|
+
'folder',
|
|
1889
|
+
'redirect',
|
|
1890
|
+
'formSubmission',
|
|
1891
|
+
'auditLog',
|
|
1892
|
+
'webhookEndpoint',
|
|
1893
|
+
'webhookDeliveryLog',
|
|
1458
1894
|
];
|
|
1459
1895
|
router.get('/health', async () => {
|
|
1460
1896
|
const cmsVersion = globalThis.__actuateCoreVersion ?? '0.0.0';
|
|
@@ -1573,8 +2009,12 @@ export function registerCMSRoutes(router) {
|
|
|
1573
2009
|
orderBy: { updatedAt: 'desc' },
|
|
1574
2010
|
take: 20,
|
|
1575
2011
|
select: {
|
|
1576
|
-
id: true,
|
|
1577
|
-
|
|
2012
|
+
id: true,
|
|
2013
|
+
title: true,
|
|
2014
|
+
status: true,
|
|
2015
|
+
collection: true,
|
|
2016
|
+
updatedAt: true,
|
|
2017
|
+
createdById: true,
|
|
1578
2018
|
createdBy: { select: { name: true, email: true } },
|
|
1579
2019
|
},
|
|
1580
2020
|
}),
|
|
@@ -1643,24 +2083,54 @@ export function registerCMSRoutes(router) {
|
|
|
1643
2083
|
if (!q)
|
|
1644
2084
|
return json({ data: { documents: [], media: [], users: [] } });
|
|
1645
2085
|
const d = db();
|
|
2086
|
+
// Documents and media are visible to all signed-in users; the user
|
|
2087
|
+
// directory is gated to EDITOR+ so a CLIENT can't enumerate the team
|
|
2088
|
+
// (or harvest emails for spear-phishing).
|
|
2089
|
+
const canSeeUserDirectory = auth.session.role === 'ADMIN' || auth.session.role === 'EDITOR';
|
|
1646
2090
|
const [documents, media, users] = await Promise.all([
|
|
1647
2091
|
safeFindMany(d.document, {
|
|
1648
|
-
where: {
|
|
2092
|
+
where: {
|
|
2093
|
+
deletedAt: null,
|
|
2094
|
+
OR: [
|
|
2095
|
+
{ title: { contains: q, mode: 'insensitive' } },
|
|
2096
|
+
{ plainText: { contains: q, mode: 'insensitive' } },
|
|
2097
|
+
],
|
|
2098
|
+
},
|
|
1649
2099
|
take: 10,
|
|
1650
2100
|
orderBy: { updatedAt: 'desc' },
|
|
1651
|
-
select: {
|
|
2101
|
+
select: {
|
|
2102
|
+
id: true,
|
|
2103
|
+
title: true,
|
|
2104
|
+
slug: true,
|
|
2105
|
+
collection: true,
|
|
2106
|
+
status: true,
|
|
2107
|
+
updatedAt: true,
|
|
2108
|
+
},
|
|
1652
2109
|
}),
|
|
1653
2110
|
safeFindMany(d.media, {
|
|
1654
|
-
where: {
|
|
2111
|
+
where: {
|
|
2112
|
+
OR: [
|
|
2113
|
+
{ filename: { contains: q, mode: 'insensitive' } },
|
|
2114
|
+
{ altText: { contains: q, mode: 'insensitive' } },
|
|
2115
|
+
],
|
|
2116
|
+
},
|
|
1655
2117
|
take: 5,
|
|
1656
2118
|
orderBy: { createdAt: 'desc' },
|
|
1657
2119
|
select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
|
|
1658
2120
|
}),
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
2121
|
+
canSeeUserDirectory
|
|
2122
|
+
? safeFindMany(d.user, {
|
|
2123
|
+
where: {
|
|
2124
|
+
isActive: true,
|
|
2125
|
+
OR: [
|
|
2126
|
+
{ name: { contains: q, mode: 'insensitive' } },
|
|
2127
|
+
{ email: { contains: q, mode: 'insensitive' } },
|
|
2128
|
+
],
|
|
2129
|
+
},
|
|
2130
|
+
take: 5,
|
|
2131
|
+
select: { id: true, name: true, email: true, role: true },
|
|
2132
|
+
})
|
|
2133
|
+
: Promise.resolve([]),
|
|
1664
2134
|
]);
|
|
1665
2135
|
return json({ data: { documents, media, users } });
|
|
1666
2136
|
}
|
|
@@ -1729,17 +2199,37 @@ export function registerCMSRoutes(router) {
|
|
|
1729
2199
|
const auth = await requireAuth(request);
|
|
1730
2200
|
if (auth.error)
|
|
1731
2201
|
return auth.error;
|
|
2202
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2203
|
+
if (roleErr)
|
|
2204
|
+
return roleErr;
|
|
1732
2205
|
const d = db();
|
|
1733
2206
|
const forms = await d.document.findMany({
|
|
1734
2207
|
where: { collection: 'forms', deletedAt: null },
|
|
1735
2208
|
orderBy: { createdAt: 'desc' },
|
|
1736
2209
|
});
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2210
|
+
// Single grouped count is far cheaper than N+1 count() per form once
|
|
2211
|
+
// the dashboard grows past a handful of forms. Fall back to per-form
|
|
2212
|
+
// .count() when the consumer's Prisma client doesn't expose groupBy
|
|
2213
|
+
// (older schemas, custom adapters).
|
|
2214
|
+
let submissionCounts = new Map();
|
|
2215
|
+
if (hasModel(d, 'formSubmission') && forms.length > 0) {
|
|
2216
|
+
const ids = forms.map((f) => f.id);
|
|
2217
|
+
if (typeof d.formSubmission.groupBy === 'function') {
|
|
2218
|
+
const grouped = await d.formSubmission.groupBy({
|
|
2219
|
+
by: ['formId'],
|
|
2220
|
+
where: { formId: { in: ids } },
|
|
2221
|
+
_count: { _all: true },
|
|
2222
|
+
});
|
|
2223
|
+
submissionCounts = new Map(grouped.map((g) => [g.formId, g._count._all]));
|
|
2224
|
+
}
|
|
2225
|
+
else {
|
|
2226
|
+
for (const id of ids) {
|
|
2227
|
+
const count = await d.formSubmission.count({ where: { formId: id } });
|
|
2228
|
+
submissionCounts.set(id, count);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
const normalized = forms.map((form) => normalizeFormDocument(form, submissionCounts.get(form.id) ?? 0));
|
|
1743
2233
|
return json({ data: normalized });
|
|
1744
2234
|
}
|
|
1745
2235
|
catch (err) {
|
|
@@ -1751,6 +2241,9 @@ export function registerCMSRoutes(router) {
|
|
|
1751
2241
|
const auth = await requireAuth(request);
|
|
1752
2242
|
if (auth.error)
|
|
1753
2243
|
return auth.error;
|
|
2244
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2245
|
+
if (roleErr)
|
|
2246
|
+
return roleErr;
|
|
1754
2247
|
const url = new URL(request.url);
|
|
1755
2248
|
const page = Number(url.searchParams.get('page')) || 1;
|
|
1756
2249
|
const pageSize = clampPageSize(url.searchParams.get('pageSize'));
|
|
@@ -1792,7 +2285,7 @@ export function registerCMSRoutes(router) {
|
|
|
1792
2285
|
return errorResponse('Form not found', 404);
|
|
1793
2286
|
}
|
|
1794
2287
|
const formData = (form.data ?? {});
|
|
1795
|
-
const body = await request.json();
|
|
2288
|
+
const body = (await request.json());
|
|
1796
2289
|
if (!body.fields || typeof body.fields !== 'object') {
|
|
1797
2290
|
return errorResponse('Missing or invalid "fields" in request body', 400);
|
|
1798
2291
|
}
|
|
@@ -1812,14 +2305,10 @@ export function registerCMSRoutes(router) {
|
|
|
1812
2305
|
submittedAt: new Date(),
|
|
1813
2306
|
},
|
|
1814
2307
|
});
|
|
1815
|
-
// Fire form hooks asynchronously (email notification, webhooks)
|
|
1816
2308
|
(async () => {
|
|
1817
2309
|
try {
|
|
1818
2310
|
const config = globalThis.__actuateConfig;
|
|
1819
|
-
const hooks = [
|
|
1820
|
-
...(config?.plugins?.forms?.hooks ?? []),
|
|
1821
|
-
...(config?._pluginHooks ?? []),
|
|
1822
|
-
];
|
|
2311
|
+
const hooks = [...(config?.plugins?.forms?.hooks ?? []), ...(config?._pluginHooks ?? [])];
|
|
1823
2312
|
const formHooks = hooks.filter((h) => h.event === 'afterCreate:form-submissions');
|
|
1824
2313
|
for (const hook of formHooks) {
|
|
1825
2314
|
await hook.handler({ formId, data: body.fields });
|
|
@@ -1869,23 +2358,51 @@ export function registerCMSRoutes(router) {
|
|
|
1869
2358
|
return auth.error;
|
|
1870
2359
|
if (auth.session.role !== 'ADMIN')
|
|
1871
2360
|
return errorResponse('Admin access required', 403);
|
|
1872
|
-
const body = await request.json();
|
|
2361
|
+
const body = (await request.json());
|
|
1873
2362
|
const source = String(body.source ?? body.from ?? '').trim();
|
|
1874
2363
|
const destination = String(body.destination ?? body.to ?? '').trim();
|
|
1875
2364
|
const requestedStatus = Number(body.statusCode ?? body.type);
|
|
1876
2365
|
if (!source || !destination) {
|
|
1877
2366
|
return errorResponse('source and destination are required', 400);
|
|
1878
2367
|
}
|
|
1879
|
-
|
|
2368
|
+
// Open-redirect defence: relative destinations (`/foo`) are always
|
|
2369
|
+
// allowed; absolute destinations must point at an explicitly trusted
|
|
2370
|
+
// host. We compare on parsed origins (not string `startsWith`) so
|
|
2371
|
+
// `https://attacker.com.example.com` no longer passes a `startsWith`
|
|
2372
|
+
// check on `https://example.com`. Configure the allowlist via
|
|
2373
|
+
// `redirects.allowedExternalHosts` (string[] of hostnames).
|
|
2374
|
+
if (destination.startsWith('http://') || destination.startsWith('https://')) {
|
|
2375
|
+
let destUrl;
|
|
1880
2376
|
try {
|
|
1881
|
-
|
|
1882
|
-
if (!['http:', 'https:'].includes(destUrl.protocol)) {
|
|
1883
|
-
return errorResponse('Invalid destination URL', 400);
|
|
1884
|
-
}
|
|
2377
|
+
destUrl = new URL(destination);
|
|
1885
2378
|
}
|
|
1886
2379
|
catch {
|
|
1887
2380
|
return errorResponse('Invalid destination URL', 400);
|
|
1888
2381
|
}
|
|
2382
|
+
if (!['http:', 'https:'].includes(destUrl.protocol)) {
|
|
2383
|
+
return errorResponse('Invalid destination URL', 400);
|
|
2384
|
+
}
|
|
2385
|
+
const cmsConfig = globalThis.__actuateConfig;
|
|
2386
|
+
const allowed = new Set([
|
|
2387
|
+
...(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
|
|
2388
|
+
? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
|
|
2389
|
+
: []),
|
|
2390
|
+
]);
|
|
2391
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
|
2392
|
+
if (siteUrl) {
|
|
2393
|
+
try {
|
|
2394
|
+
allowed.add(new URL(siteUrl).hostname.toLowerCase());
|
|
2395
|
+
}
|
|
2396
|
+
catch {
|
|
2397
|
+
/* noop */
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
if (!allowed.has(destUrl.hostname.toLowerCase())) {
|
|
2401
|
+
return errorResponse('External redirect destinations must be to an allowlisted host. Add the host to `redirects.allowedExternalHosts` in your CMS config.', 400);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
else if (!destination.startsWith('/')) {
|
|
2405
|
+
return errorResponse('Destination must be an absolute URL or a path beginning with /', 400);
|
|
1889
2406
|
}
|
|
1890
2407
|
const redirect = await db().redirect.create({
|
|
1891
2408
|
data: {
|
|
@@ -1946,48 +2463,98 @@ export function registerCMSRoutes(router) {
|
|
|
1946
2463
|
const auth = await requireAuth(request);
|
|
1947
2464
|
if (auth.error)
|
|
1948
2465
|
return auth.error;
|
|
2466
|
+
// EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
|
|
2467
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2468
|
+
if (roleErr)
|
|
2469
|
+
return roleErr;
|
|
2470
|
+
// Tight rate limit because each call fans out to many outbound requests.
|
|
2471
|
+
const ip = clientIp(request);
|
|
2472
|
+
const rateKey = isResolvedIp(ip)
|
|
2473
|
+
? `link-health:${ip}`
|
|
2474
|
+
: `link-health-user:${auth.session.userId}`;
|
|
2475
|
+
if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
|
|
2476
|
+
return errorResponse('Too many link-health scans. Please try again later.', 429);
|
|
2477
|
+
}
|
|
2478
|
+
const MAX_LINKS_PER_PAGE = 50;
|
|
2479
|
+
const MAX_TOTAL_LINKS = 500;
|
|
2480
|
+
const PER_LINK_TIMEOUT_MS = 4000;
|
|
2481
|
+
const CONCURRENCY = 8;
|
|
1949
2482
|
const docs = await db().document.findMany({
|
|
1950
2483
|
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
1951
2484
|
select: { id: true, title: true, data: true, collection: true },
|
|
1952
2485
|
});
|
|
1953
|
-
const linkResults = [];
|
|
1954
2486
|
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
|
|
1955
2487
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
|
|
1956
|
-
|
|
2488
|
+
const queue = [];
|
|
2489
|
+
const seenGlobal = new Set();
|
|
2490
|
+
outer: for (const doc of docs) {
|
|
1957
2491
|
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
1958
2492
|
const content = JSON.stringify(doc.data ?? {});
|
|
1959
2493
|
const urls = content.match(urlRegex) ?? [];
|
|
1960
|
-
const
|
|
2494
|
+
const seenInPage = new Set();
|
|
2495
|
+
let countInPage = 0;
|
|
1961
2496
|
for (const url of urls) {
|
|
1962
2497
|
const clean = url.replace(/[",;)}\]]+$/, '');
|
|
1963
|
-
if (
|
|
2498
|
+
if (seenInPage.has(clean))
|
|
2499
|
+
continue;
|
|
2500
|
+
seenInPage.add(clean);
|
|
2501
|
+
if (seenGlobal.has(clean))
|
|
1964
2502
|
continue;
|
|
1965
|
-
|
|
1966
|
-
|
|
2503
|
+
seenGlobal.add(clean);
|
|
2504
|
+
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
2505
|
+
break;
|
|
2506
|
+
if (queue.length >= MAX_TOTAL_LINKS)
|
|
2507
|
+
break outer;
|
|
2508
|
+
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
2509
|
+
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
2510
|
+
countInPage++;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
const linkResults = [];
|
|
2514
|
+
let cursor = 0;
|
|
2515
|
+
const worker = async () => {
|
|
2516
|
+
while (cursor < queue.length) {
|
|
2517
|
+
const idx = cursor++;
|
|
2518
|
+
const job = queue[idx];
|
|
2519
|
+
let status = 0;
|
|
1967
2520
|
try {
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2521
|
+
// safeFetch rejects private/loopback IPs and disables redirect
|
|
2522
|
+
// following so 302->internal can't smuggle the scanner past SSRF.
|
|
2523
|
+
const resp = await safeFetch(job.clean, {
|
|
2524
|
+
method: 'HEAD',
|
|
2525
|
+
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
2526
|
+
});
|
|
2527
|
+
status = resp.status;
|
|
2528
|
+
// Drain the body so the connection can be reused.
|
|
2529
|
+
try {
|
|
2530
|
+
await resp.body?.cancel();
|
|
2531
|
+
}
|
|
2532
|
+
catch {
|
|
2533
|
+
/* noop */
|
|
1977
2534
|
}
|
|
1978
2535
|
}
|
|
1979
|
-
catch {
|
|
2536
|
+
catch (err) {
|
|
2537
|
+
status = err instanceof SsrfBlockedError ? -1 : 0;
|
|
2538
|
+
}
|
|
2539
|
+
if (status === -1 || status === 0 || status >= 300) {
|
|
1980
2540
|
linkResults.push({
|
|
1981
|
-
id: `${
|
|
1982
|
-
page: pageTitle,
|
|
1983
|
-
url: clean,
|
|
1984
|
-
status: 0,
|
|
1985
|
-
type: isInternal ? 'internal' : 'external',
|
|
2541
|
+
id: `${job.docId}-${idx}`,
|
|
2542
|
+
page: job.pageTitle,
|
|
2543
|
+
url: job.clean,
|
|
2544
|
+
status: status === -1 ? 0 : status,
|
|
2545
|
+
type: job.isInternal ? 'internal' : 'external',
|
|
1986
2546
|
});
|
|
1987
2547
|
}
|
|
1988
2548
|
}
|
|
1989
|
-
}
|
|
1990
|
-
|
|
2549
|
+
};
|
|
2550
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
2551
|
+
return json({
|
|
2552
|
+
data: {
|
|
2553
|
+
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
2554
|
+
checked: queue.length,
|
|
2555
|
+
issues: linkResults,
|
|
2556
|
+
},
|
|
2557
|
+
});
|
|
1991
2558
|
}
|
|
1992
2559
|
catch (err) {
|
|
1993
2560
|
return internalError(err);
|
|
@@ -2003,7 +2570,14 @@ export function registerCMSRoutes(router) {
|
|
|
2003
2570
|
return roleErr;
|
|
2004
2571
|
const documents = await db().document.findMany({
|
|
2005
2572
|
where: { status: 'PUBLISHED', deletedAt: null },
|
|
2006
|
-
select: {
|
|
2573
|
+
select: {
|
|
2574
|
+
id: true,
|
|
2575
|
+
title: true,
|
|
2576
|
+
slug: true,
|
|
2577
|
+
collection: true,
|
|
2578
|
+
data: true,
|
|
2579
|
+
plainText: true,
|
|
2580
|
+
},
|
|
2007
2581
|
});
|
|
2008
2582
|
const issues = [];
|
|
2009
2583
|
for (const doc of documents) {
|
|
@@ -2020,7 +2594,11 @@ export function registerCMSRoutes(router) {
|
|
|
2020
2594
|
const plainText = (doc.plainText ?? '');
|
|
2021
2595
|
if (plainText.length > 0 && plainText.length < 300)
|
|
2022
2596
|
problems.push('Content is too short (< 300 characters)');
|
|
2023
|
-
const content = typeof data.body === 'string'
|
|
2597
|
+
const content = typeof data.body === 'string'
|
|
2598
|
+
? data.body
|
|
2599
|
+
: typeof data.content === 'string'
|
|
2600
|
+
? data.content
|
|
2601
|
+
: '';
|
|
2024
2602
|
if (content) {
|
|
2025
2603
|
const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
2026
2604
|
const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
|
|
@@ -2030,7 +2608,12 @@ export function registerCMSRoutes(router) {
|
|
|
2030
2608
|
problems.push('No H1 heading found in content');
|
|
2031
2609
|
}
|
|
2032
2610
|
if (problems.length > 0) {
|
|
2033
|
-
issues.push({
|
|
2611
|
+
issues.push({
|
|
2612
|
+
documentId: doc.id,
|
|
2613
|
+
title: doc.title ?? 'Untitled',
|
|
2614
|
+
slug: doc.slug ?? '',
|
|
2615
|
+
problems,
|
|
2616
|
+
});
|
|
2034
2617
|
}
|
|
2035
2618
|
}
|
|
2036
2619
|
const total = documents.length;
|
|
@@ -2047,7 +2630,12 @@ export function registerCMSRoutes(router) {
|
|
|
2047
2630
|
// ---------------------------------------------------------------------------
|
|
2048
2631
|
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
2049
2632
|
try {
|
|
2050
|
-
const
|
|
2633
|
+
const auth = await requireAuth(request);
|
|
2634
|
+
if (auth.error)
|
|
2635
|
+
return auth.error;
|
|
2636
|
+
const doc = await db().document.findFirst({
|
|
2637
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2638
|
+
});
|
|
2051
2639
|
if (!doc)
|
|
2052
2640
|
return errorResponse('Not found', 404);
|
|
2053
2641
|
const { analyzeContent } = await import('../seo/analysis.js');
|
|
@@ -2076,7 +2664,12 @@ export function registerCMSRoutes(router) {
|
|
|
2076
2664
|
});
|
|
2077
2665
|
router.get('/seo/readability/:documentId', async (request, params) => {
|
|
2078
2666
|
try {
|
|
2079
|
-
const
|
|
2667
|
+
const auth = await requireAuth(request);
|
|
2668
|
+
if (auth.error)
|
|
2669
|
+
return auth.error;
|
|
2670
|
+
const doc = await db().document.findFirst({
|
|
2671
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2672
|
+
});
|
|
2080
2673
|
if (!doc)
|
|
2081
2674
|
return errorResponse('Not found', 404);
|
|
2082
2675
|
const { calculateReadability, stripHtmlTags } = await import('../seo/analysis.js');
|
|
@@ -2094,12 +2687,20 @@ export function registerCMSRoutes(router) {
|
|
|
2094
2687
|
});
|
|
2095
2688
|
router.get('/seo/internal-links/:documentId', async (request, params) => {
|
|
2096
2689
|
try {
|
|
2097
|
-
const
|
|
2690
|
+
const auth = await requireAuth(request);
|
|
2691
|
+
if (auth.error)
|
|
2692
|
+
return auth.error;
|
|
2693
|
+
const doc = await db().document.findFirst({
|
|
2694
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2695
|
+
});
|
|
2098
2696
|
if (!doc)
|
|
2099
2697
|
return errorResponse('Not found', 404);
|
|
2100
2698
|
const data = doc.data || {};
|
|
2101
2699
|
const title = doc.title || data.title || '';
|
|
2102
|
-
const keywords = title
|
|
2700
|
+
const keywords = title
|
|
2701
|
+
.split(/\s+/)
|
|
2702
|
+
.filter((w) => w.length > 3)
|
|
2703
|
+
.slice(0, 5);
|
|
2103
2704
|
if (keywords.length === 0) {
|
|
2104
2705
|
return new Response(JSON.stringify({ suggestions: [] }), {
|
|
2105
2706
|
status: 200,
|
|
@@ -2110,6 +2711,7 @@ export function registerCMSRoutes(router) {
|
|
|
2110
2711
|
where: {
|
|
2111
2712
|
id: { not: params.documentId },
|
|
2112
2713
|
status: 'PUBLISHED',
|
|
2714
|
+
deletedAt: null,
|
|
2113
2715
|
OR: keywords.map((kw) => ({
|
|
2114
2716
|
title: { contains: kw, mode: 'insensitive' },
|
|
2115
2717
|
})),
|
|
@@ -2165,7 +2767,12 @@ export function registerCMSRoutes(router) {
|
|
|
2165
2767
|
});
|
|
2166
2768
|
router.get('/seo/schema/:documentId', async (request, params) => {
|
|
2167
2769
|
try {
|
|
2168
|
-
const
|
|
2770
|
+
const auth = await requireAuth(request);
|
|
2771
|
+
if (auth.error)
|
|
2772
|
+
return auth.error;
|
|
2773
|
+
const doc = await db().document.findFirst({
|
|
2774
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2775
|
+
});
|
|
2169
2776
|
if (!doc)
|
|
2170
2777
|
return errorResponse('Not found', 404);
|
|
2171
2778
|
const { buildSchemaGraph } = await import('../content/structured-data.js');
|
|
@@ -2194,7 +2801,12 @@ export function registerCMSRoutes(router) {
|
|
|
2194
2801
|
});
|
|
2195
2802
|
router.get('/seo/meta/:documentId', async (request, params) => {
|
|
2196
2803
|
try {
|
|
2197
|
-
const
|
|
2804
|
+
const auth = await requireAuth(request);
|
|
2805
|
+
if (auth.error)
|
|
2806
|
+
return auth.error;
|
|
2807
|
+
const doc = await db().document.findFirst({
|
|
2808
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2809
|
+
});
|
|
2198
2810
|
if (!doc)
|
|
2199
2811
|
return errorResponse('Not found', 404);
|
|
2200
2812
|
const { generateMetaTags } = await import('../seo/meta-tags.js');
|
|
@@ -2251,12 +2863,12 @@ export function registerCMSRoutes(router) {
|
|
|
2251
2863
|
const auth = await requireAuth(request);
|
|
2252
2864
|
if (auth.error)
|
|
2253
2865
|
return auth.error;
|
|
2254
|
-
const docs = await safeFindMany(db().document, {
|
|
2866
|
+
const docs = (await safeFindMany(db().document, {
|
|
2255
2867
|
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
2256
2868
|
select: { id: true, title: true, collection: true, data: true, plainText: true },
|
|
2257
2869
|
orderBy: { updatedAt: 'desc' },
|
|
2258
2870
|
take: 50,
|
|
2259
|
-
});
|
|
2871
|
+
}));
|
|
2260
2872
|
let missingMetaDescriptions = 0;
|
|
2261
2873
|
let missingAltText = 0;
|
|
2262
2874
|
const topContent = [];
|
|
@@ -2264,7 +2876,11 @@ export function registerCMSRoutes(router) {
|
|
|
2264
2876
|
const data = doc.data ?? {};
|
|
2265
2877
|
if (!data.metaDescription && !data.seoDescription)
|
|
2266
2878
|
missingMetaDescriptions++;
|
|
2267
|
-
const content = typeof data.body === 'string'
|
|
2879
|
+
const content = typeof data.body === 'string'
|
|
2880
|
+
? data.body
|
|
2881
|
+
: typeof data.content === 'string'
|
|
2882
|
+
? data.content
|
|
2883
|
+
: '';
|
|
2268
2884
|
if (content) {
|
|
2269
2885
|
const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
2270
2886
|
const noAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
|
|
@@ -2294,7 +2910,11 @@ export function registerCMSRoutes(router) {
|
|
|
2294
2910
|
continue;
|
|
2295
2911
|
seen.add(clean);
|
|
2296
2912
|
try {
|
|
2297
|
-
const resp = await fetch(clean, {
|
|
2913
|
+
const resp = await fetch(clean, {
|
|
2914
|
+
method: 'HEAD',
|
|
2915
|
+
redirect: 'manual',
|
|
2916
|
+
signal: AbortSignal.timeout(3000),
|
|
2917
|
+
});
|
|
2298
2918
|
if (resp.status >= 400)
|
|
2299
2919
|
brokenInternalLinks++;
|
|
2300
2920
|
}
|
|
@@ -2304,7 +2924,9 @@ export function registerCMSRoutes(router) {
|
|
|
2304
2924
|
}
|
|
2305
2925
|
}
|
|
2306
2926
|
}
|
|
2307
|
-
catch {
|
|
2927
|
+
catch {
|
|
2928
|
+
/* best effort */
|
|
2929
|
+
}
|
|
2308
2930
|
return json({
|
|
2309
2931
|
data: {
|
|
2310
2932
|
totalPages: docs.length,
|
|
@@ -2345,7 +2967,10 @@ export function registerCMSRoutes(router) {
|
|
|
2345
2967
|
}
|
|
2346
2968
|
}
|
|
2347
2969
|
if (unresolved.size > 0 && layoutConfig.inherit !== false) {
|
|
2348
|
-
const segments = path
|
|
2970
|
+
const segments = path
|
|
2971
|
+
.replace(/^\/|\/$/g, '')
|
|
2972
|
+
.split('/')
|
|
2973
|
+
.filter(Boolean);
|
|
2349
2974
|
const walkDepth = Math.min(segments.length - 1, MAX_RESOLVE_DEPTH);
|
|
2350
2975
|
for (let i = walkDepth; i > 0 && unresolved.size > 0; i--) {
|
|
2351
2976
|
const parentSlug = segments.slice(0, i).join('/');
|
|
@@ -2354,10 +2979,7 @@ export function registerCMSRoutes(router) {
|
|
|
2354
2979
|
collection: matchedCollection,
|
|
2355
2980
|
deletedAt: null,
|
|
2356
2981
|
status: 'PUBLISHED',
|
|
2357
|
-
OR: [
|
|
2358
|
-
{ data: { path: ['slug'], equals: parentSlug } },
|
|
2359
|
-
{ slug: parentSlug },
|
|
2360
|
-
],
|
|
2982
|
+
OR: [{ data: { path: ['slug'], equals: parentSlug } }, { slug: parentSlug }],
|
|
2361
2983
|
},
|
|
2362
2984
|
select: { data: true },
|
|
2363
2985
|
});
|
|
@@ -2414,14 +3036,17 @@ export function registerCMSRoutes(router) {
|
|
|
2414
3036
|
if (!pathParam) {
|
|
2415
3037
|
return errorResponse('Missing required "path" query parameter', 400);
|
|
2416
3038
|
}
|
|
2417
|
-
const segments = pathParam
|
|
3039
|
+
const segments = pathParam
|
|
3040
|
+
.replace(/^\/|\/$/g, '')
|
|
3041
|
+
.split('/')
|
|
3042
|
+
.filter(Boolean);
|
|
2418
3043
|
const configCollections = globalThis.__actuateConfig?.collections ?? {};
|
|
2419
3044
|
const collectionDefs = Object.values(configCollections);
|
|
2420
3045
|
let matchedCollection = null;
|
|
2421
3046
|
let docSlug = null;
|
|
2422
3047
|
if (segments.length === 0) {
|
|
2423
3048
|
// Root path — find a page-type collection and look for "home" or "index"
|
|
2424
|
-
const pageCol = collectionDefs.find((c) => c.type === 'page' && !(
|
|
3049
|
+
const pageCol = collectionDefs.find((c) => c.type === 'page' && !(c.urlPrefix ?? '').replace(/^\/|\/$/g, ''));
|
|
2425
3050
|
matchedCollection = pageCol?.slug ?? 'pages';
|
|
2426
3051
|
docSlug = 'home';
|
|
2427
3052
|
}
|
|
@@ -2467,16 +3092,13 @@ export function registerCMSRoutes(router) {
|
|
|
2467
3092
|
{ slug: 'home' },
|
|
2468
3093
|
{ slug: 'index' },
|
|
2469
3094
|
]
|
|
2470
|
-
: [
|
|
2471
|
-
{ data: { path: ['slug'], equals: docSlug } },
|
|
2472
|
-
{ slug: docSlug },
|
|
2473
|
-
],
|
|
3095
|
+
: [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
|
|
2474
3096
|
},
|
|
2475
3097
|
});
|
|
2476
3098
|
if (!doc) {
|
|
2477
3099
|
return errorResponse('Document not found', 404);
|
|
2478
3100
|
}
|
|
2479
|
-
const docData =
|
|
3101
|
+
const docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
|
|
2480
3102
|
const layout = await resolveLayout(pathParam, docData, matchedCollection);
|
|
2481
3103
|
const { _layout: _omit, ...cleanData } = docData;
|
|
2482
3104
|
return json({
|
|
@@ -2528,9 +3150,20 @@ export function registerCMSRoutes(router) {
|
|
|
2528
3150
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2529
3151
|
if (roleErr)
|
|
2530
3152
|
return roleErr;
|
|
2531
|
-
const body = await request.json();
|
|
3153
|
+
const body = (await request.json());
|
|
2532
3154
|
if (!body.name || !body.scope)
|
|
2533
3155
|
return errorResponse('name and scope are required', 400);
|
|
3156
|
+
// A child folder MUST live in the same scope as its parent — without
|
|
3157
|
+
// this check, a 'documents' folder could be reparented under a 'media'
|
|
3158
|
+
// folder, hiding it from both UIs.
|
|
3159
|
+
if (body.parentId) {
|
|
3160
|
+
const parent = await db().folder.findUnique({ where: { id: body.parentId } });
|
|
3161
|
+
if (!parent)
|
|
3162
|
+
return errorResponse('Parent folder not found', 404);
|
|
3163
|
+
if (parent.scope !== body.scope) {
|
|
3164
|
+
return errorResponse('Parent folder is in a different scope', 400);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
2534
3167
|
const folder = await db().folder.create({
|
|
2535
3168
|
data: {
|
|
2536
3169
|
name: body.name,
|
|
@@ -2552,7 +3185,23 @@ export function registerCMSRoutes(router) {
|
|
|
2552
3185
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2553
3186
|
if (roleErr)
|
|
2554
3187
|
return roleErr;
|
|
2555
|
-
const body = await request.json();
|
|
3188
|
+
const body = (await request.json());
|
|
3189
|
+
const existing = await db().folder.findUnique({ where: { id: params.id } });
|
|
3190
|
+
if (!existing)
|
|
3191
|
+
return errorResponse('Folder not found', 404);
|
|
3192
|
+
// Prevent reparenting into a different scope, and prevent making a folder
|
|
3193
|
+
// a descendant of itself (which would create a cycle).
|
|
3194
|
+
if (body.parentId !== undefined && body.parentId !== null) {
|
|
3195
|
+
if (body.parentId === params.id) {
|
|
3196
|
+
return errorResponse('Folder cannot be its own parent', 400);
|
|
3197
|
+
}
|
|
3198
|
+
const parent = await db().folder.findUnique({ where: { id: body.parentId } });
|
|
3199
|
+
if (!parent)
|
|
3200
|
+
return errorResponse('Parent folder not found', 404);
|
|
3201
|
+
if (parent.scope !== existing.scope) {
|
|
3202
|
+
return errorResponse('Parent folder is in a different scope', 400);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
2556
3205
|
const data = {};
|
|
2557
3206
|
if (body.name !== undefined)
|
|
2558
3207
|
data.name = body.name;
|
|
@@ -2578,7 +3227,24 @@ export function registerCMSRoutes(router) {
|
|
|
2578
3227
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2579
3228
|
if (roleErr)
|
|
2580
3229
|
return roleErr;
|
|
2581
|
-
|
|
3230
|
+
// We don't cascade-delete the contents — they would silently vanish.
|
|
3231
|
+
// Instead, refuse to delete a folder that still has documents, media,
|
|
3232
|
+
// or sub-folders inside it.
|
|
3233
|
+
const d = db();
|
|
3234
|
+
const folder = await d.folder.findUnique({ where: { id: params.id } });
|
|
3235
|
+
if (!folder)
|
|
3236
|
+
return errorResponse('Folder not found', 404);
|
|
3237
|
+
const [docCount, mediaCount, childCount] = await Promise.all([
|
|
3238
|
+
hasModel(d, 'document')
|
|
3239
|
+
? d.document.count({ where: { folderId: params.id, deletedAt: null } })
|
|
3240
|
+
: 0,
|
|
3241
|
+
hasModel(d, 'media') ? d.media.count({ where: { folderId: params.id } }) : 0,
|
|
3242
|
+
d.folder.count({ where: { parentId: params.id } }),
|
|
3243
|
+
]);
|
|
3244
|
+
if (docCount + mediaCount + childCount > 0) {
|
|
3245
|
+
return errorResponse(`Folder is not empty (${docCount} documents, ${mediaCount} media, ${childCount} sub-folders). Move or delete its contents first.`, 409);
|
|
3246
|
+
}
|
|
3247
|
+
await d.folder.delete({ where: { id: params.id } });
|
|
2582
3248
|
return json({ data: { success: true } });
|
|
2583
3249
|
}
|
|
2584
3250
|
catch (err) {
|
|
@@ -2593,7 +3259,16 @@ export function registerCMSRoutes(router) {
|
|
|
2593
3259
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2594
3260
|
if (roleErr)
|
|
2595
3261
|
return roleErr;
|
|
2596
|
-
const body = await request.json();
|
|
3262
|
+
const body = (await request.json());
|
|
3263
|
+
// Confirm the target folder is in the documents scope before moving in.
|
|
3264
|
+
if (body.folderId) {
|
|
3265
|
+
const target = await db().folder.findUnique({ where: { id: body.folderId } });
|
|
3266
|
+
if (!target)
|
|
3267
|
+
return errorResponse('Folder not found', 404);
|
|
3268
|
+
if (target.scope !== 'documents' && target.scope !== 'collections') {
|
|
3269
|
+
return errorResponse('Target folder is not a documents folder', 400);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
2597
3272
|
await db().document.update({
|
|
2598
3273
|
where: { id: params.id },
|
|
2599
3274
|
data: { folderId: body.folderId ?? null },
|
|
@@ -2612,7 +3287,15 @@ export function registerCMSRoutes(router) {
|
|
|
2612
3287
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2613
3288
|
if (roleErr)
|
|
2614
3289
|
return roleErr;
|
|
2615
|
-
const body = await request.json();
|
|
3290
|
+
const body = (await request.json());
|
|
3291
|
+
if (body.folderId) {
|
|
3292
|
+
const target = await db().folder.findUnique({ where: { id: body.folderId } });
|
|
3293
|
+
if (!target)
|
|
3294
|
+
return errorResponse('Folder not found', 404);
|
|
3295
|
+
if (target.scope !== 'media') {
|
|
3296
|
+
return errorResponse('Target folder is not a media folder', 400);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
2616
3299
|
await db().media.update({
|
|
2617
3300
|
where: { id: params.id },
|
|
2618
3301
|
data: { folderId: body.folderId ?? null },
|
|
@@ -2631,7 +3314,7 @@ export function registerCMSRoutes(router) {
|
|
|
2631
3314
|
const auth = await requireAuth(request);
|
|
2632
3315
|
if (auth.error)
|
|
2633
3316
|
return auth.error;
|
|
2634
|
-
const body = await request.json();
|
|
3317
|
+
const body = (await request.json());
|
|
2635
3318
|
if (!body.collection || !body.documentId) {
|
|
2636
3319
|
return errorResponse('collection and documentId are required', 400);
|
|
2637
3320
|
}
|
|
@@ -2673,7 +3356,7 @@ export function registerCMSRoutes(router) {
|
|
|
2673
3356
|
const auth = await requireAuth(request);
|
|
2674
3357
|
if (auth.error)
|
|
2675
3358
|
return auth.error;
|
|
2676
|
-
const body = await request.json();
|
|
3359
|
+
const body = (await request.json());
|
|
2677
3360
|
if (!body.stage)
|
|
2678
3361
|
return errorResponse('Stage is required', 400);
|
|
2679
3362
|
const { transitionDocument } = await import('../workflow/index.js');
|
|
@@ -2700,7 +3383,9 @@ export function registerCMSRoutes(router) {
|
|
|
2700
3383
|
return errorResponse('Document not found', 404);
|
|
2701
3384
|
const stage = (doc.workflowStage ?? 'DRAFT');
|
|
2702
3385
|
const transitions = getAvailableTransitions(stage, auth.session.role);
|
|
2703
|
-
return json({
|
|
3386
|
+
return json({
|
|
3387
|
+
data: { stage, transitions, reviewerId: doc.reviewerId, reviewNote: doc.reviewNote },
|
|
3388
|
+
});
|
|
2704
3389
|
}
|
|
2705
3390
|
catch (err) {
|
|
2706
3391
|
return internalError(err);
|
|
@@ -2821,17 +3506,17 @@ export function registerCMSRoutes(router) {
|
|
|
2821
3506
|
if (!doc) {
|
|
2822
3507
|
return errorResponse('Global not found', 404);
|
|
2823
3508
|
}
|
|
3509
|
+
// Globals routed through `/public/globals/:slug` are by definition
|
|
3510
|
+
// public site data — when no `access.read` is set we default to
|
|
3511
|
+
// allowed. Integrators that want to gate a global must set
|
|
3512
|
+
// `access.read` explicitly (returning `false` for public).
|
|
2824
3513
|
const readAccess = globalConfig.access?.read;
|
|
2825
|
-
const allowed = readAccess
|
|
2826
|
-
? await readAccess({ user: null, doc })
|
|
2827
|
-
: false;
|
|
3514
|
+
const allowed = readAccess ? await readAccess({ user: null, doc }) : true;
|
|
2828
3515
|
if (!allowed) {
|
|
2829
3516
|
return errorResponse('Forbidden', 403);
|
|
2830
3517
|
}
|
|
2831
3518
|
return json({
|
|
2832
|
-
data: doc.data && typeof doc.data === 'object'
|
|
2833
|
-
? doc.data
|
|
2834
|
-
: {},
|
|
3519
|
+
data: doc.data && typeof doc.data === 'object' ? doc.data : {},
|
|
2835
3520
|
});
|
|
2836
3521
|
}
|
|
2837
3522
|
catch (err) {
|
|
@@ -2862,7 +3547,7 @@ export function registerCMSRoutes(router) {
|
|
|
2862
3547
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2863
3548
|
if (roleErr)
|
|
2864
3549
|
return roleErr;
|
|
2865
|
-
const body = await request.json();
|
|
3550
|
+
const body = (await request.json());
|
|
2866
3551
|
const ctx = buildActionContext(auth.session, db());
|
|
2867
3552
|
const global = await updateGlobal(params.slug, body, ctx);
|
|
2868
3553
|
return json({ data: global });
|
|
@@ -2898,7 +3583,7 @@ export function registerCMSRoutes(router) {
|
|
|
2898
3583
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2899
3584
|
if (roleErr)
|
|
2900
3585
|
return roleErr;
|
|
2901
|
-
const body = await request.json();
|
|
3586
|
+
const body = (await request.json());
|
|
2902
3587
|
if (!body.url || !body.events?.length) {
|
|
2903
3588
|
return errorResponse('url and events are required', 400);
|
|
2904
3589
|
}
|
|
@@ -2930,7 +3615,7 @@ export function registerCMSRoutes(router) {
|
|
|
2930
3615
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
2931
3616
|
if (roleErr)
|
|
2932
3617
|
return roleErr;
|
|
2933
|
-
const body = await request.json();
|
|
3618
|
+
const body = (await request.json());
|
|
2934
3619
|
const { updateEndpoint } = await import('../webhooks/index.js');
|
|
2935
3620
|
const updated = await updateEndpoint(params.id, body);
|
|
2936
3621
|
return json({ data: updated });
|
|
@@ -3040,7 +3725,15 @@ export function registerCMSRoutes(router) {
|
|
|
3040
3725
|
bucket.push(tag.code);
|
|
3041
3726
|
}
|
|
3042
3727
|
}
|
|
3043
|
-
|
|
3728
|
+
// Public endpoint that fans out to many page renders. Add a short
|
|
3729
|
+
// edge cache so it doesn't become a per-request DB hit.
|
|
3730
|
+
const response = new Response(JSON.stringify(grouped), {
|
|
3731
|
+
status: 200,
|
|
3732
|
+
headers: { ...SECURITY_HEADERS, 'Content-Type': 'application/json' },
|
|
3733
|
+
});
|
|
3734
|
+
response.headers.set('Cache-Control', 'public, max-age=60, s-maxage=60, stale-while-revalidate=300');
|
|
3735
|
+
response.headers.set('Vary', 'path');
|
|
3736
|
+
return response;
|
|
3044
3737
|
}
|
|
3045
3738
|
catch (err) {
|
|
3046
3739
|
return internalError(err, 'script-tags/resolve');
|
|
@@ -3077,7 +3770,7 @@ export function registerCMSRoutes(router) {
|
|
|
3077
3770
|
const d = db();
|
|
3078
3771
|
if (!hasModel(d, 'scriptTag'))
|
|
3079
3772
|
return modelNotAvailable('ScriptTag');
|
|
3080
|
-
const body = await request.json();
|
|
3773
|
+
const body = (await request.json());
|
|
3081
3774
|
if (!body.name || !body.code || !body.placement) {
|
|
3082
3775
|
return errorResponse('name, code, and placement are required', 400);
|
|
3083
3776
|
}
|
|
@@ -3126,7 +3819,7 @@ export function registerCMSRoutes(router) {
|
|
|
3126
3819
|
const existing = await d.scriptTag.findUnique({ where: { id: params.id } });
|
|
3127
3820
|
if (!existing)
|
|
3128
3821
|
return errorResponse('Script tag not found', 404);
|
|
3129
|
-
const body = await request.json();
|
|
3822
|
+
const body = (await request.json());
|
|
3130
3823
|
const update = {};
|
|
3131
3824
|
if (body.name !== undefined)
|
|
3132
3825
|
update.name = body.name;
|
|
@@ -3308,7 +4001,7 @@ export function registerCMSRoutes(router) {
|
|
|
3308
4001
|
const d = db();
|
|
3309
4002
|
if (!hasModel(d, 'pageTemplate'))
|
|
3310
4003
|
return modelNotAvailable('PageTemplate');
|
|
3311
|
-
const body = await request.json();
|
|
4004
|
+
const body = (await request.json());
|
|
3312
4005
|
if (!body.name)
|
|
3313
4006
|
return errorResponse('name is required', 400);
|
|
3314
4007
|
if (!body.tree)
|
|
@@ -3359,7 +4052,7 @@ export function registerCMSRoutes(router) {
|
|
|
3359
4052
|
return errorResponse('Template not found', 404);
|
|
3360
4053
|
if (existing.builtIn)
|
|
3361
4054
|
return errorResponse('Cannot update built-in templates', 403);
|
|
3362
|
-
const body = await request.json();
|
|
4055
|
+
const body = (await request.json());
|
|
3363
4056
|
const update = {};
|
|
3364
4057
|
if (body.name !== undefined)
|
|
3365
4058
|
update.name = body.name;
|
|
@@ -3507,7 +4200,7 @@ export function registerCMSRoutes(router) {
|
|
|
3507
4200
|
const d = db();
|
|
3508
4201
|
if (!hasModel(d, 'savedSection'))
|
|
3509
4202
|
return modelNotAvailable('SavedSection');
|
|
3510
|
-
const body = await request.json();
|
|
4203
|
+
const body = (await request.json());
|
|
3511
4204
|
if (!body.name)
|
|
3512
4205
|
return errorResponse('name is required', 400);
|
|
3513
4206
|
if (!body.tree)
|
|
@@ -3556,7 +4249,7 @@ export function registerCMSRoutes(router) {
|
|
|
3556
4249
|
const existing = await d.savedSection.findUnique({ where: { id: params.id } });
|
|
3557
4250
|
if (!existing)
|
|
3558
4251
|
return errorResponse('Saved section not found', 404);
|
|
3559
|
-
const body = await request.json();
|
|
4252
|
+
const body = (await request.json());
|
|
3560
4253
|
const update = {};
|
|
3561
4254
|
if (body.name !== undefined)
|
|
3562
4255
|
update.name = body.name;
|
|
@@ -3621,6 +4314,11 @@ export function registerCMSRoutes(router) {
|
|
|
3621
4314
|
}
|
|
3622
4315
|
});
|
|
3623
4316
|
// ─── Page Builder AI Generation ─────────────────────────────────────
|
|
4317
|
+
// Hard caps for AI input to keep token cost bounded and to limit the
|
|
4318
|
+
// surface area for prompt injection. Adjust via the `ai.limits` block in
|
|
4319
|
+
// the CMS config if you want stricter values; never raise past 8k.
|
|
4320
|
+
const AI_PROMPT_MAX_CHARS = 4000;
|
|
4321
|
+
const AI_CONTEXT_MAX_CHARS = 8000;
|
|
3624
4322
|
router.post('/page-builder/generate', async (request) => {
|
|
3625
4323
|
try {
|
|
3626
4324
|
const auth = await requireAuth(request);
|
|
@@ -3629,11 +4327,23 @@ export function registerCMSRoutes(router) {
|
|
|
3629
4327
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3630
4328
|
if (roleErr)
|
|
3631
4329
|
return roleErr;
|
|
4330
|
+
// Per-user rate limit. AI generation is the single most expensive
|
|
4331
|
+
// operation in the CMS — without this, a compromised admin account
|
|
4332
|
+
// can drain a provider key in minutes.
|
|
4333
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
|
|
4334
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4335
|
+
}
|
|
3632
4336
|
const body = await request.json();
|
|
3633
4337
|
const { prompt, template, context, steps, tone } = body;
|
|
3634
4338
|
if (!prompt || typeof prompt !== 'string') {
|
|
3635
4339
|
return errorResponse('prompt is required', 400);
|
|
3636
4340
|
}
|
|
4341
|
+
if (prompt.length > AI_PROMPT_MAX_CHARS) {
|
|
4342
|
+
return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
|
|
4343
|
+
}
|
|
4344
|
+
if (typeof context === 'string' && context.length > AI_CONTEXT_MAX_CHARS) {
|
|
4345
|
+
return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
|
|
4346
|
+
}
|
|
3637
4347
|
if (!steps || !Array.isArray(steps) || steps.length === 0) {
|
|
3638
4348
|
return errorResponse('steps array is required', 400);
|
|
3639
4349
|
}
|
|
@@ -3646,7 +4356,15 @@ export function registerCMSRoutes(router) {
|
|
|
3646
4356
|
await logEvent({
|
|
3647
4357
|
event: 'settings_changed',
|
|
3648
4358
|
userId: auth.session.userId,
|
|
3649
|
-
details: {
|
|
4359
|
+
details: {
|
|
4360
|
+
action: 'page_generation_started',
|
|
4361
|
+
// Redact secrets from the prompt before persisting to the audit log.
|
|
4362
|
+
// Even an admin pasting a key into a prompt by mistake shouldn't
|
|
4363
|
+
// result in that key being mirrored into permanent storage.
|
|
4364
|
+
prompt: redactSecrets(prompt).slice(0, 500),
|
|
4365
|
+
steps,
|
|
4366
|
+
template,
|
|
4367
|
+
},
|
|
3650
4368
|
});
|
|
3651
4369
|
let generatePage = null;
|
|
3652
4370
|
try {
|
|
@@ -3681,11 +4399,21 @@ export function registerCMSRoutes(router) {
|
|
|
3681
4399
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3682
4400
|
if (roleErr)
|
|
3683
4401
|
return roleErr;
|
|
4402
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
|
|
4403
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4404
|
+
}
|
|
3684
4405
|
const body = await request.json();
|
|
3685
4406
|
const { blockType, variant, pageContext, tone } = body;
|
|
3686
4407
|
if (!blockType || typeof blockType !== 'string') {
|
|
3687
4408
|
return errorResponse('blockType is required', 400);
|
|
3688
4409
|
}
|
|
4410
|
+
// Limit caller-supplied context that flows directly into the prompt.
|
|
4411
|
+
if (pageContext) {
|
|
4412
|
+
const total = JSON.stringify(pageContext).length;
|
|
4413
|
+
if (total > AI_CONTEXT_MAX_CHARS) {
|
|
4414
|
+
return errorResponse(`pageContext exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
3689
4417
|
let generateBlockContent = null;
|
|
3690
4418
|
try {
|
|
3691
4419
|
const aiModule = await importAIPlugin();
|
|
@@ -3738,7 +4466,11 @@ export function registerCMSRoutes(router) {
|
|
|
3738
4466
|
await logEvent({
|
|
3739
4467
|
event: 'settings_changed',
|
|
3740
4468
|
userId: auth.session.userId,
|
|
3741
|
-
details: {
|
|
4469
|
+
details: {
|
|
4470
|
+
action: 'a11y_auto_fix',
|
|
4471
|
+
fixedCount: result.report.fixedCount,
|
|
4472
|
+
remainingCount: result.report.remainingCount,
|
|
4473
|
+
},
|
|
3742
4474
|
});
|
|
3743
4475
|
return json({ data: result });
|
|
3744
4476
|
}
|