@bygghammar/cms-platform 0.95.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/package.json +1 -1
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +71 -36
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/routes/preview-page.tsx +37 -29
- package/src/util/redact-url.ts +18 -0
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
* Server-only. Never import from client code.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import {
|
|
29
|
+
fetchTransport,
|
|
30
|
+
injectedFetchResolver,
|
|
31
|
+
safeRequest,
|
|
32
|
+
WEBHOOK_POLICY,
|
|
33
|
+
type Resolver,
|
|
34
|
+
type Transport,
|
|
35
|
+
} from "./outbound-http";
|
|
28
36
|
import { checkWebhookUrl } from "./webhook-url-guard";
|
|
29
37
|
|
|
30
38
|
export const POPUP_WEBHOOK_SCHEMA_VERSION = 1;
|
|
@@ -56,8 +64,15 @@ export interface OptInWebhookInput {
|
|
|
56
64
|
readonly timeoutMs?: number;
|
|
57
65
|
readonly maxResponseBytes?: number;
|
|
58
66
|
readonly maxRedirects?: number;
|
|
59
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Test seam: requests go through this fetch instead of the network. Every
|
|
69
|
+
* hop is still checked and followed by the outbound policy, but names are
|
|
70
|
+
* not looked up and the connection is not pinned. Production passes none.
|
|
71
|
+
*/
|
|
60
72
|
readonly fetchImpl?: typeof fetch;
|
|
73
|
+
/** Test seams for the pinned path: a fake resolver and transport. */
|
|
74
|
+
readonly resolver?: Resolver;
|
|
75
|
+
readonly transport?: Transport;
|
|
61
76
|
readonly now?: () => number;
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -147,71 +162,6 @@ export async function signBody(
|
|
|
147
162
|
.digest("hex");
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
/**
|
|
151
|
-
* Read at most `limit` bytes of a response, then stop.
|
|
152
|
-
*
|
|
153
|
-
* A receiver that answers with a gigabyte would otherwise hold this server's
|
|
154
|
-
* memory hostage. Nothing is done with the body but the read has to be bounded.
|
|
155
|
-
*/
|
|
156
|
-
async function drainBounded(
|
|
157
|
-
response: Response,
|
|
158
|
-
limit: number,
|
|
159
|
-
): Promise<boolean> {
|
|
160
|
-
const body = response.body;
|
|
161
|
-
if (body === null) return true;
|
|
162
|
-
const reader = body.getReader();
|
|
163
|
-
let total = 0;
|
|
164
|
-
try {
|
|
165
|
-
for (;;) {
|
|
166
|
-
const { done, value } = await reader.read();
|
|
167
|
-
if (done) return true;
|
|
168
|
-
total += value?.byteLength ?? 0;
|
|
169
|
-
if (total > limit) return false;
|
|
170
|
-
}
|
|
171
|
-
} finally {
|
|
172
|
-
await reader.cancel().catch(() => undefined);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* POST with every redirect hop re-validated.
|
|
178
|
-
*
|
|
179
|
-
* `redirect: "manual"` is what makes the guard meaningful: with the default
|
|
180
|
-
* `follow`, the runtime chases a 302 into the private network before any of
|
|
181
|
-
* this code sees it.
|
|
182
|
-
*/
|
|
183
|
-
async function postWithGuardedRedirects(
|
|
184
|
-
url: string,
|
|
185
|
-
init: RequestInit,
|
|
186
|
-
options: { fetchImpl: typeof fetch; maxRedirects: number; maxBytes: number },
|
|
187
|
-
): Promise<
|
|
188
|
-
| { response: Response; oversize: boolean }
|
|
189
|
-
| { blocked: DeliveryOutcome["errorCategory"] }
|
|
190
|
-
> {
|
|
191
|
-
let target = url;
|
|
192
|
-
for (let hop = 0; hop <= options.maxRedirects; hop += 1) {
|
|
193
|
-
const check = checkWebhookUrl(target);
|
|
194
|
-
if (!check.ok) return { blocked: "blocked-endpoint" };
|
|
195
|
-
|
|
196
|
-
const response = await options.fetchImpl(target, {
|
|
197
|
-
...init,
|
|
198
|
-
redirect: "manual",
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
if (response.status >= 300 && response.status < 400) {
|
|
202
|
-
const location = response.headers.get("location");
|
|
203
|
-
if (location === null) return { response, oversize: false };
|
|
204
|
-
// Resolve relative redirects against the hop we actually made.
|
|
205
|
-
target = new URL(location, target).toString();
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const withinLimit = await drainBounded(response, options.maxBytes);
|
|
210
|
-
return { response, oversize: !withinLimit };
|
|
211
|
-
}
|
|
212
|
-
return { blocked: "too-many-redirects" };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
165
|
/**
|
|
216
166
|
* Deliver one opt-in. Never throws.
|
|
217
167
|
*
|
|
@@ -224,9 +174,6 @@ export async function deliverOptInWebhook(
|
|
|
224
174
|
): Promise<DeliveryOutcome> {
|
|
225
175
|
const now = input.now ?? Date.now;
|
|
226
176
|
const startedAt = now();
|
|
227
|
-
const fetchImpl = input.fetchImpl ?? fetch;
|
|
228
|
-
const maxBytes = input.maxResponseBytes ?? DEFAULT_MAX_BYTES;
|
|
229
|
-
const maxRedirects = input.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
230
177
|
|
|
231
178
|
const check = checkWebhookUrl(input.endpoint);
|
|
232
179
|
if (!check.ok) {
|
|
@@ -242,84 +189,76 @@ export async function deliverOptInWebhook(
|
|
|
242
189
|
const timestamp = String(Math.floor(now() / 1000));
|
|
243
190
|
const signature = await signBody(input.signingSecret, timestamp, body);
|
|
244
191
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
input.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
192
|
+
// Every hop is checked, looked up, pinned and capped by the shared outbound
|
|
193
|
+
// policy; redirects are followed there, never by the runtime.
|
|
194
|
+
const network = input.fetchImpl
|
|
195
|
+
? { transport: fetchTransport(input.fetchImpl), resolver: injectedFetchResolver }
|
|
196
|
+
: { transport: input.transport, resolver: input.resolver };
|
|
197
|
+
const result = await safeRequest({
|
|
198
|
+
url: check.url.toString(),
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
"content-type": "application/json",
|
|
202
|
+
"user-agent": "cms-platform-webhooks/1",
|
|
203
|
+
"x-webhook-event": input.test === true ? "popup.opt_in.test" : "popup.opt_in",
|
|
204
|
+
"x-webhook-event-id": input.eventId,
|
|
205
|
+
// The receiver deduplicates on this. Retries of the same opt-in
|
|
206
|
+
// reuse the same value, which is what makes a retry safe.
|
|
207
|
+
"x-webhook-idempotency-key": input.eventId,
|
|
208
|
+
"x-webhook-timestamp": timestamp,
|
|
209
|
+
"x-webhook-signature": `v1=${signature}`,
|
|
210
|
+
},
|
|
211
|
+
body,
|
|
212
|
+
policy: {
|
|
213
|
+
...WEBHOOK_POLICY,
|
|
214
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
215
|
+
maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_BYTES,
|
|
216
|
+
maxRedirects: input.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
|
|
217
|
+
},
|
|
218
|
+
...network,
|
|
219
|
+
});
|
|
220
|
+
const durationMs = now() - startedAt;
|
|
273
221
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
222
|
+
if (!result.ok) {
|
|
223
|
+
switch (result.failure) {
|
|
224
|
+
case "blocked-url":
|
|
225
|
+
case "blocked-address":
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
errorCategory: "blocked-endpoint",
|
|
229
|
+
error: "The endpoint, or an address it redirected to, is not a public https address.",
|
|
230
|
+
durationMs,
|
|
231
|
+
};
|
|
232
|
+
case "cross-origin-redirect":
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
errorCategory: "blocked-endpoint",
|
|
236
|
+
error: "The endpoint redirected to another site; an opt-in is only sent to the configured address.",
|
|
237
|
+
durationMs,
|
|
238
|
+
};
|
|
239
|
+
case "too-many-redirects":
|
|
240
|
+
return { ok: false, errorCategory: "too-many-redirects", error: "The endpoint redirected too many times.", durationMs };
|
|
241
|
+
case "too-large":
|
|
242
|
+
return { ok: false, errorCategory: "too-large", error: "The endpoint's reply was too large.", durationMs };
|
|
243
|
+
case "timeout":
|
|
244
|
+
return { ok: false, errorCategory: "timeout", error: "The endpoint did not answer in time.", durationMs };
|
|
245
|
+
default:
|
|
246
|
+
return { ok: false, errorCategory: "network", error: "The endpoint could not be reached.", durationMs };
|
|
284
247
|
}
|
|
248
|
+
}
|
|
285
249
|
|
|
286
|
-
|
|
287
|
-
if (oversize) {
|
|
288
|
-
return {
|
|
289
|
-
ok: false,
|
|
290
|
-
status: response.status,
|
|
291
|
-
errorCategory: "too-large",
|
|
292
|
-
error: "The endpoint's reply was too large.",
|
|
293
|
-
durationMs: now() - startedAt,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
if (!response.ok) {
|
|
297
|
-
return {
|
|
298
|
-
ok: false,
|
|
299
|
-
status: response.status,
|
|
300
|
-
errorCategory: "http-error",
|
|
301
|
-
// Deliberately not the response body: it is attacker-influenced text
|
|
302
|
-
// that would end up in an administrator's screen and the logs.
|
|
303
|
-
error: `The endpoint answered ${response.status}.`,
|
|
304
|
-
durationMs: now() - startedAt,
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
return { ok: true, status: response.status, durationMs: now() - startedAt };
|
|
308
|
-
} catch (caught) {
|
|
309
|
-
const aborted =
|
|
310
|
-
caught instanceof Error &&
|
|
311
|
-
(caught.name === "AbortError" || caught.message.includes("aborted"));
|
|
250
|
+
if (result.status < 200 || result.status >= 300) {
|
|
312
251
|
return {
|
|
313
252
|
ok: false,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
253
|
+
status: result.status,
|
|
254
|
+
errorCategory: "http-error",
|
|
255
|
+
// Deliberately not the response body: it is attacker-influenced text
|
|
256
|
+
// that would end up in an administrator's screen and the logs.
|
|
257
|
+
error: `The endpoint answered ${result.status}.`,
|
|
258
|
+
durationMs,
|
|
319
259
|
};
|
|
320
|
-
} finally {
|
|
321
|
-
clearTimeout(timer);
|
|
322
260
|
}
|
|
261
|
+
return { ok: true, status: result.status, durationMs };
|
|
323
262
|
}
|
|
324
263
|
|
|
325
264
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page builder's channel to its preview canvas.
|
|
3
|
+
*
|
|
4
|
+
* The builder renders the page being edited in an iframe (the preview route)
|
|
5
|
+
* and streams unsaved drafts into it; the canvas answers with a ready signal
|
|
6
|
+
* and block selections. The preview route is public, so its page can be
|
|
7
|
+
* opened or framed by anyone — a message that only *says* it comes from the
|
|
8
|
+
* builder proves nothing. Both ends therefore check who is talking:
|
|
9
|
+
*
|
|
10
|
+
* - the canvas accepts the builder's messages only from its own parent frame
|
|
11
|
+
* (`event.source`), only from an expected origin (its own, plus any origin a
|
|
12
|
+
* site explicitly names), and only in one builder session, in order;
|
|
13
|
+
* - the builder accepts the canvas's messages only from its own iframe's
|
|
14
|
+
* window and the iframe's origin;
|
|
15
|
+
* - nothing is ever posted to `"*"`: every message names the origin that may
|
|
16
|
+
* receive it, so unsaved drafts never reach a page that navigated elsewhere.
|
|
17
|
+
*
|
|
18
|
+
* Malformed, oversized, out-of-order and repeated messages are ignored.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const EDITOR_SOURCE = "lovable-editor";
|
|
22
|
+
export const PREVIEW_SOURCE = "lovable-preview";
|
|
23
|
+
|
|
24
|
+
/** Largest draft accepted, measured as JSON. A whole page with its globals is far smaller. */
|
|
25
|
+
export const PREVIEW_MESSAGE_MAX_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
const MAX_ID = 200;
|
|
28
|
+
const KEYS_THAT_ARE_NOT_DATA = new Set(["__proto__", "constructor", "prototype"]);
|
|
29
|
+
|
|
30
|
+
export type EditorDraftMessage = {
|
|
31
|
+
source: typeof EDITOR_SOURCE;
|
|
32
|
+
session: string;
|
|
33
|
+
seq: number;
|
|
34
|
+
type: "draft";
|
|
35
|
+
blocks: unknown[];
|
|
36
|
+
globals: Record<string, unknown[]>;
|
|
37
|
+
selectedId: string | null;
|
|
38
|
+
locale?: string;
|
|
39
|
+
sharedContent?: Record<string, unknown>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type EditorScrollMessage = {
|
|
43
|
+
source: typeof EDITOR_SOURCE;
|
|
44
|
+
session: string;
|
|
45
|
+
seq: number;
|
|
46
|
+
type: "scrollTo";
|
|
47
|
+
blockId: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type EditorMessage = EditorDraftMessage | EditorScrollMessage;
|
|
51
|
+
|
|
52
|
+
export type PreviewMessage =
|
|
53
|
+
| { source: typeof PREVIEW_SOURCE; type: "ready" }
|
|
54
|
+
| { source: typeof PREVIEW_SOURCE; type: "select"; blockId: string };
|
|
55
|
+
|
|
56
|
+
/** The parts of a `MessageEvent` the guards read. */
|
|
57
|
+
export type IncomingMessage = { data: unknown; origin: string; source: unknown };
|
|
58
|
+
|
|
59
|
+
/** A serialized origin (`https://host[:port]`), or null for anything else. */
|
|
60
|
+
export function normalizeOrigin(value: string | null | undefined): string | null {
|
|
61
|
+
if (!value) return null;
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(value);
|
|
64
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
65
|
+
if (url.username || url.password) return null;
|
|
66
|
+
return url.origin;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The origins the canvas trusts: its own first, then any the site names explicitly. */
|
|
73
|
+
export function trustedEditorOrigins(own: string, extra: readonly string[] = []): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
for (const value of [own, ...extra]) {
|
|
76
|
+
const origin = normalizeOrigin(value);
|
|
77
|
+
if (origin && !out.includes(origin)) out.push(origin);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The origin of the page the builder frames, resolved against the builder's address. */
|
|
83
|
+
export function previewOriginOf(previewUrl: string, base: string): string | null {
|
|
84
|
+
try {
|
|
85
|
+
return normalizeOrigin(new URL(previewUrl, base).href);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A fresh, unguessable builder session id. */
|
|
92
|
+
export function newPreviewSession(): string {
|
|
93
|
+
const bytes = new Uint8Array(16);
|
|
94
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
95
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
100
|
+
const proto = Object.getPrototypeOf(value) as unknown;
|
|
101
|
+
return proto === Object.prototype || proto === null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasOnlyDataKeys(value: Record<string, unknown>): boolean {
|
|
105
|
+
return Object.keys(value).every((key) => !KEYS_THAT_ARE_NOT_DATA.has(key));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isId = (value: unknown): value is string =>
|
|
109
|
+
typeof value === "string" && value.length > 0 && value.length <= MAX_ID;
|
|
110
|
+
|
|
111
|
+
function sizeWithin(value: unknown, maxBytes: number): boolean {
|
|
112
|
+
try {
|
|
113
|
+
const json = JSON.stringify(value);
|
|
114
|
+
return typeof json === "string" && json.length <= maxBytes;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The builder's message, checked for shape; null when it is not one. */
|
|
121
|
+
export function parseEditorMessage(
|
|
122
|
+
data: unknown,
|
|
123
|
+
maxBytes = PREVIEW_MESSAGE_MAX_BYTES,
|
|
124
|
+
): EditorMessage | null {
|
|
125
|
+
if (!isPlainObject(data) || data["source"] !== EDITOR_SOURCE) return null;
|
|
126
|
+
const session = data["session"];
|
|
127
|
+
const seq = data["seq"];
|
|
128
|
+
if (typeof session !== "string" || session.length < 16 || session.length > 128) return null;
|
|
129
|
+
if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return null;
|
|
130
|
+
if (!sizeWithin(data, maxBytes)) return null;
|
|
131
|
+
|
|
132
|
+
if (data["type"] === "scrollTo") {
|
|
133
|
+
if (!isId(data["blockId"])) return null;
|
|
134
|
+
return { source: EDITOR_SOURCE, session, seq, type: "scrollTo", blockId: data["blockId"] };
|
|
135
|
+
}
|
|
136
|
+
if (data["type"] !== "draft") return null;
|
|
137
|
+
|
|
138
|
+
const blocks = data["blocks"];
|
|
139
|
+
const globals = data["globals"];
|
|
140
|
+
const selectedId = data["selectedId"] ?? null;
|
|
141
|
+
const locale = data["locale"];
|
|
142
|
+
const sharedContent = data["sharedContent"];
|
|
143
|
+
if (!Array.isArray(blocks)) return null;
|
|
144
|
+
if (!isPlainObject(globals) || !hasOnlyDataKeys(globals)) return null;
|
|
145
|
+
if (!Object.values(globals).every((value) => Array.isArray(value))) return null;
|
|
146
|
+
if (selectedId !== null && !isId(selectedId)) return null;
|
|
147
|
+
if (locale !== undefined && (typeof locale !== "string" || locale.length > 16)) return null;
|
|
148
|
+
if (sharedContent !== undefined && (!isPlainObject(sharedContent) || !hasOnlyDataKeys(sharedContent))) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
source: EDITOR_SOURCE,
|
|
153
|
+
session,
|
|
154
|
+
seq,
|
|
155
|
+
type: "draft",
|
|
156
|
+
blocks,
|
|
157
|
+
globals: globals as Record<string, unknown[]>,
|
|
158
|
+
selectedId,
|
|
159
|
+
...(locale !== undefined ? { locale: locale as string } : {}),
|
|
160
|
+
...(sharedContent !== undefined ? { sharedContent } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The canvas's side: accepts the builder's messages from its parent frame and
|
|
166
|
+
* a trusted origin, in one session and in order.
|
|
167
|
+
*/
|
|
168
|
+
export function createEditorMessageGuard(options: {
|
|
169
|
+
trustedOrigins: readonly string[];
|
|
170
|
+
/** The window that framed the canvas (`window.parent`). */
|
|
171
|
+
parent: () => unknown;
|
|
172
|
+
/** The canvas's own window, to tell "not framed" apart. */
|
|
173
|
+
self: () => unknown;
|
|
174
|
+
maxBytes?: number;
|
|
175
|
+
}) {
|
|
176
|
+
const trusted = new Set(options.trustedOrigins);
|
|
177
|
+
let session: string | null = null;
|
|
178
|
+
let lastSeq = -1;
|
|
179
|
+
let origin: string | null = null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
accept(event: IncomingMessage): EditorMessage | null {
|
|
183
|
+
const parent = options.parent();
|
|
184
|
+
if (!parent || parent === options.self()) return null;
|
|
185
|
+
if (event.source !== parent) return null;
|
|
186
|
+
if (!trusted.has(event.origin)) return null;
|
|
187
|
+
if (origin !== null && event.origin !== origin) return null;
|
|
188
|
+
const message = parseEditorMessage(event.data, options.maxBytes);
|
|
189
|
+
if (!message) return null;
|
|
190
|
+
if (session !== null && message.session !== session) return null;
|
|
191
|
+
if (message.seq <= lastSeq) return null;
|
|
192
|
+
session = message.session;
|
|
193
|
+
origin = event.origin;
|
|
194
|
+
lastSeq = message.seq;
|
|
195
|
+
return message;
|
|
196
|
+
},
|
|
197
|
+
/** Where replies may go: the builder's verified origin, or every trusted one before it spoke. */
|
|
198
|
+
replyOrigins(): string[] {
|
|
199
|
+
return origin !== null ? [origin] : [...trusted];
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The canvas's message, checked for shape; null when it is not one. */
|
|
205
|
+
export function parsePreviewMessage(data: unknown): PreviewMessage | null {
|
|
206
|
+
if (!isPlainObject(data) || data["source"] !== PREVIEW_SOURCE) return null;
|
|
207
|
+
if (data["type"] === "ready") return { source: PREVIEW_SOURCE, type: "ready" };
|
|
208
|
+
if (data["type"] === "select" && isId(data["blockId"])) {
|
|
209
|
+
return { source: PREVIEW_SOURCE, type: "select", blockId: data["blockId"] };
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The builder's side: accepts only its own iframe's messages, from the iframe's origin. */
|
|
215
|
+
export function acceptPreviewMessage(
|
|
216
|
+
event: IncomingMessage,
|
|
217
|
+
options: { frame: unknown; previewOrigin: string | null },
|
|
218
|
+
): PreviewMessage | null {
|
|
219
|
+
if (!options.frame || event.source !== options.frame) return null;
|
|
220
|
+
if (!options.previewOrigin || event.origin !== options.previewOrigin) return null;
|
|
221
|
+
return parsePreviewMessage(event.data);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Posts to each origin in turn; a window at any other origin receives nothing. */
|
|
225
|
+
export function postToOrigins(
|
|
226
|
+
target: { postMessage: (message: unknown, targetOrigin: string) => void } | null | undefined,
|
|
227
|
+
message: unknown,
|
|
228
|
+
origins: readonly string[],
|
|
229
|
+
): void {
|
|
230
|
+
if (!target) return;
|
|
231
|
+
for (const origin of origins) target.postMessage(message, origin);
|
|
232
|
+
}
|
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
* and draft store only.
|
|
9
9
|
*/
|
|
10
10
|
import { useQueryClient } from "@tanstack/react-query";
|
|
11
|
-
import { useEffect, useState, type ComponentType } from "react";
|
|
11
|
+
import { useEffect, useRef, useState, type ComponentType } from "react";
|
|
12
12
|
|
|
13
13
|
import { LocaleProvider, isLocale, DEFAULT_LOCALE, type Locale } from "../../i18n";
|
|
14
14
|
import type { BlockInstance } from "../../blocks/types";
|
|
15
|
+
import {
|
|
16
|
+
createEditorMessageGuard,
|
|
17
|
+
postToOrigins,
|
|
18
|
+
PREVIEW_SOURCE,
|
|
19
|
+
trustedEditorOrigins,
|
|
20
|
+
} from "../preview-messaging";
|
|
15
21
|
|
|
16
22
|
export type PreviewDraftSnapshot = {
|
|
17
23
|
blocks: BlockInstance[];
|
|
@@ -43,6 +49,12 @@ export type PreviewPageDeps = {
|
|
|
43
49
|
/** Outline colour used for block hover/selection. */
|
|
44
50
|
outlineColorVar?: string;
|
|
45
51
|
copy?: { draft?: string; published?: string; loading?: string };
|
|
52
|
+
/**
|
|
53
|
+
* Origins other than the preview's own that may drive the canvas — only
|
|
54
|
+
* when the builder is served from a different origin than the site. The
|
|
55
|
+
* canvas otherwise listens only to a builder on its own origin.
|
|
56
|
+
*/
|
|
57
|
+
trustedEditorOrigins?: readonly string[];
|
|
46
58
|
/**
|
|
47
59
|
* What a visitor would get, for the standalone Preview: a hook returning a
|
|
48
60
|
* transform the site applies to the blocks it shows (booking links removed
|
|
@@ -79,17 +91,6 @@ const asIs = (
|
|
|
79
91
|
) => snapshot;
|
|
80
92
|
const useAsIs = () => asIs;
|
|
81
93
|
|
|
82
|
-
type DraftMsg = {
|
|
83
|
-
source: "lovable-editor";
|
|
84
|
-
type: "draft" | "scrollTo";
|
|
85
|
-
blocks: BlockInstance[];
|
|
86
|
-
globals: Record<string, BlockInstance[]>;
|
|
87
|
-
selectedId?: string | null;
|
|
88
|
-
blockId?: string;
|
|
89
|
-
locale?: string;
|
|
90
|
-
sharedContent?: Record<string, unknown>;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
94
|
export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKey: string }> {
|
|
94
95
|
const copy = {
|
|
95
96
|
draft: deps.copy?.draft ?? "Draft preview — unsaved changes",
|
|
@@ -168,14 +169,23 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
168
169
|
if (q && isLocale(q)) setLocale(q);
|
|
169
170
|
}, []);
|
|
170
171
|
|
|
171
|
-
// Ready handshake plus draft/scroll messages from the editor
|
|
172
|
+
// Ready handshake plus draft/scroll messages from the editor: only from
|
|
173
|
+
// the frame that embeds this canvas, on a trusted origin, in order (see
|
|
174
|
+
// preview-messaging). Replies name that origin; nothing goes to "*".
|
|
175
|
+
const replyOrigins = useRef<() => string[]>(() => []);
|
|
172
176
|
useEffect(() => {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
177
|
+
const guard = createEditorMessageGuard({
|
|
178
|
+
trustedOrigins: trustedEditorOrigins(window.location.origin, deps.trustedEditorOrigins),
|
|
179
|
+
parent: () => window.parent,
|
|
180
|
+
self: () => window,
|
|
181
|
+
});
|
|
182
|
+
replyOrigins.current = () => guard.replyOrigins();
|
|
183
|
+
if (window.parent !== window) {
|
|
184
|
+
postToOrigins(window.parent, { source: PREVIEW_SOURCE, type: "ready" }, guard.replyOrigins());
|
|
185
|
+
}
|
|
176
186
|
const onMsg = (ev: MessageEvent) => {
|
|
177
|
-
const data = ev
|
|
178
|
-
if (!data
|
|
187
|
+
const data = guard.accept(ev);
|
|
188
|
+
if (!data) return;
|
|
179
189
|
if (data.type === "scrollTo") {
|
|
180
190
|
const id = data.blockId;
|
|
181
191
|
if (!id) return;
|
|
@@ -186,7 +196,6 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
186
196
|
});
|
|
187
197
|
return;
|
|
188
198
|
}
|
|
189
|
-
if (data.type !== "draft") return;
|
|
190
199
|
if (data.sharedContent && Object.keys(data.sharedContent).length) {
|
|
191
200
|
queryClient.setQueryData(
|
|
192
201
|
deps.sharedContentQueryKey,
|
|
@@ -196,9 +205,9 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
196
205
|
}),
|
|
197
206
|
);
|
|
198
207
|
}
|
|
199
|
-
setBlocks(data.blocks);
|
|
200
|
-
setGlobals(data.globals);
|
|
201
|
-
setSelectedId(data.selectedId
|
|
208
|
+
setBlocks(data.blocks as BlockInstance[]);
|
|
209
|
+
setGlobals(data.globals as Record<string, BlockInstance[]>);
|
|
210
|
+
setSelectedId(data.selectedId);
|
|
202
211
|
if (data.locale && isLocale(data.locale)) setLocale(data.locale);
|
|
203
212
|
setIsDraft(true);
|
|
204
213
|
setReady(true);
|
|
@@ -221,13 +230,12 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
221
230
|
}
|
|
222
231
|
e.preventDefault();
|
|
223
232
|
e.stopPropagation();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"*",
|
|
233
|
+
const blockId = el.getAttribute("data-block-id");
|
|
234
|
+
if (!blockId) return;
|
|
235
|
+
postToOrigins(
|
|
236
|
+
window.parent,
|
|
237
|
+
{ source: PREVIEW_SOURCE, type: "select", blockId },
|
|
238
|
+
replyOrigins.current(),
|
|
231
239
|
);
|
|
232
240
|
};
|
|
233
241
|
document.addEventListener("click", onClick, true);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An address as it may be stored or shown: its origin, and never its
|
|
3
|
+
* credentials, path, query string or fragment.
|
|
4
|
+
*
|
|
5
|
+
* Receivers put keys in paths (`/services/T…/B…/<key>`) as readily as in query
|
|
6
|
+
* strings, so whatever follows the origin is replaced by a fixed marker rather
|
|
7
|
+
* than trimmed. Pure, so audit code in the browser can use it too.
|
|
8
|
+
*/
|
|
9
|
+
export function redactUrl(raw: string | URL): string {
|
|
10
|
+
try {
|
|
11
|
+
const url = typeof raw === "string" ? new URL(raw) : raw;
|
|
12
|
+
if (url.origin === "null") return "(invalid address)";
|
|
13
|
+
const rest = url.pathname !== "/" || url.search !== "" || url.hash !== "";
|
|
14
|
+
return rest ? `${url.origin}/[redacted]` : url.origin;
|
|
15
|
+
} catch {
|
|
16
|
+
return "(invalid address)";
|
|
17
|
+
}
|
|
18
|
+
}
|