@orion-studios/cms 0.5.5 → 0.5.7
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/README.md +21 -4
- package/dist/{chunk-LQTXMLKZ.js → chunk-CFZP7674.js} +21 -0
- package/dist/{chunk-AYV6KYDP.js → chunk-NSAZCP4I.js} +20 -1
- package/dist/{chunk-2BCEB26C.js → chunk-ULE565KD.js} +27 -0
- package/dist/content/index.js +1 -1
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.js +5 -1
- package/dist/forms/react.d.ts +1 -1
- package/dist/forms/react.js +1 -1
- package/dist/server/index.d.ts +94 -19
- package/dist/server/index.js +803 -152
- package/dist/studio/index.d.ts +1 -2
- package/dist/studio/index.js +64 -10
- package/dist/{submission-D_x2qNJl.d.ts → submission-CGz0lElf.d.ts} +10 -1
- package/dist/{submission-CzrfXu17.d.ts → submission-CKZgx1h7.d.ts} +2 -0
- package/package.json +1 -1
- package/sql/bootstrap.sql +313 -6
- package/sql/migrations/20260829032027_cms_security_and_sync_contracts.sql +390 -0
package/dist/server/index.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTENT_CACHE_TAG
|
|
3
|
+
} from "../chunk-NSAZCP4I.js";
|
|
1
4
|
import {
|
|
2
5
|
createMemoryRateLimitStore,
|
|
6
|
+
getAutoReplyEmailFields,
|
|
3
7
|
isOriginAllowed,
|
|
4
|
-
processSubmission
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
CONTENT_CACHE_TAG
|
|
8
|
-
} from "../chunk-AYV6KYDP.js";
|
|
8
|
+
processSubmission,
|
|
9
|
+
resolveAutoReplyEmailField
|
|
10
|
+
} from "../chunk-CFZP7674.js";
|
|
9
11
|
|
|
10
12
|
// src/server/routes.ts
|
|
11
|
-
import { createHash as
|
|
13
|
+
import { createHash as createHash3, createHmac as createHmac3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
12
14
|
|
|
13
15
|
// src/analytics/aggregate.ts
|
|
14
16
|
var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
|
|
@@ -340,12 +342,16 @@ ${rendered}
|
|
|
340
342
|
}
|
|
341
343
|
return lines.join("\n");
|
|
342
344
|
}
|
|
345
|
+
function resolveSubmitterEmail(config, data) {
|
|
346
|
+
const fieldName = resolveAutoReplyEmailField(config);
|
|
347
|
+
if (!fieldName) return void 0;
|
|
348
|
+
const value = data[fieldName];
|
|
349
|
+
return isEmail(value) ? value.trim().toLowerCase() : void 0;
|
|
350
|
+
}
|
|
343
351
|
async function notifySubmission(args) {
|
|
344
352
|
const notify = args.config.notify || {};
|
|
345
353
|
const recipients = (notify.emails || []).filter(isEmail);
|
|
346
|
-
const submitterEmail =
|
|
347
|
-
([key, value]) => key.toLowerCase().includes("email") && isEmail(value)
|
|
348
|
-
)?.[1];
|
|
354
|
+
const submitterEmail = resolveSubmitterEmail(args.config, args.data);
|
|
349
355
|
const subject = (notify.subject || "New {form} submission").replace(
|
|
350
356
|
/\{form\}/g,
|
|
351
357
|
args.formTitle || "form"
|
|
@@ -362,7 +368,7 @@ async function notifySubmission(args) {
|
|
|
362
368
|
console.error("[orion-cms] submission notification failed:", error);
|
|
363
369
|
}
|
|
364
370
|
}
|
|
365
|
-
if (notify.autoReply && submitterEmail) {
|
|
371
|
+
if (notify.autoReply === true && submitterEmail && args.autoReplyAllowed === true) {
|
|
366
372
|
try {
|
|
367
373
|
await args.sendEmail({
|
|
368
374
|
to: [submitterEmail],
|
|
@@ -431,30 +437,62 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
|
|
|
431
437
|
var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
|
|
432
438
|
var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
|
|
433
439
|
var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
440
|
+
var PREVIEW_TOKEN_MAX_USES = 8;
|
|
441
|
+
var PREVIEW_SESSION_COOKIE = "orion_preview_session";
|
|
442
|
+
var keyRing = (keys) => typeof keys === "string" ? { active: { id: "legacy", secret: keys } } : keys;
|
|
443
|
+
function createPreviewToken(grant, keys, ttlMs = PREVIEW_TOKEN_TTL_MS) {
|
|
444
|
+
const active = keyRing(keys).active;
|
|
445
|
+
const issuedAt = Date.now();
|
|
446
|
+
const payload = encode(JSON.stringify({
|
|
447
|
+
version: 1,
|
|
448
|
+
audience: "cms-preview",
|
|
449
|
+
operation: "read-draft",
|
|
450
|
+
keyId: active.id,
|
|
451
|
+
...grant,
|
|
452
|
+
issuedAt,
|
|
453
|
+
expiresAt: issuedAt + Math.min(Math.max(ttlMs, 1), PREVIEW_TOKEN_TTL_MS)
|
|
454
|
+
}));
|
|
455
|
+
return `${payload}.${sign(payload, active.secret)}`;
|
|
437
456
|
}
|
|
438
|
-
function
|
|
439
|
-
const
|
|
457
|
+
function verifyPreviewGrantToken(token, keys, now = Date.now()) {
|
|
458
|
+
const segments = token.split(".");
|
|
459
|
+
if (segments.length !== 2) return null;
|
|
460
|
+
const [payload, signature] = segments;
|
|
440
461
|
if (!payload || !signature) return null;
|
|
441
|
-
const expected = sign(payload, secret);
|
|
442
|
-
const expectedBuffer = Buffer.from(expected);
|
|
443
|
-
const actualBuffer = Buffer.from(signature);
|
|
444
|
-
if (expectedBuffer.length !== actualBuffer.length) return null;
|
|
445
|
-
if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
|
|
446
462
|
try {
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
449
|
-
|
|
463
|
+
const claims = JSON.parse(decode(payload));
|
|
464
|
+
if (claims.version !== 1 || claims.audience !== "cms-preview" || claims.operation !== "read-draft" || typeof claims.keyId !== "string" || typeof claims.grantId !== "string" || typeof claims.pageId !== "string" || typeof claims.userId !== "string" || typeof claims.projectRef !== "string" || typeof claims.issuedAt !== "number" || !Number.isSafeInteger(claims.issuedAt) || typeof claims.expiresAt !== "number" || !Number.isSafeInteger(claims.expiresAt) || claims.issuedAt > now || claims.expiresAt <= now || claims.expiresAt - claims.issuedAt > PREVIEW_TOKEN_TTL_MS) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
const configured = keyRing(keys);
|
|
468
|
+
const signingKey = claims.keyId === configured.active.id || configured.active.id === "legacy" ? configured.active : claims.keyId === configured.previous?.id && now <= configured.previous.acceptUntil ? configured.previous : null;
|
|
469
|
+
if (!signingKey) return null;
|
|
470
|
+
const expectedBuffer = Buffer.from(sign(payload, signingKey.secret));
|
|
471
|
+
const actualBuffer = Buffer.from(signature);
|
|
472
|
+
if (expectedBuffer.length !== actualBuffer.length) return null;
|
|
473
|
+
if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
|
|
474
|
+
return claims;
|
|
450
475
|
} catch {
|
|
451
476
|
return null;
|
|
452
477
|
}
|
|
453
478
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
479
|
+
function verifyPreviewToken(token, keys) {
|
|
480
|
+
return verifyPreviewGrantToken(token, keys)?.pageId ?? null;
|
|
481
|
+
}
|
|
482
|
+
async function getPreviewPage(client, token, keys, expectedProjectRef) {
|
|
483
|
+
const configured = keyRing(keys);
|
|
484
|
+
if (!configured.active.id || !configured.active.secret || !expectedProjectRef) return null;
|
|
485
|
+
const claims = verifyPreviewGrantToken(token, configured);
|
|
486
|
+
if (!claims || claims.projectRef !== expectedProjectRef) return null;
|
|
487
|
+
const { data: consumed, error: consumeError } = await client.rpc("cms_consume_preview_grant", {
|
|
488
|
+
p_grant_id: claims.grantId,
|
|
489
|
+
p_page_id: claims.pageId,
|
|
490
|
+
p_user_id: claims.userId,
|
|
491
|
+
p_project_ref: claims.projectRef,
|
|
492
|
+
p_key_id: claims.keyId
|
|
493
|
+
});
|
|
494
|
+
if (consumeError || consumed !== true) return null;
|
|
495
|
+
const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", claims.pageId).maybeSingle();
|
|
458
496
|
if (!data) return null;
|
|
459
497
|
return {
|
|
460
498
|
id: String(data.id),
|
|
@@ -468,20 +506,67 @@ async function getPreviewPage(client, token, secret) {
|
|
|
468
506
|
|
|
469
507
|
// src/server/supabase.ts
|
|
470
508
|
import { createClient } from "@supabase/supabase-js";
|
|
509
|
+
var projectRefFromUrl = (supabaseUrl) => {
|
|
510
|
+
let parsed;
|
|
511
|
+
try {
|
|
512
|
+
parsed = new URL(supabaseUrl);
|
|
513
|
+
} catch {
|
|
514
|
+
throw new Error("Orion CMS: the Supabase URL is invalid.");
|
|
515
|
+
}
|
|
516
|
+
const match = /^([a-z0-9]{20})\.supabase\.co$/.exec(parsed.hostname);
|
|
517
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash || !match) {
|
|
518
|
+
throw new Error("Orion CMS: the standard HTTPS Supabase project URL is required.");
|
|
519
|
+
}
|
|
520
|
+
return match[1];
|
|
521
|
+
};
|
|
522
|
+
var legacyServiceKeyClaims = (key) => {
|
|
523
|
+
const segments = key.split(".");
|
|
524
|
+
if (segments.length !== 3) return null;
|
|
525
|
+
try {
|
|
526
|
+
const payload = Buffer.from(segments[1] || "", "base64url").toString("utf8");
|
|
527
|
+
const claims = JSON.parse(payload);
|
|
528
|
+
return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
function validateCmsEnv(env) {
|
|
534
|
+
if (!/^[a-z0-9]{20}$/.test(env.expectedProjectRef)) {
|
|
535
|
+
throw new Error("Orion CMS: CMS_EXPECTED_SUPABASE_PROJECT_REF must be set.");
|
|
536
|
+
}
|
|
537
|
+
if (projectRefFromUrl(env.supabaseUrl) !== env.expectedProjectRef) {
|
|
538
|
+
throw new Error("Orion CMS: the Supabase URL does not match the expected project.");
|
|
539
|
+
}
|
|
540
|
+
if (env.serviceRoleKey.startsWith("sb_publishable_")) {
|
|
541
|
+
throw new Error("Orion CMS: a publishable key cannot be used as the server credential.");
|
|
542
|
+
}
|
|
543
|
+
if (!env.serviceRoleKey.startsWith("sb_secret_")) {
|
|
544
|
+
const claims = legacyServiceKeyClaims(env.serviceRoleKey);
|
|
545
|
+
if (!claims || claims.role !== "service_role") {
|
|
546
|
+
throw new Error("Orion CMS: a Supabase secret or legacy service-role key is required.");
|
|
547
|
+
}
|
|
548
|
+
if (claims.ref !== env.expectedProjectRef) {
|
|
549
|
+
throw new Error("Orion CMS: the legacy service-role key does not match the expected project.");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return env;
|
|
553
|
+
}
|
|
471
554
|
function readCmsEnv() {
|
|
472
555
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
|
|
473
|
-
const serviceRoleKey = process.env.
|
|
556
|
+
const serviceRoleKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
|
557
|
+
const expectedProjectRef = process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF || "";
|
|
474
558
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
475
559
|
throw new Error(
|
|
476
|
-
"Orion CMS: NEXT_PUBLIC_SUPABASE_URL and
|
|
560
|
+
"Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY (or legacy SUPABASE_SERVICE_ROLE_KEY) must be set."
|
|
477
561
|
);
|
|
478
562
|
}
|
|
479
|
-
return { supabaseUrl, serviceRoleKey };
|
|
563
|
+
return validateCmsEnv({ expectedProjectRef, supabaseUrl, serviceRoleKey });
|
|
480
564
|
}
|
|
481
565
|
var serviceClient = null;
|
|
482
566
|
function getServiceClient(env = readCmsEnv()) {
|
|
483
567
|
if (!serviceClient) {
|
|
484
|
-
|
|
568
|
+
const validated = validateCmsEnv(env);
|
|
569
|
+
serviceClient = createClient(validated.supabaseUrl, validated.serviceRoleKey, {
|
|
485
570
|
auth: { persistSession: false, autoRefreshToken: false }
|
|
486
571
|
});
|
|
487
572
|
}
|
|
@@ -507,12 +592,19 @@ async function resolveUser(request, client = getServiceClient()) {
|
|
|
507
592
|
}
|
|
508
593
|
|
|
509
594
|
// src/server/sync.ts
|
|
595
|
+
import { createHash as createHash2 } from "crypto";
|
|
510
596
|
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
511
597
|
var publicFormConfig = (value) => {
|
|
512
598
|
const config = isRecord(value) ? { ...value } : {};
|
|
513
599
|
delete config.notify;
|
|
514
600
|
return config;
|
|
515
601
|
};
|
|
602
|
+
var canonicalize = (value) => {
|
|
603
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
604
|
+
if (!isRecord(value)) return value;
|
|
605
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
606
|
+
};
|
|
607
|
+
var sameValue = (left, right) => JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
516
608
|
var mimeByExtension = {
|
|
517
609
|
avif: "image/avif",
|
|
518
610
|
gif: "image/gif",
|
|
@@ -525,22 +617,15 @@ var mimeByExtension = {
|
|
|
525
617
|
webp: "image/webp"
|
|
526
618
|
};
|
|
527
619
|
var cleanPath = (src) => src.split("?")[0]?.split("#")[0] || src;
|
|
528
|
-
var filenameFromPath = (path) =>
|
|
529
|
-
|
|
530
|
-
return clean.split("/").filter(Boolean).pop() || clean;
|
|
531
|
-
};
|
|
532
|
-
var mimeFromPath = (path) => {
|
|
533
|
-
const extension = filenameFromPath(path).split(".").pop()?.toLowerCase() || "";
|
|
534
|
-
return mimeByExtension[extension] || "";
|
|
535
|
-
};
|
|
620
|
+
var filenameFromPath = (path) => cleanPath(path).split("/").filter(Boolean).pop() || cleanPath(path);
|
|
621
|
+
var mimeFromPath = (path) => mimeByExtension[filenameFromPath(path).split(".").pop()?.toLowerCase() || ""] || "";
|
|
536
622
|
var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
|
|
537
623
|
var toSyncMedia = (value) => {
|
|
538
624
|
const src = typeof value.src === "string" ? value.src.trim() : "";
|
|
539
625
|
if (!shouldIndexMediaPath(src)) return null;
|
|
540
|
-
const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
|
|
541
626
|
return {
|
|
542
627
|
storagePath: src,
|
|
543
|
-
filename,
|
|
628
|
+
filename: typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src),
|
|
544
629
|
alt: typeof value.alt === "string" ? value.alt : "",
|
|
545
630
|
caption: typeof value.caption === "string" ? value.caption : "",
|
|
546
631
|
mimeType: mimeFromPath(src)
|
|
@@ -573,83 +658,331 @@ var normalizeManualMedia = (item) => {
|
|
|
573
658
|
filesize: typeof item.filesize === "number" ? item.filesize : null
|
|
574
659
|
};
|
|
575
660
|
};
|
|
576
|
-
|
|
577
|
-
const pages =
|
|
578
|
-
const globals = input.globals || [];
|
|
579
|
-
const forms = input.forms || [];
|
|
661
|
+
var prepareSync = (registry, input) => {
|
|
662
|
+
const pages = [];
|
|
580
663
|
const media = /* @__PURE__ */ new Map();
|
|
581
|
-
const synced = [];
|
|
582
664
|
const skipped = [];
|
|
583
|
-
for (const page of pages) {
|
|
665
|
+
for (const page of input.pages || []) {
|
|
584
666
|
if (!page || typeof page.slug !== "string") continue;
|
|
585
667
|
const validated = registry.validateLayout(page.layout ?? []);
|
|
586
668
|
if (!validated.ok) {
|
|
587
669
|
skipped.push({ slug: page.slug, issues: validated.issues });
|
|
588
670
|
continue;
|
|
589
671
|
}
|
|
590
|
-
const { error } = await client.rpc("cms_sync_page", {
|
|
591
|
-
p_slug: page.slug,
|
|
592
|
-
p_path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
|
|
593
|
-
p_title: typeof page.title === "string" ? page.title : page.slug,
|
|
594
|
-
p_seo: isRecord(page.seo) ? page.seo : {},
|
|
595
|
-
p_layout: validated.layout
|
|
596
|
-
});
|
|
597
|
-
if (error) {
|
|
598
|
-
skipped.push({ slug: page.slug, issues: error.message });
|
|
599
|
-
continue;
|
|
600
|
-
}
|
|
601
672
|
collectLayoutMedia(validated.layout, media);
|
|
602
|
-
|
|
673
|
+
pages.push({
|
|
674
|
+
slug: page.slug,
|
|
675
|
+
path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
|
|
676
|
+
title: typeof page.title === "string" ? page.title : page.slug,
|
|
677
|
+
seo: isRecord(page.seo) ? page.seo : {},
|
|
678
|
+
layout: validated.layout
|
|
679
|
+
});
|
|
603
680
|
}
|
|
604
681
|
for (const item of input.media || []) {
|
|
605
682
|
const normalized = normalizeManualMedia(item);
|
|
606
683
|
if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
|
|
607
684
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
685
|
+
return {
|
|
686
|
+
pages,
|
|
687
|
+
globals: (input.globals || []).filter((item) => Boolean(item && typeof item.key === "string" && isRecord(item.data))),
|
|
688
|
+
forms: (input.forms || []).filter((item) => Boolean(item && typeof item.slug === "string")).map((form) => ({
|
|
689
|
+
slug: form.slug,
|
|
690
|
+
title: typeof form.title === "string" ? form.title : form.slug,
|
|
691
|
+
config: publicFormConfig(form.config),
|
|
692
|
+
successMessage: typeof form.successMessage === "string" ? form.successMessage : ""
|
|
693
|
+
})),
|
|
694
|
+
media: [...media.values()],
|
|
695
|
+
redirects: (input.redirects || []).filter((item) => Boolean(
|
|
696
|
+
item && typeof item.fromPath === "string" && typeof item.toPath === "string"
|
|
697
|
+
)).map((item) => ({
|
|
698
|
+
fromPath: item.fromPath,
|
|
699
|
+
toPath: item.toPath,
|
|
700
|
+
permanent: item.permanent !== false
|
|
701
|
+
})),
|
|
702
|
+
skipped
|
|
703
|
+
};
|
|
704
|
+
};
|
|
705
|
+
var readOne = async (client, table, columns, key, value) => {
|
|
706
|
+
const { data, error } = await client.from(table).select(columns).eq(key, value).maybeSingle();
|
|
707
|
+
if (error) throw new Error(error.message);
|
|
708
|
+
return data;
|
|
709
|
+
};
|
|
710
|
+
var readMany = async (client, table, columns) => {
|
|
711
|
+
const { data, error } = await client.from(table).select(columns);
|
|
712
|
+
if (error) throw new Error(error.message);
|
|
713
|
+
return data || [];
|
|
714
|
+
};
|
|
715
|
+
var targetReceiptHash = async (client, expectedProjectRef = "") => {
|
|
716
|
+
if (expectedProjectRef === "memory") {
|
|
717
|
+
return createHash2("sha256").update("memory:uninitialized").digest("hex");
|
|
718
|
+
}
|
|
719
|
+
const marker = await readOne(client, "cms_target_identity", "project_ref", "singleton", true);
|
|
720
|
+
const projectRef = typeof marker?.project_ref === "string" ? marker.project_ref : "";
|
|
721
|
+
if (!expectedProjectRef && !projectRef) {
|
|
722
|
+
return createHash2("sha256").update("unbound-local:uninitialized").digest("hex");
|
|
723
|
+
}
|
|
724
|
+
if (!projectRef || projectRef !== expectedProjectRef) {
|
|
725
|
+
throw new Error("Sync target identity does not match the expected CMS project.");
|
|
726
|
+
}
|
|
727
|
+
const migrations = await readMany(client, "cms_migrations", "version");
|
|
728
|
+
const migrationHead = migrations.map((row) => String(row.version)).sort().at(-1) || "empty";
|
|
729
|
+
return createHash2("sha256").update(JSON.stringify(canonicalize({
|
|
730
|
+
migrationHead,
|
|
731
|
+
projectRefHash: createHash2("sha256").update(projectRef).digest("hex").slice(0, 16)
|
|
732
|
+
}))).digest("hex");
|
|
733
|
+
};
|
|
734
|
+
async function buildPlan(client, registry, input, expectedProjectRef = "") {
|
|
735
|
+
const prepared = prepareSync(registry, input);
|
|
736
|
+
const operations = [];
|
|
737
|
+
for (const page of prepared.pages) {
|
|
738
|
+
const current = await readOne(client, "cms_pages", "slug, path, title, seo, draft_layout, builder_owned", "slug", page.slug);
|
|
739
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
740
|
+
{ path: current.path, title: current.title, seo: current.seo, layout: current.draft_layout },
|
|
741
|
+
{ path: page.path, title: page.title, seo: page.seo, layout: page.layout }
|
|
742
|
+
) ? "noop" : "update";
|
|
743
|
+
operations.push({ kind: "page", key: page.slug, action });
|
|
744
|
+
}
|
|
745
|
+
for (const global of prepared.globals) {
|
|
746
|
+
const current = await readOne(client, "cms_globals", "key, data, builder_owned", "key", global.key);
|
|
747
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(current.data, global.data) ? "noop" : "update";
|
|
748
|
+
operations.push({ kind: "global", key: global.key, action });
|
|
749
|
+
}
|
|
750
|
+
for (const form of prepared.forms) {
|
|
751
|
+
const current = await readOne(client, "cms_forms", "slug, title, config, success_message, builder_owned", "slug", form.slug);
|
|
752
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
753
|
+
{ title: current.title, config: current.config, successMessage: current.success_message },
|
|
754
|
+
{ title: form.title, config: form.config, successMessage: form.successMessage }
|
|
755
|
+
) ? "noop" : "update";
|
|
756
|
+
operations.push({ kind: "form", key: form.slug, action });
|
|
757
|
+
}
|
|
758
|
+
for (const item of prepared.media) {
|
|
759
|
+
const current = await readOne(client, "cms_media", "id, storage_path, filename, alt, caption, mime_type, width, height, filesize, builder_owned", "storage_path", item.storagePath);
|
|
760
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
636
761
|
{
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
762
|
+
filename: current.filename,
|
|
763
|
+
alt: current.alt,
|
|
764
|
+
caption: current.caption,
|
|
765
|
+
mimeType: current.mime_type,
|
|
766
|
+
width: current.width,
|
|
767
|
+
height: current.height,
|
|
768
|
+
filesize: current.filesize
|
|
642
769
|
},
|
|
643
|
-
{
|
|
770
|
+
{
|
|
771
|
+
filename: item.filename,
|
|
772
|
+
alt: item.alt,
|
|
773
|
+
caption: item.caption,
|
|
774
|
+
mimeType: item.mimeType,
|
|
775
|
+
width: item.width ?? null,
|
|
776
|
+
height: item.height ?? null,
|
|
777
|
+
filesize: item.filesize ?? null
|
|
778
|
+
}
|
|
779
|
+
) ? "noop" : "update";
|
|
780
|
+
operations.push({ kind: "media", key: item.storagePath, action });
|
|
781
|
+
}
|
|
782
|
+
for (const redirect of prepared.redirects) {
|
|
783
|
+
const current = await readOne(client, "cms_redirects", "from_path, to_path, permanent, builder_owned", "from_path", redirect.fromPath);
|
|
784
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
785
|
+
{ toPath: current.to_path, permanent: current.permanent },
|
|
786
|
+
{ toPath: redirect.toPath, permanent: redirect.permanent }
|
|
787
|
+
) ? "noop" : "update";
|
|
788
|
+
operations.push({ kind: "redirect", key: redirect.fromPath, action });
|
|
789
|
+
}
|
|
790
|
+
const ownershipSets = /* @__PURE__ */ new Map([
|
|
791
|
+
["page", new Set(prepared.pages.map((item) => item.slug))],
|
|
792
|
+
["global", new Set(prepared.globals.map((item) => item.key))],
|
|
793
|
+
["form", new Set(prepared.forms.map((item) => item.slug))],
|
|
794
|
+
["media", new Set(prepared.media.map((item) => item.storagePath))],
|
|
795
|
+
["redirect", new Set(prepared.redirects.map((item) => item.fromPath))]
|
|
796
|
+
]);
|
|
797
|
+
const remoteOwned = [
|
|
798
|
+
["page", "cms_pages", "slug"],
|
|
799
|
+
["global", "cms_globals", "key"],
|
|
800
|
+
["form", "cms_forms", "slug"],
|
|
801
|
+
["media", "cms_media", "storage_path"],
|
|
802
|
+
["redirect", "cms_redirects", "from_path"]
|
|
803
|
+
];
|
|
804
|
+
for (const [kind, table, key] of remoteOwned) {
|
|
805
|
+
const rows = await readMany(client, table, `${key}, builder_owned`);
|
|
806
|
+
for (const row of rows) {
|
|
807
|
+
const value = String(row[key] ?? "");
|
|
808
|
+
if (row.builder_owned === false && value && !ownershipSets.get(kind)?.has(value)) {
|
|
809
|
+
operations.push({ kind, key: value, action: "blocked-delete" });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const kindOrder = {
|
|
814
|
+
page: 0,
|
|
815
|
+
global: 1,
|
|
816
|
+
form: 2,
|
|
817
|
+
media: 3,
|
|
818
|
+
redirect: 4
|
|
819
|
+
};
|
|
820
|
+
operations.sort(
|
|
821
|
+
(left, right) => kindOrder[left.kind] - kindOrder[right.kind] || left.key.localeCompare(right.key)
|
|
822
|
+
);
|
|
823
|
+
const receiptHash = await targetReceiptHash(client, expectedProjectRef);
|
|
824
|
+
const manifestHash = createHash2("sha256").update(JSON.stringify(canonicalize({
|
|
825
|
+
input: prepared,
|
|
826
|
+
targetReceiptHash: receiptHash,
|
|
827
|
+
version: 1
|
|
828
|
+
}))).digest("hex");
|
|
829
|
+
return {
|
|
830
|
+
prepared,
|
|
831
|
+
plan: {
|
|
832
|
+
mode: "dry-run",
|
|
833
|
+
manifestHash,
|
|
834
|
+
targetReceiptHash: receiptHash,
|
|
835
|
+
operations,
|
|
836
|
+
skipped: prepared.skipped
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
async function runContentSync(client, registry, input, options = { mode: "dry-run" }) {
|
|
841
|
+
if (options.mode === "apply") {
|
|
842
|
+
const currentTargetReceiptHash = await targetReceiptHash(client, options.expectedProjectRef);
|
|
843
|
+
if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== currentTargetReceiptHash) {
|
|
844
|
+
throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
|
|
845
|
+
}
|
|
846
|
+
const completed = await readOne(
|
|
847
|
+
client,
|
|
848
|
+
"cms_sync_runs",
|
|
849
|
+
"manifest_hash, target_receipt_hash, status, result",
|
|
850
|
+
"manifest_hash",
|
|
851
|
+
options.expectedManifestHash
|
|
644
852
|
);
|
|
645
|
-
if (
|
|
646
|
-
|
|
853
|
+
if (completed?.status === "complete" && completed.target_receipt_hash === currentTargetReceiptHash && isRecord(completed.result)) {
|
|
854
|
+
return completed.result;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const { prepared, plan } = await buildPlan(client, registry, input, options.expectedProjectRef);
|
|
858
|
+
if (options.mode !== "apply") return plan;
|
|
859
|
+
if (!options.expectedManifestHash || options.expectedManifestHash !== plan.manifestHash) {
|
|
860
|
+
throw new Error("Sync manifest changed. Run a new dry run and apply its exact manifest hash.");
|
|
861
|
+
}
|
|
862
|
+
if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== plan.targetReceiptHash) {
|
|
863
|
+
throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
|
|
864
|
+
}
|
|
865
|
+
const { data: lease, error: leaseError } = await client.rpc("cms_begin_sync", {
|
|
866
|
+
p_manifest_hash: plan.manifestHash,
|
|
867
|
+
p_target_receipt_hash: plan.targetReceiptHash
|
|
868
|
+
});
|
|
869
|
+
if (leaseError) throw new Error(leaseError.message);
|
|
870
|
+
const leaseResult = isRecord(lease) ? lease : {};
|
|
871
|
+
if (leaseResult.status === "busy") {
|
|
872
|
+
throw new Error("Another content sync is active for this CMS target.");
|
|
873
|
+
}
|
|
874
|
+
if (leaseResult.status === "complete" && isRecord(leaseResult.result)) {
|
|
875
|
+
return leaseResult.result;
|
|
876
|
+
}
|
|
877
|
+
if (!["started", "resume"].includes(String(leaseResult.status))) {
|
|
878
|
+
throw new Error("The CMS sync lease could not be acquired.");
|
|
879
|
+
}
|
|
880
|
+
try {
|
|
881
|
+
const synced = [];
|
|
882
|
+
const failed = [];
|
|
883
|
+
const conflicts = plan.operations.filter((operation) => operation.action === "conflict");
|
|
884
|
+
let globals = 0;
|
|
885
|
+
let forms = 0;
|
|
886
|
+
let media = 0;
|
|
887
|
+
for (const page of prepared.pages) {
|
|
888
|
+
const operation = plan.operations.find((item) => item.kind === "page" && item.key === page.slug);
|
|
889
|
+
if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
|
|
890
|
+
const { error } = await client.rpc("cms_sync_page", {
|
|
891
|
+
p_slug: page.slug,
|
|
892
|
+
p_path: page.path,
|
|
893
|
+
p_title: page.title,
|
|
894
|
+
p_seo: page.seo,
|
|
895
|
+
p_layout: page.layout
|
|
896
|
+
});
|
|
897
|
+
if (error) failed.push({ kind: "page", key: page.slug, error: error.message });
|
|
898
|
+
else synced.push(page.slug);
|
|
899
|
+
}
|
|
900
|
+
for (const global of prepared.globals) {
|
|
901
|
+
const operation = plan.operations.find((item) => item.kind === "global" && item.key === global.key);
|
|
902
|
+
if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
|
|
903
|
+
const { data, error } = await client.rpc("cms_sync_global", { p_key: global.key, p_data: global.data });
|
|
904
|
+
if (error) failed.push({ kind: "global", key: global.key, error: error.message });
|
|
905
|
+
else if (data === true) globals += 1;
|
|
906
|
+
else conflicts.push({ kind: "global", key: global.key, action: "conflict" });
|
|
907
|
+
}
|
|
908
|
+
for (const form of prepared.forms) {
|
|
909
|
+
const operation = plan.operations.find((item) => item.kind === "form" && item.key === form.slug);
|
|
910
|
+
if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
|
|
911
|
+
const { data, error } = await client.rpc("cms_sync_form", {
|
|
912
|
+
p_slug: form.slug,
|
|
913
|
+
p_title: form.title,
|
|
914
|
+
p_config: form.config,
|
|
915
|
+
p_success_message: form.successMessage
|
|
916
|
+
});
|
|
917
|
+
if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
|
|
918
|
+
else if (data === true) forms += 1;
|
|
919
|
+
else conflicts.push({ kind: "form", key: form.slug, action: "conflict" });
|
|
920
|
+
}
|
|
921
|
+
for (const item of prepared.media) {
|
|
922
|
+
const operation = plan.operations.find((entry) => entry.kind === "media" && entry.key === item.storagePath);
|
|
923
|
+
if (!operation || !["create", "update"].includes(operation.action)) continue;
|
|
924
|
+
const { data, error } = await client.rpc("cms_sync_media", {
|
|
925
|
+
p_storage_path: item.storagePath,
|
|
926
|
+
p_filename: item.filename || filenameFromPath(item.storagePath),
|
|
927
|
+
p_alt: item.alt || "",
|
|
928
|
+
p_caption: item.caption || "",
|
|
929
|
+
p_mime_type: item.mimeType || mimeFromPath(item.storagePath),
|
|
930
|
+
p_width: item.width ?? null,
|
|
931
|
+
p_height: item.height ?? null,
|
|
932
|
+
p_filesize: item.filesize ?? null
|
|
933
|
+
});
|
|
934
|
+
if (error) failed.push({ kind: "media", key: item.storagePath, error: error.message });
|
|
935
|
+
else if (data === true) media += 1;
|
|
936
|
+
else conflicts.push({ kind: "media", key: item.storagePath, action: "conflict" });
|
|
937
|
+
}
|
|
938
|
+
for (const redirect of prepared.redirects) {
|
|
939
|
+
const operation = plan.operations.find((entry) => entry.kind === "redirect" && entry.key === redirect.fromPath);
|
|
940
|
+
if (!operation || !["create", "update"].includes(operation.action)) continue;
|
|
941
|
+
const { data, error } = await client.rpc("cms_sync_redirect", {
|
|
942
|
+
p_from_path: redirect.fromPath,
|
|
943
|
+
p_to_path: redirect.toPath,
|
|
944
|
+
p_permanent: redirect.permanent
|
|
945
|
+
});
|
|
946
|
+
if (error) failed.push({ kind: "redirect", key: redirect.fromPath, error: error.message });
|
|
947
|
+
else if (data !== true) conflicts.push({ kind: "redirect", key: redirect.fromPath, action: "conflict" });
|
|
948
|
+
}
|
|
949
|
+
const result = {
|
|
950
|
+
mode: "apply",
|
|
951
|
+
manifestHash: plan.manifestHash,
|
|
952
|
+
targetReceiptHash: plan.targetReceiptHash,
|
|
953
|
+
synced,
|
|
954
|
+
skipped: plan.skipped,
|
|
955
|
+
globals,
|
|
956
|
+
forms,
|
|
957
|
+
media,
|
|
958
|
+
failed,
|
|
959
|
+
conflicts
|
|
960
|
+
};
|
|
961
|
+
const completionStatus = failed.length === 0 ? "complete" : "failed";
|
|
962
|
+
const completionError = completionStatus === "failed" ? `${failed.length} content sync operation${failed.length === 1 ? "" : "s"} failed.` : null;
|
|
963
|
+
const { data: finished, error: finishError } = await client.rpc("cms_finish_sync", {
|
|
964
|
+
p_manifest_hash: plan.manifestHash,
|
|
965
|
+
p_status: completionStatus,
|
|
966
|
+
p_result: completionStatus === "complete" ? result : null,
|
|
967
|
+
p_error: completionError
|
|
968
|
+
});
|
|
969
|
+
if (finishError || finished !== true) {
|
|
970
|
+
throw new Error(finishError?.message || "The CMS sync completion receipt was not recorded.");
|
|
971
|
+
}
|
|
972
|
+
if (completionError) throw new Error(completionError);
|
|
973
|
+
return result;
|
|
974
|
+
} catch (error) {
|
|
975
|
+
await client.rpc("cms_finish_sync", {
|
|
976
|
+
p_manifest_hash: plan.manifestHash,
|
|
977
|
+
p_status: "failed",
|
|
978
|
+
p_result: null,
|
|
979
|
+
p_error: error instanceof Error ? error.message : "Content sync failed."
|
|
980
|
+
});
|
|
981
|
+
throw error;
|
|
647
982
|
}
|
|
648
|
-
return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
|
|
649
983
|
}
|
|
650
984
|
|
|
651
985
|
// src/server/routes.ts
|
|
652
|
-
import { timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
653
986
|
var tokenEquals = (candidate, secret) => {
|
|
654
987
|
if (!candidate || !secret) return false;
|
|
655
988
|
const a = Buffer.from(candidate);
|
|
@@ -731,6 +1064,7 @@ var clientKey = (request) => {
|
|
|
731
1064
|
};
|
|
732
1065
|
var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
|
|
733
1066
|
var MAX_PUBLIC_BODY_DEPTH = 12;
|
|
1067
|
+
var MAX_SYNC_BODY_BYTES = 2 * 1024 * 1024;
|
|
734
1068
|
function exceedsDepth(value, limit, depth = 0) {
|
|
735
1069
|
if (depth > limit) return true;
|
|
736
1070
|
if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
|
|
@@ -738,13 +1072,38 @@ function exceedsDepth(value, limit, depth = 0) {
|
|
|
738
1072
|
return false;
|
|
739
1073
|
}
|
|
740
1074
|
async function readJsonLimited(request, limits) {
|
|
741
|
-
|
|
1075
|
+
const declaredLength = request.headers.get("content-length");
|
|
1076
|
+
if (declaredLength !== null) {
|
|
1077
|
+
if (!/^[0-9]+$/.test(declaredLength)) return null;
|
|
1078
|
+
const length = Number(declaredLength);
|
|
1079
|
+
if (!Number.isSafeInteger(length) || length > limits.maxBytes) return null;
|
|
1080
|
+
}
|
|
1081
|
+
if (!request.body) return null;
|
|
1082
|
+
const reader = request.body.getReader();
|
|
1083
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1084
|
+
let totalBytes = 0;
|
|
1085
|
+
let text = "";
|
|
742
1086
|
try {
|
|
743
|
-
|
|
1087
|
+
while (true) {
|
|
1088
|
+
const { done, value } = await reader.read();
|
|
1089
|
+
if (done) break;
|
|
1090
|
+
totalBytes += value.byteLength;
|
|
1091
|
+
if (totalBytes > limits.maxBytes) {
|
|
1092
|
+
await reader.cancel("CMS request body exceeded the configured byte limit.");
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
text += decoder.decode(value, { stream: true });
|
|
1096
|
+
}
|
|
1097
|
+
text += decoder.decode();
|
|
744
1098
|
} catch {
|
|
1099
|
+
try {
|
|
1100
|
+
await reader.cancel();
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
745
1103
|
return null;
|
|
1104
|
+
} finally {
|
|
1105
|
+
reader.releaseLock();
|
|
746
1106
|
}
|
|
747
|
-
if (text.length > limits.maxBytes) return null;
|
|
748
1107
|
let parsed;
|
|
749
1108
|
try {
|
|
750
1109
|
parsed = JSON.parse(text);
|
|
@@ -755,7 +1114,7 @@ async function readJsonLimited(request, limits) {
|
|
|
755
1114
|
if (exceedsDepth(parsed, limits.maxDepth)) return null;
|
|
756
1115
|
return parsed;
|
|
757
1116
|
}
|
|
758
|
-
var hashedClientKey = (ip) =>
|
|
1117
|
+
var hashedClientKey = (ip) => createHash3("sha256").update(ip).digest("hex").slice(0, 24);
|
|
759
1118
|
var requestPagePath = (request) => {
|
|
760
1119
|
const referrer = request.headers.get("referer");
|
|
761
1120
|
if (!referrer) return "";
|
|
@@ -791,17 +1150,64 @@ function createDurableRateLimitStore(getClient, options) {
|
|
|
791
1150
|
};
|
|
792
1151
|
}
|
|
793
1152
|
function createCmsRoutes(options) {
|
|
794
|
-
const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
|
|
1153
|
+
const { registry, allowedOrigins, syncToken, cronToken, knownGoodEmailDomains } = options;
|
|
795
1154
|
const db = () => options.client ?? getServiceClient();
|
|
796
1155
|
const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
|
|
797
1156
|
const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
|
|
798
1157
|
const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
|
|
799
1158
|
const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
800
|
-
const
|
|
801
|
-
if (hostedMemoryMode) return
|
|
802
|
-
|
|
803
|
-
|
|
1159
|
+
const previewKeys = () => {
|
|
1160
|
+
if (hostedMemoryMode) return null;
|
|
1161
|
+
const activeSecret = options.previewSecret || (options.memoryMode ? "orion-memory-preview-secret" : "");
|
|
1162
|
+
const activeId = options.previewKeyId || (options.memoryMode ? "memory-v1" : "");
|
|
1163
|
+
if (!activeSecret || !activeId) return null;
|
|
1164
|
+
const previous = options.previewPreviousSecret && options.previewPreviousKeyId && options.previewPreviousValidUntil ? {
|
|
1165
|
+
id: options.previewPreviousKeyId,
|
|
1166
|
+
secret: options.previewPreviousSecret,
|
|
1167
|
+
acceptUntil: options.previewPreviousValidUntil
|
|
1168
|
+
} : void 0;
|
|
1169
|
+
return { active: { id: activeId, secret: activeSecret }, ...previous ? { previous } : {} };
|
|
1170
|
+
};
|
|
1171
|
+
const previewProjectRef = options.projectRef || (options.memoryMode ? "memory" : "");
|
|
1172
|
+
const analyticsSecret = options.analyticsSecret || (options.memoryMode ? "orion-memory-analytics-secret" : "");
|
|
1173
|
+
const autoReplyHashSecret = options.autoReplyHashSecret || (options.memoryMode ? "orion-memory-auto-reply-secret" : "");
|
|
804
1174
|
const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
1175
|
+
const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
1176
|
+
const boundedAutoReplyMax = (name, fallback) => {
|
|
1177
|
+
const candidate = options.autoReplyLimits?.[name];
|
|
1178
|
+
return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
|
|
1179
|
+
};
|
|
1180
|
+
const autoReplyLimits = {
|
|
1181
|
+
recipientPerHour: autoReplyLimit(
|
|
1182
|
+
boundedAutoReplyMax("recipientPerHour", 3),
|
|
1183
|
+
36e5,
|
|
1184
|
+
"auto-reply-recipient-hour"
|
|
1185
|
+
),
|
|
1186
|
+
sitePerMinute: autoReplyLimit(
|
|
1187
|
+
boundedAutoReplyMax("sitePerMinute", 10),
|
|
1188
|
+
6e4,
|
|
1189
|
+
"auto-reply-site-minute"
|
|
1190
|
+
),
|
|
1191
|
+
sitePerDay: autoReplyLimit(
|
|
1192
|
+
boundedAutoReplyMax("sitePerDay", 200),
|
|
1193
|
+
864e5,
|
|
1194
|
+
"auto-reply-site-day"
|
|
1195
|
+
)
|
|
1196
|
+
};
|
|
1197
|
+
const autoReplyRecipientKey = (email) => createHmac3("sha256", autoReplyHashSecret).update(`recipient:${email}`).digest("hex").slice(0, 24);
|
|
1198
|
+
const maySendAutoReply = async (config, data) => {
|
|
1199
|
+
if (config.notify?.autoReply !== true) return false;
|
|
1200
|
+
const recipient = resolveSubmitterEmail(config, data);
|
|
1201
|
+
if (!recipient) return false;
|
|
1202
|
+
if (!autoReplyHashSecret) return false;
|
|
1203
|
+
const now = Date.now();
|
|
1204
|
+
if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
if (await autoReplyLimits.sitePerMinute.isLimited("all", now)) return false;
|
|
1208
|
+
if (await autoReplyLimits.sitePerDay.isLimited("all", now)) return false;
|
|
1209
|
+
return true;
|
|
1210
|
+
};
|
|
805
1211
|
const boundedAnalyticsMax = (name, fallback) => {
|
|
806
1212
|
const candidate = options.analyticsLimits?.[name];
|
|
807
1213
|
return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
|
|
@@ -845,8 +1251,8 @@ function createCmsRoutes(options) {
|
|
|
845
1251
|
};
|
|
846
1252
|
const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
|
|
847
1253
|
const requestSessionKey = (request) => {
|
|
848
|
-
|
|
849
|
-
return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "",
|
|
1254
|
+
if (!analyticsSecret) return "";
|
|
1255
|
+
return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", analyticsSecret);
|
|
850
1256
|
};
|
|
851
1257
|
const guard = async (request, action) => {
|
|
852
1258
|
if (hostedMemoryMode) return errors.unauthorized();
|
|
@@ -855,6 +1261,13 @@ function createCmsRoutes(options) {
|
|
|
855
1261
|
if (!can(user, action)) return errors.forbidden();
|
|
856
1262
|
return { user };
|
|
857
1263
|
};
|
|
1264
|
+
const revokePreviewGrants = async (filter) => {
|
|
1265
|
+
let query = db().from("cms_preview_grants").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1266
|
+
if (filter.pageId) query = query.eq("page_id", filter.pageId);
|
|
1267
|
+
if (filter.userId) query = query.eq("user_id", filter.userId);
|
|
1268
|
+
const { error } = await query;
|
|
1269
|
+
return error?.message ?? null;
|
|
1270
|
+
};
|
|
858
1271
|
const logActivity = async (user, action, subject) => {
|
|
859
1272
|
try {
|
|
860
1273
|
await db().from("cms_activity").insert({
|
|
@@ -974,6 +1387,7 @@ function createCmsRoutes(options) {
|
|
|
974
1387
|
}
|
|
975
1388
|
patch.path = path;
|
|
976
1389
|
}
|
|
1390
|
+
patch.builder_owned = true;
|
|
977
1391
|
if (Object.keys(patch).length > 0) {
|
|
978
1392
|
const oldPath = String(current.path || "");
|
|
979
1393
|
const wasPublished = current.status === "published";
|
|
@@ -987,7 +1401,12 @@ function createCmsRoutes(options) {
|
|
|
987
1401
|
const newPath = typeof patch.path === "string" ? patch.path : oldPath;
|
|
988
1402
|
if (newPath !== oldPath && wasPublished && oldPath !== "/") {
|
|
989
1403
|
await db().from("cms_redirects").delete().eq("from_path", newPath);
|
|
990
|
-
await db().from("cms_redirects").upsert({
|
|
1404
|
+
await db().from("cms_redirects").upsert({
|
|
1405
|
+
from_path: oldPath,
|
|
1406
|
+
to_path: newPath,
|
|
1407
|
+
permanent: true,
|
|
1408
|
+
builder_owned: true
|
|
1409
|
+
}, { onConflict: "from_path" });
|
|
991
1410
|
}
|
|
992
1411
|
if (newPath !== oldPath) {
|
|
993
1412
|
await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
|
|
@@ -1047,6 +1466,8 @@ function createCmsRoutes(options) {
|
|
|
1047
1466
|
if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
|
|
1048
1467
|
return errors.conflict("Page version conflict: save the draft again before publishing.");
|
|
1049
1468
|
}
|
|
1469
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1470
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1050
1471
|
const { data, error } = await db().rpc("cms_publish_page", {
|
|
1051
1472
|
p_page_id: id,
|
|
1052
1473
|
p_expected_version_id: expectedVersionId,
|
|
@@ -1063,6 +1484,8 @@ function createCmsRoutes(options) {
|
|
|
1063
1484
|
const unpublishPage = async (request, id) => {
|
|
1064
1485
|
const auth = await guard(request, "pages.publish");
|
|
1065
1486
|
if (auth instanceof Response) return auth;
|
|
1487
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1488
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1066
1489
|
const { data, error } = await db().from("cms_pages").update({
|
|
1067
1490
|
status: "draft",
|
|
1068
1491
|
publish_at: null,
|
|
@@ -1121,32 +1544,46 @@ function createCmsRoutes(options) {
|
|
|
1121
1544
|
const previewToken = async (request, id) => {
|
|
1122
1545
|
const auth = await guard(request, "pages.read");
|
|
1123
1546
|
if (auth instanceof Response) return auth;
|
|
1124
|
-
const
|
|
1125
|
-
if (!
|
|
1547
|
+
const keys = previewKeys();
|
|
1548
|
+
if (!keys || !previewProjectRef) return errors.badRequest("Preview is not configured on this site.");
|
|
1126
1549
|
const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
|
|
1127
1550
|
if (!data) return errors.notFound();
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const { data } = await db().from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
|
|
1139
|
-
if (!data) return errors.notFound();
|
|
1140
|
-
return json({
|
|
1141
|
-
page: {
|
|
1142
|
-
id: data.id,
|
|
1143
|
-
slug: data.slug,
|
|
1144
|
-
path: data.path,
|
|
1145
|
-
title: data.title,
|
|
1146
|
-
seo: data.seo ?? {},
|
|
1147
|
-
layout: data.draft_layout ?? []
|
|
1148
|
-
}
|
|
1551
|
+
const grantId = randomUUID();
|
|
1552
|
+
const expiresAt = new Date(Date.now() + PREVIEW_TOKEN_TTL_MS).toISOString();
|
|
1553
|
+
const { error: grantError } = await db().from("cms_preview_grants").insert({
|
|
1554
|
+
id: grantId,
|
|
1555
|
+
page_id: id,
|
|
1556
|
+
user_id: auth.user.id,
|
|
1557
|
+
project_ref: previewProjectRef,
|
|
1558
|
+
key_id: keys.active.id,
|
|
1559
|
+
expires_at: expiresAt,
|
|
1560
|
+
max_uses: PREVIEW_TOKEN_MAX_USES
|
|
1149
1561
|
});
|
|
1562
|
+
if (grantError) return errors.badRequest("Unable to create a preview grant.");
|
|
1563
|
+
const token = createPreviewToken({
|
|
1564
|
+
grantId,
|
|
1565
|
+
pageId: id,
|
|
1566
|
+
userId: auth.user.id,
|
|
1567
|
+
projectRef: previewProjectRef
|
|
1568
|
+
}, keys);
|
|
1569
|
+
const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
|
|
1570
|
+
const response = json({ path: data.path, url: previewUrl });
|
|
1571
|
+
const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
|
|
1572
|
+
response.headers.set("cache-control", "private, no-store");
|
|
1573
|
+
response.headers.set("referrer-policy", "no-referrer");
|
|
1574
|
+
response.headers.append(
|
|
1575
|
+
"set-cookie",
|
|
1576
|
+
`${PREVIEW_SESSION_COOKIE}=${token}; Path=${previewUrl}; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(PREVIEW_TOKEN_TTL_MS / 1e3)}${secure ? "; Secure" : ""}`
|
|
1577
|
+
);
|
|
1578
|
+
return response;
|
|
1579
|
+
};
|
|
1580
|
+
const revokePagePreviews = async (request, id) => {
|
|
1581
|
+
const auth = await guard(request, "pages.read");
|
|
1582
|
+
if (auth instanceof Response) return auth;
|
|
1583
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1584
|
+
if (revokeError) return errors.badRequest("Unable to revoke preview grants.");
|
|
1585
|
+
await logActivity(auth.user, "page.preview.revoke", id);
|
|
1586
|
+
return json({ success: true });
|
|
1150
1587
|
};
|
|
1151
1588
|
const listVersions = async (request, id) => {
|
|
1152
1589
|
const auth = await guard(request, "pages.restore");
|
|
@@ -1174,6 +1611,10 @@ function createCmsRoutes(options) {
|
|
|
1174
1611
|
const restoreVersion = async (request, versionId) => {
|
|
1175
1612
|
const auth = await guard(request, "pages.restore");
|
|
1176
1613
|
if (auth instanceof Response) return auth;
|
|
1614
|
+
const { data: version } = await db().from("cms_page_versions").select("page_id").eq("id", Number(versionId)).maybeSingle();
|
|
1615
|
+
if (!version) return errors.notFound();
|
|
1616
|
+
const revokeError = await revokePreviewGrants({ pageId: String(version.page_id) });
|
|
1617
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1177
1618
|
const { data, error } = await db().rpc("cms_restore_page_version", {
|
|
1178
1619
|
p_version_id: Number(versionId),
|
|
1179
1620
|
p_actor: auth.user.id
|
|
@@ -1219,6 +1660,7 @@ function createCmsRoutes(options) {
|
|
|
1219
1660
|
const { data, error } = await db().from("cms_globals").upsert({
|
|
1220
1661
|
key: version.key,
|
|
1221
1662
|
data: version.data,
|
|
1663
|
+
builder_owned: true,
|
|
1222
1664
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1223
1665
|
}).select("*").single();
|
|
1224
1666
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1308,7 +1750,8 @@ function createCmsRoutes(options) {
|
|
|
1308
1750
|
mime_type: normalizedFile.type,
|
|
1309
1751
|
width: prepared.width,
|
|
1310
1752
|
height: prepared.height,
|
|
1311
|
-
filesize: normalizedFile.size
|
|
1753
|
+
filesize: normalizedFile.size,
|
|
1754
|
+
builder_owned: true
|
|
1312
1755
|
}).select("*").single();
|
|
1313
1756
|
if (error) return errors.badRequest(error.message);
|
|
1314
1757
|
if (!options.memoryMode) {
|
|
@@ -1326,7 +1769,10 @@ function createCmsRoutes(options) {
|
|
|
1326
1769
|
if (auth instanceof Response) return auth;
|
|
1327
1770
|
const body = await readJson(request);
|
|
1328
1771
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1329
|
-
const patch = {
|
|
1772
|
+
const patch = {
|
|
1773
|
+
builder_owned: true,
|
|
1774
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1775
|
+
};
|
|
1330
1776
|
if (typeof body.alt === "string") patch.alt = body.alt;
|
|
1331
1777
|
if (typeof body.caption === "string") patch.caption = body.caption;
|
|
1332
1778
|
if (typeof body.filename === "string" && body.filename.trim()) {
|
|
@@ -1385,6 +1831,7 @@ function createCmsRoutes(options) {
|
|
|
1385
1831
|
filesize: normalizedFile.size,
|
|
1386
1832
|
width: prepared.width,
|
|
1387
1833
|
height: prepared.height,
|
|
1834
|
+
builder_owned: true,
|
|
1388
1835
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1389
1836
|
}).eq("id", id).select("*").single();
|
|
1390
1837
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1453,7 +1900,8 @@ function createCmsRoutes(options) {
|
|
|
1453
1900
|
slug,
|
|
1454
1901
|
title,
|
|
1455
1902
|
config: { steps: [{ title: "", fields: [] }] },
|
|
1456
|
-
success_message: "Thanks \u2014 we received your submission."
|
|
1903
|
+
success_message: "Thanks \u2014 we received your submission.",
|
|
1904
|
+
builder_owned: true
|
|
1457
1905
|
}).select("*").single();
|
|
1458
1906
|
if (error) {
|
|
1459
1907
|
if (error.message.includes("duplicate")) {
|
|
@@ -1467,9 +1915,15 @@ function createCmsRoutes(options) {
|
|
|
1467
1915
|
const getForm = async (request, slug) => {
|
|
1468
1916
|
const auth = await guard(request, "forms.read");
|
|
1469
1917
|
if (auth instanceof Response) return auth;
|
|
1470
|
-
const
|
|
1918
|
+
const projection = can(auth.user, "forms.write") ? "id, slug, title, config, notify, success_message, updated_at" : "id, slug, title, config, success_message, updated_at";
|
|
1919
|
+
const { data, error } = await db().from("cms_forms").select(projection).eq("slug", slug).maybeSingle();
|
|
1471
1920
|
if (error) return errors.badRequest(error.message);
|
|
1472
1921
|
if (!data) return errors.notFound();
|
|
1922
|
+
if (!can(auth.user, "forms.write")) {
|
|
1923
|
+
const { notify, ...publicForm } = data;
|
|
1924
|
+
void notify;
|
|
1925
|
+
return json({ form: publicForm });
|
|
1926
|
+
}
|
|
1473
1927
|
return json({ form: data });
|
|
1474
1928
|
};
|
|
1475
1929
|
const updateForm = async (request, slug) => {
|
|
@@ -1478,8 +1932,32 @@ function createCmsRoutes(options) {
|
|
|
1478
1932
|
const body = await readJson(request);
|
|
1479
1933
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1480
1934
|
const config = isRecord2(body.config) ? { ...body.config } : {};
|
|
1481
|
-
|
|
1935
|
+
if (body.notify !== void 0 && !isRecord2(body.notify)) {
|
|
1936
|
+
return errors.badRequest("Notification settings must be an object.");
|
|
1937
|
+
}
|
|
1938
|
+
if (body.notify === void 0 && config.notify !== void 0 && !isRecord2(config.notify)) {
|
|
1939
|
+
return errors.badRequest("Notification settings must be an object.");
|
|
1940
|
+
}
|
|
1941
|
+
const rawNotify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
|
|
1482
1942
|
delete config.notify;
|
|
1943
|
+
if (rawNotify.autoReply !== void 0 && typeof rawNotify.autoReply !== "boolean") {
|
|
1944
|
+
return errors.badRequest("autoReply must be a boolean.");
|
|
1945
|
+
}
|
|
1946
|
+
if (rawNotify.autoReplyEmailField !== void 0 && (typeof rawNotify.autoReplyEmailField !== "string" || !rawNotify.autoReplyEmailField.trim())) {
|
|
1947
|
+
return errors.badRequest("autoReplyEmailField must name a declared email field.");
|
|
1948
|
+
}
|
|
1949
|
+
const emailFields = getAutoReplyEmailFields(config);
|
|
1950
|
+
const selectedEmailField = typeof rawNotify.autoReplyEmailField === "string" ? rawNotify.autoReplyEmailField.trim() : "";
|
|
1951
|
+
if (selectedEmailField && !emailFields.includes(selectedEmailField)) {
|
|
1952
|
+
return errors.badRequest("autoReplyEmailField must name a declared email field.");
|
|
1953
|
+
}
|
|
1954
|
+
if (rawNotify.autoReply === true && emailFields.length !== 1 && !selectedEmailField) {
|
|
1955
|
+
return errors.badRequest("Auto-reply requires one selected declared email field.");
|
|
1956
|
+
}
|
|
1957
|
+
const notify = {
|
|
1958
|
+
...rawNotify,
|
|
1959
|
+
...selectedEmailField ? { autoReplyEmailField: selectedEmailField } : {}
|
|
1960
|
+
};
|
|
1483
1961
|
const { data, error } = await db().from("cms_forms").upsert(
|
|
1484
1962
|
{
|
|
1485
1963
|
slug,
|
|
@@ -1487,6 +1965,7 @@ function createCmsRoutes(options) {
|
|
|
1487
1965
|
config,
|
|
1488
1966
|
notify,
|
|
1489
1967
|
success_message: typeof body.successMessage === "string" ? body.successMessage : "",
|
|
1968
|
+
builder_owned: true,
|
|
1490
1969
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1491
1970
|
},
|
|
1492
1971
|
{ onConflict: "slug" }
|
|
@@ -1542,7 +2021,7 @@ function createCmsRoutes(options) {
|
|
|
1542
2021
|
const csvEscape = (value) => {
|
|
1543
2022
|
let text = Array.isArray(value) ? value.join("; ") : typeof value === "string" ? value : value === null || value === void 0 ? "" : JSON.stringify(value);
|
|
1544
2023
|
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
|
|
1545
|
-
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
2024
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
1546
2025
|
};
|
|
1547
2026
|
const exportSubmissions = async (request) => {
|
|
1548
2027
|
const auth = await guard(request, "submissions.read");
|
|
@@ -1660,17 +2139,18 @@ function createCmsRoutes(options) {
|
|
|
1660
2139
|
} catch {
|
|
1661
2140
|
}
|
|
1662
2141
|
if (sendEmail) {
|
|
2142
|
+
const notificationConfig = {
|
|
2143
|
+
...form.config || {},
|
|
2144
|
+
...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
|
|
2145
|
+
};
|
|
1663
2146
|
await notifySubmission({
|
|
1664
2147
|
sendEmail,
|
|
1665
2148
|
formTitle: String(form.title || form.slug),
|
|
1666
|
-
|
|
1667
|
-
config: {
|
|
1668
|
-
...form.config || {},
|
|
1669
|
-
...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
|
|
1670
|
-
},
|
|
2149
|
+
config: notificationConfig,
|
|
1671
2150
|
successMessage: String(form.success_message || ""),
|
|
1672
2151
|
data: result.normalizedData,
|
|
1673
|
-
siteName: options.siteName
|
|
2152
|
+
siteName: options.siteName,
|
|
2153
|
+
autoReplyAllowed: await maySendAutoReply(notificationConfig, result.normalizedData)
|
|
1674
2154
|
});
|
|
1675
2155
|
}
|
|
1676
2156
|
return json({ success: true, id: created.id });
|
|
@@ -1694,7 +2174,12 @@ function createCmsRoutes(options) {
|
|
|
1694
2174
|
return errors.badRequest("To path must start with / or be a full URL.");
|
|
1695
2175
|
}
|
|
1696
2176
|
if (fromPath === toPath) return errors.badRequest("A redirect cannot point at itself.");
|
|
1697
|
-
const { data, error } = await db().from("cms_redirects").upsert({
|
|
2177
|
+
const { data, error } = await db().from("cms_redirects").upsert({
|
|
2178
|
+
from_path: fromPath,
|
|
2179
|
+
to_path: toPath,
|
|
2180
|
+
permanent,
|
|
2181
|
+
builder_owned: true
|
|
2182
|
+
}, { onConflict: "from_path" }).select("*").single();
|
|
1698
2183
|
if (error) return errors.badRequest(error.message);
|
|
1699
2184
|
await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
|
|
1700
2185
|
return json({ redirect: data }, 201);
|
|
@@ -1708,6 +2193,7 @@ function createCmsRoutes(options) {
|
|
|
1708
2193
|
};
|
|
1709
2194
|
const ingestEvents = async (request) => {
|
|
1710
2195
|
if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
|
|
2196
|
+
if (!analyticsSecret) return json({ success: true });
|
|
1711
2197
|
const userAgent = request.headers.get("user-agent") || "";
|
|
1712
2198
|
if (isBotRequest(userAgent)) return json({ success: true });
|
|
1713
2199
|
const now = Date.now();
|
|
@@ -1724,7 +2210,7 @@ function createCmsRoutes(options) {
|
|
|
1724
2210
|
sessionKey: requestSessionKey(request),
|
|
1725
2211
|
visitorKey: visitorKeyFor(
|
|
1726
2212
|
body?.visitorId,
|
|
1727
|
-
|
|
2213
|
+
analyticsSecret
|
|
1728
2214
|
),
|
|
1729
2215
|
device: deviceFrom(userAgent),
|
|
1730
2216
|
...geoFrom(request)
|
|
@@ -1801,7 +2287,7 @@ function createCmsRoutes(options) {
|
|
|
1801
2287
|
const cronPublishDue = async (request) => {
|
|
1802
2288
|
if (hostedMemoryMode) return errors.forbidden();
|
|
1803
2289
|
const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
|
1804
|
-
let authorized = tokenEquals(bearer,
|
|
2290
|
+
let authorized = tokenEquals(bearer, cronToken || "");
|
|
1805
2291
|
if (!authorized) {
|
|
1806
2292
|
const user = await resolveUser(request, db());
|
|
1807
2293
|
authorized = Boolean(user && can(user, "pages.publish"));
|
|
@@ -1960,6 +2446,10 @@ function createCmsRoutes(options) {
|
|
|
1960
2446
|
return errors.badRequest("You can only assign roles at or below your own.");
|
|
1961
2447
|
}
|
|
1962
2448
|
}
|
|
2449
|
+
if (body.role !== void 0 && body.role !== membership.role) {
|
|
2450
|
+
const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
|
|
2451
|
+
if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
|
|
2452
|
+
}
|
|
1963
2453
|
if (typeof body.password === "string") {
|
|
1964
2454
|
if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
|
|
1965
2455
|
const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
|
|
@@ -1988,6 +2478,8 @@ function createCmsRoutes(options) {
|
|
|
1988
2478
|
return errors.badRequest("You can't remove your own account.");
|
|
1989
2479
|
}
|
|
1990
2480
|
if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
|
|
2481
|
+
const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
|
|
2482
|
+
if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
|
|
1991
2483
|
const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
|
|
1992
2484
|
if (authError) return errors.badRequest(authError.message);
|
|
1993
2485
|
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
|
|
@@ -2004,10 +2496,30 @@ function createCmsRoutes(options) {
|
|
|
2004
2496
|
authorized = Boolean(user && can(user, "sync.run"));
|
|
2005
2497
|
}
|
|
2006
2498
|
if (!authorized) return errors.forbidden();
|
|
2007
|
-
const body = await
|
|
2499
|
+
const body = await readJsonLimited(request, { maxBytes: MAX_SYNC_BODY_BYTES, maxDepth: 32 });
|
|
2008
2500
|
if (!body) return errors.badRequest("Invalid body.");
|
|
2009
|
-
const
|
|
2010
|
-
|
|
2501
|
+
const input = isRecord2(body.input) ? body.input : body;
|
|
2502
|
+
const mode = body.mode === "apply" ? "apply" : "dry-run";
|
|
2503
|
+
const expectedManifestHash = typeof body.expectedManifestHash === "string" ? body.expectedManifestHash : "";
|
|
2504
|
+
const expectedTargetReceiptHash = typeof body.expectedTargetReceiptHash === "string" ? body.expectedTargetReceiptHash : "";
|
|
2505
|
+
let result;
|
|
2506
|
+
try {
|
|
2507
|
+
result = mode === "apply" ? await runContentSync(db(), registry, input, {
|
|
2508
|
+
mode,
|
|
2509
|
+
expectedManifestHash,
|
|
2510
|
+
expectedProjectRef: previewProjectRef,
|
|
2511
|
+
expectedTargetReceiptHash
|
|
2512
|
+
}) : await runContentSync(db(), registry, input, {
|
|
2513
|
+
mode,
|
|
2514
|
+
expectedProjectRef: previewProjectRef
|
|
2515
|
+
});
|
|
2516
|
+
} catch (error) {
|
|
2517
|
+
return errors.conflict(error instanceof Error ? error.message : "Sync manifest conflict.");
|
|
2518
|
+
}
|
|
2519
|
+
if (result.mode === "apply") {
|
|
2520
|
+
await logActivity(null, "sync.apply", result.manifestHash);
|
|
2521
|
+
await revalidateContent();
|
|
2522
|
+
}
|
|
2011
2523
|
return json({ success: true, ...result });
|
|
2012
2524
|
};
|
|
2013
2525
|
const dispatch = async (request, context) => {
|
|
@@ -2031,11 +2543,12 @@ function createCmsRoutes(options) {
|
|
|
2031
2543
|
return duplicatePage(request, second);
|
|
2032
2544
|
} else if (third === "preview" && method === "POST") {
|
|
2033
2545
|
return previewToken(request, second);
|
|
2546
|
+
} else if (third === "preview" && method === "DELETE") {
|
|
2547
|
+
return revokePagePreviews(request, second);
|
|
2034
2548
|
} else if (third === "versions" && method === "GET") {
|
|
2035
2549
|
return listVersions(request, second);
|
|
2036
2550
|
}
|
|
2037
2551
|
}
|
|
2038
|
-
if (head === "preview" && !second && method === "GET") return previewPage(request);
|
|
2039
2552
|
if (head === "versions" && second) {
|
|
2040
2553
|
if (third === "restore" && method === "POST") return restoreVersion(request, second);
|
|
2041
2554
|
if (!third && method === "GET") return getVersion(request, second);
|
|
@@ -2406,10 +2919,11 @@ function runRpc(store, fn, args = {}) {
|
|
|
2406
2919
|
const key = String(args.p_key);
|
|
2407
2920
|
let row = globals.find((r) => r.key === key);
|
|
2408
2921
|
if (!row) {
|
|
2409
|
-
row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
|
|
2922
|
+
row = { key, label: "", data: args.p_data ?? {}, builder_owned: true, updated_at: nowIso() };
|
|
2410
2923
|
globals.push(row);
|
|
2411
2924
|
} else {
|
|
2412
2925
|
row.data = args.p_data ?? {};
|
|
2926
|
+
row.builder_owned = true;
|
|
2413
2927
|
row.updated_at = nowIso();
|
|
2414
2928
|
}
|
|
2415
2929
|
globalVersions.push({
|
|
@@ -2454,6 +2968,55 @@ function runRpc(store, fn, args = {}) {
|
|
|
2454
2968
|
store.setTable("cms_rate_limits", kept);
|
|
2455
2969
|
return { data: limits.length - kept.length, error: null };
|
|
2456
2970
|
}
|
|
2971
|
+
case "cms_consume_preview_grant": {
|
|
2972
|
+
const grant = store.table("cms_preview_grants").find(
|
|
2973
|
+
(row) => row.id === args.p_grant_id && row.page_id === args.p_page_id && row.user_id === args.p_user_id && row.project_ref === args.p_project_ref && row.key_id === args.p_key_id
|
|
2974
|
+
);
|
|
2975
|
+
if (!grant || grant.revoked_at) return { data: false, error: null };
|
|
2976
|
+
const expiresAt = typeof grant.expires_at === "string" ? Date.parse(grant.expires_at) : NaN;
|
|
2977
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return { data: false, error: null };
|
|
2978
|
+
const useCount = typeof grant.use_count === "number" ? grant.use_count : 0;
|
|
2979
|
+
const maxUses = typeof grant.max_uses === "number" ? grant.max_uses : 0;
|
|
2980
|
+
if (useCount >= maxUses) return { data: false, error: null };
|
|
2981
|
+
grant.use_count = useCount + 1;
|
|
2982
|
+
grant.last_used_at = nowIso();
|
|
2983
|
+
return { data: true, error: null };
|
|
2984
|
+
}
|
|
2985
|
+
case "cms_begin_sync": {
|
|
2986
|
+
const runs = store.table("cms_sync_runs");
|
|
2987
|
+
const requested = runs.find((row) => row.manifest_hash === args.p_manifest_hash);
|
|
2988
|
+
if (requested?.status === "complete") {
|
|
2989
|
+
return { data: { status: "complete", result: requested.result }, error: null };
|
|
2990
|
+
}
|
|
2991
|
+
if (requested?.status === "active") return { data: { status: "busy" }, error: null };
|
|
2992
|
+
const active = runs.find(
|
|
2993
|
+
(row) => row.status === "active" && row.manifest_hash !== args.p_manifest_hash
|
|
2994
|
+
);
|
|
2995
|
+
if (active) return { data: { status: "busy" }, error: null };
|
|
2996
|
+
if (requested) {
|
|
2997
|
+
requested.status = "active";
|
|
2998
|
+
requested.target_receipt_hash = args.p_target_receipt_hash;
|
|
2999
|
+
requested.updated_at = nowIso();
|
|
3000
|
+
return { data: { status: "resume" }, error: null };
|
|
3001
|
+
}
|
|
3002
|
+
runs.push({
|
|
3003
|
+
manifest_hash: args.p_manifest_hash,
|
|
3004
|
+
target_receipt_hash: args.p_target_receipt_hash,
|
|
3005
|
+
status: "active",
|
|
3006
|
+
started_at: nowIso(),
|
|
3007
|
+
updated_at: nowIso()
|
|
3008
|
+
});
|
|
3009
|
+
return { data: { status: "started" }, error: null };
|
|
3010
|
+
}
|
|
3011
|
+
case "cms_finish_sync": {
|
|
3012
|
+
const run = store.table("cms_sync_runs").find((row) => row.manifest_hash === args.p_manifest_hash);
|
|
3013
|
+
if (!run || run.status !== "active") return { data: false, error: null };
|
|
3014
|
+
run.status = args.p_status;
|
|
3015
|
+
run.result = args.p_status === "complete" ? args.p_result : null;
|
|
3016
|
+
run.error = args.p_status === "failed" ? args.p_error : null;
|
|
3017
|
+
run.updated_at = nowIso();
|
|
3018
|
+
return { data: true, error: null };
|
|
3019
|
+
}
|
|
2457
3020
|
case "cms_publish_due_pages": {
|
|
2458
3021
|
const nowMs = Date.now();
|
|
2459
3022
|
const published = [];
|
|
@@ -2532,6 +3095,90 @@ function runRpc(store, fn, args = {}) {
|
|
|
2532
3095
|
}
|
|
2533
3096
|
return { data: page, error: null };
|
|
2534
3097
|
}
|
|
3098
|
+
case "cms_sync_global": {
|
|
3099
|
+
const globals = store.table("cms_globals");
|
|
3100
|
+
const key = String(args.p_key);
|
|
3101
|
+
let row = globals.find((item) => item.key === key);
|
|
3102
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3103
|
+
if (!row) {
|
|
3104
|
+
row = { key, label: "", data: args.p_data ?? {}, builder_owned: false, updated_at: nowIso() };
|
|
3105
|
+
globals.push(row);
|
|
3106
|
+
} else {
|
|
3107
|
+
row.data = args.p_data ?? {};
|
|
3108
|
+
row.updated_at = nowIso();
|
|
3109
|
+
}
|
|
3110
|
+
return { data: true, error: null };
|
|
3111
|
+
}
|
|
3112
|
+
case "cms_sync_form": {
|
|
3113
|
+
const forms = store.table("cms_forms");
|
|
3114
|
+
const slug = String(args.p_slug);
|
|
3115
|
+
let row = forms.find((item) => item.slug === slug);
|
|
3116
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3117
|
+
if (!row) {
|
|
3118
|
+
row = {
|
|
3119
|
+
id: newId(),
|
|
3120
|
+
slug,
|
|
3121
|
+
title: args.p_title ?? slug,
|
|
3122
|
+
config: args.p_config ?? {},
|
|
3123
|
+
notify: {},
|
|
3124
|
+
success_message: args.p_success_message ?? "",
|
|
3125
|
+
builder_owned: false,
|
|
3126
|
+
created_at: nowIso(),
|
|
3127
|
+
updated_at: nowIso()
|
|
3128
|
+
};
|
|
3129
|
+
forms.push(row);
|
|
3130
|
+
} else {
|
|
3131
|
+
row.title = args.p_title ?? slug;
|
|
3132
|
+
row.config = args.p_config ?? {};
|
|
3133
|
+
row.success_message = args.p_success_message ?? "";
|
|
3134
|
+
row.updated_at = nowIso();
|
|
3135
|
+
}
|
|
3136
|
+
return { data: true, error: null };
|
|
3137
|
+
}
|
|
3138
|
+
case "cms_sync_media": {
|
|
3139
|
+
const media = store.table("cms_media");
|
|
3140
|
+
const storagePath = String(args.p_storage_path);
|
|
3141
|
+
let row = media.find((item) => item.storage_path === storagePath);
|
|
3142
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3143
|
+
const values = {
|
|
3144
|
+
storage_path: storagePath,
|
|
3145
|
+
filename: args.p_filename,
|
|
3146
|
+
alt: args.p_alt,
|
|
3147
|
+
caption: args.p_caption,
|
|
3148
|
+
mime_type: args.p_mime_type,
|
|
3149
|
+
width: args.p_width,
|
|
3150
|
+
height: args.p_height,
|
|
3151
|
+
filesize: args.p_filesize,
|
|
3152
|
+
builder_owned: false,
|
|
3153
|
+
updated_at: nowIso()
|
|
3154
|
+
};
|
|
3155
|
+
if (!row) {
|
|
3156
|
+
row = { id: newId(), created_at: nowIso(), ...values };
|
|
3157
|
+
media.push(row);
|
|
3158
|
+
} else {
|
|
3159
|
+
Object.assign(row, values);
|
|
3160
|
+
}
|
|
3161
|
+
return { data: true, error: null };
|
|
3162
|
+
}
|
|
3163
|
+
case "cms_sync_redirect": {
|
|
3164
|
+
const redirects = store.table("cms_redirects");
|
|
3165
|
+
const fromPath = String(args.p_from_path);
|
|
3166
|
+
let row = redirects.find((item) => item.from_path === fromPath);
|
|
3167
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3168
|
+
const values = {
|
|
3169
|
+
from_path: fromPath,
|
|
3170
|
+
to_path: args.p_to_path,
|
|
3171
|
+
permanent: args.p_permanent,
|
|
3172
|
+
builder_owned: false
|
|
3173
|
+
};
|
|
3174
|
+
if (!row) {
|
|
3175
|
+
row = { id: newId(), created_at: nowIso(), ...values };
|
|
3176
|
+
redirects.push(row);
|
|
3177
|
+
} else {
|
|
3178
|
+
Object.assign(row, values);
|
|
3179
|
+
}
|
|
3180
|
+
return { data: true, error: null };
|
|
3181
|
+
}
|
|
2535
3182
|
default:
|
|
2536
3183
|
return { data: null, error: { message: `unknown function ${fn}` } };
|
|
2537
3184
|
}
|
|
@@ -2586,6 +3233,8 @@ function getMemoryCms() {
|
|
|
2586
3233
|
export {
|
|
2587
3234
|
CONTENT_CACHE_TAG,
|
|
2588
3235
|
MEMORY_DEV_TOKEN,
|
|
3236
|
+
PREVIEW_SESSION_COOKIE,
|
|
3237
|
+
PREVIEW_TOKEN_MAX_USES,
|
|
2589
3238
|
PREVIEW_TOKEN_TTL_MS,
|
|
2590
3239
|
aggregateAnalytics,
|
|
2591
3240
|
can,
|
|
@@ -2609,6 +3258,8 @@ export {
|
|
|
2609
3258
|
runContentSync,
|
|
2610
3259
|
sessionKeyFor,
|
|
2611
3260
|
setServiceClientForTesting,
|
|
3261
|
+
validateCmsEnv,
|
|
3262
|
+
verifyPreviewGrantToken,
|
|
2612
3263
|
verifyPreviewToken,
|
|
2613
3264
|
visitorKeyFor
|
|
2614
3265
|
};
|