@actuate-media/cms-core 0.10.4 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/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__/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/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/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/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/actions.d.ts.map +1 -1
- package/dist/actions.js +170 -34
- package/dist/actions.js.map +1 -1
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +64 -9
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +673 -116
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/openapi.d.ts.map +1 -1
- package/dist/api/openapi.js +38 -0
- package/dist/api/openapi.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 +109 -20
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/reset.d.ts.map +1 -1
- package/dist/auth/reset.js +26 -2
- 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 +20 -2
- package/dist/auth/session.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.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/page-builder/__tests__/blocks.test.js +104 -1
- package/dist/page-builder/__tests__/blocks.test.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 +22 -2
- package/dist/page-builder/blocks.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/client-ip.d.ts +33 -0
- package/dist/security/client-ip.d.ts.map +1 -0
- package/dist/security/client-ip.js +39 -0
- package/dist/security/client-ip.js.map +1 -0
- package/dist/security/index.d.ts +7 -0
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +5 -0
- 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 +120 -12
- package/dist/security/ip-allowlist.js.map +1 -1
- package/dist/security/rate-limit.d.ts.map +1 -1
- package/dist/security/rate-limit.js +49 -17
- package/dist/security/rate-limit.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 +41 -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/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/upload.d.ts +23 -4
- package/dist/security/upload.d.ts.map +1 -1
- package/dist/security/upload.js +110 -21
- package/dist/security/upload.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 +149 -0
- package/dist/server-site.js.map +1 -0
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +19 -1
- 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/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +20 -9
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +1 -1
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() {
|
|
@@ -302,6 +311,36 @@ export function parseCookieHeader(cookieHeader) {
|
|
|
302
311
|
}
|
|
303
312
|
return cookies;
|
|
304
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Verify a password (or "current password") posted in the X-Reauth-Password
|
|
316
|
+
* header against the authenticated user. Returns an error Response when the
|
|
317
|
+
* header is missing or the password is wrong; returns null on success.
|
|
318
|
+
*
|
|
319
|
+
* Use this for high-impact account changes (TOTP enable/disable, role change,
|
|
320
|
+
* delete user, etc.) so a stolen session alone cannot perform them.
|
|
321
|
+
*/
|
|
322
|
+
async function requirePasswordReauth(request, userId) {
|
|
323
|
+
const password = request.headers.get('x-reauth-password');
|
|
324
|
+
if (!password) {
|
|
325
|
+
return new Response(JSON.stringify({
|
|
326
|
+
error: 'Re-authentication required for this action.',
|
|
327
|
+
code: 'REAUTH_REQUIRED',
|
|
328
|
+
method: 'password',
|
|
329
|
+
}), { status: 401, headers: { ...SECURITY_HEADERS } });
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const { getDB } = await import('../db.js');
|
|
333
|
+
const ok = await verifyReauth(userId, password, 'password', getDB());
|
|
334
|
+
if (!ok) {
|
|
335
|
+
return errorResponse('Re-authentication failed.', 401);
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
console.error('[actuate][reauth] verify failed:', err instanceof Error ? err.message : err);
|
|
341
|
+
return errorResponse('Re-authentication failed.', 401);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
305
344
|
async function requireAuth(request) {
|
|
306
345
|
if (isSecretMissing()) {
|
|
307
346
|
return {
|
|
@@ -334,11 +373,49 @@ function requireRole(role, allowedRoles) {
|
|
|
334
373
|
return null;
|
|
335
374
|
}
|
|
336
375
|
const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
|
|
376
|
+
const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });
|
|
337
377
|
const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
|
|
378
|
+
const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
|
|
379
|
+
const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
|
|
338
380
|
async function checkRateLimitAsync(limiter, key) {
|
|
339
381
|
const result = await limiter.check(key);
|
|
340
382
|
return result.allowed;
|
|
341
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
|
|
386
|
+
* then x-forwarded-for only when ACTUATE_TRUST_PROXY=1). Returns 'unknown' when
|
|
387
|
+
* no trustworthy source is available — callers MUST treat that case as a hard
|
|
388
|
+
* failure for security-sensitive decisions like rate-limit keys and IP allowlists.
|
|
389
|
+
*/
|
|
390
|
+
function clientIp(request) {
|
|
391
|
+
return getClientIp(request);
|
|
392
|
+
}
|
|
393
|
+
const MAX_CONCURRENT_SESSIONS = 5;
|
|
394
|
+
/**
|
|
395
|
+
* After a successful primary-credential check, prune the user's active sessions
|
|
396
|
+
* down to the configured concurrent maximum (default 5; configurable via
|
|
397
|
+
* `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
|
|
398
|
+
* always sign in.
|
|
399
|
+
*/
|
|
400
|
+
async function enforceSessionLimitsForUser(d, userId) {
|
|
401
|
+
if (!hasModel(d, 'session'))
|
|
402
|
+
return;
|
|
403
|
+
const config = globalThis.__actuateConfig?.auth ?? {};
|
|
404
|
+
const max = typeof config.maxConcurrentSessions === 'number' && config.maxConcurrentSessions > 0
|
|
405
|
+
? config.maxConcurrentSessions
|
|
406
|
+
: MAX_CONCURRENT_SESSIONS;
|
|
407
|
+
const active = await d.session.findMany({
|
|
408
|
+
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
409
|
+
select: { id: true, createdAt: true },
|
|
410
|
+
});
|
|
411
|
+
const decision = enforceSessionLimits(active.map((s) => ({ sessionId: s.id, userId, createdAt: s.createdAt })), { maxConcurrentSessions: max, strategy: 'revoke_oldest' });
|
|
412
|
+
if (decision.sessionsToRevoke.length > 0) {
|
|
413
|
+
await d.session.updateMany({
|
|
414
|
+
where: { id: { in: decision.sessionsToRevoke } },
|
|
415
|
+
data: { revokedAt: new Date() },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
342
419
|
function getAdminPath() {
|
|
343
420
|
return process.env.ACTUATE_ADMIN_PATH
|
|
344
421
|
?? globalThis.__actuateConfig?.admin?.path
|
|
@@ -451,13 +528,18 @@ export function registerCMSRoutes(router) {
|
|
|
451
528
|
if (!email || !password) {
|
|
452
529
|
return errorResponse('Email and password are required', 400);
|
|
453
530
|
}
|
|
454
|
-
const
|
|
455
|
-
|
|
531
|
+
const ip = clientIp(request);
|
|
532
|
+
const userAgent = request.headers.get('user-agent');
|
|
533
|
+
// Bucket by IP when we have a trustworthy one; otherwise fall back to a
|
|
534
|
+
// global bucket. We deliberately avoid keying on the email alone — that
|
|
535
|
+
// would let attackers lock out legitimate users by spamming logins.
|
|
536
|
+
const rateLimitKey = isResolvedIp(ip) ? `login:${ip}` : 'login:unknown';
|
|
537
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
456
538
|
return errorResponse('Too many login attempts. Please try again later.', 429);
|
|
457
539
|
}
|
|
458
540
|
const captchaConfig = getCaptchaConfig();
|
|
459
541
|
if (captchaConfig.provider !== 'none') {
|
|
460
|
-
const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig,
|
|
542
|
+
const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
|
|
461
543
|
if (!captchaResult.success) {
|
|
462
544
|
return errorResponse('CAPTCHA verification failed. Please try again.', 403);
|
|
463
545
|
}
|
|
@@ -479,8 +561,8 @@ export function registerCMSRoutes(router) {
|
|
|
479
561
|
await logEvent({
|
|
480
562
|
event: 'login_failed',
|
|
481
563
|
userId: user.id,
|
|
482
|
-
ipAddress:
|
|
483
|
-
userAgent:
|
|
564
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
565
|
+
userAgent: userAgent ?? undefined,
|
|
484
566
|
});
|
|
485
567
|
return errorResponse('Invalid email or password', 401);
|
|
486
568
|
}
|
|
@@ -488,8 +570,14 @@ export function registerCMSRoutes(router) {
|
|
|
488
570
|
return errorResponse('Account is deactivated', 403);
|
|
489
571
|
}
|
|
490
572
|
if (user.totpEnabled) {
|
|
491
|
-
|
|
573
|
+
// Hand back an opaque short-lived token instead of the raw userId.
|
|
574
|
+
// The /auth/totp/login endpoint will verify both this token and a
|
|
575
|
+
// stable browser fingerprint before checking the TOTP code.
|
|
576
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
577
|
+
const mfaPendingToken = await createMfaPendingToken({ userId: user.id, fingerprint }, getSessionSecret());
|
|
578
|
+
return json({ data: { requiresTOTP: true, mfaPendingToken } });
|
|
492
579
|
}
|
|
580
|
+
await enforceSessionLimitsForUser(d, user.id);
|
|
493
581
|
const tempSessionId = crypto.randomUUID();
|
|
494
582
|
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
495
583
|
await db().session.create({
|
|
@@ -498,8 +586,8 @@ export function registerCMSRoutes(router) {
|
|
|
498
586
|
userId: user.id,
|
|
499
587
|
token,
|
|
500
588
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
501
|
-
ipAddress:
|
|
502
|
-
userAgent:
|
|
589
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
590
|
+
userAgent: userAgent ?? null,
|
|
503
591
|
},
|
|
504
592
|
});
|
|
505
593
|
const response = json({
|
|
@@ -532,8 +620,8 @@ export function registerCMSRoutes(router) {
|
|
|
532
620
|
await logEvent({
|
|
533
621
|
event: 'login_success',
|
|
534
622
|
userId: user.id,
|
|
535
|
-
ipAddress:
|
|
536
|
-
userAgent:
|
|
623
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
624
|
+
userAgent: userAgent ?? undefined,
|
|
537
625
|
});
|
|
538
626
|
return response;
|
|
539
627
|
}
|
|
@@ -547,10 +635,11 @@ export function registerCMSRoutes(router) {
|
|
|
547
635
|
if (auth.error)
|
|
548
636
|
return auth.error;
|
|
549
637
|
await revokeSession(auth.session.sessionId, db());
|
|
638
|
+
const ip = clientIp(request);
|
|
550
639
|
await logEvent({
|
|
551
640
|
event: 'logout',
|
|
552
641
|
userId: auth.session.userId,
|
|
553
|
-
ipAddress:
|
|
642
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
554
643
|
});
|
|
555
644
|
const response = json({ data: { success: true } });
|
|
556
645
|
response.headers.set('Set-Cookie', 'actuate_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
@@ -567,8 +656,9 @@ export function registerCMSRoutes(router) {
|
|
|
567
656
|
if (!email) {
|
|
568
657
|
return errorResponse('Email is required', 400);
|
|
569
658
|
}
|
|
570
|
-
const
|
|
571
|
-
|
|
659
|
+
const ip = clientIp(request);
|
|
660
|
+
const rateLimitKey = isResolvedIp(ip) ? `forgot:${ip}` : 'forgot:unknown';
|
|
661
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
572
662
|
return errorResponse('Too many requests. Please try again later.', 429);
|
|
573
663
|
}
|
|
574
664
|
const d = db();
|
|
@@ -582,7 +672,7 @@ export function registerCMSRoutes(router) {
|
|
|
582
672
|
});
|
|
583
673
|
await logEvent({
|
|
584
674
|
event: 'password_reset_request',
|
|
585
|
-
ipAddress:
|
|
675
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
586
676
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
587
677
|
details: { email: email.toLowerCase().trim() },
|
|
588
678
|
});
|
|
@@ -602,8 +692,9 @@ export function registerCMSRoutes(router) {
|
|
|
602
692
|
if (password.length < 8) {
|
|
603
693
|
return errorResponse('Password must be at least 8 characters', 400);
|
|
604
694
|
}
|
|
605
|
-
const
|
|
606
|
-
|
|
695
|
+
const ip = clientIp(request);
|
|
696
|
+
const rateLimitKey = isResolvedIp(ip) ? `reset:${ip}` : 'reset:unknown';
|
|
697
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
607
698
|
return errorResponse('Too many requests. Please try again later.', 429);
|
|
608
699
|
}
|
|
609
700
|
const d = db();
|
|
@@ -615,7 +706,7 @@ export function registerCMSRoutes(router) {
|
|
|
615
706
|
}
|
|
616
707
|
await logEvent({
|
|
617
708
|
event: 'password_reset_complete',
|
|
618
|
-
ipAddress:
|
|
709
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
619
710
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
620
711
|
});
|
|
621
712
|
return json({ data: { success: true } });
|
|
@@ -636,7 +727,24 @@ export function registerCMSRoutes(router) {
|
|
|
636
727
|
if (!user) {
|
|
637
728
|
return errorResponse('User not found', 404);
|
|
638
729
|
}
|
|
639
|
-
|
|
730
|
+
const response = json({ data: user });
|
|
731
|
+
// Bootstrap (or refresh) the double-submit CSRF cookie. Without this
|
|
732
|
+
// the admin app's first non-GET after a hard reload races to populate
|
|
733
|
+
// it; some users hit "Invalid CSRF token" on the very first save.
|
|
734
|
+
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
735
|
+
if (!cookies['actuate_csrf']) {
|
|
736
|
+
const csrfToken = await generateCsrfToken();
|
|
737
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
738
|
+
const csrfCookie = [
|
|
739
|
+
`actuate_csrf=${csrfToken}`,
|
|
740
|
+
'Path=/',
|
|
741
|
+
'SameSite=Lax',
|
|
742
|
+
'Max-Age=86400',
|
|
743
|
+
...(isProduction ? ['Secure'] : []),
|
|
744
|
+
].join('; ');
|
|
745
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
746
|
+
}
|
|
747
|
+
return response;
|
|
640
748
|
}
|
|
641
749
|
catch (err) {
|
|
642
750
|
return internalError(err, 'auth/me');
|
|
@@ -650,6 +758,11 @@ export function registerCMSRoutes(router) {
|
|
|
650
758
|
const auth = await requireAuth(request);
|
|
651
759
|
if (auth.error)
|
|
652
760
|
return auth.error;
|
|
761
|
+
// Sensitive operation — require recent password reauth so a stolen
|
|
762
|
+
// session can't silently rotate someone's TOTP secret.
|
|
763
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
764
|
+
if (reauthErr)
|
|
765
|
+
return reauthErr;
|
|
653
766
|
const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp.js');
|
|
654
767
|
const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { email: true, totpEnabled: true } });
|
|
655
768
|
if (!user)
|
|
@@ -659,7 +772,14 @@ export function registerCMSRoutes(router) {
|
|
|
659
772
|
const secret = generateTOTPSecret();
|
|
660
773
|
const uri = generateTOTPUri(secret, user.email);
|
|
661
774
|
const backups = generateBackupCodes();
|
|
662
|
-
|
|
775
|
+
// Persist encrypted; the plaintext is only returned to the user once
|
|
776
|
+
// (so they can scan the QR / save backup codes).
|
|
777
|
+
const encryptedSecret = await encryptSecret(secret);
|
|
778
|
+
const encryptedBackups = await encryptStringArray(backups);
|
|
779
|
+
await db().user.update({
|
|
780
|
+
where: { id: auth.session.userId },
|
|
781
|
+
data: { totpSecret: encryptedSecret, backupCodes: encryptedBackups },
|
|
782
|
+
});
|
|
663
783
|
return json({ data: { secret, uri, backupCodes: backups } });
|
|
664
784
|
}
|
|
665
785
|
catch (err) {
|
|
@@ -678,10 +798,12 @@ export function registerCMSRoutes(router) {
|
|
|
678
798
|
const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { totpSecret: true } });
|
|
679
799
|
if (!user?.totpSecret)
|
|
680
800
|
return errorResponse('TOTP not set up', 400);
|
|
681
|
-
const
|
|
801
|
+
const secret = await decryptSecret(user.totpSecret);
|
|
802
|
+
const valid = verifyTOTP(body.code, secret);
|
|
682
803
|
if (!valid)
|
|
683
804
|
return errorResponse('Invalid code', 400);
|
|
684
805
|
await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: true } });
|
|
806
|
+
await logEvent({ event: 'totp_enabled', userId: auth.session.userId });
|
|
685
807
|
return json({ data: { enabled: true } });
|
|
686
808
|
}
|
|
687
809
|
catch (err) {
|
|
@@ -693,7 +815,28 @@ export function registerCMSRoutes(router) {
|
|
|
693
815
|
const auth = await requireAuth(request);
|
|
694
816
|
if (auth.error)
|
|
695
817
|
return auth.error;
|
|
696
|
-
|
|
818
|
+
// Disabling MFA is a high-impact security change. Require recent password
|
|
819
|
+
// reauth and revoke every other session so a compromised cookie loses
|
|
820
|
+
// access immediately after MFA goes away.
|
|
821
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
822
|
+
if (reauthErr)
|
|
823
|
+
return reauthErr;
|
|
824
|
+
const d = db();
|
|
825
|
+
await d.user.update({
|
|
826
|
+
where: { id: auth.session.userId },
|
|
827
|
+
data: { totpEnabled: false, totpSecret: null, backupCodes: null },
|
|
828
|
+
});
|
|
829
|
+
if (hasModel(d, 'session')) {
|
|
830
|
+
await d.session.updateMany({
|
|
831
|
+
where: {
|
|
832
|
+
userId: auth.session.userId,
|
|
833
|
+
id: { not: auth.session.sessionId },
|
|
834
|
+
revokedAt: null,
|
|
835
|
+
},
|
|
836
|
+
data: { revokedAt: new Date() },
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
await logEvent({ event: 'totp_disabled', userId: auth.session.userId });
|
|
697
840
|
return json({ data: { enabled: false } });
|
|
698
841
|
}
|
|
699
842
|
catch (err) {
|
|
@@ -703,21 +846,107 @@ export function registerCMSRoutes(router) {
|
|
|
703
846
|
router.post('/auth/totp/login', async (request) => {
|
|
704
847
|
try {
|
|
705
848
|
const body = await request.json();
|
|
706
|
-
if (!body.
|
|
707
|
-
return errorResponse('
|
|
849
|
+
if (!body.mfaPendingToken || !body.code) {
|
|
850
|
+
return errorResponse('mfaPendingToken and code are required', 400);
|
|
851
|
+
}
|
|
852
|
+
const ip = clientIp(request);
|
|
853
|
+
const userAgent = request.headers.get('user-agent');
|
|
854
|
+
// Per-IP and per-token rate limits. The per-token limit is the actual
|
|
855
|
+
// brute-force defence: even with IP rotation, an attacker has to obtain
|
|
856
|
+
// a fresh mfaPendingToken (which requires the password) for every
|
|
857
|
+
// window. The per-IP limit guards the IP-aware case and limits log noise.
|
|
858
|
+
const ipBucket = isResolvedIp(ip) ? `totp-ip:${ip}` : 'totp-ip:unknown';
|
|
859
|
+
if (!(await checkRateLimitAsync(totpLimiter, ipBucket))) {
|
|
860
|
+
return errorResponse('Too many TOTP attempts. Please try again later.', 429);
|
|
861
|
+
}
|
|
862
|
+
let pending;
|
|
863
|
+
try {
|
|
864
|
+
pending = await verifyMfaPendingToken(body.mfaPendingToken, getSessionSecret());
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
return errorResponse('Session expired. Please sign in again.', 401);
|
|
868
|
+
}
|
|
869
|
+
// Validate the request comes from the same browser that completed the
|
|
870
|
+
// password step. This frustrates attempts to ship a captured pending
|
|
871
|
+
// token to a different host.
|
|
872
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
873
|
+
if (fingerprint !== pending.fingerprint) {
|
|
874
|
+
return errorResponse('Session fingerprint mismatch. Please sign in again.', 401);
|
|
875
|
+
}
|
|
876
|
+
// Per-userId bucket is what actually caps brute-force. With the default
|
|
877
|
+
// 10 attempts / 15 min, an attacker would need ~190 years to cover the
|
|
878
|
+
// 1M code space even with unbounded IPs.
|
|
879
|
+
const userBucket = `totp-user:${pending.userId}`;
|
|
880
|
+
if (!(await checkRateLimitAsync(totpLimiter, userBucket))) {
|
|
881
|
+
return errorResponse('Too many TOTP attempts. Please try again later.', 429);
|
|
882
|
+
}
|
|
708
883
|
const { verifyTOTP } = await import('../auth/totp.js');
|
|
709
|
-
const user = await db().user.findUnique({
|
|
710
|
-
|
|
884
|
+
const user = await db().user.findUnique({
|
|
885
|
+
where: { id: pending.userId },
|
|
886
|
+
select: { id: true, email: true, name: true, role: true, totpSecret: true, totpEnabled: true, isActive: true },
|
|
887
|
+
});
|
|
888
|
+
if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret) {
|
|
711
889
|
return errorResponse('Invalid request', 400);
|
|
712
|
-
|
|
713
|
-
|
|
890
|
+
}
|
|
891
|
+
const decryptedSecret = await decryptSecret(user.totpSecret);
|
|
892
|
+
const valid = verifyTOTP(body.code, decryptedSecret);
|
|
893
|
+
if (!valid) {
|
|
894
|
+
await logEvent({
|
|
895
|
+
event: 'login_failed',
|
|
896
|
+
userId: user.id,
|
|
897
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
898
|
+
userAgent: userAgent ?? undefined,
|
|
899
|
+
details: { reason: 'totp_invalid' },
|
|
900
|
+
});
|
|
714
901
|
return errorResponse('Invalid code', 401);
|
|
902
|
+
}
|
|
903
|
+
const d = db();
|
|
904
|
+
await enforceSessionLimitsForUser(d, user.id);
|
|
715
905
|
const tempSessionId = crypto.randomUUID();
|
|
716
906
|
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
717
|
-
await
|
|
907
|
+
await d.session.create({
|
|
908
|
+
data: {
|
|
909
|
+
id: tempSessionId,
|
|
910
|
+
userId: user.id,
|
|
911
|
+
token,
|
|
912
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
913
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
914
|
+
userAgent: userAgent ?? null,
|
|
915
|
+
},
|
|
916
|
+
});
|
|
917
|
+
await logEvent({
|
|
918
|
+
event: 'login_success',
|
|
919
|
+
userId: user.id,
|
|
920
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
921
|
+
userAgent: userAgent ?? undefined,
|
|
922
|
+
details: { mfa: 'totp' },
|
|
923
|
+
});
|
|
718
924
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
719
|
-
const sessionCookie = [
|
|
720
|
-
|
|
925
|
+
const sessionCookie = [
|
|
926
|
+
`actuate_session=${token}`,
|
|
927
|
+
'Path=/',
|
|
928
|
+
'HttpOnly',
|
|
929
|
+
'SameSite=Lax',
|
|
930
|
+
`Max-Age=${7 * 24 * 3600}`,
|
|
931
|
+
...(isProduction ? ['Secure'] : []),
|
|
932
|
+
].join('; ');
|
|
933
|
+
const csrfToken = await generateCsrfToken();
|
|
934
|
+
const csrfCookie = [
|
|
935
|
+
`actuate_csrf=${csrfToken}`,
|
|
936
|
+
'Path=/',
|
|
937
|
+
'SameSite=Lax',
|
|
938
|
+
'Max-Age=86400',
|
|
939
|
+
...(isProduction ? ['Secure'] : []),
|
|
940
|
+
].join('; ');
|
|
941
|
+
const response = new Response(JSON.stringify({
|
|
942
|
+
data: {
|
|
943
|
+
token,
|
|
944
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
945
|
+
},
|
|
946
|
+
}), { status: 200, headers: { ...SECURITY_HEADERS } });
|
|
947
|
+
response.headers.append('Set-Cookie', sessionCookie);
|
|
948
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
949
|
+
return response;
|
|
721
950
|
}
|
|
722
951
|
catch (err) {
|
|
723
952
|
return internalError(err);
|
|
@@ -748,9 +977,25 @@ export function registerCMSRoutes(router) {
|
|
|
748
977
|
};
|
|
749
978
|
const codeVerifier = generateCodeVerifier();
|
|
750
979
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
751
|
-
|
|
980
|
+
// Generate a one-time nonce, embed it in the signed state, and also set
|
|
981
|
+
// it on a host-only cookie. The callback will require both to match —
|
|
982
|
+
// this binds the OAuth flow to the browser that started it and prevents
|
|
983
|
+
// an attacker from delivering a state token to a victim's browser.
|
|
984
|
+
const nonce = generateOAuthNonce();
|
|
985
|
+
const state = await generateState(provider, codeVerifier, getAdminPath(), secret, nonce);
|
|
752
986
|
const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
|
|
753
|
-
|
|
987
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
988
|
+
const nonceCookie = [
|
|
989
|
+
`actuate_oauth_nonce=${nonce}`,
|
|
990
|
+
'Path=/',
|
|
991
|
+
'HttpOnly',
|
|
992
|
+
'SameSite=Lax',
|
|
993
|
+
'Max-Age=600',
|
|
994
|
+
...(isProduction ? ['Secure'] : []),
|
|
995
|
+
].join('; ');
|
|
996
|
+
const response = json({ data: { url } });
|
|
997
|
+
response.headers.append('Set-Cookie', nonceCookie);
|
|
998
|
+
return response;
|
|
754
999
|
}
|
|
755
1000
|
catch (err) {
|
|
756
1001
|
return internalError(err);
|
|
@@ -785,31 +1030,39 @@ export function registerCMSRoutes(router) {
|
|
|
785
1030
|
clientSecret: process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '',
|
|
786
1031
|
redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
|
|
787
1032
|
};
|
|
788
|
-
const
|
|
789
|
-
const
|
|
1033
|
+
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
1034
|
+
const expectedNonce = cookies['actuate_oauth_nonce'] ?? null;
|
|
1035
|
+
const cmsConfig = globalThis.__actuateConfig;
|
|
1036
|
+
const allowSelfSignup = cmsConfig?.auth?.oauth?.allowSelfSignup === true;
|
|
1037
|
+
const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db(), { expectedNonce, allowSelfSignup });
|
|
1038
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
1039
|
+
const sessionCookieFlags = [
|
|
790
1040
|
`actuate_session=${result.token}`,
|
|
791
1041
|
'Path=/',
|
|
792
1042
|
'HttpOnly',
|
|
793
1043
|
'SameSite=Lax',
|
|
794
1044
|
'Max-Age=604800',
|
|
795
1045
|
];
|
|
796
|
-
if (siteUrl.startsWith('https')) {
|
|
797
|
-
|
|
1046
|
+
if (siteUrl.startsWith('https') || isProduction) {
|
|
1047
|
+
sessionCookieFlags.push('Secure');
|
|
798
1048
|
}
|
|
799
|
-
|
|
1049
|
+
const response = new Response(null, {
|
|
800
1050
|
status: 302,
|
|
801
|
-
headers: {
|
|
802
|
-
Location: getAdminPath(),
|
|
803
|
-
'Set-Cookie': cookieFlags.join('; '),
|
|
804
|
-
},
|
|
1051
|
+
headers: { Location: getAdminPath() },
|
|
805
1052
|
});
|
|
1053
|
+
response.headers.append('Set-Cookie', sessionCookieFlags.join('; '));
|
|
1054
|
+
// Clear the one-time nonce cookie regardless of outcome.
|
|
1055
|
+
response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
1056
|
+
return response;
|
|
806
1057
|
}
|
|
807
1058
|
catch (err) {
|
|
808
1059
|
const message = err instanceof Error ? err.message : 'OAuth callback failed';
|
|
809
|
-
|
|
1060
|
+
const response = new Response(null, {
|
|
810
1061
|
status: 302,
|
|
811
1062
|
headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(message)}` },
|
|
812
1063
|
});
|
|
1064
|
+
response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
1065
|
+
return response;
|
|
813
1066
|
}
|
|
814
1067
|
});
|
|
815
1068
|
// ---------------------------------------------------------------------------
|
|
@@ -862,17 +1115,10 @@ export function registerCMSRoutes(router) {
|
|
|
862
1115
|
if (!doc) {
|
|
863
1116
|
return errorResponse('Document not found', 404);
|
|
864
1117
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
return json({
|
|
870
|
-
data: {
|
|
871
|
-
...doc,
|
|
872
|
-
data: await applyFieldAccess('read', fields, doc.data, user),
|
|
873
|
-
},
|
|
874
|
-
});
|
|
875
|
-
}
|
|
1118
|
+
// `getDocument` already lifts `_layout` / `_pageSettings` to the
|
|
1119
|
+
// top-level page builder envelope and strips internal keys from
|
|
1120
|
+
// `data`. Field-level access has been applied as well — we don't
|
|
1121
|
+
// need to re-apply it here.
|
|
876
1122
|
return json({ data: doc });
|
|
877
1123
|
}
|
|
878
1124
|
catch (err) {
|
|
@@ -984,6 +1230,12 @@ export function registerCMSRoutes(router) {
|
|
|
984
1230
|
return internalError(err);
|
|
985
1231
|
}
|
|
986
1232
|
});
|
|
1233
|
+
// The /media/presign endpoint returns advisory upload metadata; the actual
|
|
1234
|
+
// upload still goes through /media/upload (which performs validation).
|
|
1235
|
+
// Returning a presigned URL pointing at our own non-presigned endpoint was
|
|
1236
|
+
// misleading, so we deprecated this in favour of just using /media/upload
|
|
1237
|
+
// directly. We keep the route for backward compatibility but it now just
|
|
1238
|
+
// returns a hint to use the upload endpoint.
|
|
987
1239
|
router.post('/media/presign', async (request) => {
|
|
988
1240
|
try {
|
|
989
1241
|
const auth = await requireAuth(request);
|
|
@@ -993,12 +1245,13 @@ export function registerCMSRoutes(router) {
|
|
|
993
1245
|
if (!body.filename || !body.contentType) {
|
|
994
1246
|
return errorResponse('filename and contentType are required', 400);
|
|
995
1247
|
}
|
|
996
|
-
const storageKey = `actuate/media/${Date.now()}-${body.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
997
1248
|
return json({
|
|
998
1249
|
data: {
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1250
|
+
uploadUrl: '/api/cms/media/upload',
|
|
1251
|
+
method: 'POST',
|
|
1252
|
+
field: 'file',
|
|
1253
|
+
deprecated: true,
|
|
1254
|
+
message: 'Direct multipart upload to /media/upload is preferred; this endpoint will be removed in a future release.',
|
|
1002
1255
|
},
|
|
1003
1256
|
});
|
|
1004
1257
|
}
|
|
@@ -1007,6 +1260,26 @@ export function registerCMSRoutes(router) {
|
|
|
1007
1260
|
}
|
|
1008
1261
|
});
|
|
1009
1262
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
1263
|
+
/**
|
|
1264
|
+
* Allowlist of accepted upload mime types. Storing a file outside this set
|
|
1265
|
+
* would let an attacker upload e.g. `.html`/`.js` and serve it from the same
|
|
1266
|
+
* origin as the admin (cookie-leaking XSS), or `.svg` with embedded
|
|
1267
|
+
* `<script>` (also XSS). Add to this list deliberately.
|
|
1268
|
+
*/
|
|
1269
|
+
const ALLOWED_UPLOAD_MIME_TYPES = [
|
|
1270
|
+
'image/jpeg',
|
|
1271
|
+
'image/png',
|
|
1272
|
+
'image/gif',
|
|
1273
|
+
'image/webp',
|
|
1274
|
+
'image/avif',
|
|
1275
|
+
'image/svg+xml',
|
|
1276
|
+
'application/pdf',
|
|
1277
|
+
'video/mp4',
|
|
1278
|
+
'video/webm',
|
|
1279
|
+
'audio/mpeg',
|
|
1280
|
+
'audio/ogg',
|
|
1281
|
+
'audio/wav',
|
|
1282
|
+
];
|
|
1010
1283
|
router.post('/media/upload', async (request) => {
|
|
1011
1284
|
try {
|
|
1012
1285
|
const auth = await requireAuth(request);
|
|
@@ -1028,7 +1301,21 @@ export function registerCMSRoutes(router) {
|
|
|
1028
1301
|
if (originalSize > 50 * 1024 * 1024) {
|
|
1029
1302
|
return errorResponse('File exceeds maximum size of 50MB', 413);
|
|
1030
1303
|
}
|
|
1304
|
+
// 1. Block file types that aren't on our allowlist outright.
|
|
1305
|
+
if (!validateMimeType(contentType, ALLOWED_UPLOAD_MIME_TYPES)) {
|
|
1306
|
+
return errorResponse(`Unsupported file type "${contentType || 'unknown'}". Allowed types: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`, 415);
|
|
1307
|
+
}
|
|
1031
1308
|
const arrayBuffer = await file.arrayBuffer();
|
|
1309
|
+
// 2. Verify the file's actual bytes match the claimed mime type. Without
|
|
1310
|
+
// this, an attacker can upload a `.exe` with `Content-Type: image/png`
|
|
1311
|
+
// and have it served from our origin.
|
|
1312
|
+
const magicCheck = checkMagicBytes(arrayBuffer, contentType);
|
|
1313
|
+
if (!magicCheck.valid) {
|
|
1314
|
+
return errorResponse(`File contents do not match declared type "${contentType}".`, 415);
|
|
1315
|
+
}
|
|
1316
|
+
// 3. SVGs need an extra pass — even when the mime type is correct, the
|
|
1317
|
+
// XML body can contain `<script>` or event-handler attributes that
|
|
1318
|
+
// execute when an admin previews the file. Sanitize before storing.
|
|
1032
1319
|
let uploadBuffer;
|
|
1033
1320
|
let finalFilename = originalFilename;
|
|
1034
1321
|
let finalMimeType = contentType;
|
|
@@ -1037,7 +1324,17 @@ export function registerCMSRoutes(router) {
|
|
|
1037
1324
|
let height = null;
|
|
1038
1325
|
let blurHash = null;
|
|
1039
1326
|
let savings = 0;
|
|
1040
|
-
if (
|
|
1327
|
+
if (contentType === 'image/svg+xml') {
|
|
1328
|
+
// Strip <script>, on*, javascript: URLs, foreignObject, etc.
|
|
1329
|
+
const xml = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer);
|
|
1330
|
+
const sanitized = sanitizeHtml(xml, {
|
|
1331
|
+
allowedTags: ['svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polygon', 'polyline', 'rect', 'text', 'tspan', 'defs', 'use', 'symbol', 'title', 'desc', 'style', 'linearGradient', 'radialGradient', 'stop', 'mask', 'clipPath', 'pattern', 'filter', 'feGaussianBlur', 'feColorMatrix', 'feOffset', 'feBlend', 'feFlood', 'feComposite', 'feMerge', 'feMergeNode'],
|
|
1332
|
+
allowedAttributes: { '*': ['id', 'class', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'transform', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'width', 'height', 'viewBox', 'xmlns', 'xmlns:xlink', 'preserveAspectRatio', 'points', 'points', 'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'href', 'xlink:href', 'fill-rule', 'clip-rule', 'mask', 'clip-path', 'filter', 'patternUnits', 'patternContentUnits', 'in', 'in2', 'result', 'mode', 'values', 'type', 'stdDeviation', 'dx', 'dy'] },
|
|
1333
|
+
});
|
|
1334
|
+
uploadBuffer = Buffer.from(sanitized, 'utf-8');
|
|
1335
|
+
finalSize = uploadBuffer.byteLength;
|
|
1336
|
+
}
|
|
1337
|
+
else if (!skipOptimize && contentType.startsWith('image/')) {
|
|
1041
1338
|
const result = await optimizeImage(arrayBuffer, originalFilename, contentType);
|
|
1042
1339
|
uploadBuffer = result.buffer;
|
|
1043
1340
|
finalFilename = result.filename;
|
|
@@ -1054,16 +1351,35 @@ export function registerCMSRoutes(router) {
|
|
|
1054
1351
|
const sanitizedName = finalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1055
1352
|
const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
|
|
1056
1353
|
let publicUrl = '';
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1354
|
+
// Prefer the configured platform storage adapter (e.g. platform-vercel,
|
|
1355
|
+
// platform-aws, or a consumer-provided one). Falling through to
|
|
1356
|
+
// @vercel/blob via dynamic import preserves the legacy behavior for
|
|
1357
|
+
// installs that haven't wired up a platform package yet.
|
|
1358
|
+
const { getStorageAdapter } = await import('../storage/index.js');
|
|
1359
|
+
const storage = getStorageAdapter();
|
|
1360
|
+
if (storage) {
|
|
1361
|
+
try {
|
|
1362
|
+
publicUrl = await storage.upload(storageKey, uploadBuffer, finalMimeType);
|
|
1363
|
+
}
|
|
1364
|
+
catch (err) {
|
|
1365
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
1366
|
+
console.error('[Actuate CMS] Storage adapter upload failed:', err);
|
|
1367
|
+
}
|
|
1368
|
+
return errorResponse('Storage upload failed', 500);
|
|
1369
|
+
}
|
|
1064
1370
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1371
|
+
else {
|
|
1372
|
+
try {
|
|
1373
|
+
const blob = await importBlobStorage();
|
|
1374
|
+
const result = await blob.put(storageKey, uploadBuffer, {
|
|
1375
|
+
access: 'public',
|
|
1376
|
+
contentType: finalMimeType,
|
|
1377
|
+
});
|
|
1378
|
+
publicUrl = result.url;
|
|
1379
|
+
}
|
|
1380
|
+
catch {
|
|
1381
|
+
publicUrl = `/api/cms/media/file/${storageKey}`;
|
|
1382
|
+
}
|
|
1067
1383
|
}
|
|
1068
1384
|
const media = await db().media.create({
|
|
1069
1385
|
data: {
|
|
@@ -1643,6 +1959,10 @@ export function registerCMSRoutes(router) {
|
|
|
1643
1959
|
if (!q)
|
|
1644
1960
|
return json({ data: { documents: [], media: [], users: [] } });
|
|
1645
1961
|
const d = db();
|
|
1962
|
+
// Documents and media are visible to all signed-in users; the user
|
|
1963
|
+
// directory is gated to EDITOR+ so a CLIENT can't enumerate the team
|
|
1964
|
+
// (or harvest emails for spear-phishing).
|
|
1965
|
+
const canSeeUserDirectory = auth.session.role === 'ADMIN' || auth.session.role === 'EDITOR';
|
|
1646
1966
|
const [documents, media, users] = await Promise.all([
|
|
1647
1967
|
safeFindMany(d.document, {
|
|
1648
1968
|
where: { deletedAt: null, OR: [{ title: { contains: q, mode: 'insensitive' } }, { plainText: { contains: q, mode: 'insensitive' } }] },
|
|
@@ -1656,11 +1976,19 @@ export function registerCMSRoutes(router) {
|
|
|
1656
1976
|
orderBy: { createdAt: 'desc' },
|
|
1657
1977
|
select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
|
|
1658
1978
|
}),
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1979
|
+
canSeeUserDirectory
|
|
1980
|
+
? safeFindMany(d.user, {
|
|
1981
|
+
where: {
|
|
1982
|
+
isActive: true,
|
|
1983
|
+
OR: [
|
|
1984
|
+
{ name: { contains: q, mode: 'insensitive' } },
|
|
1985
|
+
{ email: { contains: q, mode: 'insensitive' } },
|
|
1986
|
+
],
|
|
1987
|
+
},
|
|
1988
|
+
take: 5,
|
|
1989
|
+
select: { id: true, name: true, email: true, role: true },
|
|
1990
|
+
})
|
|
1991
|
+
: Promise.resolve([]),
|
|
1664
1992
|
]);
|
|
1665
1993
|
return json({ data: { documents, media, users } });
|
|
1666
1994
|
}
|
|
@@ -1729,17 +2057,37 @@ export function registerCMSRoutes(router) {
|
|
|
1729
2057
|
const auth = await requireAuth(request);
|
|
1730
2058
|
if (auth.error)
|
|
1731
2059
|
return auth.error;
|
|
2060
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2061
|
+
if (roleErr)
|
|
2062
|
+
return roleErr;
|
|
1732
2063
|
const d = db();
|
|
1733
2064
|
const forms = await d.document.findMany({
|
|
1734
2065
|
where: { collection: 'forms', deletedAt: null },
|
|
1735
2066
|
orderBy: { createdAt: 'desc' },
|
|
1736
2067
|
});
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2068
|
+
// Single grouped count is far cheaper than N+1 count() per form once
|
|
2069
|
+
// the dashboard grows past a handful of forms. Fall back to per-form
|
|
2070
|
+
// .count() when the consumer's Prisma client doesn't expose groupBy
|
|
2071
|
+
// (older schemas, custom adapters).
|
|
2072
|
+
let submissionCounts = new Map();
|
|
2073
|
+
if (hasModel(d, 'formSubmission') && forms.length > 0) {
|
|
2074
|
+
const ids = forms.map((f) => f.id);
|
|
2075
|
+
if (typeof d.formSubmission.groupBy === 'function') {
|
|
2076
|
+
const grouped = await d.formSubmission.groupBy({
|
|
2077
|
+
by: ['formId'],
|
|
2078
|
+
where: { formId: { in: ids } },
|
|
2079
|
+
_count: { _all: true },
|
|
2080
|
+
});
|
|
2081
|
+
submissionCounts = new Map(grouped.map((g) => [g.formId, g._count._all]));
|
|
2082
|
+
}
|
|
2083
|
+
else {
|
|
2084
|
+
for (const id of ids) {
|
|
2085
|
+
const count = await d.formSubmission.count({ where: { formId: id } });
|
|
2086
|
+
submissionCounts.set(id, count);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
const normalized = forms.map((form) => normalizeFormDocument(form, submissionCounts.get(form.id) ?? 0));
|
|
1743
2091
|
return json({ data: normalized });
|
|
1744
2092
|
}
|
|
1745
2093
|
catch (err) {
|
|
@@ -1751,6 +2099,9 @@ export function registerCMSRoutes(router) {
|
|
|
1751
2099
|
const auth = await requireAuth(request);
|
|
1752
2100
|
if (auth.error)
|
|
1753
2101
|
return auth.error;
|
|
2102
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2103
|
+
if (roleErr)
|
|
2104
|
+
return roleErr;
|
|
1754
2105
|
const url = new URL(request.url);
|
|
1755
2106
|
const page = Number(url.searchParams.get('page')) || 1;
|
|
1756
2107
|
const pageSize = clampPageSize(url.searchParams.get('pageSize'));
|
|
@@ -1876,16 +2227,42 @@ export function registerCMSRoutes(router) {
|
|
|
1876
2227
|
if (!source || !destination) {
|
|
1877
2228
|
return errorResponse('source and destination are required', 400);
|
|
1878
2229
|
}
|
|
1879
|
-
|
|
2230
|
+
// Open-redirect defence: relative destinations (`/foo`) are always
|
|
2231
|
+
// allowed; absolute destinations must point at an explicitly trusted
|
|
2232
|
+
// host. We compare on parsed origins (not string `startsWith`) so
|
|
2233
|
+
// `https://attacker.com.example.com` no longer passes a `startsWith`
|
|
2234
|
+
// check on `https://example.com`. Configure the allowlist via
|
|
2235
|
+
// `redirects.allowedExternalHosts` (string[] of hostnames).
|
|
2236
|
+
if (destination.startsWith('http://') || destination.startsWith('https://')) {
|
|
2237
|
+
let destUrl;
|
|
1880
2238
|
try {
|
|
1881
|
-
|
|
1882
|
-
if (!['http:', 'https:'].includes(destUrl.protocol)) {
|
|
1883
|
-
return errorResponse('Invalid destination URL', 400);
|
|
1884
|
-
}
|
|
2239
|
+
destUrl = new URL(destination);
|
|
1885
2240
|
}
|
|
1886
2241
|
catch {
|
|
1887
2242
|
return errorResponse('Invalid destination URL', 400);
|
|
1888
2243
|
}
|
|
2244
|
+
if (!['http:', 'https:'].includes(destUrl.protocol)) {
|
|
2245
|
+
return errorResponse('Invalid destination URL', 400);
|
|
2246
|
+
}
|
|
2247
|
+
const cmsConfig = globalThis.__actuateConfig;
|
|
2248
|
+
const allowed = new Set([
|
|
2249
|
+
...(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
|
|
2250
|
+
? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
|
|
2251
|
+
: []),
|
|
2252
|
+
]);
|
|
2253
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
|
2254
|
+
if (siteUrl) {
|
|
2255
|
+
try {
|
|
2256
|
+
allowed.add(new URL(siteUrl).hostname.toLowerCase());
|
|
2257
|
+
}
|
|
2258
|
+
catch { /* noop */ }
|
|
2259
|
+
}
|
|
2260
|
+
if (!allowed.has(destUrl.hostname.toLowerCase())) {
|
|
2261
|
+
return errorResponse('External redirect destinations must be to an allowlisted host. Add the host to `redirects.allowedExternalHosts` in your CMS config.', 400);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
else if (!destination.startsWith('/')) {
|
|
2265
|
+
return errorResponse('Destination must be an absolute URL or a path beginning with /', 400);
|
|
1889
2266
|
}
|
|
1890
2267
|
const redirect = await db().redirect.create({
|
|
1891
2268
|
data: {
|
|
@@ -1946,48 +2323,96 @@ export function registerCMSRoutes(router) {
|
|
|
1946
2323
|
const auth = await requireAuth(request);
|
|
1947
2324
|
if (auth.error)
|
|
1948
2325
|
return auth.error;
|
|
2326
|
+
// EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
|
|
2327
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2328
|
+
if (roleErr)
|
|
2329
|
+
return roleErr;
|
|
2330
|
+
// Tight rate limit because each call fans out to many outbound requests.
|
|
2331
|
+
const ip = clientIp(request);
|
|
2332
|
+
const rateKey = isResolvedIp(ip)
|
|
2333
|
+
? `link-health:${ip}`
|
|
2334
|
+
: `link-health-user:${auth.session.userId}`;
|
|
2335
|
+
if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
|
|
2336
|
+
return errorResponse('Too many link-health scans. Please try again later.', 429);
|
|
2337
|
+
}
|
|
2338
|
+
const MAX_LINKS_PER_PAGE = 50;
|
|
2339
|
+
const MAX_TOTAL_LINKS = 500;
|
|
2340
|
+
const PER_LINK_TIMEOUT_MS = 4000;
|
|
2341
|
+
const CONCURRENCY = 8;
|
|
1949
2342
|
const docs = await db().document.findMany({
|
|
1950
2343
|
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
1951
2344
|
select: { id: true, title: true, data: true, collection: true },
|
|
1952
2345
|
});
|
|
1953
|
-
const linkResults = [];
|
|
1954
2346
|
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
|
|
1955
2347
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
|
|
1956
|
-
|
|
2348
|
+
const queue = [];
|
|
2349
|
+
const seenGlobal = new Set();
|
|
2350
|
+
outer: for (const doc of docs) {
|
|
1957
2351
|
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
1958
2352
|
const content = JSON.stringify(doc.data ?? {});
|
|
1959
2353
|
const urls = content.match(urlRegex) ?? [];
|
|
1960
|
-
const
|
|
2354
|
+
const seenInPage = new Set();
|
|
2355
|
+
let countInPage = 0;
|
|
1961
2356
|
for (const url of urls) {
|
|
1962
2357
|
const clean = url.replace(/[",;)}\]]+$/, '');
|
|
1963
|
-
if (
|
|
2358
|
+
if (seenInPage.has(clean))
|
|
2359
|
+
continue;
|
|
2360
|
+
seenInPage.add(clean);
|
|
2361
|
+
if (seenGlobal.has(clean))
|
|
1964
2362
|
continue;
|
|
1965
|
-
|
|
1966
|
-
|
|
2363
|
+
seenGlobal.add(clean);
|
|
2364
|
+
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
2365
|
+
break;
|
|
2366
|
+
if (queue.length >= MAX_TOTAL_LINKS)
|
|
2367
|
+
break outer;
|
|
2368
|
+
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
2369
|
+
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
2370
|
+
countInPage++;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
const linkResults = [];
|
|
2374
|
+
let cursor = 0;
|
|
2375
|
+
const worker = async () => {
|
|
2376
|
+
while (cursor < queue.length) {
|
|
2377
|
+
const idx = cursor++;
|
|
2378
|
+
const job = queue[idx];
|
|
2379
|
+
let status = 0;
|
|
1967
2380
|
try {
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2381
|
+
// safeFetch rejects private/loopback IPs and disables redirect
|
|
2382
|
+
// following so 302->internal can't smuggle the scanner past SSRF.
|
|
2383
|
+
const resp = await safeFetch(job.clean, {
|
|
2384
|
+
method: 'HEAD',
|
|
2385
|
+
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
2386
|
+
});
|
|
2387
|
+
status = resp.status;
|
|
2388
|
+
// Drain the body so the connection can be reused.
|
|
2389
|
+
try {
|
|
2390
|
+
await resp.body?.cancel();
|
|
1977
2391
|
}
|
|
2392
|
+
catch { /* noop */ }
|
|
2393
|
+
}
|
|
2394
|
+
catch (err) {
|
|
2395
|
+
status = err instanceof SsrfBlockedError ? -1 : 0;
|
|
1978
2396
|
}
|
|
1979
|
-
|
|
2397
|
+
if (status === -1 || status === 0 || status >= 300) {
|
|
1980
2398
|
linkResults.push({
|
|
1981
|
-
id: `${
|
|
1982
|
-
page: pageTitle,
|
|
1983
|
-
url: clean,
|
|
1984
|
-
status: 0,
|
|
1985
|
-
type: isInternal ? 'internal' : 'external',
|
|
2399
|
+
id: `${job.docId}-${idx}`,
|
|
2400
|
+
page: job.pageTitle,
|
|
2401
|
+
url: job.clean,
|
|
2402
|
+
status: status === -1 ? 0 : status,
|
|
2403
|
+
type: job.isInternal ? 'internal' : 'external',
|
|
1986
2404
|
});
|
|
1987
2405
|
}
|
|
1988
2406
|
}
|
|
1989
|
-
}
|
|
1990
|
-
|
|
2407
|
+
};
|
|
2408
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
2409
|
+
return json({
|
|
2410
|
+
data: {
|
|
2411
|
+
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
2412
|
+
checked: queue.length,
|
|
2413
|
+
issues: linkResults,
|
|
2414
|
+
},
|
|
2415
|
+
});
|
|
1991
2416
|
}
|
|
1992
2417
|
catch (err) {
|
|
1993
2418
|
return internalError(err);
|
|
@@ -2047,7 +2472,12 @@ export function registerCMSRoutes(router) {
|
|
|
2047
2472
|
// ---------------------------------------------------------------------------
|
|
2048
2473
|
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
2049
2474
|
try {
|
|
2050
|
-
const
|
|
2475
|
+
const auth = await requireAuth(request);
|
|
2476
|
+
if (auth.error)
|
|
2477
|
+
return auth.error;
|
|
2478
|
+
const doc = await db().document.findFirst({
|
|
2479
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2480
|
+
});
|
|
2051
2481
|
if (!doc)
|
|
2052
2482
|
return errorResponse('Not found', 404);
|
|
2053
2483
|
const { analyzeContent } = await import('../seo/analysis.js');
|
|
@@ -2076,7 +2506,12 @@ export function registerCMSRoutes(router) {
|
|
|
2076
2506
|
});
|
|
2077
2507
|
router.get('/seo/readability/:documentId', async (request, params) => {
|
|
2078
2508
|
try {
|
|
2079
|
-
const
|
|
2509
|
+
const auth = await requireAuth(request);
|
|
2510
|
+
if (auth.error)
|
|
2511
|
+
return auth.error;
|
|
2512
|
+
const doc = await db().document.findFirst({
|
|
2513
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2514
|
+
});
|
|
2080
2515
|
if (!doc)
|
|
2081
2516
|
return errorResponse('Not found', 404);
|
|
2082
2517
|
const { calculateReadability, stripHtmlTags } = await import('../seo/analysis.js');
|
|
@@ -2094,7 +2529,12 @@ export function registerCMSRoutes(router) {
|
|
|
2094
2529
|
});
|
|
2095
2530
|
router.get('/seo/internal-links/:documentId', async (request, params) => {
|
|
2096
2531
|
try {
|
|
2097
|
-
const
|
|
2532
|
+
const auth = await requireAuth(request);
|
|
2533
|
+
if (auth.error)
|
|
2534
|
+
return auth.error;
|
|
2535
|
+
const doc = await db().document.findFirst({
|
|
2536
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2537
|
+
});
|
|
2098
2538
|
if (!doc)
|
|
2099
2539
|
return errorResponse('Not found', 404);
|
|
2100
2540
|
const data = doc.data || {};
|
|
@@ -2110,6 +2550,7 @@ export function registerCMSRoutes(router) {
|
|
|
2110
2550
|
where: {
|
|
2111
2551
|
id: { not: params.documentId },
|
|
2112
2552
|
status: 'PUBLISHED',
|
|
2553
|
+
deletedAt: null,
|
|
2113
2554
|
OR: keywords.map((kw) => ({
|
|
2114
2555
|
title: { contains: kw, mode: 'insensitive' },
|
|
2115
2556
|
})),
|
|
@@ -2165,7 +2606,12 @@ export function registerCMSRoutes(router) {
|
|
|
2165
2606
|
});
|
|
2166
2607
|
router.get('/seo/schema/:documentId', async (request, params) => {
|
|
2167
2608
|
try {
|
|
2168
|
-
const
|
|
2609
|
+
const auth = await requireAuth(request);
|
|
2610
|
+
if (auth.error)
|
|
2611
|
+
return auth.error;
|
|
2612
|
+
const doc = await db().document.findFirst({
|
|
2613
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2614
|
+
});
|
|
2169
2615
|
if (!doc)
|
|
2170
2616
|
return errorResponse('Not found', 404);
|
|
2171
2617
|
const { buildSchemaGraph } = await import('../content/structured-data.js');
|
|
@@ -2194,7 +2640,12 @@ export function registerCMSRoutes(router) {
|
|
|
2194
2640
|
});
|
|
2195
2641
|
router.get('/seo/meta/:documentId', async (request, params) => {
|
|
2196
2642
|
try {
|
|
2197
|
-
const
|
|
2643
|
+
const auth = await requireAuth(request);
|
|
2644
|
+
if (auth.error)
|
|
2645
|
+
return auth.error;
|
|
2646
|
+
const doc = await db().document.findFirst({
|
|
2647
|
+
where: { id: params.documentId, deletedAt: null },
|
|
2648
|
+
});
|
|
2198
2649
|
if (!doc)
|
|
2199
2650
|
return errorResponse('Not found', 404);
|
|
2200
2651
|
const { generateMetaTags } = await import('../seo/meta-tags.js');
|
|
@@ -2531,6 +2982,17 @@ export function registerCMSRoutes(router) {
|
|
|
2531
2982
|
const body = await request.json();
|
|
2532
2983
|
if (!body.name || !body.scope)
|
|
2533
2984
|
return errorResponse('name and scope are required', 400);
|
|
2985
|
+
// A child folder MUST live in the same scope as its parent — without
|
|
2986
|
+
// this check, a 'documents' folder could be reparented under a 'media'
|
|
2987
|
+
// folder, hiding it from both UIs.
|
|
2988
|
+
if (body.parentId) {
|
|
2989
|
+
const parent = await db().folder.findUnique({ where: { id: body.parentId } });
|
|
2990
|
+
if (!parent)
|
|
2991
|
+
return errorResponse('Parent folder not found', 404);
|
|
2992
|
+
if (parent.scope !== body.scope) {
|
|
2993
|
+
return errorResponse('Parent folder is in a different scope', 400);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2534
2996
|
const folder = await db().folder.create({
|
|
2535
2997
|
data: {
|
|
2536
2998
|
name: body.name,
|
|
@@ -2553,6 +3015,22 @@ export function registerCMSRoutes(router) {
|
|
|
2553
3015
|
if (roleErr)
|
|
2554
3016
|
return roleErr;
|
|
2555
3017
|
const body = await request.json();
|
|
3018
|
+
const existing = await db().folder.findUnique({ where: { id: params.id } });
|
|
3019
|
+
if (!existing)
|
|
3020
|
+
return errorResponse('Folder not found', 404);
|
|
3021
|
+
// Prevent reparenting into a different scope, and prevent making a folder
|
|
3022
|
+
// a descendant of itself (which would create a cycle).
|
|
3023
|
+
if (body.parentId !== undefined && body.parentId !== null) {
|
|
3024
|
+
if (body.parentId === params.id) {
|
|
3025
|
+
return errorResponse('Folder cannot be its own parent', 400);
|
|
3026
|
+
}
|
|
3027
|
+
const parent = await db().folder.findUnique({ where: { id: body.parentId } });
|
|
3028
|
+
if (!parent)
|
|
3029
|
+
return errorResponse('Parent folder not found', 404);
|
|
3030
|
+
if (parent.scope !== existing.scope) {
|
|
3031
|
+
return errorResponse('Parent folder is in a different scope', 400);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
2556
3034
|
const data = {};
|
|
2557
3035
|
if (body.name !== undefined)
|
|
2558
3036
|
data.name = body.name;
|
|
@@ -2578,7 +3056,22 @@ export function registerCMSRoutes(router) {
|
|
|
2578
3056
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2579
3057
|
if (roleErr)
|
|
2580
3058
|
return roleErr;
|
|
2581
|
-
|
|
3059
|
+
// We don't cascade-delete the contents — they would silently vanish.
|
|
3060
|
+
// Instead, refuse to delete a folder that still has documents, media,
|
|
3061
|
+
// or sub-folders inside it.
|
|
3062
|
+
const d = db();
|
|
3063
|
+
const folder = await d.folder.findUnique({ where: { id: params.id } });
|
|
3064
|
+
if (!folder)
|
|
3065
|
+
return errorResponse('Folder not found', 404);
|
|
3066
|
+
const [docCount, mediaCount, childCount] = await Promise.all([
|
|
3067
|
+
hasModel(d, 'document') ? d.document.count({ where: { folderId: params.id, deletedAt: null } }) : 0,
|
|
3068
|
+
hasModel(d, 'media') ? d.media.count({ where: { folderId: params.id } }) : 0,
|
|
3069
|
+
d.folder.count({ where: { parentId: params.id } }),
|
|
3070
|
+
]);
|
|
3071
|
+
if (docCount + mediaCount + childCount > 0) {
|
|
3072
|
+
return errorResponse(`Folder is not empty (${docCount} documents, ${mediaCount} media, ${childCount} sub-folders). Move or delete its contents first.`, 409);
|
|
3073
|
+
}
|
|
3074
|
+
await d.folder.delete({ where: { id: params.id } });
|
|
2582
3075
|
return json({ data: { success: true } });
|
|
2583
3076
|
}
|
|
2584
3077
|
catch (err) {
|
|
@@ -2594,6 +3087,15 @@ export function registerCMSRoutes(router) {
|
|
|
2594
3087
|
if (roleErr)
|
|
2595
3088
|
return roleErr;
|
|
2596
3089
|
const body = await request.json();
|
|
3090
|
+
// Confirm the target folder is in the documents scope before moving in.
|
|
3091
|
+
if (body.folderId) {
|
|
3092
|
+
const target = await db().folder.findUnique({ where: { id: body.folderId } });
|
|
3093
|
+
if (!target)
|
|
3094
|
+
return errorResponse('Folder not found', 404);
|
|
3095
|
+
if (target.scope !== 'documents' && target.scope !== 'collections') {
|
|
3096
|
+
return errorResponse('Target folder is not a documents folder', 400);
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
2597
3099
|
await db().document.update({
|
|
2598
3100
|
where: { id: params.id },
|
|
2599
3101
|
data: { folderId: body.folderId ?? null },
|
|
@@ -2613,6 +3115,14 @@ export function registerCMSRoutes(router) {
|
|
|
2613
3115
|
if (roleErr)
|
|
2614
3116
|
return roleErr;
|
|
2615
3117
|
const body = await request.json();
|
|
3118
|
+
if (body.folderId) {
|
|
3119
|
+
const target = await db().folder.findUnique({ where: { id: body.folderId } });
|
|
3120
|
+
if (!target)
|
|
3121
|
+
return errorResponse('Folder not found', 404);
|
|
3122
|
+
if (target.scope !== 'media') {
|
|
3123
|
+
return errorResponse('Target folder is not a media folder', 400);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
2616
3126
|
await db().media.update({
|
|
2617
3127
|
where: { id: params.id },
|
|
2618
3128
|
data: { folderId: body.folderId ?? null },
|
|
@@ -2821,10 +3331,14 @@ export function registerCMSRoutes(router) {
|
|
|
2821
3331
|
if (!doc) {
|
|
2822
3332
|
return errorResponse('Global not found', 404);
|
|
2823
3333
|
}
|
|
3334
|
+
// Globals routed through `/public/globals/:slug` are by definition
|
|
3335
|
+
// public site data — when no `access.read` is set we default to
|
|
3336
|
+
// allowed. Integrators that want to gate a global must set
|
|
3337
|
+
// `access.read` explicitly (returning `false` for public).
|
|
2824
3338
|
const readAccess = globalConfig.access?.read;
|
|
2825
3339
|
const allowed = readAccess
|
|
2826
3340
|
? await readAccess({ user: null, doc })
|
|
2827
|
-
:
|
|
3341
|
+
: true;
|
|
2828
3342
|
if (!allowed) {
|
|
2829
3343
|
return errorResponse('Forbidden', 403);
|
|
2830
3344
|
}
|
|
@@ -3040,7 +3554,15 @@ export function registerCMSRoutes(router) {
|
|
|
3040
3554
|
bucket.push(tag.code);
|
|
3041
3555
|
}
|
|
3042
3556
|
}
|
|
3043
|
-
|
|
3557
|
+
// Public endpoint that fans out to many page renders. Add a short
|
|
3558
|
+
// edge cache so it doesn't become a per-request DB hit.
|
|
3559
|
+
const response = new Response(JSON.stringify(grouped), {
|
|
3560
|
+
status: 200,
|
|
3561
|
+
headers: { ...SECURITY_HEADERS, 'Content-Type': 'application/json' },
|
|
3562
|
+
});
|
|
3563
|
+
response.headers.set('Cache-Control', 'public, max-age=60, s-maxage=60, stale-while-revalidate=300');
|
|
3564
|
+
response.headers.set('Vary', 'path');
|
|
3565
|
+
return response;
|
|
3044
3566
|
}
|
|
3045
3567
|
catch (err) {
|
|
3046
3568
|
return internalError(err, 'script-tags/resolve');
|
|
@@ -3621,6 +4143,11 @@ export function registerCMSRoutes(router) {
|
|
|
3621
4143
|
}
|
|
3622
4144
|
});
|
|
3623
4145
|
// ─── Page Builder AI Generation ─────────────────────────────────────
|
|
4146
|
+
// Hard caps for AI input to keep token cost bounded and to limit the
|
|
4147
|
+
// surface area for prompt injection. Adjust via the `ai.limits` block in
|
|
4148
|
+
// the CMS config if you want stricter values; never raise past 8k.
|
|
4149
|
+
const AI_PROMPT_MAX_CHARS = 4000;
|
|
4150
|
+
const AI_CONTEXT_MAX_CHARS = 8000;
|
|
3624
4151
|
router.post('/page-builder/generate', async (request) => {
|
|
3625
4152
|
try {
|
|
3626
4153
|
const auth = await requireAuth(request);
|
|
@@ -3629,11 +4156,23 @@ export function registerCMSRoutes(router) {
|
|
|
3629
4156
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3630
4157
|
if (roleErr)
|
|
3631
4158
|
return roleErr;
|
|
4159
|
+
// Per-user rate limit. AI generation is the single most expensive
|
|
4160
|
+
// operation in the CMS — without this, a compromised admin account
|
|
4161
|
+
// can drain a provider key in minutes.
|
|
4162
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
|
|
4163
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4164
|
+
}
|
|
3632
4165
|
const body = await request.json();
|
|
3633
4166
|
const { prompt, template, context, steps, tone } = body;
|
|
3634
4167
|
if (!prompt || typeof prompt !== 'string') {
|
|
3635
4168
|
return errorResponse('prompt is required', 400);
|
|
3636
4169
|
}
|
|
4170
|
+
if (prompt.length > AI_PROMPT_MAX_CHARS) {
|
|
4171
|
+
return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
|
|
4172
|
+
}
|
|
4173
|
+
if (typeof context === 'string' && context.length > AI_CONTEXT_MAX_CHARS) {
|
|
4174
|
+
return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
|
|
4175
|
+
}
|
|
3637
4176
|
if (!steps || !Array.isArray(steps) || steps.length === 0) {
|
|
3638
4177
|
return errorResponse('steps array is required', 400);
|
|
3639
4178
|
}
|
|
@@ -3646,7 +4185,15 @@ export function registerCMSRoutes(router) {
|
|
|
3646
4185
|
await logEvent({
|
|
3647
4186
|
event: 'settings_changed',
|
|
3648
4187
|
userId: auth.session.userId,
|
|
3649
|
-
details: {
|
|
4188
|
+
details: {
|
|
4189
|
+
action: 'page_generation_started',
|
|
4190
|
+
// Redact secrets from the prompt before persisting to the audit log.
|
|
4191
|
+
// Even an admin pasting a key into a prompt by mistake shouldn't
|
|
4192
|
+
// result in that key being mirrored into permanent storage.
|
|
4193
|
+
prompt: redactSecrets(prompt).slice(0, 500),
|
|
4194
|
+
steps,
|
|
4195
|
+
template,
|
|
4196
|
+
},
|
|
3650
4197
|
});
|
|
3651
4198
|
let generatePage = null;
|
|
3652
4199
|
try {
|
|
@@ -3681,11 +4228,21 @@ export function registerCMSRoutes(router) {
|
|
|
3681
4228
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3682
4229
|
if (roleErr)
|
|
3683
4230
|
return roleErr;
|
|
4231
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
|
|
4232
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4233
|
+
}
|
|
3684
4234
|
const body = await request.json();
|
|
3685
4235
|
const { blockType, variant, pageContext, tone } = body;
|
|
3686
4236
|
if (!blockType || typeof blockType !== 'string') {
|
|
3687
4237
|
return errorResponse('blockType is required', 400);
|
|
3688
4238
|
}
|
|
4239
|
+
// Limit caller-supplied context that flows directly into the prompt.
|
|
4240
|
+
if (pageContext) {
|
|
4241
|
+
const total = JSON.stringify(pageContext).length;
|
|
4242
|
+
if (total > AI_CONTEXT_MAX_CHARS) {
|
|
4243
|
+
return errorResponse(`pageContext exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
3689
4246
|
let generateBlockContent = null;
|
|
3690
4247
|
try {
|
|
3691
4248
|
const aiModule = await importAIPlugin();
|