@orion-studios/cms 0.5.6 → 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-AYV6KYDP.js → chunk-NSAZCP4I.js} +20 -1
- package/dist/content/index.js +1 -1
- package/dist/server/index.d.ts +84 -18
- package/dist/server/index.js +708 -117
- package/dist/studio/index.js +25 -5
- 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,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONTENT_CACHE_TAG
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-NSAZCP4I.js";
|
|
4
4
|
import {
|
|
5
5
|
createMemoryRateLimitStore,
|
|
6
6
|
getAutoReplyEmailFields,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "../chunk-CFZP7674.js";
|
|
11
11
|
|
|
12
12
|
// src/server/routes.ts
|
|
13
|
-
import { createHash as
|
|
13
|
+
import { createHash as createHash3, createHmac as createHmac3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
14
14
|
|
|
15
15
|
// src/analytics/aggregate.ts
|
|
16
16
|
var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
|
|
@@ -437,33 +437,62 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
|
|
|
437
437
|
var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
|
|
438
438
|
var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
|
|
439
439
|
var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
440
|
+
var PREVIEW_TOKEN_MAX_USES = 8;
|
|
440
441
|
var PREVIEW_SESSION_COOKIE = "orion_preview_session";
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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)}`;
|
|
444
456
|
}
|
|
445
|
-
function
|
|
457
|
+
function verifyPreviewGrantToken(token, keys, now = Date.now()) {
|
|
446
458
|
const segments = token.split(".");
|
|
447
459
|
if (segments.length !== 2) return null;
|
|
448
460
|
const [payload, signature] = segments;
|
|
449
461
|
if (!payload || !signature) return null;
|
|
450
|
-
const expected = sign(payload, secret);
|
|
451
|
-
const expectedBuffer = Buffer.from(expected);
|
|
452
|
-
const actualBuffer = Buffer.from(signature);
|
|
453
|
-
if (expectedBuffer.length !== actualBuffer.length) return null;
|
|
454
|
-
if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
|
|
455
462
|
try {
|
|
456
|
-
const
|
|
457
|
-
if (
|
|
458
|
-
|
|
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;
|
|
459
475
|
} catch {
|
|
460
476
|
return null;
|
|
461
477
|
}
|
|
462
478
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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();
|
|
467
496
|
if (!data) return null;
|
|
468
497
|
return {
|
|
469
498
|
id: String(data.id),
|
|
@@ -477,20 +506,67 @@ async function getPreviewPage(client, token, secret) {
|
|
|
477
506
|
|
|
478
507
|
// src/server/supabase.ts
|
|
479
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
|
+
}
|
|
480
554
|
function readCmsEnv() {
|
|
481
555
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
|
|
482
|
-
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 || "";
|
|
483
558
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
484
559
|
throw new Error(
|
|
485
|
-
"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."
|
|
486
561
|
);
|
|
487
562
|
}
|
|
488
|
-
return { supabaseUrl, serviceRoleKey };
|
|
563
|
+
return validateCmsEnv({ expectedProjectRef, supabaseUrl, serviceRoleKey });
|
|
489
564
|
}
|
|
490
565
|
var serviceClient = null;
|
|
491
566
|
function getServiceClient(env = readCmsEnv()) {
|
|
492
567
|
if (!serviceClient) {
|
|
493
|
-
|
|
568
|
+
const validated = validateCmsEnv(env);
|
|
569
|
+
serviceClient = createClient(validated.supabaseUrl, validated.serviceRoleKey, {
|
|
494
570
|
auth: { persistSession: false, autoRefreshToken: false }
|
|
495
571
|
});
|
|
496
572
|
}
|
|
@@ -516,12 +592,19 @@ async function resolveUser(request, client = getServiceClient()) {
|
|
|
516
592
|
}
|
|
517
593
|
|
|
518
594
|
// src/server/sync.ts
|
|
595
|
+
import { createHash as createHash2 } from "crypto";
|
|
519
596
|
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
520
597
|
var publicFormConfig = (value) => {
|
|
521
598
|
const config = isRecord(value) ? { ...value } : {};
|
|
522
599
|
delete config.notify;
|
|
523
600
|
return config;
|
|
524
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));
|
|
525
608
|
var mimeByExtension = {
|
|
526
609
|
avif: "image/avif",
|
|
527
610
|
gif: "image/gif",
|
|
@@ -534,22 +617,15 @@ var mimeByExtension = {
|
|
|
534
617
|
webp: "image/webp"
|
|
535
618
|
};
|
|
536
619
|
var cleanPath = (src) => src.split("?")[0]?.split("#")[0] || src;
|
|
537
|
-
var filenameFromPath = (path) =>
|
|
538
|
-
|
|
539
|
-
return clean.split("/").filter(Boolean).pop() || clean;
|
|
540
|
-
};
|
|
541
|
-
var mimeFromPath = (path) => {
|
|
542
|
-
const extension = filenameFromPath(path).split(".").pop()?.toLowerCase() || "";
|
|
543
|
-
return mimeByExtension[extension] || "";
|
|
544
|
-
};
|
|
620
|
+
var filenameFromPath = (path) => cleanPath(path).split("/").filter(Boolean).pop() || cleanPath(path);
|
|
621
|
+
var mimeFromPath = (path) => mimeByExtension[filenameFromPath(path).split(".").pop()?.toLowerCase() || ""] || "";
|
|
545
622
|
var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
|
|
546
623
|
var toSyncMedia = (value) => {
|
|
547
624
|
const src = typeof value.src === "string" ? value.src.trim() : "";
|
|
548
625
|
if (!shouldIndexMediaPath(src)) return null;
|
|
549
|
-
const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
|
|
550
626
|
return {
|
|
551
627
|
storagePath: src,
|
|
552
|
-
filename,
|
|
628
|
+
filename: typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src),
|
|
553
629
|
alt: typeof value.alt === "string" ? value.alt : "",
|
|
554
630
|
caption: typeof value.caption === "string" ? value.caption : "",
|
|
555
631
|
mimeType: mimeFromPath(src)
|
|
@@ -582,79 +658,328 @@ var normalizeManualMedia = (item) => {
|
|
|
582
658
|
filesize: typeof item.filesize === "number" ? item.filesize : null
|
|
583
659
|
};
|
|
584
660
|
};
|
|
585
|
-
|
|
586
|
-
const pages =
|
|
587
|
-
const globals = input.globals || [];
|
|
588
|
-
const forms = input.forms || [];
|
|
661
|
+
var prepareSync = (registry, input) => {
|
|
662
|
+
const pages = [];
|
|
589
663
|
const media = /* @__PURE__ */ new Map();
|
|
590
|
-
const synced = [];
|
|
591
664
|
const skipped = [];
|
|
592
|
-
for (const page of pages) {
|
|
665
|
+
for (const page of input.pages || []) {
|
|
593
666
|
if (!page || typeof page.slug !== "string") continue;
|
|
594
667
|
const validated = registry.validateLayout(page.layout ?? []);
|
|
595
668
|
if (!validated.ok) {
|
|
596
669
|
skipped.push({ slug: page.slug, issues: validated.issues });
|
|
597
670
|
continue;
|
|
598
671
|
}
|
|
599
|
-
const { error } = await client.rpc("cms_sync_page", {
|
|
600
|
-
p_slug: page.slug,
|
|
601
|
-
p_path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
|
|
602
|
-
p_title: typeof page.title === "string" ? page.title : page.slug,
|
|
603
|
-
p_seo: isRecord(page.seo) ? page.seo : {},
|
|
604
|
-
p_layout: validated.layout
|
|
605
|
-
});
|
|
606
|
-
if (error) {
|
|
607
|
-
skipped.push({ slug: page.slug, issues: error.message });
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
672
|
collectLayoutMedia(validated.layout, media);
|
|
611
|
-
|
|
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
|
+
});
|
|
612
680
|
}
|
|
613
681
|
for (const item of input.media || []) {
|
|
614
682
|
const normalized = normalizeManualMedia(item);
|
|
615
683
|
if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
|
|
616
684
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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(
|
|
645
761
|
{
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
651
769
|
},
|
|
652
|
-
{
|
|
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
|
|
653
852
|
);
|
|
654
|
-
if (
|
|
655
|
-
|
|
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;
|
|
656
982
|
}
|
|
657
|
-
return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
|
|
658
983
|
}
|
|
659
984
|
|
|
660
985
|
// src/server/routes.ts
|
|
@@ -739,6 +1064,7 @@ var clientKey = (request) => {
|
|
|
739
1064
|
};
|
|
740
1065
|
var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
|
|
741
1066
|
var MAX_PUBLIC_BODY_DEPTH = 12;
|
|
1067
|
+
var MAX_SYNC_BODY_BYTES = 2 * 1024 * 1024;
|
|
742
1068
|
function exceedsDepth(value, limit, depth = 0) {
|
|
743
1069
|
if (depth > limit) return true;
|
|
744
1070
|
if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
|
|
@@ -746,13 +1072,38 @@ function exceedsDepth(value, limit, depth = 0) {
|
|
|
746
1072
|
return false;
|
|
747
1073
|
}
|
|
748
1074
|
async function readJsonLimited(request, limits) {
|
|
749
|
-
|
|
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 = "";
|
|
750
1086
|
try {
|
|
751
|
-
|
|
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();
|
|
752
1098
|
} catch {
|
|
1099
|
+
try {
|
|
1100
|
+
await reader.cancel();
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
753
1103
|
return null;
|
|
1104
|
+
} finally {
|
|
1105
|
+
reader.releaseLock();
|
|
754
1106
|
}
|
|
755
|
-
if (text.length > limits.maxBytes) return null;
|
|
756
1107
|
let parsed;
|
|
757
1108
|
try {
|
|
758
1109
|
parsed = JSON.parse(text);
|
|
@@ -763,7 +1114,7 @@ async function readJsonLimited(request, limits) {
|
|
|
763
1114
|
if (exceedsDepth(parsed, limits.maxDepth)) return null;
|
|
764
1115
|
return parsed;
|
|
765
1116
|
}
|
|
766
|
-
var hashedClientKey = (ip) =>
|
|
1117
|
+
var hashedClientKey = (ip) => createHash3("sha256").update(ip).digest("hex").slice(0, 24);
|
|
767
1118
|
var requestPagePath = (request) => {
|
|
768
1119
|
const referrer = request.headers.get("referer");
|
|
769
1120
|
if (!referrer) return "";
|
|
@@ -799,16 +1150,27 @@ function createDurableRateLimitStore(getClient, options) {
|
|
|
799
1150
|
};
|
|
800
1151
|
}
|
|
801
1152
|
function createCmsRoutes(options) {
|
|
802
|
-
const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
|
|
1153
|
+
const { registry, allowedOrigins, syncToken, cronToken, knownGoodEmailDomains } = options;
|
|
803
1154
|
const db = () => options.client ?? getServiceClient();
|
|
804
1155
|
const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
|
|
805
1156
|
const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
|
|
806
1157
|
const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
|
|
807
1158
|
const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
808
|
-
const
|
|
809
|
-
if (hostedMemoryMode) return
|
|
810
|
-
|
|
811
|
-
|
|
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" : "");
|
|
812
1174
|
const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
813
1175
|
const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
814
1176
|
const boundedAutoReplyMax = (name, fallback) => {
|
|
@@ -832,14 +1194,12 @@ function createCmsRoutes(options) {
|
|
|
832
1194
|
"auto-reply-site-day"
|
|
833
1195
|
)
|
|
834
1196
|
};
|
|
835
|
-
const autoReplyRecipientKey = (email) => createHmac3(
|
|
836
|
-
"sha256",
|
|
837
|
-
previewSecret() || syncToken || "orion-memory-auto-reply-secret"
|
|
838
|
-
).update(`recipient:${email}`).digest("hex").slice(0, 24);
|
|
1197
|
+
const autoReplyRecipientKey = (email) => createHmac3("sha256", autoReplyHashSecret).update(`recipient:${email}`).digest("hex").slice(0, 24);
|
|
839
1198
|
const maySendAutoReply = async (config, data) => {
|
|
840
1199
|
if (config.notify?.autoReply !== true) return false;
|
|
841
1200
|
const recipient = resolveSubmitterEmail(config, data);
|
|
842
1201
|
if (!recipient) return false;
|
|
1202
|
+
if (!autoReplyHashSecret) return false;
|
|
843
1203
|
const now = Date.now();
|
|
844
1204
|
if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
|
|
845
1205
|
return false;
|
|
@@ -891,8 +1251,8 @@ function createCmsRoutes(options) {
|
|
|
891
1251
|
};
|
|
892
1252
|
const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
|
|
893
1253
|
const requestSessionKey = (request) => {
|
|
894
|
-
|
|
895
|
-
return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "",
|
|
1254
|
+
if (!analyticsSecret) return "";
|
|
1255
|
+
return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", analyticsSecret);
|
|
896
1256
|
};
|
|
897
1257
|
const guard = async (request, action) => {
|
|
898
1258
|
if (hostedMemoryMode) return errors.unauthorized();
|
|
@@ -901,6 +1261,13 @@ function createCmsRoutes(options) {
|
|
|
901
1261
|
if (!can(user, action)) return errors.forbidden();
|
|
902
1262
|
return { user };
|
|
903
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
|
+
};
|
|
904
1271
|
const logActivity = async (user, action, subject) => {
|
|
905
1272
|
try {
|
|
906
1273
|
await db().from("cms_activity").insert({
|
|
@@ -1020,6 +1387,7 @@ function createCmsRoutes(options) {
|
|
|
1020
1387
|
}
|
|
1021
1388
|
patch.path = path;
|
|
1022
1389
|
}
|
|
1390
|
+
patch.builder_owned = true;
|
|
1023
1391
|
if (Object.keys(patch).length > 0) {
|
|
1024
1392
|
const oldPath = String(current.path || "");
|
|
1025
1393
|
const wasPublished = current.status === "published";
|
|
@@ -1033,7 +1401,12 @@ function createCmsRoutes(options) {
|
|
|
1033
1401
|
const newPath = typeof patch.path === "string" ? patch.path : oldPath;
|
|
1034
1402
|
if (newPath !== oldPath && wasPublished && oldPath !== "/") {
|
|
1035
1403
|
await db().from("cms_redirects").delete().eq("from_path", newPath);
|
|
1036
|
-
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" });
|
|
1037
1410
|
}
|
|
1038
1411
|
if (newPath !== oldPath) {
|
|
1039
1412
|
await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
|
|
@@ -1093,6 +1466,8 @@ function createCmsRoutes(options) {
|
|
|
1093
1466
|
if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
|
|
1094
1467
|
return errors.conflict("Page version conflict: save the draft again before publishing.");
|
|
1095
1468
|
}
|
|
1469
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1470
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1096
1471
|
const { data, error } = await db().rpc("cms_publish_page", {
|
|
1097
1472
|
p_page_id: id,
|
|
1098
1473
|
p_expected_version_id: expectedVersionId,
|
|
@@ -1109,6 +1484,8 @@ function createCmsRoutes(options) {
|
|
|
1109
1484
|
const unpublishPage = async (request, id) => {
|
|
1110
1485
|
const auth = await guard(request, "pages.publish");
|
|
1111
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.");
|
|
1112
1489
|
const { data, error } = await db().from("cms_pages").update({
|
|
1113
1490
|
status: "draft",
|
|
1114
1491
|
publish_at: null,
|
|
@@ -1167,11 +1544,28 @@ function createCmsRoutes(options) {
|
|
|
1167
1544
|
const previewToken = async (request, id) => {
|
|
1168
1545
|
const auth = await guard(request, "pages.read");
|
|
1169
1546
|
if (auth instanceof Response) return auth;
|
|
1170
|
-
const
|
|
1171
|
-
if (!
|
|
1547
|
+
const keys = previewKeys();
|
|
1548
|
+
if (!keys || !previewProjectRef) return errors.badRequest("Preview is not configured on this site.");
|
|
1172
1549
|
const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
|
|
1173
1550
|
if (!data) return errors.notFound();
|
|
1174
|
-
const
|
|
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
|
|
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);
|
|
1175
1569
|
const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
|
|
1176
1570
|
const response = json({ path: data.path, url: previewUrl });
|
|
1177
1571
|
const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
|
|
@@ -1183,6 +1577,14 @@ function createCmsRoutes(options) {
|
|
|
1183
1577
|
);
|
|
1184
1578
|
return response;
|
|
1185
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 });
|
|
1587
|
+
};
|
|
1186
1588
|
const listVersions = async (request, id) => {
|
|
1187
1589
|
const auth = await guard(request, "pages.restore");
|
|
1188
1590
|
if (auth instanceof Response) return auth;
|
|
@@ -1209,6 +1611,10 @@ function createCmsRoutes(options) {
|
|
|
1209
1611
|
const restoreVersion = async (request, versionId) => {
|
|
1210
1612
|
const auth = await guard(request, "pages.restore");
|
|
1211
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.");
|
|
1212
1618
|
const { data, error } = await db().rpc("cms_restore_page_version", {
|
|
1213
1619
|
p_version_id: Number(versionId),
|
|
1214
1620
|
p_actor: auth.user.id
|
|
@@ -1254,6 +1660,7 @@ function createCmsRoutes(options) {
|
|
|
1254
1660
|
const { data, error } = await db().from("cms_globals").upsert({
|
|
1255
1661
|
key: version.key,
|
|
1256
1662
|
data: version.data,
|
|
1663
|
+
builder_owned: true,
|
|
1257
1664
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1258
1665
|
}).select("*").single();
|
|
1259
1666
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1343,7 +1750,8 @@ function createCmsRoutes(options) {
|
|
|
1343
1750
|
mime_type: normalizedFile.type,
|
|
1344
1751
|
width: prepared.width,
|
|
1345
1752
|
height: prepared.height,
|
|
1346
|
-
filesize: normalizedFile.size
|
|
1753
|
+
filesize: normalizedFile.size,
|
|
1754
|
+
builder_owned: true
|
|
1347
1755
|
}).select("*").single();
|
|
1348
1756
|
if (error) return errors.badRequest(error.message);
|
|
1349
1757
|
if (!options.memoryMode) {
|
|
@@ -1361,7 +1769,10 @@ function createCmsRoutes(options) {
|
|
|
1361
1769
|
if (auth instanceof Response) return auth;
|
|
1362
1770
|
const body = await readJson(request);
|
|
1363
1771
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1364
|
-
const patch = {
|
|
1772
|
+
const patch = {
|
|
1773
|
+
builder_owned: true,
|
|
1774
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1775
|
+
};
|
|
1365
1776
|
if (typeof body.alt === "string") patch.alt = body.alt;
|
|
1366
1777
|
if (typeof body.caption === "string") patch.caption = body.caption;
|
|
1367
1778
|
if (typeof body.filename === "string" && body.filename.trim()) {
|
|
@@ -1420,6 +1831,7 @@ function createCmsRoutes(options) {
|
|
|
1420
1831
|
filesize: normalizedFile.size,
|
|
1421
1832
|
width: prepared.width,
|
|
1422
1833
|
height: prepared.height,
|
|
1834
|
+
builder_owned: true,
|
|
1423
1835
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1424
1836
|
}).eq("id", id).select("*").single();
|
|
1425
1837
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1488,7 +1900,8 @@ function createCmsRoutes(options) {
|
|
|
1488
1900
|
slug,
|
|
1489
1901
|
title,
|
|
1490
1902
|
config: { steps: [{ title: "", fields: [] }] },
|
|
1491
|
-
success_message: "Thanks \u2014 we received your submission."
|
|
1903
|
+
success_message: "Thanks \u2014 we received your submission.",
|
|
1904
|
+
builder_owned: true
|
|
1492
1905
|
}).select("*").single();
|
|
1493
1906
|
if (error) {
|
|
1494
1907
|
if (error.message.includes("duplicate")) {
|
|
@@ -1502,9 +1915,15 @@ function createCmsRoutes(options) {
|
|
|
1502
1915
|
const getForm = async (request, slug) => {
|
|
1503
1916
|
const auth = await guard(request, "forms.read");
|
|
1504
1917
|
if (auth instanceof Response) return auth;
|
|
1505
|
-
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();
|
|
1506
1920
|
if (error) return errors.badRequest(error.message);
|
|
1507
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
|
+
}
|
|
1508
1927
|
return json({ form: data });
|
|
1509
1928
|
};
|
|
1510
1929
|
const updateForm = async (request, slug) => {
|
|
@@ -1546,6 +1965,7 @@ function createCmsRoutes(options) {
|
|
|
1546
1965
|
config,
|
|
1547
1966
|
notify,
|
|
1548
1967
|
success_message: typeof body.successMessage === "string" ? body.successMessage : "",
|
|
1968
|
+
builder_owned: true,
|
|
1549
1969
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1550
1970
|
},
|
|
1551
1971
|
{ onConflict: "slug" }
|
|
@@ -1754,7 +2174,12 @@ function createCmsRoutes(options) {
|
|
|
1754
2174
|
return errors.badRequest("To path must start with / or be a full URL.");
|
|
1755
2175
|
}
|
|
1756
2176
|
if (fromPath === toPath) return errors.badRequest("A redirect cannot point at itself.");
|
|
1757
|
-
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();
|
|
1758
2183
|
if (error) return errors.badRequest(error.message);
|
|
1759
2184
|
await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
|
|
1760
2185
|
return json({ redirect: data }, 201);
|
|
@@ -1768,6 +2193,7 @@ function createCmsRoutes(options) {
|
|
|
1768
2193
|
};
|
|
1769
2194
|
const ingestEvents = async (request) => {
|
|
1770
2195
|
if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
|
|
2196
|
+
if (!analyticsSecret) return json({ success: true });
|
|
1771
2197
|
const userAgent = request.headers.get("user-agent") || "";
|
|
1772
2198
|
if (isBotRequest(userAgent)) return json({ success: true });
|
|
1773
2199
|
const now = Date.now();
|
|
@@ -1784,7 +2210,7 @@ function createCmsRoutes(options) {
|
|
|
1784
2210
|
sessionKey: requestSessionKey(request),
|
|
1785
2211
|
visitorKey: visitorKeyFor(
|
|
1786
2212
|
body?.visitorId,
|
|
1787
|
-
|
|
2213
|
+
analyticsSecret
|
|
1788
2214
|
),
|
|
1789
2215
|
device: deviceFrom(userAgent),
|
|
1790
2216
|
...geoFrom(request)
|
|
@@ -1861,7 +2287,7 @@ function createCmsRoutes(options) {
|
|
|
1861
2287
|
const cronPublishDue = async (request) => {
|
|
1862
2288
|
if (hostedMemoryMode) return errors.forbidden();
|
|
1863
2289
|
const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
|
1864
|
-
let authorized = tokenEquals(bearer,
|
|
2290
|
+
let authorized = tokenEquals(bearer, cronToken || "");
|
|
1865
2291
|
if (!authorized) {
|
|
1866
2292
|
const user = await resolveUser(request, db());
|
|
1867
2293
|
authorized = Boolean(user && can(user, "pages.publish"));
|
|
@@ -2020,6 +2446,10 @@ function createCmsRoutes(options) {
|
|
|
2020
2446
|
return errors.badRequest("You can only assign roles at or below your own.");
|
|
2021
2447
|
}
|
|
2022
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
|
+
}
|
|
2023
2453
|
if (typeof body.password === "string") {
|
|
2024
2454
|
if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
|
|
2025
2455
|
const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
|
|
@@ -2048,6 +2478,8 @@ function createCmsRoutes(options) {
|
|
|
2048
2478
|
return errors.badRequest("You can't remove your own account.");
|
|
2049
2479
|
}
|
|
2050
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.");
|
|
2051
2483
|
const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
|
|
2052
2484
|
if (authError) return errors.badRequest(authError.message);
|
|
2053
2485
|
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
|
|
@@ -2064,10 +2496,30 @@ function createCmsRoutes(options) {
|
|
|
2064
2496
|
authorized = Boolean(user && can(user, "sync.run"));
|
|
2065
2497
|
}
|
|
2066
2498
|
if (!authorized) return errors.forbidden();
|
|
2067
|
-
const body = await
|
|
2499
|
+
const body = await readJsonLimited(request, { maxBytes: MAX_SYNC_BODY_BYTES, maxDepth: 32 });
|
|
2068
2500
|
if (!body) return errors.badRequest("Invalid body.");
|
|
2069
|
-
const
|
|
2070
|
-
|
|
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
|
+
}
|
|
2071
2523
|
return json({ success: true, ...result });
|
|
2072
2524
|
};
|
|
2073
2525
|
const dispatch = async (request, context) => {
|
|
@@ -2091,6 +2543,8 @@ function createCmsRoutes(options) {
|
|
|
2091
2543
|
return duplicatePage(request, second);
|
|
2092
2544
|
} else if (third === "preview" && method === "POST") {
|
|
2093
2545
|
return previewToken(request, second);
|
|
2546
|
+
} else if (third === "preview" && method === "DELETE") {
|
|
2547
|
+
return revokePagePreviews(request, second);
|
|
2094
2548
|
} else if (third === "versions" && method === "GET") {
|
|
2095
2549
|
return listVersions(request, second);
|
|
2096
2550
|
}
|
|
@@ -2465,10 +2919,11 @@ function runRpc(store, fn, args = {}) {
|
|
|
2465
2919
|
const key = String(args.p_key);
|
|
2466
2920
|
let row = globals.find((r) => r.key === key);
|
|
2467
2921
|
if (!row) {
|
|
2468
|
-
row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
|
|
2922
|
+
row = { key, label: "", data: args.p_data ?? {}, builder_owned: true, updated_at: nowIso() };
|
|
2469
2923
|
globals.push(row);
|
|
2470
2924
|
} else {
|
|
2471
2925
|
row.data = args.p_data ?? {};
|
|
2926
|
+
row.builder_owned = true;
|
|
2472
2927
|
row.updated_at = nowIso();
|
|
2473
2928
|
}
|
|
2474
2929
|
globalVersions.push({
|
|
@@ -2513,6 +2968,55 @@ function runRpc(store, fn, args = {}) {
|
|
|
2513
2968
|
store.setTable("cms_rate_limits", kept);
|
|
2514
2969
|
return { data: limits.length - kept.length, error: null };
|
|
2515
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
|
+
}
|
|
2516
3020
|
case "cms_publish_due_pages": {
|
|
2517
3021
|
const nowMs = Date.now();
|
|
2518
3022
|
const published = [];
|
|
@@ -2591,6 +3095,90 @@ function runRpc(store, fn, args = {}) {
|
|
|
2591
3095
|
}
|
|
2592
3096
|
return { data: page, error: null };
|
|
2593
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
|
+
}
|
|
2594
3182
|
default:
|
|
2595
3183
|
return { data: null, error: { message: `unknown function ${fn}` } };
|
|
2596
3184
|
}
|
|
@@ -2646,6 +3234,7 @@ export {
|
|
|
2646
3234
|
CONTENT_CACHE_TAG,
|
|
2647
3235
|
MEMORY_DEV_TOKEN,
|
|
2648
3236
|
PREVIEW_SESSION_COOKIE,
|
|
3237
|
+
PREVIEW_TOKEN_MAX_USES,
|
|
2649
3238
|
PREVIEW_TOKEN_TTL_MS,
|
|
2650
3239
|
aggregateAnalytics,
|
|
2651
3240
|
can,
|
|
@@ -2669,6 +3258,8 @@ export {
|
|
|
2669
3258
|
runContentSync,
|
|
2670
3259
|
sessionKeyFor,
|
|
2671
3260
|
setServiceClientForTesting,
|
|
3261
|
+
validateCmsEnv,
|
|
3262
|
+
verifyPreviewGrantToken,
|
|
2672
3263
|
verifyPreviewToken,
|
|
2673
3264
|
visitorKeyFor
|
|
2674
3265
|
};
|