@arcblock/did-connect-service 4.1.56 → 4.1.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/handlers/oauth-handler.d.ts.map +1 -1
- package/dist/handlers/oauth-handler.js +17 -19
- package/dist/handlers/oauth-handler.js.map +1 -1
- package/dist/og/generator.d.ts +13 -0
- package/dist/og/generator.d.ts.map +1 -1
- package/dist/og/generator.js +19 -5
- package/dist/og/generator.js.map +1 -1
- package/dist/og/index.d.ts +4 -2
- package/dist/og/index.d.ts.map +1 -1
- package/dist/og/index.js +2 -1
- package/dist/og/index.js.map +1 -1
- package/dist/og/node-config.d.ts +2 -0
- package/dist/og/node-config.d.ts.map +1 -1
- package/dist/og/node-config.js +1 -0
- package/dist/og/node-config.js.map +1 -1
- package/dist/og/node-wasm-fallback.d.ts.map +1 -1
- package/dist/og/node-wasm-fallback.js +12 -5
- package/dist/og/node-wasm-fallback.js.map +1 -1
- package/dist/og/tree-node.d.ts +73 -0
- package/dist/og/tree-node.d.ts.map +1 -0
- package/dist/og/tree-node.js +192 -0
- package/dist/og/tree-node.js.map +1 -0
- package/dist/og/tree.d.ts +192 -0
- package/dist/og/tree.d.ts.map +1 -0
- package/dist/og/tree.js +921 -0
- package/dist/og/tree.js.map +1 -0
- package/dist/og/worker.d.ts +74 -2
- package/dist/og/worker.d.ts.map +1 -1
- package/dist/og/worker.js +194 -57
- package/dist/og/worker.js.map +1 -1
- package/package.json +3 -3
package/dist/og/tree.js
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral half of the tree renderer (`renderOgTree`, ArcBlock/did#300).
|
|
3
|
+
*
|
|
4
|
+
* A caller hands in a plain satori object tree (the same `{ type, props }`
|
|
5
|
+
* shape `templates.ts` builds). Before anything is parsed or rasterized, this
|
|
6
|
+
* module walks the whole tree and enforces the image contract:
|
|
7
|
+
*
|
|
8
|
+
* - every image reference (`src`, `href`, any `url(...)` in a style or other
|
|
9
|
+
* prop) must be a `data:` URI — anything else rejects the WHOLE render and
|
|
10
|
+
* no request is ever made (keeps did#220's no-remote-media stance);
|
|
11
|
+
* - the bytes must be PNG / JPEG / SVG by magic bytes, matching the declared
|
|
12
|
+
* MIME type;
|
|
13
|
+
* - hard limits are read from the image headers (never by decoding):
|
|
14
|
+
* PNG/JPEG ≤ 1.5 MP and ≤ 1 MB each, SVG ≤ 256 KB, the whole tree
|
|
15
|
+
* ≤ 2.5 MP and ≤ 2 MB of raw image bytes. Nothing is resized; an
|
|
16
|
+
* over-limit tree is refused with a code that names the reason.
|
|
17
|
+
*
|
|
18
|
+
* Both runtime entries (`worker.ts` → `og-worker`, `tree-node.ts` → `og-node`)
|
|
19
|
+
* call {@link validateOgTree} and share {@link createSerialQueue}.
|
|
20
|
+
*/
|
|
21
|
+
import { OgValidationError } from "./types.js";
|
|
22
|
+
/** Font family name trees should use in `fontFamily` (same as the templates). */
|
|
23
|
+
export const OG_TREE_FONT_FAMILY = "Noto";
|
|
24
|
+
export const OG_TREE_LIMITS = {
|
|
25
|
+
/** PNG/JPEG: max pixels per image. */
|
|
26
|
+
maxImagePixels: 1_500_000,
|
|
27
|
+
/** PNG/JPEG: max raw bytes per image. */
|
|
28
|
+
maxImageBytes: 1_000_000,
|
|
29
|
+
/** SVG: max raw bytes per image. */
|
|
30
|
+
maxSvgBytes: 256 * 1024,
|
|
31
|
+
/** Sum of PNG/JPEG pixels over every image occurrence in the tree. */
|
|
32
|
+
maxTotalPixels: 2_500_000,
|
|
33
|
+
/** Sum of raw image bytes (all types) over every image occurrence. */
|
|
34
|
+
maxTotalBytes: 2_000_000,
|
|
35
|
+
/** Image occurrences per tree. */
|
|
36
|
+
maxImages: 32,
|
|
37
|
+
/** Output canvas. */
|
|
38
|
+
maxCanvasDimension: 2400,
|
|
39
|
+
maxCanvasPixels: 1_000_000,
|
|
40
|
+
/** Tree shape. */
|
|
41
|
+
maxDepth: 64,
|
|
42
|
+
maxNodes: 5000,
|
|
43
|
+
/** Characters per text string (satori lays out every glyph). */
|
|
44
|
+
maxStringChars: 2000,
|
|
45
|
+
/** Characters of text over the whole tree. */
|
|
46
|
+
maxTextChars: 10_000,
|
|
47
|
+
/** Characters per non-image prop string; inline `<svg>` props (path data) get more. */
|
|
48
|
+
maxPropStringChars: 2000,
|
|
49
|
+
maxSvgPropStringChars: 16_384,
|
|
50
|
+
/** Characters over all non-image prop strings in the tree. */
|
|
51
|
+
maxPropChars: 65_536,
|
|
52
|
+
/** box-shadow / text-shadow: entries per value; |offset| per entry, in px. */
|
|
53
|
+
maxShadows: 4,
|
|
54
|
+
maxShadowLengthPx: 100,
|
|
55
|
+
/** Blur radius and spread for shadows, drop-shadow() and blur(), in px — blur drives render cost. */
|
|
56
|
+
maxBlurPx: 16,
|
|
57
|
+
maxSpreadPx: 16,
|
|
58
|
+
/** CSS filter: functions per value. */
|
|
59
|
+
maxFilterFunctions: 4,
|
|
60
|
+
/**
|
|
61
|
+
* Tree-wide effect budget, in units. Render cost of a shadow or filter
|
|
62
|
+
* follows the AREA it covers, not its blur radius (measured on the Worker
|
|
63
|
+
* stack: a full-canvas box-shadow ≈ 0.4 s, a CSS filter on a full-canvas
|
|
64
|
+
* group ≈ 1.6 s at blur 4px and 16px alike). An element's area unit count
|
|
65
|
+
* is ceil(width × height / 250,000) from numeric px `width`/`height` in its
|
|
66
|
+
* style, or the whole canvas when either is not a number. Charges:
|
|
67
|
+
* - each boxShadow entry: 1 × area units;
|
|
68
|
+
* - each CSS filter function: 1 × area units, `blur()` 4 × area units (so a
|
|
69
|
+
* blur fits only on an element ≤ 250,000 px); filters are allowed only on
|
|
70
|
+
* elements without children (a filtered group rasterizes its whole,
|
|
71
|
+
* possibly overflowing, subtree: 6–8 s measured);
|
|
72
|
+
* - each textShadow entry: 1 per started 250 characters of every text
|
|
73
|
+
* string it reaches (textShadow is inherited, so a declaration on a
|
|
74
|
+
* container is paid for every text node below it).
|
|
75
|
+
* `drop-shadow()` is refused (use boxShadow).
|
|
76
|
+
*/
|
|
77
|
+
maxEffects: 4,
|
|
78
|
+
effectAreaUnitPx: 250_000,
|
|
79
|
+
effectCostBlur: 4,
|
|
80
|
+
textShadowCharsPerUnit: 250,
|
|
81
|
+
/** Inline <svg>: nodes and prop characters per tree (paths are costly to rasterize). */
|
|
82
|
+
maxSvgNodes: 500,
|
|
83
|
+
maxSvgChars: 16_384,
|
|
84
|
+
};
|
|
85
|
+
export const OG_TREE_ERROR_CODES = {
|
|
86
|
+
treeInvalid: "OG_TREE_INVALID",
|
|
87
|
+
canvasInvalid: "OG_TREE_CANVAS_INVALID",
|
|
88
|
+
weightsInvalid: "OG_TREE_WEIGHTS_INVALID",
|
|
89
|
+
imageNotDataUri: "OG_IMAGE_NOT_DATA_URI",
|
|
90
|
+
imageUnsupportedType: "OG_IMAGE_UNSUPPORTED_TYPE",
|
|
91
|
+
imageUnreadable: "OG_IMAGE_UNREADABLE",
|
|
92
|
+
imageTooManyPixels: "OG_IMAGE_TOO_MANY_PIXELS",
|
|
93
|
+
imageTooLarge: "OG_IMAGE_TOO_LARGE",
|
|
94
|
+
svgTooLarge: "OG_SVG_TOO_LARGE",
|
|
95
|
+
svgUnsafe: "OG_SVG_UNSAFE",
|
|
96
|
+
treeTooManyImages: "OG_TREE_TOO_MANY_IMAGES",
|
|
97
|
+
treeTooManyPixels: "OG_TREE_TOO_MANY_PIXELS",
|
|
98
|
+
treeTooManyBytes: "OG_TREE_TOO_MANY_BYTES",
|
|
99
|
+
svgFilterForbidden: "OG_SVG_FILTER_FORBIDDEN",
|
|
100
|
+
shadowLimit: "OG_TREE_SHADOW_LIMIT",
|
|
101
|
+
cssFilterLimit: "OG_TREE_FILTER_LIMIT",
|
|
102
|
+
textTooLong: "OG_TREE_TEXT_TOO_LONG",
|
|
103
|
+
/** satori/resvg failed on a tree that passed validation (generic; no input echoed). */
|
|
104
|
+
renderFailed: "OG_TREE_RENDER_FAILED",
|
|
105
|
+
};
|
|
106
|
+
/** Rejection of a tree render. `statusCode` 400 (inherited): the input is at fault. */
|
|
107
|
+
export class OgTreeRenderError extends OgValidationError {
|
|
108
|
+
code;
|
|
109
|
+
constructor(code, message) {
|
|
110
|
+
super(`${code}: ${message}`);
|
|
111
|
+
this.name = "OgTreeRenderError";
|
|
112
|
+
this.code = code;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export const OG_RENDER_UNAVAILABLE_CODES = {
|
|
116
|
+
/** A font read did not finish within the timeout; the cached slot was dropped. */
|
|
117
|
+
fontTimeout: "OG_FONT_TIMEOUT",
|
|
118
|
+
/** The render waited in the isolate/process queue past its deadline. */
|
|
119
|
+
queueTimeout: "OG_QUEUE_TIMEOUT",
|
|
120
|
+
};
|
|
121
|
+
/** The renderer is temporarily unable to serve (font storage hung, queue backed up). Retryable: 503. */
|
|
122
|
+
export class OgRenderUnavailableError extends Error {
|
|
123
|
+
statusCode = 503;
|
|
124
|
+
code;
|
|
125
|
+
constructor(code, message) {
|
|
126
|
+
super(`${code}: ${message}`);
|
|
127
|
+
this.name = "OgRenderUnavailableError";
|
|
128
|
+
this.code = code;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Rejects with `onTimeout()` if `promise` has not settled within `ms`. */
|
|
132
|
+
export function withTimeout(promise, ms, onTimeout) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(() => reject(onTimeout()), ms);
|
|
135
|
+
promise.then((value) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
resolve(value);
|
|
138
|
+
}, (error) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
reject(error);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
145
|
+
const JPEG_MAGIC = [0xff, 0xd8, 0xff];
|
|
146
|
+
function bytesStartWith(bytes, magic) {
|
|
147
|
+
if (bytes.length < magic.length)
|
|
148
|
+
return false;
|
|
149
|
+
for (let i = 0; i < magic.length; i++) {
|
|
150
|
+
if (bytes[i] !== magic[i])
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
/** SVG has no binary magic number — sniff the head as trimmed XML/tag text. */
|
|
156
|
+
function looksLikeSvg(bytes) {
|
|
157
|
+
const head = bytes.subarray(0, Math.min(bytes.length, 512));
|
|
158
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(head).trimStart();
|
|
159
|
+
return text.startsWith("<?xml") || /^<svg[\s>]/i.test(text);
|
|
160
|
+
}
|
|
161
|
+
/** Detects PNG / JPEG / SVG from the bytes themselves; anything else is `undefined`. */
|
|
162
|
+
export function sniffOgImageKind(bytes) {
|
|
163
|
+
if (bytesStartWith(bytes, PNG_MAGIC))
|
|
164
|
+
return "png";
|
|
165
|
+
if (bytesStartWith(bytes, JPEG_MAGIC))
|
|
166
|
+
return "jpeg";
|
|
167
|
+
if (looksLikeSvg(bytes))
|
|
168
|
+
return "svg";
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const MIME_BY_KIND = {
|
|
172
|
+
png: "image/png",
|
|
173
|
+
jpeg: "image/jpeg",
|
|
174
|
+
svg: "image/svg+xml",
|
|
175
|
+
};
|
|
176
|
+
export function ogImageMime(kind) {
|
|
177
|
+
return MIME_BY_KIND[kind];
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Header dimension readers
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
function pngDimensions(bytes) {
|
|
183
|
+
// signature(8) + length(4) + "IHDR"(4) + width(4) + height(4)
|
|
184
|
+
if (bytes.length < 24)
|
|
185
|
+
return undefined;
|
|
186
|
+
if (bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52)
|
|
187
|
+
return undefined;
|
|
188
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
189
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
190
|
+
}
|
|
191
|
+
const JPEG_SOF = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
|
192
|
+
/**
|
|
193
|
+
* Walks every JPEG marker segment in the file — including after each SOS,
|
|
194
|
+
* skipping entropy-coded data — and returns the LARGEST frame dimensions any
|
|
195
|
+
* SOF declares. Taking the max (rather than the first SOF) means a file that
|
|
196
|
+
* declares a small frame first and a huge one later cannot slip a big decode
|
|
197
|
+
* past the limit. A frame with a 0 dimension (height deferred to a DNL
|
|
198
|
+
* marker) is treated as unreadable.
|
|
199
|
+
*/
|
|
200
|
+
function jpegDimensions(bytes) {
|
|
201
|
+
let width = 0;
|
|
202
|
+
let height = 0;
|
|
203
|
+
let sawFrame = false;
|
|
204
|
+
let i = 2;
|
|
205
|
+
const n = bytes.length;
|
|
206
|
+
while (i < n) {
|
|
207
|
+
if (bytes[i] !== 0xff) {
|
|
208
|
+
i += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Skip fill bytes.
|
|
212
|
+
while (i < n && bytes[i] === 0xff)
|
|
213
|
+
i += 1;
|
|
214
|
+
if (i >= n)
|
|
215
|
+
break;
|
|
216
|
+
const marker = bytes[i];
|
|
217
|
+
i += 1;
|
|
218
|
+
// Standalone markers: TEM, RSTn, SOI, EOI.
|
|
219
|
+
if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
220
|
+
if (marker === 0xd9)
|
|
221
|
+
break;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (i + 1 >= n)
|
|
225
|
+
break;
|
|
226
|
+
const length = (bytes[i] << 8) | bytes[i + 1];
|
|
227
|
+
if (length < 2)
|
|
228
|
+
return undefined;
|
|
229
|
+
if (JPEG_SOF.has(marker)) {
|
|
230
|
+
if (i + 7 > n)
|
|
231
|
+
return undefined;
|
|
232
|
+
const h = (bytes[i + 3] << 8) | bytes[i + 4];
|
|
233
|
+
const w = (bytes[i + 5] << 8) | bytes[i + 6];
|
|
234
|
+
if (w === 0 || h === 0)
|
|
235
|
+
return undefined;
|
|
236
|
+
sawFrame = true;
|
|
237
|
+
width = Math.max(width, w);
|
|
238
|
+
height = Math.max(height, h);
|
|
239
|
+
}
|
|
240
|
+
i += length;
|
|
241
|
+
if (marker === 0xda) {
|
|
242
|
+
// Entropy-coded data follows SOS: advance to the next real marker
|
|
243
|
+
// (0xFF not followed by 0x00 stuffing or an RSTn).
|
|
244
|
+
while (i + 1 < n) {
|
|
245
|
+
if (bytes[i] === 0xff && bytes[i + 1] !== 0x00 && !(bytes[i + 1] >= 0xd0 && bytes[i + 1] <= 0xd7))
|
|
246
|
+
break;
|
|
247
|
+
i += 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return sawFrame ? { width, height } : undefined;
|
|
252
|
+
}
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// SVG safety: no nested raster/remote content can hide inside an SVG
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
/** Local name of an XML/JSX name: everything after the last `:` (any prefix), lowercased. */
|
|
257
|
+
function localName(name) {
|
|
258
|
+
return name.slice(name.lastIndexOf(":") + 1).toLowerCase();
|
|
259
|
+
}
|
|
260
|
+
/** Elements that load or embed content (or run script) — never allowed inside an SVG. */
|
|
261
|
+
const SVG_FORBIDDEN_ELEMENTS = new Set(["image", "feimage", "foreignobject", "script", "iframe", "object", "embed"]);
|
|
262
|
+
/**
|
|
263
|
+
* An SVG passes the byte limit but could still smuggle a pixel bomb (an
|
|
264
|
+
* embedded `<image href="data:image/png…">` decoded at full size) or point at
|
|
265
|
+
* external content. resvg-wasm has no network, but nested data images would
|
|
266
|
+
* bypass every pixel limit above — so they are refused outright. Internal
|
|
267
|
+
* fragment references (`href="#id"`, `url(#grad)`) stay allowed: logos use them.
|
|
268
|
+
*
|
|
269
|
+
* Names are matched by LOCAL name with any namespace prefix (`<a.b:image>`,
|
|
270
|
+
* `c.d:href`), and the whole text must not contain `data:` at all or a
|
|
271
|
+
* character reference (`&#…;` could spell `data:` after XML decoding).
|
|
272
|
+
*/
|
|
273
|
+
function svgUnsafeReason(text) {
|
|
274
|
+
const unsafe = (reason) => ({ code: OG_TREE_ERROR_CODES.svgUnsafe, reason });
|
|
275
|
+
const filter = { code: OG_TREE_ERROR_CODES.svgFilterForbidden, reason: "SVG filters and styles are not allowed" };
|
|
276
|
+
// Refuse whole classes by plain substring search (comments and escapes
|
|
277
|
+
// included): resvg runs CSS filter functions from <style>/style= without any
|
|
278
|
+
// <filter> element, so any filter/blur/shadow text and any style is out.
|
|
279
|
+
if (hasEffectWord(text) || hasStyleWord(text))
|
|
280
|
+
return filter;
|
|
281
|
+
if (/<!(?:DOCTYPE|ENTITY|\[CDATA\[)/i.test(text))
|
|
282
|
+
return unsafe("DOCTYPE/ENTITY/CDATA sections are not allowed");
|
|
283
|
+
if (/&#/.test(text))
|
|
284
|
+
return unsafe("character references are not allowed");
|
|
285
|
+
if (/data\s*:/i.test(text))
|
|
286
|
+
return unsafe("nested data: URIs are not allowed");
|
|
287
|
+
if (/@import/i.test(text))
|
|
288
|
+
return unsafe("@import is not allowed");
|
|
289
|
+
for (const match of text.matchAll(/<\s*([^\s/>!?<]+)/g)) {
|
|
290
|
+
const name = localName(match[1]);
|
|
291
|
+
// Filter primitives make render cost unbounded (feTurbulence numOctaves,
|
|
292
|
+
// feMorphology radius, chained blurs) — logos and avatars do not need them.
|
|
293
|
+
if (isFilterElement(name))
|
|
294
|
+
return filter;
|
|
295
|
+
if (SVG_FORBIDDEN_ELEMENTS.has(name))
|
|
296
|
+
return unsafe("embedded image/foreignObject/script elements are not allowed");
|
|
297
|
+
}
|
|
298
|
+
// Linear-time patterns only: an attribute name must follow whitespace (the
|
|
299
|
+
// lookbehind fails in O(1) inside long runs), and url() arguments stop at
|
|
300
|
+
// the next quote/paren — a 256 KB SVG must not cost quadratic time here.
|
|
301
|
+
for (const match of text.matchAll(/(?<=\s)([^\s=<>"'/]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
302
|
+
const name = localName(match[1]);
|
|
303
|
+
if (name === "filter")
|
|
304
|
+
return filter;
|
|
305
|
+
const value = match[2] ?? match[3] ?? "";
|
|
306
|
+
if (name.endsWith("href") && !value.trim().startsWith("#"))
|
|
307
|
+
return unsafe("href must reference an in-document #fragment");
|
|
308
|
+
}
|
|
309
|
+
if (/(?:^|[\s;{"'])filter\s*:/i.test(text))
|
|
310
|
+
return filter;
|
|
311
|
+
for (const match of text.matchAll(/url\(\s*["']?([^"'()]*)/gi)) {
|
|
312
|
+
if (!match[1].trim().startsWith("#"))
|
|
313
|
+
return unsafe("url() must reference an in-document #fragment");
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
/** `filter`, `blur`, `shadow` (also `drop-shadow`) anywhere, case-insensitive. */
|
|
318
|
+
function hasEffectWord(text) {
|
|
319
|
+
return /filter|blur|shadow/i.test(text);
|
|
320
|
+
}
|
|
321
|
+
/** `style` anywhere except inside the presentation attribute `font-style`. */
|
|
322
|
+
function hasStyleWord(text) {
|
|
323
|
+
return /style/i.test(text.replace(/font-style/gi, ""));
|
|
324
|
+
}
|
|
325
|
+
function isFilterElement(name) {
|
|
326
|
+
return name === "filter" || /^fe[a-z]/.test(name);
|
|
327
|
+
}
|
|
328
|
+
function base64ToBytes(payload) {
|
|
329
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload))
|
|
330
|
+
return undefined;
|
|
331
|
+
let binary;
|
|
332
|
+
try {
|
|
333
|
+
binary = atob(payload);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const out = new Uint8Array(binary.length);
|
|
339
|
+
for (let i = 0; i < binary.length; i++)
|
|
340
|
+
out[i] = binary.charCodeAt(i);
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Same grammar satori uses to read a data: URI (`dist/standalone.js`, `xr`):
|
|
345
|
+
* lowercase `data:`, a `[a-z/+]` MIME type, optional `;k=v` params, an optional
|
|
346
|
+
* `;encoding`, then the payload. The payload must not contain whitespace —
|
|
347
|
+
* satori's `.*` stops at a newline — so both sides always read the same bytes.
|
|
348
|
+
*/
|
|
349
|
+
const DATA_URI = /^data:([a-z/+]+)((?:;[^;=,]+=[^;=,]+)*)(?:;([^;,=]+))?,([^\s]*)$/;
|
|
350
|
+
function decodeDataUri(uri, budget) {
|
|
351
|
+
const match = DATA_URI.exec(uri);
|
|
352
|
+
if (!match)
|
|
353
|
+
return undefined;
|
|
354
|
+
const mime = match[1];
|
|
355
|
+
const encoding = match[3];
|
|
356
|
+
const payload = match[4];
|
|
357
|
+
const base64 = encoding === "base64";
|
|
358
|
+
// Upper bound on decoded size, checked BEFORE decoding so a huge string is never materialized.
|
|
359
|
+
const bound = base64 ? Math.ceil((payload.length * 3) / 4) : payload.length;
|
|
360
|
+
if (bound > budget * 2 + 1024)
|
|
361
|
+
return "too-large";
|
|
362
|
+
if (base64) {
|
|
363
|
+
const bytes = base64ToBytes(payload);
|
|
364
|
+
return bytes ? { mime, bytes } : undefined;
|
|
365
|
+
}
|
|
366
|
+
// satori only sizes non-base64 data URIs for SVG; rasters must be base64.
|
|
367
|
+
if (mime !== "image/svg+xml")
|
|
368
|
+
return undefined;
|
|
369
|
+
try {
|
|
370
|
+
return { mime, bytes: new TextEncoder().encode(decodeURIComponent(payload)) };
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Tree walk
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
/** Prop keys whose value is itself an image reference (not a CSS value), besides any `*href`. */
|
|
380
|
+
const IMAGE_REF_KEYS = new Set(["src", "srcset", "poster"]);
|
|
381
|
+
function isImageRefKey(key) {
|
|
382
|
+
const name = localName(key);
|
|
383
|
+
return IMAGE_REF_KEYS.has(name) || name.endsWith("href");
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* satori serializes an inline `<svg>` subtree as markup WITHOUT escaping
|
|
387
|
+
* names, attribute values or text (`Na` / `Ui` in its dist), so names must be
|
|
388
|
+
* plain identifiers and values / text must not be able to break out.
|
|
389
|
+
*/
|
|
390
|
+
const SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*$/;
|
|
391
|
+
const SVG_UNSAFE_TEXT = /[<>"&]/;
|
|
392
|
+
const EMPTY_COUNTS = {
|
|
393
|
+
imageCount: 0,
|
|
394
|
+
totalPixels: 0,
|
|
395
|
+
totalBytes: 0,
|
|
396
|
+
nodes: 0,
|
|
397
|
+
textChars: 0,
|
|
398
|
+
propChars: 0,
|
|
399
|
+
effects: 0,
|
|
400
|
+
svgNodes: 0,
|
|
401
|
+
svgChars: 0,
|
|
402
|
+
canvasPixels: 0,
|
|
403
|
+
};
|
|
404
|
+
function fail(code, message) {
|
|
405
|
+
throw new OgTreeRenderError(code, message);
|
|
406
|
+
}
|
|
407
|
+
function checkImageRef(ref, state) {
|
|
408
|
+
if (!ref.startsWith("data:")) {
|
|
409
|
+
fail(OG_TREE_ERROR_CODES.imageNotDataUri, "image source must be a data: URI");
|
|
410
|
+
}
|
|
411
|
+
state.imageCount += 1;
|
|
412
|
+
if (state.imageCount > OG_TREE_LIMITS.maxImages) {
|
|
413
|
+
fail(OG_TREE_ERROR_CODES.treeTooManyImages, `tree has more than ${OG_TREE_LIMITS.maxImages} images`);
|
|
414
|
+
}
|
|
415
|
+
const decoded = decodeDataUri(ref, OG_TREE_LIMITS.maxImageBytes);
|
|
416
|
+
if (decoded === "too-large") {
|
|
417
|
+
fail(OG_TREE_ERROR_CODES.imageTooLarge, `image data: URI exceeds ${OG_TREE_LIMITS.maxImageBytes} bytes`);
|
|
418
|
+
}
|
|
419
|
+
if (!decoded || decoded.bytes.length === 0) {
|
|
420
|
+
fail(OG_TREE_ERROR_CODES.imageUnreadable, "image data: URI could not be decoded");
|
|
421
|
+
}
|
|
422
|
+
const { mime, bytes } = decoded;
|
|
423
|
+
const kind = sniffOgImageKind(bytes);
|
|
424
|
+
if (!kind)
|
|
425
|
+
fail(OG_TREE_ERROR_CODES.imageUnsupportedType, "image bytes are not PNG, JPEG or SVG");
|
|
426
|
+
if (mime !== MIME_BY_KIND[kind]) {
|
|
427
|
+
fail(OG_TREE_ERROR_CODES.imageUnsupportedType, `declared type does not match the ${kind} bytes`);
|
|
428
|
+
}
|
|
429
|
+
if (kind === "svg") {
|
|
430
|
+
if (bytes.length > OG_TREE_LIMITS.maxSvgBytes) {
|
|
431
|
+
fail(OG_TREE_ERROR_CODES.svgTooLarge, `SVG is ${bytes.length} bytes (max ${OG_TREE_LIMITS.maxSvgBytes})`);
|
|
432
|
+
}
|
|
433
|
+
const unsafe = svgUnsafeReason(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
|
434
|
+
if (unsafe)
|
|
435
|
+
fail(unsafe.code, unsafe.reason);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
if (bytes.length > OG_TREE_LIMITS.maxImageBytes) {
|
|
439
|
+
fail(OG_TREE_ERROR_CODES.imageTooLarge, `${kind} is ${bytes.length} bytes (max ${OG_TREE_LIMITS.maxImageBytes})`);
|
|
440
|
+
}
|
|
441
|
+
const dims = kind === "png" ? pngDimensions(bytes) : jpegDimensions(bytes);
|
|
442
|
+
if (!dims || dims.width === 0 || dims.height === 0) {
|
|
443
|
+
fail(OG_TREE_ERROR_CODES.imageUnreadable, `${kind} header has no readable dimensions`);
|
|
444
|
+
}
|
|
445
|
+
const pixels = dims.width * dims.height;
|
|
446
|
+
if (pixels > OG_TREE_LIMITS.maxImagePixels) {
|
|
447
|
+
fail(OG_TREE_ERROR_CODES.imageTooManyPixels, `${kind} is ${dims.width}x${dims.height} (${pixels} px, max ${OG_TREE_LIMITS.maxImagePixels})`);
|
|
448
|
+
}
|
|
449
|
+
state.totalPixels += pixels;
|
|
450
|
+
if (state.totalPixels > OG_TREE_LIMITS.maxTotalPixels) {
|
|
451
|
+
fail(OG_TREE_ERROR_CODES.treeTooManyPixels, `tree images exceed ${OG_TREE_LIMITS.maxTotalPixels} px in total`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
state.totalBytes += bytes.length;
|
|
455
|
+
if (state.totalBytes > OG_TREE_LIMITS.maxTotalBytes) {
|
|
456
|
+
fail(OG_TREE_ERROR_CODES.treeTooManyBytes, `tree images exceed ${OG_TREE_LIMITS.maxTotalBytes} bytes in total`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const URL_TOKEN = /url\s*\(/i;
|
|
460
|
+
const URL_REF = /url\s*\(\s*(?:"([^"]*)"|'([^']*)'|([^"'()\s]*))\s*\)/gi;
|
|
461
|
+
/**
|
|
462
|
+
* Finds every `url(...)` in a CSS-ish string and validates each target. After
|
|
463
|
+
* removing every reference this parser understood, neither `url(` nor `data:`
|
|
464
|
+
* may remain — so an argument it cannot delimit (e.g. an unquoted value with a
|
|
465
|
+
* stray `)`) or a data URI hiding in some other attribute is refused instead
|
|
466
|
+
* of being read differently by satori or resvg. The remainder (everything but
|
|
467
|
+
* validated image references) counts toward the prop-string budgets.
|
|
468
|
+
*/
|
|
469
|
+
function checkStringValue(value, state, inSvg) {
|
|
470
|
+
if (inSvg && SVG_UNSAFE_TEXT.test(value)) {
|
|
471
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "inline <svg> values must not contain < > \" &");
|
|
472
|
+
}
|
|
473
|
+
if (inSvg && (hasEffectWord(value) || hasStyleWord(value))) {
|
|
474
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "SVG filters and styles are not allowed");
|
|
475
|
+
}
|
|
476
|
+
if (URL_TOKEN.test(value)) {
|
|
477
|
+
for (const match of value.matchAll(URL_REF)) {
|
|
478
|
+
const target = match[1] ?? match[2] ?? match[3] ?? "";
|
|
479
|
+
if (inSvg && target.startsWith("#"))
|
|
480
|
+
continue; // in-document paint server
|
|
481
|
+
checkImageRef(target, state);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const rest = value.replace(URL_REF, "");
|
|
485
|
+
if (URL_TOKEN.test(rest)) {
|
|
486
|
+
fail(OG_TREE_ERROR_CODES.imageNotDataUri, "could not parse a url() reference; quote data: URIs");
|
|
487
|
+
}
|
|
488
|
+
if (/data\s*:/i.test(rest)) {
|
|
489
|
+
fail(OG_TREE_ERROR_CODES.imageNotDataUri, "a data: URI is only accepted as an image src/href or inside url()");
|
|
490
|
+
}
|
|
491
|
+
if (inSvg) {
|
|
492
|
+
state.svgChars += rest.length;
|
|
493
|
+
if (state.svgChars > OG_TREE_LIMITS.maxSvgChars)
|
|
494
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, "inline <svg> content is too large");
|
|
495
|
+
}
|
|
496
|
+
const cap = inSvg ? OG_TREE_LIMITS.maxSvgPropStringChars : OG_TREE_LIMITS.maxPropStringChars;
|
|
497
|
+
if (rest.length > cap)
|
|
498
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, `a property value is longer than ${cap} characters`);
|
|
499
|
+
state.propChars += rest.length;
|
|
500
|
+
if (state.propChars > OG_TREE_LIMITS.maxPropChars) {
|
|
501
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, `property values exceed ${OG_TREE_LIMITS.maxPropChars} characters in total`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Splits a CSS list on top-level commas (not inside parentheses). */
|
|
505
|
+
function splitCssList(value) {
|
|
506
|
+
const parts = [];
|
|
507
|
+
let depth = 0;
|
|
508
|
+
let start = 0;
|
|
509
|
+
for (let i = 0; i < value.length; i++) {
|
|
510
|
+
const ch = value[i];
|
|
511
|
+
if (ch === "(")
|
|
512
|
+
depth += 1;
|
|
513
|
+
else if (ch === ")")
|
|
514
|
+
depth = Math.max(0, depth - 1);
|
|
515
|
+
else if (ch === "," && depth === 0) {
|
|
516
|
+
parts.push(value.slice(start, i));
|
|
517
|
+
start = i + 1;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
parts.push(value.slice(start));
|
|
521
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
522
|
+
}
|
|
523
|
+
/** Every length in `text` outside parentheses (color functions), as px; non-px units are refused. */
|
|
524
|
+
function cssLengthsPx(text, code) {
|
|
525
|
+
const outside = text.replace(/\([^()]*\)/g, " ").replace(/#[0-9a-f]+/gi, " ");
|
|
526
|
+
const lengths = [];
|
|
527
|
+
for (const match of outside.matchAll(/(-?\d*\.?\d+(?:e[+-]?\d+)?)([a-z%]*)/gi)) {
|
|
528
|
+
const unit = match[2].toLowerCase();
|
|
529
|
+
const n = Number(match[1]);
|
|
530
|
+
if (unit !== "" && unit !== "px")
|
|
531
|
+
fail(code, "only px lengths are allowed here");
|
|
532
|
+
lengths.push(Math.abs(n));
|
|
533
|
+
}
|
|
534
|
+
return lengths;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* box-shadow / text-shadow become SVG blur filters whose cost grows with the
|
|
538
|
+
* blur and spread radius and the number of entries (200 × `0 0 2000px 2000px`
|
|
539
|
+
* took 80 s) — bound both.
|
|
540
|
+
*/
|
|
541
|
+
/** Length gate BEFORE any CSS parsing, so the parsers below only ever see short strings. */
|
|
542
|
+
function checkCssLength(value) {
|
|
543
|
+
if (value.length > OG_TREE_LIMITS.maxPropStringChars) {
|
|
544
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, `a property value is longer than ${OG_TREE_LIMITS.maxPropStringChars} characters`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function refuseCssFunctions(value, code) {
|
|
548
|
+
if (/calc\s*\(|var\s*\(|env\s*\(|min\s*\(|max\s*\(|clamp\s*\(/i.test(value)) {
|
|
549
|
+
fail(code, "calc()/var()/min()/max()/clamp() are not allowed here");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Validates a box-shadow / text-shadow value and returns its entry count.
|
|
554
|
+
* Offsets ≤ 100px, blur ≤ 16px, spread ≤ 16px — blur radius drives render
|
|
555
|
+
* cost (one 100px blur measured 5.4 s; an 8px–24px card shadow is ~0.3 s).
|
|
556
|
+
*/
|
|
557
|
+
function checkShadow(value) {
|
|
558
|
+
const code = OG_TREE_ERROR_CODES.shadowLimit;
|
|
559
|
+
if (typeof value !== "string")
|
|
560
|
+
fail(code, "shadows must be CSS strings");
|
|
561
|
+
checkCssLength(value);
|
|
562
|
+
refuseCssFunctions(value, code);
|
|
563
|
+
if (/^\s*none\s*$/i.test(value))
|
|
564
|
+
return 0;
|
|
565
|
+
const entries = splitCssList(value);
|
|
566
|
+
if (entries.length > OG_TREE_LIMITS.maxShadows)
|
|
567
|
+
fail(code, `at most ${OG_TREE_LIMITS.maxShadows} shadows per value`);
|
|
568
|
+
for (const entry of entries) {
|
|
569
|
+
const [x = 0, y = 0, blur = 0, spread = 0, ...extra] = cssLengthsPx(entry, code);
|
|
570
|
+
if (extra.length)
|
|
571
|
+
fail(code, "a shadow has at most four lengths");
|
|
572
|
+
if (x > OG_TREE_LIMITS.maxShadowLengthPx || y > OG_TREE_LIMITS.maxShadowLengthPx) {
|
|
573
|
+
fail(code, `shadow offsets must be at most ${OG_TREE_LIMITS.maxShadowLengthPx}px`);
|
|
574
|
+
}
|
|
575
|
+
if (blur > OG_TREE_LIMITS.maxBlurPx)
|
|
576
|
+
fail(code, `shadow blur must be at most ${OG_TREE_LIMITS.maxBlurPx}px`);
|
|
577
|
+
if (spread > OG_TREE_LIMITS.maxSpreadPx)
|
|
578
|
+
fail(code, `shadow spread must be at most ${OG_TREE_LIMITS.maxSpreadPx}px`);
|
|
579
|
+
}
|
|
580
|
+
return entries.length;
|
|
581
|
+
}
|
|
582
|
+
/** CSS `filter`: few functions, `blur()` ≤ 16px, no `drop-shadow()`. Returns its effect units. */
|
|
583
|
+
function checkCssFilter(value) {
|
|
584
|
+
const code = OG_TREE_ERROR_CODES.cssFilterLimit;
|
|
585
|
+
if (typeof value !== "string")
|
|
586
|
+
fail(code, "filter must be a CSS string");
|
|
587
|
+
checkCssLength(value);
|
|
588
|
+
refuseCssFunctions(value, code);
|
|
589
|
+
if (/^\s*none\s*$/i.test(value))
|
|
590
|
+
return 0;
|
|
591
|
+
// `\b` anchors the name to the start of a word: inside a long run of letters
|
|
592
|
+
// every other start position fails in O(1), keeping this linear.
|
|
593
|
+
const functions = [...value.matchAll(/\b([a-z-]+)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)/gi)];
|
|
594
|
+
if (functions.length > OG_TREE_LIMITS.maxFilterFunctions) {
|
|
595
|
+
fail(code, `at most ${OG_TREE_LIMITS.maxFilterFunctions} filter functions`);
|
|
596
|
+
}
|
|
597
|
+
for (const [, name, args] of functions) {
|
|
598
|
+
const fn = name.toLowerCase();
|
|
599
|
+
if (fn === "url")
|
|
600
|
+
fail(code, "filter url() references are not allowed");
|
|
601
|
+
if (fn === "blur") {
|
|
602
|
+
for (const length of cssLengthsPx(args, code)) {
|
|
603
|
+
if (length > OG_TREE_LIMITS.maxBlurPx)
|
|
604
|
+
fail(code, `blur radius must be at most ${OG_TREE_LIMITS.maxBlurPx}px`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (fn === "drop-shadow")
|
|
608
|
+
fail(code, "drop-shadow() is not allowed; use boxShadow");
|
|
609
|
+
}
|
|
610
|
+
return functions.reduce((units, [, name]) => units + (name.toLowerCase() === "blur" ? OG_TREE_LIMITS.effectCostBlur : 1), 0);
|
|
611
|
+
}
|
|
612
|
+
function hasContent(children) {
|
|
613
|
+
if (children === null || children === undefined || typeof children === "boolean")
|
|
614
|
+
return false;
|
|
615
|
+
if (typeof children === "string")
|
|
616
|
+
return children.length > 0;
|
|
617
|
+
if (Array.isArray(children))
|
|
618
|
+
return children.some(hasContent);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
/** px number from a style length (number or "Npx"); undefined for anything else (%, auto, missing). */
|
|
622
|
+
function pxValue(value) {
|
|
623
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
624
|
+
return Math.abs(value);
|
|
625
|
+
if (typeof value === "string") {
|
|
626
|
+
const m = /^\s*(\d+(?:\.\d+)?)(?:px)?\s*$/i.exec(value);
|
|
627
|
+
if (m)
|
|
628
|
+
return Number(m[1]);
|
|
629
|
+
}
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
/** Area units of an element for the effect budget (whole canvas when its size is not numeric). */
|
|
633
|
+
function areaUnits(style, state) {
|
|
634
|
+
const w = pxValue(style.width);
|
|
635
|
+
const h = pxValue(style.height);
|
|
636
|
+
const area = w !== undefined && h !== undefined ? Math.min(w * h, state.canvasPixels) : state.canvasPixels;
|
|
637
|
+
return Math.max(1, Math.ceil(area / OG_TREE_LIMITS.effectAreaUnitPx));
|
|
638
|
+
}
|
|
639
|
+
function addEffects(state, count) {
|
|
640
|
+
state.effects += count;
|
|
641
|
+
if (state.effects > OG_TREE_LIMITS.maxEffects) {
|
|
642
|
+
fail(OG_TREE_ERROR_CODES.shadowLimit, `shadow/filter effects exceed ${OG_TREE_LIMITS.maxEffects} units per tree`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function checkPropValue(key, value, state, depth, inSvg) {
|
|
646
|
+
if (depth > OG_TREE_LIMITS.maxDepth)
|
|
647
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "props nested too deeply");
|
|
648
|
+
const name = localName(key);
|
|
649
|
+
if (inSvg && name === "filter")
|
|
650
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "SVG filters are not allowed");
|
|
651
|
+
// Validation only; the budget is charged per element in walkNode.
|
|
652
|
+
if (name.includes("shadow"))
|
|
653
|
+
checkShadow(value);
|
|
654
|
+
else if (name.endsWith("filter"))
|
|
655
|
+
checkCssFilter(value);
|
|
656
|
+
if (typeof value === "string") {
|
|
657
|
+
checkStringValue(value, state, inSvg);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (typeof value === "function")
|
|
661
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "functions are not allowed in a tree");
|
|
662
|
+
if (Array.isArray(value)) {
|
|
663
|
+
for (const item of value)
|
|
664
|
+
checkPropValue(key, item, state, depth + 1, inSvg);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (value && typeof value === "object") {
|
|
668
|
+
for (const [childKey, item] of Object.entries(value)) {
|
|
669
|
+
if (!SAFE_NAME.test(childKey))
|
|
670
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "invalid property name");
|
|
671
|
+
checkPropValue(childKey, item, state, depth + 1, inSvg);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function checkText(text, state) {
|
|
676
|
+
if (text.length > OG_TREE_LIMITS.maxStringChars) {
|
|
677
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, `a text string is longer than ${OG_TREE_LIMITS.maxStringChars} characters`);
|
|
678
|
+
}
|
|
679
|
+
state.textChars += text.length;
|
|
680
|
+
if (state.textChars > OG_TREE_LIMITS.maxTextChars) {
|
|
681
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, `tree text exceeds ${OG_TREE_LIMITS.maxTextChars} characters in total`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function walkNode(node, state, depth, inSvg, textShadow = 0) {
|
|
685
|
+
if (node === null || node === undefined || typeof node === "boolean")
|
|
686
|
+
return;
|
|
687
|
+
if (typeof node === "number") {
|
|
688
|
+
checkText(String(node), state);
|
|
689
|
+
if (!inSvg)
|
|
690
|
+
addEffects(state, textShadow);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (typeof node === "string") {
|
|
694
|
+
// Text is laid out by satori (turned into paths) — except inside an
|
|
695
|
+
// inline <svg>, where it is pasted into markup verbatim.
|
|
696
|
+
if (inSvg && SVG_UNSAFE_TEXT.test(node))
|
|
697
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "inline <svg> text must not contain < > \" &");
|
|
698
|
+
if (inSvg && (hasEffectWord(node) || hasStyleWord(node))) {
|
|
699
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "SVG filters and styles are not allowed");
|
|
700
|
+
}
|
|
701
|
+
checkText(node, state);
|
|
702
|
+
if (!inSvg && node.trim()) {
|
|
703
|
+
addEffects(state, textShadow * Math.ceil(node.length / OG_TREE_LIMITS.textShadowCharsPerUnit));
|
|
704
|
+
}
|
|
705
|
+
if (inSvg) {
|
|
706
|
+
state.svgChars += node.length;
|
|
707
|
+
if (state.svgChars > OG_TREE_LIMITS.maxSvgChars)
|
|
708
|
+
fail(OG_TREE_ERROR_CODES.textTooLong, "inline <svg> content is too large");
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (depth > OG_TREE_LIMITS.maxDepth)
|
|
713
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, `tree deeper than ${OG_TREE_LIMITS.maxDepth}`);
|
|
714
|
+
if (Array.isArray(node)) {
|
|
715
|
+
for (const child of node)
|
|
716
|
+
walkNode(child, state, depth + 1, inSvg, textShadow);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (typeof node !== "object")
|
|
720
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "unsupported node value");
|
|
721
|
+
const { type, props } = node;
|
|
722
|
+
// A function `type` is a component satori would CALL — its output could not
|
|
723
|
+
// be validated here. Only plain element names are accepted.
|
|
724
|
+
if (typeof type !== "string" || !SAFE_NAME.test(type)) {
|
|
725
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "every node needs a plain string `type`");
|
|
726
|
+
}
|
|
727
|
+
const svgScope = inSvg || type === "svg";
|
|
728
|
+
if (svgScope && isFilterElement(localName(type))) {
|
|
729
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "SVG filters are not allowed");
|
|
730
|
+
}
|
|
731
|
+
if (svgScope && localName(type) === "style") {
|
|
732
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "<style> is not allowed inside an inline <svg>");
|
|
733
|
+
}
|
|
734
|
+
if (svgScope && SVG_FORBIDDEN_ELEMENTS.has(localName(type))) {
|
|
735
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "image/foreignObject/script nodes are not allowed inside an inline <svg>; use an <img> node");
|
|
736
|
+
}
|
|
737
|
+
state.nodes += 1;
|
|
738
|
+
if (svgScope) {
|
|
739
|
+
state.svgNodes += 1;
|
|
740
|
+
if (state.svgNodes > OG_TREE_LIMITS.maxSvgNodes) {
|
|
741
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, `inline <svg> content has more than ${OG_TREE_LIMITS.maxSvgNodes} nodes`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (state.nodes > OG_TREE_LIMITS.maxNodes)
|
|
745
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, `tree has more than ${OG_TREE_LIMITS.maxNodes} nodes`);
|
|
746
|
+
if (props === undefined || props === null)
|
|
747
|
+
return;
|
|
748
|
+
if (typeof props !== "object" || Array.isArray(props))
|
|
749
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "`props` must be an object");
|
|
750
|
+
const record = props;
|
|
751
|
+
for (const [key, value] of Object.entries(record)) {
|
|
752
|
+
if (key === "children")
|
|
753
|
+
continue;
|
|
754
|
+
if (!SAFE_NAME.test(key))
|
|
755
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "invalid property name");
|
|
756
|
+
// satori expands `tw` (Tailwind) classes into styles this walk would not see.
|
|
757
|
+
if (key === "tw")
|
|
758
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "the `tw` prop is not supported; use `style`");
|
|
759
|
+
// Below the <svg> root, `style` would be serialized into markup resvg styles.
|
|
760
|
+
if (inSvg && localName(key) === "style")
|
|
761
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "styles are not allowed inside an inline <svg>");
|
|
762
|
+
if (svgScope && (hasEffectWord(key) || hasStyleWord(key)) && !(key === "style" && !inSvg)) {
|
|
763
|
+
fail(OG_TREE_ERROR_CODES.svgFilterForbidden, "SVG filters and styles are not allowed");
|
|
764
|
+
}
|
|
765
|
+
if (isImageRefKey(key)) {
|
|
766
|
+
if (value === undefined || value === null)
|
|
767
|
+
continue;
|
|
768
|
+
if (typeof value !== "string")
|
|
769
|
+
fail(OG_TREE_ERROR_CODES.imageNotDataUri, "image references must be data: URI strings");
|
|
770
|
+
if (svgScope) {
|
|
771
|
+
// Inside an inline <svg> only in-document references are allowed.
|
|
772
|
+
if (!value.startsWith("#") || SVG_UNSAFE_TEXT.test(value)) {
|
|
773
|
+
fail(OG_TREE_ERROR_CODES.imageNotDataUri, "hrefs inside an inline <svg> must be #fragments");
|
|
774
|
+
}
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
checkImageRef(value, state);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
checkPropValue(key, value, state, depth + 1, svgScope);
|
|
781
|
+
}
|
|
782
|
+
if (type === "img" && typeof record.src !== "string") {
|
|
783
|
+
fail(OG_TREE_ERROR_CODES.imageNotDataUri, "<img> needs a data: URI `src`");
|
|
784
|
+
}
|
|
785
|
+
const style = record.style && typeof record.style === "object" ? record.style : undefined;
|
|
786
|
+
let ownTextShadow;
|
|
787
|
+
if (style) {
|
|
788
|
+
for (const [key, value] of Object.entries(style)) {
|
|
789
|
+
const name = localName(key);
|
|
790
|
+
if (name === "textshadow")
|
|
791
|
+
ownTextShadow = checkShadow(value);
|
|
792
|
+
else if (name.includes("shadow"))
|
|
793
|
+
addEffects(state, checkShadow(value) * areaUnits(style, state));
|
|
794
|
+
else if (name.endsWith("filter")) {
|
|
795
|
+
const units = checkCssFilter(value);
|
|
796
|
+
// A filter rasterizes its whole subtree into a layer as large as the
|
|
797
|
+
// subtree's bounds (children can overflow a small box) — allow it only
|
|
798
|
+
// on leaves (an <img> or an empty box), whose area is their own size.
|
|
799
|
+
if (units > 0 && hasContent(record.children)) {
|
|
800
|
+
fail(OG_TREE_ERROR_CODES.cssFilterLimit, "filter is only allowed on elements without children");
|
|
801
|
+
}
|
|
802
|
+
addEffects(state, units * areaUnits(style, state));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
walkNode(record.children, state, depth + 1, svgScope, ownTextShadow ?? textShadow);
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Snapshot of the caller's tree as plain data: getters are read once,
|
|
810
|
+
* non-enumerable / inherited props and prototypes are dropped. The renderer
|
|
811
|
+
* validates and renders THIS copy, so satori can never see a value the
|
|
812
|
+
* validator did not.
|
|
813
|
+
*/
|
|
814
|
+
function snapshotTree(tree) {
|
|
815
|
+
try {
|
|
816
|
+
return structuredClone(tree);
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
return fail(OG_TREE_ERROR_CODES.treeInvalid, `tree must be plain data (${error instanceof Error ? error.name : "clone failed"})`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function checkCanvas(options) {
|
|
823
|
+
const { width, height } = options;
|
|
824
|
+
if (!Number.isInteger(width) ||
|
|
825
|
+
!Number.isInteger(height) ||
|
|
826
|
+
width < 1 ||
|
|
827
|
+
height < 1 ||
|
|
828
|
+
width > OG_TREE_LIMITS.maxCanvasDimension ||
|
|
829
|
+
height > OG_TREE_LIMITS.maxCanvasDimension ||
|
|
830
|
+
width * height > OG_TREE_LIMITS.maxCanvasPixels) {
|
|
831
|
+
fail(OG_TREE_ERROR_CODES.canvasInvalid, `canvas must be integer sizes up to ${OG_TREE_LIMITS.maxCanvasDimension}px a side and ${OG_TREE_LIMITS.maxCanvasPixels} px`);
|
|
832
|
+
}
|
|
833
|
+
if (options.weights !== undefined && !Array.isArray(options.weights)) {
|
|
834
|
+
fail(OG_TREE_ERROR_CODES.weightsInvalid, "weights must be an array");
|
|
835
|
+
}
|
|
836
|
+
for (const weight of options.weights ?? []) {
|
|
837
|
+
if (weight !== 400 && weight !== 700)
|
|
838
|
+
fail(OG_TREE_ERROR_CODES.weightsInvalid, "weights may only be 400 or 700");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
function checkTree(tree, canvasPixels) {
|
|
842
|
+
if (!tree || typeof tree !== "object" || Array.isArray(tree))
|
|
843
|
+
fail(OG_TREE_ERROR_CODES.treeInvalid, "tree must be a node object");
|
|
844
|
+
const state = { ...EMPTY_COUNTS, canvasPixels };
|
|
845
|
+
walkNode(tree, state, 0, false);
|
|
846
|
+
return { imageCount: state.imageCount, totalPixels: state.totalPixels, totalBytes: state.totalBytes };
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Validates a tree render request without rendering. Throws an
|
|
850
|
+
* {@link OgTreeRenderError} naming the first violated rule; never performs I/O.
|
|
851
|
+
*/
|
|
852
|
+
export function validateOgTree(options) {
|
|
853
|
+
checkCanvas(options);
|
|
854
|
+
return checkTree(snapshotTree(options.tree), options.width * options.height);
|
|
855
|
+
}
|
|
856
|
+
/** Validation for the renderers: returns the snapshot that was validated — render exactly this. */
|
|
857
|
+
export function prepareOgTree(options) {
|
|
858
|
+
checkCanvas(options);
|
|
859
|
+
const tree = snapshotTree(options.tree);
|
|
860
|
+
checkTree(tree, options.width * options.height);
|
|
861
|
+
return tree;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Checks ONE image data: URI against the per-image rules (type, bytes, pixels,
|
|
865
|
+
* SVG safety) without rendering, so a caller can drop an image before building
|
|
866
|
+
* a tree. `width`/`height` are null for SVG. Tree-wide totals are not applied.
|
|
867
|
+
*/
|
|
868
|
+
export function inspectOgImage(dataUri) {
|
|
869
|
+
const state = { ...EMPTY_COUNTS };
|
|
870
|
+
try {
|
|
871
|
+
checkImageRef(dataUri, state);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
if (error instanceof OgTreeRenderError)
|
|
875
|
+
return { code: error.code };
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
const decoded = decodeDataUri(dataUri, OG_TREE_LIMITS.maxImageBytes);
|
|
879
|
+
const bytes = decoded && decoded !== "too-large" ? decoded.bytes : new Uint8Array();
|
|
880
|
+
const kind = sniffOgImageKind(bytes);
|
|
881
|
+
const dims = kind === "png" ? pngDimensions(bytes) : kind === "jpeg" ? jpegDimensions(bytes) : undefined;
|
|
882
|
+
return { kind, width: dims?.width ?? null, height: dims?.height ?? null, bytes: bytes.length };
|
|
883
|
+
}
|
|
884
|
+
export function wantsBold(weights) {
|
|
885
|
+
return (weights ?? []).includes(700);
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* A FIFO mutex: a task starts only after every previously queued task has
|
|
889
|
+
* settled (fulfilled or rejected). One queue per isolate/process keeps
|
|
890
|
+
* satori's per-render font parse and resvg's wasm heap from stacking.
|
|
891
|
+
*
|
|
892
|
+
* `maxWaitMs`: a task still waiting when the deadline passes is rejected with
|
|
893
|
+
* an {@link OgRenderUnavailableError} (`OG_QUEUE_TIMEOUT`) and never runs, so a
|
|
894
|
+
* wedged predecessor cannot pile up callers forever.
|
|
895
|
+
*/
|
|
896
|
+
export function createSerialQueue() {
|
|
897
|
+
let tail = Promise.resolve();
|
|
898
|
+
return (task, options = {}) => new Promise((resolve, reject) => {
|
|
899
|
+
const enqueuedAt = performance.now();
|
|
900
|
+
let expired = false;
|
|
901
|
+
const timer = options.maxWaitMs === undefined
|
|
902
|
+
? undefined
|
|
903
|
+
: setTimeout(() => {
|
|
904
|
+
expired = true;
|
|
905
|
+
reject(new OgRenderUnavailableError(OG_RENDER_UNAVAILABLE_CODES.queueTimeout, "render queue wait exceeded"));
|
|
906
|
+
}, options.maxWaitMs);
|
|
907
|
+
tail = tail.then(async () => {
|
|
908
|
+
if (expired)
|
|
909
|
+
return;
|
|
910
|
+
if (timer !== undefined)
|
|
911
|
+
clearTimeout(timer);
|
|
912
|
+
try {
|
|
913
|
+
resolve(await task({ queueMs: performance.now() - enqueuedAt }));
|
|
914
|
+
}
|
|
915
|
+
catch (error) {
|
|
916
|
+
reject(error);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
//# sourceMappingURL=tree.js.map
|