@orion-studios/cms 0.5.6 → 0.5.8
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/analytics/react.d.ts +3 -1
- package/dist/analytics/react.js +5 -0
- package/dist/chunk-DPKXKG2S.js +8 -0
- package/dist/{chunk-AYV6KYDP.js → chunk-NSAZCP4I.js} +20 -1
- package/dist/{chunk-ULE565KD.js → chunk-Z52R6XR7.js} +227 -72
- package/dist/content/index.js +1 -1
- package/dist/forms/react.d.ts +12 -2
- package/dist/forms/react.js +6 -3
- package/dist/server/index.d.ts +93 -21
- package/dist/server/index.js +803 -143
- package/dist/studio/index.js +29 -7
- 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,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CONTENT_CACHE_TAG
|
|
3
|
-
} from "../chunk-AYV6KYDP.js";
|
|
4
1
|
import {
|
|
5
2
|
createMemoryRateLimitStore,
|
|
6
3
|
getAutoReplyEmailFields,
|
|
@@ -8,9 +5,12 @@ import {
|
|
|
8
5
|
processSubmission,
|
|
9
6
|
resolveAutoReplyEmailField
|
|
10
7
|
} from "../chunk-CFZP7674.js";
|
|
8
|
+
import {
|
|
9
|
+
CONTENT_CACHE_TAG
|
|
10
|
+
} from "../chunk-NSAZCP4I.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"]);
|
|
@@ -40,9 +40,10 @@ function normalizeSource(utmSource, referrer) {
|
|
|
40
40
|
if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
|
|
41
41
|
return host;
|
|
42
42
|
}
|
|
43
|
-
var
|
|
43
|
+
var isFormSubmitEvent = (event) => event.type === "form" && event.name.endsWith(":submit");
|
|
44
|
+
var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || isFormSubmitEvent(event));
|
|
44
45
|
var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
|
|
45
|
-
function computeKpis(events) {
|
|
46
|
+
function computeKpis(events, submissions) {
|
|
46
47
|
const sessions = /* @__PURE__ */ new Set();
|
|
47
48
|
const visitors = /* @__PURE__ */ new Set();
|
|
48
49
|
const visitorSessions = /* @__PURE__ */ new Map();
|
|
@@ -65,12 +66,15 @@ function computeKpis(events) {
|
|
|
65
66
|
if (event.type === "click" && event.name === "call") calls += 1;
|
|
66
67
|
if (event.type === "click" && event.name === "email") emails += 1;
|
|
67
68
|
if (event.type === "click" && event.name === "portal") portalClicks += 1;
|
|
68
|
-
if (event.server_verified === true && event
|
|
69
|
-
formSubmits += 1;
|
|
70
|
-
}
|
|
69
|
+
if (event.server_verified === true && isFormSubmitEvent(event)) formSubmits += 1;
|
|
71
70
|
if (isConversion(event) && event.session_key) converting.add(event.session_key);
|
|
72
71
|
}
|
|
73
|
-
const
|
|
72
|
+
for (const submission of submissions || []) {
|
|
73
|
+
if (submission.session_key && sessions.has(submission.session_key)) {
|
|
74
|
+
converting.add(submission.session_key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const conversions = events.filter(isConversion).length + (submissions?.length ?? 0);
|
|
74
78
|
const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
|
|
75
79
|
return {
|
|
76
80
|
visitors: visitors.size,
|
|
@@ -80,19 +84,21 @@ function computeKpis(events) {
|
|
|
80
84
|
pageviews,
|
|
81
85
|
pagesPerVisitor: visitors.size > 0 ? pageviews / visitors.size : 0,
|
|
82
86
|
calls,
|
|
83
|
-
formSubmits,
|
|
87
|
+
formSubmits: submissions?.length ?? formSubmits,
|
|
84
88
|
emails,
|
|
85
89
|
portalClicks,
|
|
86
90
|
conversions,
|
|
87
91
|
conversionRate: sessions.size > 0 ? converting.size / sessions.size : 0
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
|
-
function aggregateAnalytics(events, previousEvents, range) {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
94
|
+
function aggregateAnalytics(events, previousEvents, range, submissions, previousSubmissions) {
|
|
95
|
+
const trafficEvents = submissions === void 0 ? events : events.filter((event) => !isFormSubmitEvent(event));
|
|
96
|
+
const previousTrafficEvents = previousSubmissions === void 0 ? previousEvents : previousEvents.filter((event) => !isFormSubmitEvent(event));
|
|
97
|
+
const kpis = computeKpis(trafficEvents, submissions);
|
|
98
|
+
const previous = computeKpis(previousTrafficEvents, previousSubmissions);
|
|
93
99
|
const byDay = /* @__PURE__ */ new Map();
|
|
94
100
|
const dayOf = (iso) => iso.slice(0, 10);
|
|
95
|
-
for (const event of
|
|
101
|
+
for (const event of trafficEvents) {
|
|
96
102
|
const day = dayOf(event.created_at);
|
|
97
103
|
const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
|
|
98
104
|
const identity = event.visitor_key || event.session_key;
|
|
@@ -100,6 +106,12 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
100
106
|
if (isConversion(event)) entry.conversions += 1;
|
|
101
107
|
byDay.set(day, entry);
|
|
102
108
|
}
|
|
109
|
+
for (const submission of submissions || []) {
|
|
110
|
+
const day = dayOf(submission.created_at);
|
|
111
|
+
const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
|
|
112
|
+
entry.conversions += 1;
|
|
113
|
+
byDay.set(day, entry);
|
|
114
|
+
}
|
|
103
115
|
const trend = [];
|
|
104
116
|
for (let cursor = /* @__PURE__ */ new Date(`${dayOf(range.from)}T00:00:00Z`); cursor.toISOString().slice(0, 10) <= dayOf(range.to); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
|
105
117
|
const day = cursor.toISOString().slice(0, 10);
|
|
@@ -108,7 +120,7 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
108
120
|
if (trend.length > 370) break;
|
|
109
121
|
}
|
|
110
122
|
const bySession = /* @__PURE__ */ new Map();
|
|
111
|
-
for (const event of
|
|
123
|
+
for (const event of trafficEvents) {
|
|
112
124
|
if (!event.session_key) continue;
|
|
113
125
|
const list = bySession.get(event.session_key) || [];
|
|
114
126
|
list.push(event);
|
|
@@ -117,27 +129,39 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
117
129
|
for (const list of bySession.values()) {
|
|
118
130
|
list.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
119
131
|
}
|
|
132
|
+
const convertedSessionKeys = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const event of trafficEvents) {
|
|
134
|
+
if (isConversion(event) && event.session_key) convertedSessionKeys.add(event.session_key);
|
|
135
|
+
}
|
|
136
|
+
for (const submission of submissions || []) {
|
|
137
|
+
if (submission.session_key) convertedSessionKeys.add(submission.session_key);
|
|
138
|
+
}
|
|
120
139
|
const pages = /* @__PURE__ */ new Map();
|
|
121
140
|
const pageEntry = (path) => {
|
|
122
141
|
const entry = pages.get(path) || { views: 0, entries: 0, conversions: 0 };
|
|
123
142
|
pages.set(path, entry);
|
|
124
143
|
return entry;
|
|
125
144
|
};
|
|
126
|
-
for (const event of
|
|
145
|
+
for (const event of trafficEvents) {
|
|
127
146
|
if (event.type === "pageview") pageEntry(event.path).views += 1;
|
|
128
147
|
if (isConversion(event)) pageEntry(event.path).conversions += 1;
|
|
129
148
|
}
|
|
149
|
+
for (const submission of submissions || []) {
|
|
150
|
+
const list = bySession.get(submission.session_key);
|
|
151
|
+
const lastPageview = list?.filter((event) => event.type === "pageview" && event.created_at <= submission.created_at).at(-1);
|
|
152
|
+
if (lastPageview) pageEntry(lastPageview.path).conversions += 1;
|
|
153
|
+
}
|
|
130
154
|
for (const list of bySession.values()) {
|
|
131
155
|
const first = list.find((event) => event.type === "pageview");
|
|
132
156
|
if (first) pageEntry(first.path).entries += 1;
|
|
133
157
|
}
|
|
134
158
|
const sources = /* @__PURE__ */ new Map();
|
|
135
|
-
for (const list of bySession.
|
|
159
|
+
for (const [sessionKey, list] of bySession.entries()) {
|
|
136
160
|
const first = list.find((event) => event.type === "pageview");
|
|
137
161
|
const label = normalizeSource(first?.utm?.source || "", first?.referrer || "");
|
|
138
162
|
const entry = sources.get(label) || { sessions: 0, conversions: 0 };
|
|
139
163
|
entry.sessions += 1;
|
|
140
|
-
if (
|
|
164
|
+
if (convertedSessionKeys.has(sessionKey)) entry.conversions += 1;
|
|
141
165
|
sources.set(label, entry);
|
|
142
166
|
}
|
|
143
167
|
const locations = /* @__PURE__ */ new Map();
|
|
@@ -149,13 +173,13 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
149
173
|
if (sample.device) devices.set(sample.device, (devices.get(sample.device) || 0) + 1);
|
|
150
174
|
}
|
|
151
175
|
const hours = new Array(24).fill(0);
|
|
152
|
-
for (const event of
|
|
176
|
+
for (const event of trafficEvents) {
|
|
153
177
|
if (event.type !== "pageview") continue;
|
|
154
178
|
const hour = new Date(event.created_at).getUTCHours();
|
|
155
179
|
if (Number.isFinite(hour)) hours[hour] += 1;
|
|
156
180
|
}
|
|
157
181
|
const pathCounts = /* @__PURE__ */ new Map();
|
|
158
|
-
for (const list of bySession.
|
|
182
|
+
for (const [sessionKey, list] of bySession.entries()) {
|
|
159
183
|
const steps = [];
|
|
160
184
|
for (const event of list) {
|
|
161
185
|
if (event.type !== "pageview") continue;
|
|
@@ -166,22 +190,28 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
166
190
|
const key = steps.join(" \u2192 ");
|
|
167
191
|
const entry = pathCounts.get(key) || { count: 0, converted: 0 };
|
|
168
192
|
entry.count += 1;
|
|
169
|
-
if (
|
|
193
|
+
if (convertedSessionKeys.has(sessionKey)) entry.converted += 1;
|
|
170
194
|
pathCounts.set(key, entry);
|
|
171
195
|
}
|
|
172
196
|
const forms = /* @__PURE__ */ new Map();
|
|
173
|
-
for (const event of
|
|
197
|
+
for (const event of trafficEvents) {
|
|
174
198
|
if (event.type !== "form") continue;
|
|
175
199
|
const [slug, stage] = event.name.split(":");
|
|
176
200
|
if (!slug || !stage) continue;
|
|
201
|
+
if (stage !== "viewed" && stage !== "start" && stage !== "submit") continue;
|
|
177
202
|
const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
|
|
178
|
-
if (stage === "
|
|
203
|
+
if (stage === "viewed") entry.views += 1;
|
|
179
204
|
if (stage === "start") entry.starts += 1;
|
|
180
205
|
if (stage === "submit" && event.server_verified === true) entry.submits += 1;
|
|
181
206
|
forms.set(slug, entry);
|
|
182
207
|
}
|
|
208
|
+
for (const submission of submissions || []) {
|
|
209
|
+
const entry = forms.get(submission.form) || { views: 0, starts: 0, submits: 0 };
|
|
210
|
+
entry.submits += 1;
|
|
211
|
+
forms.set(submission.form, entry);
|
|
212
|
+
}
|
|
183
213
|
const notFound = /* @__PURE__ */ new Map();
|
|
184
|
-
for (const event of
|
|
214
|
+
for (const event of trafficEvents) {
|
|
185
215
|
if (event.type === "not_found") notFound.set(event.path, (notFound.get(event.path) || 0) + 1);
|
|
186
216
|
}
|
|
187
217
|
return {
|
|
@@ -437,33 +467,62 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
|
|
|
437
467
|
var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
|
|
438
468
|
var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
|
|
439
469
|
var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
470
|
+
var PREVIEW_TOKEN_MAX_USES = 8;
|
|
440
471
|
var PREVIEW_SESSION_COOKIE = "orion_preview_session";
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
472
|
+
var keyRing = (keys) => typeof keys === "string" ? { active: { id: "legacy", secret: keys } } : keys;
|
|
473
|
+
function createPreviewToken(grant, keys, ttlMs = PREVIEW_TOKEN_TTL_MS) {
|
|
474
|
+
const active = keyRing(keys).active;
|
|
475
|
+
const issuedAt = Date.now();
|
|
476
|
+
const payload = encode(JSON.stringify({
|
|
477
|
+
version: 1,
|
|
478
|
+
audience: "cms-preview",
|
|
479
|
+
operation: "read-draft",
|
|
480
|
+
keyId: active.id,
|
|
481
|
+
...grant,
|
|
482
|
+
issuedAt,
|
|
483
|
+
expiresAt: issuedAt + Math.min(Math.max(ttlMs, 1), PREVIEW_TOKEN_TTL_MS)
|
|
484
|
+
}));
|
|
485
|
+
return `${payload}.${sign(payload, active.secret)}`;
|
|
444
486
|
}
|
|
445
|
-
function
|
|
487
|
+
function verifyPreviewGrantToken(token, keys, now = Date.now()) {
|
|
446
488
|
const segments = token.split(".");
|
|
447
489
|
if (segments.length !== 2) return null;
|
|
448
490
|
const [payload, signature] = segments;
|
|
449
491
|
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
492
|
try {
|
|
456
|
-
const
|
|
457
|
-
if (
|
|
458
|
-
|
|
493
|
+
const claims = JSON.parse(decode(payload));
|
|
494
|
+
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) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const configured = keyRing(keys);
|
|
498
|
+
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;
|
|
499
|
+
if (!signingKey) return null;
|
|
500
|
+
const expectedBuffer = Buffer.from(sign(payload, signingKey.secret));
|
|
501
|
+
const actualBuffer = Buffer.from(signature);
|
|
502
|
+
if (expectedBuffer.length !== actualBuffer.length) return null;
|
|
503
|
+
if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
|
|
504
|
+
return claims;
|
|
459
505
|
} catch {
|
|
460
506
|
return null;
|
|
461
507
|
}
|
|
462
508
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
509
|
+
function verifyPreviewToken(token, keys) {
|
|
510
|
+
return verifyPreviewGrantToken(token, keys)?.pageId ?? null;
|
|
511
|
+
}
|
|
512
|
+
async function getPreviewPage(client, token, keys, expectedProjectRef) {
|
|
513
|
+
const configured = keyRing(keys);
|
|
514
|
+
if (!configured.active.id || !configured.active.secret || !expectedProjectRef) return null;
|
|
515
|
+
const claims = verifyPreviewGrantToken(token, configured);
|
|
516
|
+
if (!claims || claims.projectRef !== expectedProjectRef) return null;
|
|
517
|
+
const { data: consumed, error: consumeError } = await client.rpc("cms_consume_preview_grant", {
|
|
518
|
+
p_grant_id: claims.grantId,
|
|
519
|
+
p_page_id: claims.pageId,
|
|
520
|
+
p_user_id: claims.userId,
|
|
521
|
+
p_project_ref: claims.projectRef,
|
|
522
|
+
p_key_id: claims.keyId
|
|
523
|
+
});
|
|
524
|
+
if (consumeError || consumed !== true) return null;
|
|
525
|
+
const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", claims.pageId).maybeSingle();
|
|
467
526
|
if (!data) return null;
|
|
468
527
|
return {
|
|
469
528
|
id: String(data.id),
|
|
@@ -477,20 +536,67 @@ async function getPreviewPage(client, token, secret) {
|
|
|
477
536
|
|
|
478
537
|
// src/server/supabase.ts
|
|
479
538
|
import { createClient } from "@supabase/supabase-js";
|
|
539
|
+
var projectRefFromUrl = (supabaseUrl) => {
|
|
540
|
+
let parsed;
|
|
541
|
+
try {
|
|
542
|
+
parsed = new URL(supabaseUrl);
|
|
543
|
+
} catch {
|
|
544
|
+
throw new Error("Orion CMS: the Supabase URL is invalid.");
|
|
545
|
+
}
|
|
546
|
+
const match = /^([a-z0-9]{20})\.supabase\.co$/.exec(parsed.hostname);
|
|
547
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash || !match) {
|
|
548
|
+
throw new Error("Orion CMS: the standard HTTPS Supabase project URL is required.");
|
|
549
|
+
}
|
|
550
|
+
return match[1];
|
|
551
|
+
};
|
|
552
|
+
var legacyServiceKeyClaims = (key) => {
|
|
553
|
+
const segments = key.split(".");
|
|
554
|
+
if (segments.length !== 3) return null;
|
|
555
|
+
try {
|
|
556
|
+
const payload = Buffer.from(segments[1] || "", "base64url").toString("utf8");
|
|
557
|
+
const claims = JSON.parse(payload);
|
|
558
|
+
return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
|
|
559
|
+
} catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
function validateCmsEnv(env) {
|
|
564
|
+
if (!/^[a-z0-9]{20}$/.test(env.expectedProjectRef)) {
|
|
565
|
+
throw new Error("Orion CMS: CMS_EXPECTED_SUPABASE_PROJECT_REF must be set.");
|
|
566
|
+
}
|
|
567
|
+
if (projectRefFromUrl(env.supabaseUrl) !== env.expectedProjectRef) {
|
|
568
|
+
throw new Error("Orion CMS: the Supabase URL does not match the expected project.");
|
|
569
|
+
}
|
|
570
|
+
if (env.serviceRoleKey.startsWith("sb_publishable_")) {
|
|
571
|
+
throw new Error("Orion CMS: a publishable key cannot be used as the server credential.");
|
|
572
|
+
}
|
|
573
|
+
if (!env.serviceRoleKey.startsWith("sb_secret_")) {
|
|
574
|
+
const claims = legacyServiceKeyClaims(env.serviceRoleKey);
|
|
575
|
+
if (!claims || claims.role !== "service_role") {
|
|
576
|
+
throw new Error("Orion CMS: a Supabase secret or legacy service-role key is required.");
|
|
577
|
+
}
|
|
578
|
+
if (claims.ref !== env.expectedProjectRef) {
|
|
579
|
+
throw new Error("Orion CMS: the legacy service-role key does not match the expected project.");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return env;
|
|
583
|
+
}
|
|
480
584
|
function readCmsEnv() {
|
|
481
585
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
|
|
482
|
-
const serviceRoleKey = process.env.
|
|
586
|
+
const serviceRoleKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
|
587
|
+
const expectedProjectRef = process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF || "";
|
|
483
588
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
484
589
|
throw new Error(
|
|
485
|
-
"Orion CMS: NEXT_PUBLIC_SUPABASE_URL and
|
|
590
|
+
"Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY (or legacy SUPABASE_SERVICE_ROLE_KEY) must be set."
|
|
486
591
|
);
|
|
487
592
|
}
|
|
488
|
-
return { supabaseUrl, serviceRoleKey };
|
|
593
|
+
return validateCmsEnv({ expectedProjectRef, supabaseUrl, serviceRoleKey });
|
|
489
594
|
}
|
|
490
595
|
var serviceClient = null;
|
|
491
596
|
function getServiceClient(env = readCmsEnv()) {
|
|
492
597
|
if (!serviceClient) {
|
|
493
|
-
|
|
598
|
+
const validated = validateCmsEnv(env);
|
|
599
|
+
serviceClient = createClient(validated.supabaseUrl, validated.serviceRoleKey, {
|
|
494
600
|
auth: { persistSession: false, autoRefreshToken: false }
|
|
495
601
|
});
|
|
496
602
|
}
|
|
@@ -516,12 +622,19 @@ async function resolveUser(request, client = getServiceClient()) {
|
|
|
516
622
|
}
|
|
517
623
|
|
|
518
624
|
// src/server/sync.ts
|
|
625
|
+
import { createHash as createHash2 } from "crypto";
|
|
519
626
|
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
520
627
|
var publicFormConfig = (value) => {
|
|
521
628
|
const config = isRecord(value) ? { ...value } : {};
|
|
522
629
|
delete config.notify;
|
|
523
630
|
return config;
|
|
524
631
|
};
|
|
632
|
+
var canonicalize = (value) => {
|
|
633
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
634
|
+
if (!isRecord(value)) return value;
|
|
635
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
636
|
+
};
|
|
637
|
+
var sameValue = (left, right) => JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
525
638
|
var mimeByExtension = {
|
|
526
639
|
avif: "image/avif",
|
|
527
640
|
gif: "image/gif",
|
|
@@ -534,22 +647,15 @@ var mimeByExtension = {
|
|
|
534
647
|
webp: "image/webp"
|
|
535
648
|
};
|
|
536
649
|
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
|
-
};
|
|
650
|
+
var filenameFromPath = (path) => cleanPath(path).split("/").filter(Boolean).pop() || cleanPath(path);
|
|
651
|
+
var mimeFromPath = (path) => mimeByExtension[filenameFromPath(path).split(".").pop()?.toLowerCase() || ""] || "";
|
|
545
652
|
var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
|
|
546
653
|
var toSyncMedia = (value) => {
|
|
547
654
|
const src = typeof value.src === "string" ? value.src.trim() : "";
|
|
548
655
|
if (!shouldIndexMediaPath(src)) return null;
|
|
549
|
-
const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
|
|
550
656
|
return {
|
|
551
657
|
storagePath: src,
|
|
552
|
-
filename,
|
|
658
|
+
filename: typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src),
|
|
553
659
|
alt: typeof value.alt === "string" ? value.alt : "",
|
|
554
660
|
caption: typeof value.caption === "string" ? value.caption : "",
|
|
555
661
|
mimeType: mimeFromPath(src)
|
|
@@ -582,79 +688,328 @@ var normalizeManualMedia = (item) => {
|
|
|
582
688
|
filesize: typeof item.filesize === "number" ? item.filesize : null
|
|
583
689
|
};
|
|
584
690
|
};
|
|
585
|
-
|
|
586
|
-
const pages =
|
|
587
|
-
const globals = input.globals || [];
|
|
588
|
-
const forms = input.forms || [];
|
|
691
|
+
var prepareSync = (registry, input) => {
|
|
692
|
+
const pages = [];
|
|
589
693
|
const media = /* @__PURE__ */ new Map();
|
|
590
|
-
const synced = [];
|
|
591
694
|
const skipped = [];
|
|
592
|
-
for (const page of pages) {
|
|
695
|
+
for (const page of input.pages || []) {
|
|
593
696
|
if (!page || typeof page.slug !== "string") continue;
|
|
594
697
|
const validated = registry.validateLayout(page.layout ?? []);
|
|
595
698
|
if (!validated.ok) {
|
|
596
699
|
skipped.push({ slug: page.slug, issues: validated.issues });
|
|
597
700
|
continue;
|
|
598
701
|
}
|
|
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
702
|
collectLayoutMedia(validated.layout, media);
|
|
611
|
-
|
|
703
|
+
pages.push({
|
|
704
|
+
slug: page.slug,
|
|
705
|
+
path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
|
|
706
|
+
title: typeof page.title === "string" ? page.title : page.slug,
|
|
707
|
+
seo: isRecord(page.seo) ? page.seo : {},
|
|
708
|
+
layout: validated.layout
|
|
709
|
+
});
|
|
612
710
|
}
|
|
613
711
|
for (const item of input.media || []) {
|
|
614
712
|
const normalized = normalizeManualMedia(item);
|
|
615
713
|
if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
|
|
616
714
|
}
|
|
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
|
-
|
|
715
|
+
return {
|
|
716
|
+
pages,
|
|
717
|
+
globals: (input.globals || []).filter((item) => Boolean(item && typeof item.key === "string" && isRecord(item.data))),
|
|
718
|
+
forms: (input.forms || []).filter((item) => Boolean(item && typeof item.slug === "string")).map((form) => ({
|
|
719
|
+
slug: form.slug,
|
|
720
|
+
title: typeof form.title === "string" ? form.title : form.slug,
|
|
721
|
+
config: publicFormConfig(form.config),
|
|
722
|
+
successMessage: typeof form.successMessage === "string" ? form.successMessage : ""
|
|
723
|
+
})),
|
|
724
|
+
media: [...media.values()],
|
|
725
|
+
redirects: (input.redirects || []).filter((item) => Boolean(
|
|
726
|
+
item && typeof item.fromPath === "string" && typeof item.toPath === "string"
|
|
727
|
+
)).map((item) => ({
|
|
728
|
+
fromPath: item.fromPath,
|
|
729
|
+
toPath: item.toPath,
|
|
730
|
+
permanent: item.permanent !== false
|
|
731
|
+
})),
|
|
732
|
+
skipped
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
var readOne = async (client, table, columns, key, value) => {
|
|
736
|
+
const { data, error } = await client.from(table).select(columns).eq(key, value).maybeSingle();
|
|
737
|
+
if (error) throw new Error(error.message);
|
|
738
|
+
return data;
|
|
739
|
+
};
|
|
740
|
+
var readMany = async (client, table, columns) => {
|
|
741
|
+
const { data, error } = await client.from(table).select(columns);
|
|
742
|
+
if (error) throw new Error(error.message);
|
|
743
|
+
return data || [];
|
|
744
|
+
};
|
|
745
|
+
var targetReceiptHash = async (client, expectedProjectRef = "") => {
|
|
746
|
+
if (expectedProjectRef === "memory") {
|
|
747
|
+
return createHash2("sha256").update("memory:uninitialized").digest("hex");
|
|
748
|
+
}
|
|
749
|
+
const marker = await readOne(client, "cms_target_identity", "project_ref", "singleton", true);
|
|
750
|
+
const projectRef = typeof marker?.project_ref === "string" ? marker.project_ref : "";
|
|
751
|
+
if (!expectedProjectRef && !projectRef) {
|
|
752
|
+
return createHash2("sha256").update("unbound-local:uninitialized").digest("hex");
|
|
753
|
+
}
|
|
754
|
+
if (!projectRef || projectRef !== expectedProjectRef) {
|
|
755
|
+
throw new Error("Sync target identity does not match the expected CMS project.");
|
|
756
|
+
}
|
|
757
|
+
const migrations = await readMany(client, "cms_migrations", "version");
|
|
758
|
+
const migrationHead = migrations.map((row) => String(row.version)).sort().at(-1) || "empty";
|
|
759
|
+
return createHash2("sha256").update(JSON.stringify(canonicalize({
|
|
760
|
+
migrationHead,
|
|
761
|
+
projectRefHash: createHash2("sha256").update(projectRef).digest("hex").slice(0, 16)
|
|
762
|
+
}))).digest("hex");
|
|
763
|
+
};
|
|
764
|
+
async function buildPlan(client, registry, input, expectedProjectRef = "") {
|
|
765
|
+
const prepared = prepareSync(registry, input);
|
|
766
|
+
const operations = [];
|
|
767
|
+
for (const page of prepared.pages) {
|
|
768
|
+
const current = await readOne(client, "cms_pages", "slug, path, title, seo, draft_layout, builder_owned", "slug", page.slug);
|
|
769
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
770
|
+
{ path: current.path, title: current.title, seo: current.seo, layout: current.draft_layout },
|
|
771
|
+
{ path: page.path, title: page.title, seo: page.seo, layout: page.layout }
|
|
772
|
+
) ? "noop" : "update";
|
|
773
|
+
operations.push({ kind: "page", key: page.slug, action });
|
|
774
|
+
}
|
|
775
|
+
for (const global of prepared.globals) {
|
|
776
|
+
const current = await readOne(client, "cms_globals", "key, data, builder_owned", "key", global.key);
|
|
777
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(current.data, global.data) ? "noop" : "update";
|
|
778
|
+
operations.push({ kind: "global", key: global.key, action });
|
|
779
|
+
}
|
|
780
|
+
for (const form of prepared.forms) {
|
|
781
|
+
const current = await readOne(client, "cms_forms", "slug, title, config, success_message, builder_owned", "slug", form.slug);
|
|
782
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
783
|
+
{ title: current.title, config: current.config, successMessage: current.success_message },
|
|
784
|
+
{ title: form.title, config: form.config, successMessage: form.successMessage }
|
|
785
|
+
) ? "noop" : "update";
|
|
786
|
+
operations.push({ kind: "form", key: form.slug, action });
|
|
787
|
+
}
|
|
788
|
+
for (const item of prepared.media) {
|
|
789
|
+
const current = await readOne(client, "cms_media", "id, storage_path, filename, alt, caption, mime_type, width, height, filesize, builder_owned", "storage_path", item.storagePath);
|
|
790
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
645
791
|
{
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
792
|
+
filename: current.filename,
|
|
793
|
+
alt: current.alt,
|
|
794
|
+
caption: current.caption,
|
|
795
|
+
mimeType: current.mime_type,
|
|
796
|
+
width: current.width,
|
|
797
|
+
height: current.height,
|
|
798
|
+
filesize: current.filesize
|
|
651
799
|
},
|
|
652
|
-
{
|
|
800
|
+
{
|
|
801
|
+
filename: item.filename,
|
|
802
|
+
alt: item.alt,
|
|
803
|
+
caption: item.caption,
|
|
804
|
+
mimeType: item.mimeType,
|
|
805
|
+
width: item.width ?? null,
|
|
806
|
+
height: item.height ?? null,
|
|
807
|
+
filesize: item.filesize ?? null
|
|
808
|
+
}
|
|
809
|
+
) ? "noop" : "update";
|
|
810
|
+
operations.push({ kind: "media", key: item.storagePath, action });
|
|
811
|
+
}
|
|
812
|
+
for (const redirect of prepared.redirects) {
|
|
813
|
+
const current = await readOne(client, "cms_redirects", "from_path, to_path, permanent, builder_owned", "from_path", redirect.fromPath);
|
|
814
|
+
const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
|
|
815
|
+
{ toPath: current.to_path, permanent: current.permanent },
|
|
816
|
+
{ toPath: redirect.toPath, permanent: redirect.permanent }
|
|
817
|
+
) ? "noop" : "update";
|
|
818
|
+
operations.push({ kind: "redirect", key: redirect.fromPath, action });
|
|
819
|
+
}
|
|
820
|
+
const ownershipSets = /* @__PURE__ */ new Map([
|
|
821
|
+
["page", new Set(prepared.pages.map((item) => item.slug))],
|
|
822
|
+
["global", new Set(prepared.globals.map((item) => item.key))],
|
|
823
|
+
["form", new Set(prepared.forms.map((item) => item.slug))],
|
|
824
|
+
["media", new Set(prepared.media.map((item) => item.storagePath))],
|
|
825
|
+
["redirect", new Set(prepared.redirects.map((item) => item.fromPath))]
|
|
826
|
+
]);
|
|
827
|
+
const remoteOwned = [
|
|
828
|
+
["page", "cms_pages", "slug"],
|
|
829
|
+
["global", "cms_globals", "key"],
|
|
830
|
+
["form", "cms_forms", "slug"],
|
|
831
|
+
["media", "cms_media", "storage_path"],
|
|
832
|
+
["redirect", "cms_redirects", "from_path"]
|
|
833
|
+
];
|
|
834
|
+
for (const [kind, table, key] of remoteOwned) {
|
|
835
|
+
const rows = await readMany(client, table, `${key}, builder_owned`);
|
|
836
|
+
for (const row of rows) {
|
|
837
|
+
const value = String(row[key] ?? "");
|
|
838
|
+
if (row.builder_owned === false && value && !ownershipSets.get(kind)?.has(value)) {
|
|
839
|
+
operations.push({ kind, key: value, action: "blocked-delete" });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const kindOrder = {
|
|
844
|
+
page: 0,
|
|
845
|
+
global: 1,
|
|
846
|
+
form: 2,
|
|
847
|
+
media: 3,
|
|
848
|
+
redirect: 4
|
|
849
|
+
};
|
|
850
|
+
operations.sort(
|
|
851
|
+
(left, right) => kindOrder[left.kind] - kindOrder[right.kind] || left.key.localeCompare(right.key)
|
|
852
|
+
);
|
|
853
|
+
const receiptHash = await targetReceiptHash(client, expectedProjectRef);
|
|
854
|
+
const manifestHash = createHash2("sha256").update(JSON.stringify(canonicalize({
|
|
855
|
+
input: prepared,
|
|
856
|
+
targetReceiptHash: receiptHash,
|
|
857
|
+
version: 1
|
|
858
|
+
}))).digest("hex");
|
|
859
|
+
return {
|
|
860
|
+
prepared,
|
|
861
|
+
plan: {
|
|
862
|
+
mode: "dry-run",
|
|
863
|
+
manifestHash,
|
|
864
|
+
targetReceiptHash: receiptHash,
|
|
865
|
+
operations,
|
|
866
|
+
skipped: prepared.skipped
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
async function runContentSync(client, registry, input, options = { mode: "dry-run" }) {
|
|
871
|
+
if (options.mode === "apply") {
|
|
872
|
+
const currentTargetReceiptHash = await targetReceiptHash(client, options.expectedProjectRef);
|
|
873
|
+
if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== currentTargetReceiptHash) {
|
|
874
|
+
throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
|
|
875
|
+
}
|
|
876
|
+
const completed = await readOne(
|
|
877
|
+
client,
|
|
878
|
+
"cms_sync_runs",
|
|
879
|
+
"manifest_hash, target_receipt_hash, status, result",
|
|
880
|
+
"manifest_hash",
|
|
881
|
+
options.expectedManifestHash
|
|
653
882
|
);
|
|
654
|
-
if (
|
|
655
|
-
|
|
883
|
+
if (completed?.status === "complete" && completed.target_receipt_hash === currentTargetReceiptHash && isRecord(completed.result)) {
|
|
884
|
+
return completed.result;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const { prepared, plan } = await buildPlan(client, registry, input, options.expectedProjectRef);
|
|
888
|
+
if (options.mode !== "apply") return plan;
|
|
889
|
+
if (!options.expectedManifestHash || options.expectedManifestHash !== plan.manifestHash) {
|
|
890
|
+
throw new Error("Sync manifest changed. Run a new dry run and apply its exact manifest hash.");
|
|
891
|
+
}
|
|
892
|
+
if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== plan.targetReceiptHash) {
|
|
893
|
+
throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
|
|
894
|
+
}
|
|
895
|
+
const { data: lease, error: leaseError } = await client.rpc("cms_begin_sync", {
|
|
896
|
+
p_manifest_hash: plan.manifestHash,
|
|
897
|
+
p_target_receipt_hash: plan.targetReceiptHash
|
|
898
|
+
});
|
|
899
|
+
if (leaseError) throw new Error(leaseError.message);
|
|
900
|
+
const leaseResult = isRecord(lease) ? lease : {};
|
|
901
|
+
if (leaseResult.status === "busy") {
|
|
902
|
+
throw new Error("Another content sync is active for this CMS target.");
|
|
903
|
+
}
|
|
904
|
+
if (leaseResult.status === "complete" && isRecord(leaseResult.result)) {
|
|
905
|
+
return leaseResult.result;
|
|
906
|
+
}
|
|
907
|
+
if (!["started", "resume"].includes(String(leaseResult.status))) {
|
|
908
|
+
throw new Error("The CMS sync lease could not be acquired.");
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
const synced = [];
|
|
912
|
+
const failed = [];
|
|
913
|
+
const conflicts = plan.operations.filter((operation) => operation.action === "conflict");
|
|
914
|
+
let globals = 0;
|
|
915
|
+
let forms = 0;
|
|
916
|
+
let media = 0;
|
|
917
|
+
for (const page of prepared.pages) {
|
|
918
|
+
const operation = plan.operations.find((item) => item.kind === "page" && item.key === page.slug);
|
|
919
|
+
if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
|
|
920
|
+
const { error } = await client.rpc("cms_sync_page", {
|
|
921
|
+
p_slug: page.slug,
|
|
922
|
+
p_path: page.path,
|
|
923
|
+
p_title: page.title,
|
|
924
|
+
p_seo: page.seo,
|
|
925
|
+
p_layout: page.layout
|
|
926
|
+
});
|
|
927
|
+
if (error) failed.push({ kind: "page", key: page.slug, error: error.message });
|
|
928
|
+
else synced.push(page.slug);
|
|
929
|
+
}
|
|
930
|
+
for (const global of prepared.globals) {
|
|
931
|
+
const operation = plan.operations.find((item) => item.kind === "global" && item.key === global.key);
|
|
932
|
+
if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
|
|
933
|
+
const { data, error } = await client.rpc("cms_sync_global", { p_key: global.key, p_data: global.data });
|
|
934
|
+
if (error) failed.push({ kind: "global", key: global.key, error: error.message });
|
|
935
|
+
else if (data === true) globals += 1;
|
|
936
|
+
else conflicts.push({ kind: "global", key: global.key, action: "conflict" });
|
|
937
|
+
}
|
|
938
|
+
for (const form of prepared.forms) {
|
|
939
|
+
const operation = plan.operations.find((item) => item.kind === "form" && item.key === form.slug);
|
|
940
|
+
if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
|
|
941
|
+
const { data, error } = await client.rpc("cms_sync_form", {
|
|
942
|
+
p_slug: form.slug,
|
|
943
|
+
p_title: form.title,
|
|
944
|
+
p_config: form.config,
|
|
945
|
+
p_success_message: form.successMessage
|
|
946
|
+
});
|
|
947
|
+
if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
|
|
948
|
+
else if (data === true) forms += 1;
|
|
949
|
+
else conflicts.push({ kind: "form", key: form.slug, action: "conflict" });
|
|
950
|
+
}
|
|
951
|
+
for (const item of prepared.media) {
|
|
952
|
+
const operation = plan.operations.find((entry) => entry.kind === "media" && entry.key === item.storagePath);
|
|
953
|
+
if (!operation || !["create", "update"].includes(operation.action)) continue;
|
|
954
|
+
const { data, error } = await client.rpc("cms_sync_media", {
|
|
955
|
+
p_storage_path: item.storagePath,
|
|
956
|
+
p_filename: item.filename || filenameFromPath(item.storagePath),
|
|
957
|
+
p_alt: item.alt || "",
|
|
958
|
+
p_caption: item.caption || "",
|
|
959
|
+
p_mime_type: item.mimeType || mimeFromPath(item.storagePath),
|
|
960
|
+
p_width: item.width ?? null,
|
|
961
|
+
p_height: item.height ?? null,
|
|
962
|
+
p_filesize: item.filesize ?? null
|
|
963
|
+
});
|
|
964
|
+
if (error) failed.push({ kind: "media", key: item.storagePath, error: error.message });
|
|
965
|
+
else if (data === true) media += 1;
|
|
966
|
+
else conflicts.push({ kind: "media", key: item.storagePath, action: "conflict" });
|
|
967
|
+
}
|
|
968
|
+
for (const redirect of prepared.redirects) {
|
|
969
|
+
const operation = plan.operations.find((entry) => entry.kind === "redirect" && entry.key === redirect.fromPath);
|
|
970
|
+
if (!operation || !["create", "update"].includes(operation.action)) continue;
|
|
971
|
+
const { data, error } = await client.rpc("cms_sync_redirect", {
|
|
972
|
+
p_from_path: redirect.fromPath,
|
|
973
|
+
p_to_path: redirect.toPath,
|
|
974
|
+
p_permanent: redirect.permanent
|
|
975
|
+
});
|
|
976
|
+
if (error) failed.push({ kind: "redirect", key: redirect.fromPath, error: error.message });
|
|
977
|
+
else if (data !== true) conflicts.push({ kind: "redirect", key: redirect.fromPath, action: "conflict" });
|
|
978
|
+
}
|
|
979
|
+
const result = {
|
|
980
|
+
mode: "apply",
|
|
981
|
+
manifestHash: plan.manifestHash,
|
|
982
|
+
targetReceiptHash: plan.targetReceiptHash,
|
|
983
|
+
synced,
|
|
984
|
+
skipped: plan.skipped,
|
|
985
|
+
globals,
|
|
986
|
+
forms,
|
|
987
|
+
media,
|
|
988
|
+
failed,
|
|
989
|
+
conflicts
|
|
990
|
+
};
|
|
991
|
+
const completionStatus = failed.length === 0 ? "complete" : "failed";
|
|
992
|
+
const completionError = completionStatus === "failed" ? `${failed.length} content sync operation${failed.length === 1 ? "" : "s"} failed.` : null;
|
|
993
|
+
const { data: finished, error: finishError } = await client.rpc("cms_finish_sync", {
|
|
994
|
+
p_manifest_hash: plan.manifestHash,
|
|
995
|
+
p_status: completionStatus,
|
|
996
|
+
p_result: completionStatus === "complete" ? result : null,
|
|
997
|
+
p_error: completionError
|
|
998
|
+
});
|
|
999
|
+
if (finishError || finished !== true) {
|
|
1000
|
+
throw new Error(finishError?.message || "The CMS sync completion receipt was not recorded.");
|
|
1001
|
+
}
|
|
1002
|
+
if (completionError) throw new Error(completionError);
|
|
1003
|
+
return result;
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
await client.rpc("cms_finish_sync", {
|
|
1006
|
+
p_manifest_hash: plan.manifestHash,
|
|
1007
|
+
p_status: "failed",
|
|
1008
|
+
p_result: null,
|
|
1009
|
+
p_error: error instanceof Error ? error.message : "Content sync failed."
|
|
1010
|
+
});
|
|
1011
|
+
throw error;
|
|
656
1012
|
}
|
|
657
|
-
return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
|
|
658
1013
|
}
|
|
659
1014
|
|
|
660
1015
|
// src/server/routes.ts
|
|
@@ -739,6 +1094,7 @@ var clientKey = (request) => {
|
|
|
739
1094
|
};
|
|
740
1095
|
var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
|
|
741
1096
|
var MAX_PUBLIC_BODY_DEPTH = 12;
|
|
1097
|
+
var MAX_SYNC_BODY_BYTES = 2 * 1024 * 1024;
|
|
742
1098
|
function exceedsDepth(value, limit, depth = 0) {
|
|
743
1099
|
if (depth > limit) return true;
|
|
744
1100
|
if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
|
|
@@ -746,13 +1102,38 @@ function exceedsDepth(value, limit, depth = 0) {
|
|
|
746
1102
|
return false;
|
|
747
1103
|
}
|
|
748
1104
|
async function readJsonLimited(request, limits) {
|
|
749
|
-
|
|
1105
|
+
const declaredLength = request.headers.get("content-length");
|
|
1106
|
+
if (declaredLength !== null) {
|
|
1107
|
+
if (!/^[0-9]+$/.test(declaredLength)) return null;
|
|
1108
|
+
const length = Number(declaredLength);
|
|
1109
|
+
if (!Number.isSafeInteger(length) || length > limits.maxBytes) return null;
|
|
1110
|
+
}
|
|
1111
|
+
if (!request.body) return null;
|
|
1112
|
+
const reader = request.body.getReader();
|
|
1113
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1114
|
+
let totalBytes = 0;
|
|
1115
|
+
let text = "";
|
|
750
1116
|
try {
|
|
751
|
-
|
|
1117
|
+
while (true) {
|
|
1118
|
+
const { done, value } = await reader.read();
|
|
1119
|
+
if (done) break;
|
|
1120
|
+
totalBytes += value.byteLength;
|
|
1121
|
+
if (totalBytes > limits.maxBytes) {
|
|
1122
|
+
await reader.cancel("CMS request body exceeded the configured byte limit.");
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
text += decoder.decode(value, { stream: true });
|
|
1126
|
+
}
|
|
1127
|
+
text += decoder.decode();
|
|
752
1128
|
} catch {
|
|
1129
|
+
try {
|
|
1130
|
+
await reader.cancel();
|
|
1131
|
+
} catch {
|
|
1132
|
+
}
|
|
753
1133
|
return null;
|
|
1134
|
+
} finally {
|
|
1135
|
+
reader.releaseLock();
|
|
754
1136
|
}
|
|
755
|
-
if (text.length > limits.maxBytes) return null;
|
|
756
1137
|
let parsed;
|
|
757
1138
|
try {
|
|
758
1139
|
parsed = JSON.parse(text);
|
|
@@ -763,7 +1144,7 @@ async function readJsonLimited(request, limits) {
|
|
|
763
1144
|
if (exceedsDepth(parsed, limits.maxDepth)) return null;
|
|
764
1145
|
return parsed;
|
|
765
1146
|
}
|
|
766
|
-
var hashedClientKey = (ip) =>
|
|
1147
|
+
var hashedClientKey = (ip) => createHash3("sha256").update(ip).digest("hex").slice(0, 24);
|
|
767
1148
|
var requestPagePath = (request) => {
|
|
768
1149
|
const referrer = request.headers.get("referer");
|
|
769
1150
|
if (!referrer) return "";
|
|
@@ -799,16 +1180,27 @@ function createDurableRateLimitStore(getClient, options) {
|
|
|
799
1180
|
};
|
|
800
1181
|
}
|
|
801
1182
|
function createCmsRoutes(options) {
|
|
802
|
-
const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
|
|
1183
|
+
const { registry, allowedOrigins, syncToken, cronToken, knownGoodEmailDomains } = options;
|
|
803
1184
|
const db = () => options.client ?? getServiceClient();
|
|
804
1185
|
const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
|
|
805
1186
|
const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
|
|
806
1187
|
const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
|
|
807
1188
|
const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
808
|
-
const
|
|
809
|
-
if (hostedMemoryMode) return
|
|
810
|
-
|
|
811
|
-
|
|
1189
|
+
const previewKeys = () => {
|
|
1190
|
+
if (hostedMemoryMode) return null;
|
|
1191
|
+
const activeSecret = options.previewSecret || (options.memoryMode ? "orion-memory-preview-secret" : "");
|
|
1192
|
+
const activeId = options.previewKeyId || (options.memoryMode ? "memory-v1" : "");
|
|
1193
|
+
if (!activeSecret || !activeId) return null;
|
|
1194
|
+
const previous = options.previewPreviousSecret && options.previewPreviousKeyId && options.previewPreviousValidUntil ? {
|
|
1195
|
+
id: options.previewPreviousKeyId,
|
|
1196
|
+
secret: options.previewPreviousSecret,
|
|
1197
|
+
acceptUntil: options.previewPreviousValidUntil
|
|
1198
|
+
} : void 0;
|
|
1199
|
+
return { active: { id: activeId, secret: activeSecret }, ...previous ? { previous } : {} };
|
|
1200
|
+
};
|
|
1201
|
+
const previewProjectRef = options.projectRef || (options.memoryMode ? "memory" : "");
|
|
1202
|
+
const analyticsSecret = options.analyticsSecret || (options.memoryMode ? "orion-memory-analytics-secret" : "");
|
|
1203
|
+
const autoReplyHashSecret = options.autoReplyHashSecret || (options.memoryMode ? "orion-memory-auto-reply-secret" : "");
|
|
812
1204
|
const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
813
1205
|
const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
814
1206
|
const boundedAutoReplyMax = (name, fallback) => {
|
|
@@ -832,14 +1224,12 @@ function createCmsRoutes(options) {
|
|
|
832
1224
|
"auto-reply-site-day"
|
|
833
1225
|
)
|
|
834
1226
|
};
|
|
835
|
-
const autoReplyRecipientKey = (email) => createHmac3(
|
|
836
|
-
"sha256",
|
|
837
|
-
previewSecret() || syncToken || "orion-memory-auto-reply-secret"
|
|
838
|
-
).update(`recipient:${email}`).digest("hex").slice(0, 24);
|
|
1227
|
+
const autoReplyRecipientKey = (email) => createHmac3("sha256", autoReplyHashSecret).update(`recipient:${email}`).digest("hex").slice(0, 24);
|
|
839
1228
|
const maySendAutoReply = async (config, data) => {
|
|
840
1229
|
if (config.notify?.autoReply !== true) return false;
|
|
841
1230
|
const recipient = resolveSubmitterEmail(config, data);
|
|
842
1231
|
if (!recipient) return false;
|
|
1232
|
+
if (!autoReplyHashSecret) return false;
|
|
843
1233
|
const now = Date.now();
|
|
844
1234
|
if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
|
|
845
1235
|
return false;
|
|
@@ -891,8 +1281,8 @@ function createCmsRoutes(options) {
|
|
|
891
1281
|
};
|
|
892
1282
|
const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
|
|
893
1283
|
const requestSessionKey = (request) => {
|
|
894
|
-
|
|
895
|
-
return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "",
|
|
1284
|
+
if (!analyticsSecret) return "";
|
|
1285
|
+
return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", analyticsSecret);
|
|
896
1286
|
};
|
|
897
1287
|
const guard = async (request, action) => {
|
|
898
1288
|
if (hostedMemoryMode) return errors.unauthorized();
|
|
@@ -901,6 +1291,13 @@ function createCmsRoutes(options) {
|
|
|
901
1291
|
if (!can(user, action)) return errors.forbidden();
|
|
902
1292
|
return { user };
|
|
903
1293
|
};
|
|
1294
|
+
const revokePreviewGrants = async (filter) => {
|
|
1295
|
+
let query = db().from("cms_preview_grants").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1296
|
+
if (filter.pageId) query = query.eq("page_id", filter.pageId);
|
|
1297
|
+
if (filter.userId) query = query.eq("user_id", filter.userId);
|
|
1298
|
+
const { error } = await query;
|
|
1299
|
+
return error?.message ?? null;
|
|
1300
|
+
};
|
|
904
1301
|
const logActivity = async (user, action, subject) => {
|
|
905
1302
|
try {
|
|
906
1303
|
await db().from("cms_activity").insert({
|
|
@@ -1020,6 +1417,7 @@ function createCmsRoutes(options) {
|
|
|
1020
1417
|
}
|
|
1021
1418
|
patch.path = path;
|
|
1022
1419
|
}
|
|
1420
|
+
patch.builder_owned = true;
|
|
1023
1421
|
if (Object.keys(patch).length > 0) {
|
|
1024
1422
|
const oldPath = String(current.path || "");
|
|
1025
1423
|
const wasPublished = current.status === "published";
|
|
@@ -1033,7 +1431,12 @@ function createCmsRoutes(options) {
|
|
|
1033
1431
|
const newPath = typeof patch.path === "string" ? patch.path : oldPath;
|
|
1034
1432
|
if (newPath !== oldPath && wasPublished && oldPath !== "/") {
|
|
1035
1433
|
await db().from("cms_redirects").delete().eq("from_path", newPath);
|
|
1036
|
-
await db().from("cms_redirects").upsert({
|
|
1434
|
+
await db().from("cms_redirects").upsert({
|
|
1435
|
+
from_path: oldPath,
|
|
1436
|
+
to_path: newPath,
|
|
1437
|
+
permanent: true,
|
|
1438
|
+
builder_owned: true
|
|
1439
|
+
}, { onConflict: "from_path" });
|
|
1037
1440
|
}
|
|
1038
1441
|
if (newPath !== oldPath) {
|
|
1039
1442
|
await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
|
|
@@ -1093,6 +1496,8 @@ function createCmsRoutes(options) {
|
|
|
1093
1496
|
if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
|
|
1094
1497
|
return errors.conflict("Page version conflict: save the draft again before publishing.");
|
|
1095
1498
|
}
|
|
1499
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1500
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1096
1501
|
const { data, error } = await db().rpc("cms_publish_page", {
|
|
1097
1502
|
p_page_id: id,
|
|
1098
1503
|
p_expected_version_id: expectedVersionId,
|
|
@@ -1109,6 +1514,8 @@ function createCmsRoutes(options) {
|
|
|
1109
1514
|
const unpublishPage = async (request, id) => {
|
|
1110
1515
|
const auth = await guard(request, "pages.publish");
|
|
1111
1516
|
if (auth instanceof Response) return auth;
|
|
1517
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1518
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1112
1519
|
const { data, error } = await db().from("cms_pages").update({
|
|
1113
1520
|
status: "draft",
|
|
1114
1521
|
publish_at: null,
|
|
@@ -1167,11 +1574,28 @@ function createCmsRoutes(options) {
|
|
|
1167
1574
|
const previewToken = async (request, id) => {
|
|
1168
1575
|
const auth = await guard(request, "pages.read");
|
|
1169
1576
|
if (auth instanceof Response) return auth;
|
|
1170
|
-
const
|
|
1171
|
-
if (!
|
|
1577
|
+
const keys = previewKeys();
|
|
1578
|
+
if (!keys || !previewProjectRef) return errors.badRequest("Preview is not configured on this site.");
|
|
1172
1579
|
const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
|
|
1173
1580
|
if (!data) return errors.notFound();
|
|
1174
|
-
const
|
|
1581
|
+
const grantId = randomUUID();
|
|
1582
|
+
const expiresAt = new Date(Date.now() + PREVIEW_TOKEN_TTL_MS).toISOString();
|
|
1583
|
+
const { error: grantError } = await db().from("cms_preview_grants").insert({
|
|
1584
|
+
id: grantId,
|
|
1585
|
+
page_id: id,
|
|
1586
|
+
user_id: auth.user.id,
|
|
1587
|
+
project_ref: previewProjectRef,
|
|
1588
|
+
key_id: keys.active.id,
|
|
1589
|
+
expires_at: expiresAt,
|
|
1590
|
+
max_uses: PREVIEW_TOKEN_MAX_USES
|
|
1591
|
+
});
|
|
1592
|
+
if (grantError) return errors.badRequest("Unable to create a preview grant.");
|
|
1593
|
+
const token = createPreviewToken({
|
|
1594
|
+
grantId,
|
|
1595
|
+
pageId: id,
|
|
1596
|
+
userId: auth.user.id,
|
|
1597
|
+
projectRef: previewProjectRef
|
|
1598
|
+
}, keys);
|
|
1175
1599
|
const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
|
|
1176
1600
|
const response = json({ path: data.path, url: previewUrl });
|
|
1177
1601
|
const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
|
|
@@ -1183,6 +1607,14 @@ function createCmsRoutes(options) {
|
|
|
1183
1607
|
);
|
|
1184
1608
|
return response;
|
|
1185
1609
|
};
|
|
1610
|
+
const revokePagePreviews = async (request, id) => {
|
|
1611
|
+
const auth = await guard(request, "pages.read");
|
|
1612
|
+
if (auth instanceof Response) return auth;
|
|
1613
|
+
const revokeError = await revokePreviewGrants({ pageId: id });
|
|
1614
|
+
if (revokeError) return errors.badRequest("Unable to revoke preview grants.");
|
|
1615
|
+
await logActivity(auth.user, "page.preview.revoke", id);
|
|
1616
|
+
return json({ success: true });
|
|
1617
|
+
};
|
|
1186
1618
|
const listVersions = async (request, id) => {
|
|
1187
1619
|
const auth = await guard(request, "pages.restore");
|
|
1188
1620
|
if (auth instanceof Response) return auth;
|
|
@@ -1209,6 +1641,10 @@ function createCmsRoutes(options) {
|
|
|
1209
1641
|
const restoreVersion = async (request, versionId) => {
|
|
1210
1642
|
const auth = await guard(request, "pages.restore");
|
|
1211
1643
|
if (auth instanceof Response) return auth;
|
|
1644
|
+
const { data: version } = await db().from("cms_page_versions").select("page_id").eq("id", Number(versionId)).maybeSingle();
|
|
1645
|
+
if (!version) return errors.notFound();
|
|
1646
|
+
const revokeError = await revokePreviewGrants({ pageId: String(version.page_id) });
|
|
1647
|
+
if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
|
|
1212
1648
|
const { data, error } = await db().rpc("cms_restore_page_version", {
|
|
1213
1649
|
p_version_id: Number(versionId),
|
|
1214
1650
|
p_actor: auth.user.id
|
|
@@ -1254,6 +1690,7 @@ function createCmsRoutes(options) {
|
|
|
1254
1690
|
const { data, error } = await db().from("cms_globals").upsert({
|
|
1255
1691
|
key: version.key,
|
|
1256
1692
|
data: version.data,
|
|
1693
|
+
builder_owned: true,
|
|
1257
1694
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1258
1695
|
}).select("*").single();
|
|
1259
1696
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1343,7 +1780,8 @@ function createCmsRoutes(options) {
|
|
|
1343
1780
|
mime_type: normalizedFile.type,
|
|
1344
1781
|
width: prepared.width,
|
|
1345
1782
|
height: prepared.height,
|
|
1346
|
-
filesize: normalizedFile.size
|
|
1783
|
+
filesize: normalizedFile.size,
|
|
1784
|
+
builder_owned: true
|
|
1347
1785
|
}).select("*").single();
|
|
1348
1786
|
if (error) return errors.badRequest(error.message);
|
|
1349
1787
|
if (!options.memoryMode) {
|
|
@@ -1361,7 +1799,10 @@ function createCmsRoutes(options) {
|
|
|
1361
1799
|
if (auth instanceof Response) return auth;
|
|
1362
1800
|
const body = await readJson(request);
|
|
1363
1801
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1364
|
-
const patch = {
|
|
1802
|
+
const patch = {
|
|
1803
|
+
builder_owned: true,
|
|
1804
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1805
|
+
};
|
|
1365
1806
|
if (typeof body.alt === "string") patch.alt = body.alt;
|
|
1366
1807
|
if (typeof body.caption === "string") patch.caption = body.caption;
|
|
1367
1808
|
if (typeof body.filename === "string" && body.filename.trim()) {
|
|
@@ -1420,6 +1861,7 @@ function createCmsRoutes(options) {
|
|
|
1420
1861
|
filesize: normalizedFile.size,
|
|
1421
1862
|
width: prepared.width,
|
|
1422
1863
|
height: prepared.height,
|
|
1864
|
+
builder_owned: true,
|
|
1423
1865
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1424
1866
|
}).eq("id", id).select("*").single();
|
|
1425
1867
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1488,7 +1930,8 @@ function createCmsRoutes(options) {
|
|
|
1488
1930
|
slug,
|
|
1489
1931
|
title,
|
|
1490
1932
|
config: { steps: [{ title: "", fields: [] }] },
|
|
1491
|
-
success_message: "Thanks \u2014 we received your submission."
|
|
1933
|
+
success_message: "Thanks \u2014 we received your submission.",
|
|
1934
|
+
builder_owned: true
|
|
1492
1935
|
}).select("*").single();
|
|
1493
1936
|
if (error) {
|
|
1494
1937
|
if (error.message.includes("duplicate")) {
|
|
@@ -1502,9 +1945,15 @@ function createCmsRoutes(options) {
|
|
|
1502
1945
|
const getForm = async (request, slug) => {
|
|
1503
1946
|
const auth = await guard(request, "forms.read");
|
|
1504
1947
|
if (auth instanceof Response) return auth;
|
|
1505
|
-
const
|
|
1948
|
+
const projection = can(auth.user, "forms.write") ? "id, slug, title, config, notify, success_message, updated_at" : "id, slug, title, config, success_message, updated_at";
|
|
1949
|
+
const { data, error } = await db().from("cms_forms").select(projection).eq("slug", slug).maybeSingle();
|
|
1506
1950
|
if (error) return errors.badRequest(error.message);
|
|
1507
1951
|
if (!data) return errors.notFound();
|
|
1952
|
+
if (!can(auth.user, "forms.write")) {
|
|
1953
|
+
const { notify, ...publicForm } = data;
|
|
1954
|
+
void notify;
|
|
1955
|
+
return json({ form: publicForm });
|
|
1956
|
+
}
|
|
1508
1957
|
return json({ form: data });
|
|
1509
1958
|
};
|
|
1510
1959
|
const updateForm = async (request, slug) => {
|
|
@@ -1546,6 +1995,7 @@ function createCmsRoutes(options) {
|
|
|
1546
1995
|
config,
|
|
1547
1996
|
notify,
|
|
1548
1997
|
success_message: typeof body.successMessage === "string" ? body.successMessage : "",
|
|
1998
|
+
builder_owned: true,
|
|
1549
1999
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1550
2000
|
},
|
|
1551
2001
|
{ onConflict: "slug" }
|
|
@@ -1754,7 +2204,12 @@ function createCmsRoutes(options) {
|
|
|
1754
2204
|
return errors.badRequest("To path must start with / or be a full URL.");
|
|
1755
2205
|
}
|
|
1756
2206
|
if (fromPath === toPath) return errors.badRequest("A redirect cannot point at itself.");
|
|
1757
|
-
const { data, error } = await db().from("cms_redirects").upsert({
|
|
2207
|
+
const { data, error } = await db().from("cms_redirects").upsert({
|
|
2208
|
+
from_path: fromPath,
|
|
2209
|
+
to_path: toPath,
|
|
2210
|
+
permanent,
|
|
2211
|
+
builder_owned: true
|
|
2212
|
+
}, { onConflict: "from_path" }).select("*").single();
|
|
1758
2213
|
if (error) return errors.badRequest(error.message);
|
|
1759
2214
|
await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
|
|
1760
2215
|
return json({ redirect: data }, 201);
|
|
@@ -1768,6 +2223,7 @@ function createCmsRoutes(options) {
|
|
|
1768
2223
|
};
|
|
1769
2224
|
const ingestEvents = async (request) => {
|
|
1770
2225
|
if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
|
|
2226
|
+
if (!analyticsSecret) return json({ success: true });
|
|
1771
2227
|
const userAgent = request.headers.get("user-agent") || "";
|
|
1772
2228
|
if (isBotRequest(userAgent)) return json({ success: true });
|
|
1773
2229
|
const now = Date.now();
|
|
@@ -1784,7 +2240,7 @@ function createCmsRoutes(options) {
|
|
|
1784
2240
|
sessionKey: requestSessionKey(request),
|
|
1785
2241
|
visitorKey: visitorKeyFor(
|
|
1786
2242
|
body?.visitorId,
|
|
1787
|
-
|
|
2243
|
+
analyticsSecret
|
|
1788
2244
|
),
|
|
1789
2245
|
device: deviceFrom(userAgent),
|
|
1790
2246
|
...geoFrom(request)
|
|
@@ -1825,6 +2281,30 @@ function createCmsRoutes(options) {
|
|
|
1825
2281
|
}
|
|
1826
2282
|
return all;
|
|
1827
2283
|
};
|
|
2284
|
+
const fetchFormSubmissions = async (fromIso, toIso) => {
|
|
2285
|
+
const all = [];
|
|
2286
|
+
let cursor = 0;
|
|
2287
|
+
for (let page = 0; page < 60; page += 1) {
|
|
2288
|
+
const { data, error } = await db().from("cms_form_submissions").select("id, form_id, session_key, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
|
|
2289
|
+
if (error) return all.length === 0 ? null : all;
|
|
2290
|
+
if (!data || data.length === 0) break;
|
|
2291
|
+
all.push(...data);
|
|
2292
|
+
cursor = Number(data[data.length - 1].id);
|
|
2293
|
+
if (data.length < 1e3) break;
|
|
2294
|
+
}
|
|
2295
|
+
return all;
|
|
2296
|
+
};
|
|
2297
|
+
const fetchFormSlugs = async () => {
|
|
2298
|
+
const { data, error } = await db().from("cms_forms").select("id, slug").limit(1e3);
|
|
2299
|
+
const slugs = /* @__PURE__ */ new Map();
|
|
2300
|
+
if (error || !data) return slugs;
|
|
2301
|
+
for (const form of data) {
|
|
2302
|
+
const id = String(form.id || "");
|
|
2303
|
+
const slug = String(form.slug || "");
|
|
2304
|
+
if (id && slug) slugs.set(id, slug);
|
|
2305
|
+
}
|
|
2306
|
+
return slugs;
|
|
2307
|
+
};
|
|
1828
2308
|
const getAnalytics = async (request) => {
|
|
1829
2309
|
const auth = await guard(request, "analytics.read");
|
|
1830
2310
|
if (auth instanceof Response) return auth;
|
|
@@ -1836,11 +2316,26 @@ function createCmsRoutes(options) {
|
|
|
1836
2316
|
const from = new Date(fromMs).toISOString();
|
|
1837
2317
|
const to = new Date(toMs).toISOString();
|
|
1838
2318
|
const previousFrom = new Date(fromMs - windowMs).toISOString();
|
|
1839
|
-
const [events, previousEvents] = await Promise.all([
|
|
2319
|
+
const [events, previousEvents, submissionRows, formSlugs] = await Promise.all([
|
|
1840
2320
|
fetchEvents(from, to),
|
|
1841
|
-
fetchEvents(previousFrom, from)
|
|
2321
|
+
fetchEvents(previousFrom, from),
|
|
2322
|
+
fetchFormSubmissions(previousFrom, to),
|
|
2323
|
+
fetchFormSlugs()
|
|
1842
2324
|
]);
|
|
1843
|
-
|
|
2325
|
+
const submissions = submissionRows?.map((submission) => ({
|
|
2326
|
+
form: formSlugs.get(String(submission.form_id)) || String(submission.form_id),
|
|
2327
|
+
session_key: String(submission.session_key || ""),
|
|
2328
|
+
created_at: String(submission.created_at)
|
|
2329
|
+
}));
|
|
2330
|
+
const currentSubmissions = submissions?.filter(
|
|
2331
|
+
(submission) => Date.parse(submission.created_at) >= fromMs
|
|
2332
|
+
);
|
|
2333
|
+
const previousSubmissions = submissions?.filter(
|
|
2334
|
+
(submission) => Date.parse(submission.created_at) < fromMs
|
|
2335
|
+
);
|
|
2336
|
+
return json(
|
|
2337
|
+
aggregateAnalytics(events, previousEvents, { from, to }, currentSubmissions, previousSubmissions)
|
|
2338
|
+
);
|
|
1844
2339
|
};
|
|
1845
2340
|
const pruneEvents = async () => {
|
|
1846
2341
|
const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
|
|
@@ -1861,7 +2356,7 @@ function createCmsRoutes(options) {
|
|
|
1861
2356
|
const cronPublishDue = async (request) => {
|
|
1862
2357
|
if (hostedMemoryMode) return errors.forbidden();
|
|
1863
2358
|
const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
|
1864
|
-
let authorized = tokenEquals(bearer,
|
|
2359
|
+
let authorized = tokenEquals(bearer, cronToken || "");
|
|
1865
2360
|
if (!authorized) {
|
|
1866
2361
|
const user = await resolveUser(request, db());
|
|
1867
2362
|
authorized = Boolean(user && can(user, "pages.publish"));
|
|
@@ -2020,6 +2515,10 @@ function createCmsRoutes(options) {
|
|
|
2020
2515
|
return errors.badRequest("You can only assign roles at or below your own.");
|
|
2021
2516
|
}
|
|
2022
2517
|
}
|
|
2518
|
+
if (body.role !== void 0 && body.role !== membership.role) {
|
|
2519
|
+
const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
|
|
2520
|
+
if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
|
|
2521
|
+
}
|
|
2023
2522
|
if (typeof body.password === "string") {
|
|
2024
2523
|
if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
|
|
2025
2524
|
const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
|
|
@@ -2048,6 +2547,8 @@ function createCmsRoutes(options) {
|
|
|
2048
2547
|
return errors.badRequest("You can't remove your own account.");
|
|
2049
2548
|
}
|
|
2050
2549
|
if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
|
|
2550
|
+
const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
|
|
2551
|
+
if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
|
|
2051
2552
|
const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
|
|
2052
2553
|
if (authError) return errors.badRequest(authError.message);
|
|
2053
2554
|
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
|
|
@@ -2064,10 +2565,30 @@ function createCmsRoutes(options) {
|
|
|
2064
2565
|
authorized = Boolean(user && can(user, "sync.run"));
|
|
2065
2566
|
}
|
|
2066
2567
|
if (!authorized) return errors.forbidden();
|
|
2067
|
-
const body = await
|
|
2568
|
+
const body = await readJsonLimited(request, { maxBytes: MAX_SYNC_BODY_BYTES, maxDepth: 32 });
|
|
2068
2569
|
if (!body) return errors.badRequest("Invalid body.");
|
|
2069
|
-
const
|
|
2070
|
-
|
|
2570
|
+
const input = isRecord2(body.input) ? body.input : body;
|
|
2571
|
+
const mode = body.mode === "apply" ? "apply" : "dry-run";
|
|
2572
|
+
const expectedManifestHash = typeof body.expectedManifestHash === "string" ? body.expectedManifestHash : "";
|
|
2573
|
+
const expectedTargetReceiptHash = typeof body.expectedTargetReceiptHash === "string" ? body.expectedTargetReceiptHash : "";
|
|
2574
|
+
let result;
|
|
2575
|
+
try {
|
|
2576
|
+
result = mode === "apply" ? await runContentSync(db(), registry, input, {
|
|
2577
|
+
mode,
|
|
2578
|
+
expectedManifestHash,
|
|
2579
|
+
expectedProjectRef: previewProjectRef,
|
|
2580
|
+
expectedTargetReceiptHash
|
|
2581
|
+
}) : await runContentSync(db(), registry, input, {
|
|
2582
|
+
mode,
|
|
2583
|
+
expectedProjectRef: previewProjectRef
|
|
2584
|
+
});
|
|
2585
|
+
} catch (error) {
|
|
2586
|
+
return errors.conflict(error instanceof Error ? error.message : "Sync manifest conflict.");
|
|
2587
|
+
}
|
|
2588
|
+
if (result.mode === "apply") {
|
|
2589
|
+
await logActivity(null, "sync.apply", result.manifestHash);
|
|
2590
|
+
await revalidateContent();
|
|
2591
|
+
}
|
|
2071
2592
|
return json({ success: true, ...result });
|
|
2072
2593
|
};
|
|
2073
2594
|
const dispatch = async (request, context) => {
|
|
@@ -2091,6 +2612,8 @@ function createCmsRoutes(options) {
|
|
|
2091
2612
|
return duplicatePage(request, second);
|
|
2092
2613
|
} else if (third === "preview" && method === "POST") {
|
|
2093
2614
|
return previewToken(request, second);
|
|
2615
|
+
} else if (third === "preview" && method === "DELETE") {
|
|
2616
|
+
return revokePagePreviews(request, second);
|
|
2094
2617
|
} else if (third === "versions" && method === "GET") {
|
|
2095
2618
|
return listVersions(request, second);
|
|
2096
2619
|
}
|
|
@@ -2465,10 +2988,11 @@ function runRpc(store, fn, args = {}) {
|
|
|
2465
2988
|
const key = String(args.p_key);
|
|
2466
2989
|
let row = globals.find((r) => r.key === key);
|
|
2467
2990
|
if (!row) {
|
|
2468
|
-
row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
|
|
2991
|
+
row = { key, label: "", data: args.p_data ?? {}, builder_owned: true, updated_at: nowIso() };
|
|
2469
2992
|
globals.push(row);
|
|
2470
2993
|
} else {
|
|
2471
2994
|
row.data = args.p_data ?? {};
|
|
2995
|
+
row.builder_owned = true;
|
|
2472
2996
|
row.updated_at = nowIso();
|
|
2473
2997
|
}
|
|
2474
2998
|
globalVersions.push({
|
|
@@ -2513,6 +3037,55 @@ function runRpc(store, fn, args = {}) {
|
|
|
2513
3037
|
store.setTable("cms_rate_limits", kept);
|
|
2514
3038
|
return { data: limits.length - kept.length, error: null };
|
|
2515
3039
|
}
|
|
3040
|
+
case "cms_consume_preview_grant": {
|
|
3041
|
+
const grant = store.table("cms_preview_grants").find(
|
|
3042
|
+
(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
|
|
3043
|
+
);
|
|
3044
|
+
if (!grant || grant.revoked_at) return { data: false, error: null };
|
|
3045
|
+
const expiresAt = typeof grant.expires_at === "string" ? Date.parse(grant.expires_at) : NaN;
|
|
3046
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return { data: false, error: null };
|
|
3047
|
+
const useCount = typeof grant.use_count === "number" ? grant.use_count : 0;
|
|
3048
|
+
const maxUses = typeof grant.max_uses === "number" ? grant.max_uses : 0;
|
|
3049
|
+
if (useCount >= maxUses) return { data: false, error: null };
|
|
3050
|
+
grant.use_count = useCount + 1;
|
|
3051
|
+
grant.last_used_at = nowIso();
|
|
3052
|
+
return { data: true, error: null };
|
|
3053
|
+
}
|
|
3054
|
+
case "cms_begin_sync": {
|
|
3055
|
+
const runs = store.table("cms_sync_runs");
|
|
3056
|
+
const requested = runs.find((row) => row.manifest_hash === args.p_manifest_hash);
|
|
3057
|
+
if (requested?.status === "complete") {
|
|
3058
|
+
return { data: { status: "complete", result: requested.result }, error: null };
|
|
3059
|
+
}
|
|
3060
|
+
if (requested?.status === "active") return { data: { status: "busy" }, error: null };
|
|
3061
|
+
const active = runs.find(
|
|
3062
|
+
(row) => row.status === "active" && row.manifest_hash !== args.p_manifest_hash
|
|
3063
|
+
);
|
|
3064
|
+
if (active) return { data: { status: "busy" }, error: null };
|
|
3065
|
+
if (requested) {
|
|
3066
|
+
requested.status = "active";
|
|
3067
|
+
requested.target_receipt_hash = args.p_target_receipt_hash;
|
|
3068
|
+
requested.updated_at = nowIso();
|
|
3069
|
+
return { data: { status: "resume" }, error: null };
|
|
3070
|
+
}
|
|
3071
|
+
runs.push({
|
|
3072
|
+
manifest_hash: args.p_manifest_hash,
|
|
3073
|
+
target_receipt_hash: args.p_target_receipt_hash,
|
|
3074
|
+
status: "active",
|
|
3075
|
+
started_at: nowIso(),
|
|
3076
|
+
updated_at: nowIso()
|
|
3077
|
+
});
|
|
3078
|
+
return { data: { status: "started" }, error: null };
|
|
3079
|
+
}
|
|
3080
|
+
case "cms_finish_sync": {
|
|
3081
|
+
const run = store.table("cms_sync_runs").find((row) => row.manifest_hash === args.p_manifest_hash);
|
|
3082
|
+
if (!run || run.status !== "active") return { data: false, error: null };
|
|
3083
|
+
run.status = args.p_status;
|
|
3084
|
+
run.result = args.p_status === "complete" ? args.p_result : null;
|
|
3085
|
+
run.error = args.p_status === "failed" ? args.p_error : null;
|
|
3086
|
+
run.updated_at = nowIso();
|
|
3087
|
+
return { data: true, error: null };
|
|
3088
|
+
}
|
|
2516
3089
|
case "cms_publish_due_pages": {
|
|
2517
3090
|
const nowMs = Date.now();
|
|
2518
3091
|
const published = [];
|
|
@@ -2591,6 +3164,90 @@ function runRpc(store, fn, args = {}) {
|
|
|
2591
3164
|
}
|
|
2592
3165
|
return { data: page, error: null };
|
|
2593
3166
|
}
|
|
3167
|
+
case "cms_sync_global": {
|
|
3168
|
+
const globals = store.table("cms_globals");
|
|
3169
|
+
const key = String(args.p_key);
|
|
3170
|
+
let row = globals.find((item) => item.key === key);
|
|
3171
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3172
|
+
if (!row) {
|
|
3173
|
+
row = { key, label: "", data: args.p_data ?? {}, builder_owned: false, updated_at: nowIso() };
|
|
3174
|
+
globals.push(row);
|
|
3175
|
+
} else {
|
|
3176
|
+
row.data = args.p_data ?? {};
|
|
3177
|
+
row.updated_at = nowIso();
|
|
3178
|
+
}
|
|
3179
|
+
return { data: true, error: null };
|
|
3180
|
+
}
|
|
3181
|
+
case "cms_sync_form": {
|
|
3182
|
+
const forms = store.table("cms_forms");
|
|
3183
|
+
const slug = String(args.p_slug);
|
|
3184
|
+
let row = forms.find((item) => item.slug === slug);
|
|
3185
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3186
|
+
if (!row) {
|
|
3187
|
+
row = {
|
|
3188
|
+
id: newId(),
|
|
3189
|
+
slug,
|
|
3190
|
+
title: args.p_title ?? slug,
|
|
3191
|
+
config: args.p_config ?? {},
|
|
3192
|
+
notify: {},
|
|
3193
|
+
success_message: args.p_success_message ?? "",
|
|
3194
|
+
builder_owned: false,
|
|
3195
|
+
created_at: nowIso(),
|
|
3196
|
+
updated_at: nowIso()
|
|
3197
|
+
};
|
|
3198
|
+
forms.push(row);
|
|
3199
|
+
} else {
|
|
3200
|
+
row.title = args.p_title ?? slug;
|
|
3201
|
+
row.config = args.p_config ?? {};
|
|
3202
|
+
row.success_message = args.p_success_message ?? "";
|
|
3203
|
+
row.updated_at = nowIso();
|
|
3204
|
+
}
|
|
3205
|
+
return { data: true, error: null };
|
|
3206
|
+
}
|
|
3207
|
+
case "cms_sync_media": {
|
|
3208
|
+
const media = store.table("cms_media");
|
|
3209
|
+
const storagePath = String(args.p_storage_path);
|
|
3210
|
+
let row = media.find((item) => item.storage_path === storagePath);
|
|
3211
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3212
|
+
const values = {
|
|
3213
|
+
storage_path: storagePath,
|
|
3214
|
+
filename: args.p_filename,
|
|
3215
|
+
alt: args.p_alt,
|
|
3216
|
+
caption: args.p_caption,
|
|
3217
|
+
mime_type: args.p_mime_type,
|
|
3218
|
+
width: args.p_width,
|
|
3219
|
+
height: args.p_height,
|
|
3220
|
+
filesize: args.p_filesize,
|
|
3221
|
+
builder_owned: false,
|
|
3222
|
+
updated_at: nowIso()
|
|
3223
|
+
};
|
|
3224
|
+
if (!row) {
|
|
3225
|
+
row = { id: newId(), created_at: nowIso(), ...values };
|
|
3226
|
+
media.push(row);
|
|
3227
|
+
} else {
|
|
3228
|
+
Object.assign(row, values);
|
|
3229
|
+
}
|
|
3230
|
+
return { data: true, error: null };
|
|
3231
|
+
}
|
|
3232
|
+
case "cms_sync_redirect": {
|
|
3233
|
+
const redirects = store.table("cms_redirects");
|
|
3234
|
+
const fromPath = String(args.p_from_path);
|
|
3235
|
+
let row = redirects.find((item) => item.from_path === fromPath);
|
|
3236
|
+
if (row?.builder_owned) return { data: false, error: null };
|
|
3237
|
+
const values = {
|
|
3238
|
+
from_path: fromPath,
|
|
3239
|
+
to_path: args.p_to_path,
|
|
3240
|
+
permanent: args.p_permanent,
|
|
3241
|
+
builder_owned: false
|
|
3242
|
+
};
|
|
3243
|
+
if (!row) {
|
|
3244
|
+
row = { id: newId(), created_at: nowIso(), ...values };
|
|
3245
|
+
redirects.push(row);
|
|
3246
|
+
} else {
|
|
3247
|
+
Object.assign(row, values);
|
|
3248
|
+
}
|
|
3249
|
+
return { data: true, error: null };
|
|
3250
|
+
}
|
|
2594
3251
|
default:
|
|
2595
3252
|
return { data: null, error: { message: `unknown function ${fn}` } };
|
|
2596
3253
|
}
|
|
@@ -2646,6 +3303,7 @@ export {
|
|
|
2646
3303
|
CONTENT_CACHE_TAG,
|
|
2647
3304
|
MEMORY_DEV_TOKEN,
|
|
2648
3305
|
PREVIEW_SESSION_COOKIE,
|
|
3306
|
+
PREVIEW_TOKEN_MAX_USES,
|
|
2649
3307
|
PREVIEW_TOKEN_TTL_MS,
|
|
2650
3308
|
aggregateAnalytics,
|
|
2651
3309
|
can,
|
|
@@ -2669,6 +3327,8 @@ export {
|
|
|
2669
3327
|
runContentSync,
|
|
2670
3328
|
sessionKeyFor,
|
|
2671
3329
|
setServiceClientForTesting,
|
|
3330
|
+
validateCmsEnv,
|
|
3331
|
+
verifyPreviewGrantToken,
|
|
2672
3332
|
verifyPreviewToken,
|
|
2673
3333
|
visitorKeyFor
|
|
2674
3334
|
};
|