nuxt-og-image 6.6.0 → 6.7.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.
Files changed (35) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/tw4.cjs +7 -7
  3. package/dist/chunks/tw4.mjs +7 -7
  4. package/dist/chunks/uno.cjs +6 -6
  5. package/dist/chunks/uno.mjs +6 -6
  6. package/dist/cli.cjs +12 -5
  7. package/dist/cli.mjs +10 -3
  8. package/dist/module.cjs +5 -5
  9. package/dist/module.d.cts +5 -3
  10. package/dist/module.d.mts +5 -3
  11. package/dist/module.d.ts +5 -3
  12. package/dist/module.json +1 -1
  13. package/dist/module.mjs +5 -5
  14. package/dist/runtime/server/og-image/bindings/font-assets/cloudflare.d.ts +1 -1
  15. package/dist/runtime/server/og-image/bindings/font-assets/cloudflare.js +6 -1
  16. package/dist/runtime/server/og-image/bindings/font-assets/dev-prerender.js +13 -6
  17. package/dist/runtime/server/og-image/bindings/font-assets/external-url.d.ts +36 -0
  18. package/dist/runtime/server/og-image/bindings/font-assets/external-url.js +47 -0
  19. package/dist/runtime/server/og-image/bindings/font-assets/node.d.ts +1 -1
  20. package/dist/runtime/server/og-image/bindings/font-assets/node.js +14 -7
  21. package/dist/runtime/server/og-image/bindings/takumi/node-dev.js +7 -1
  22. package/dist/runtime/server/og-image/fonts.d.ts +4 -0
  23. package/dist/runtime/server/og-image/fonts.js +21 -5
  24. package/dist/runtime/server/og-image/satori/instances.d.ts +1 -2
  25. package/dist/runtime/server/og-image/takumi/renderer.js +61 -19
  26. package/dist/runtime/server/util/ssrf.d.ts +10 -0
  27. package/dist/runtime/server/util/ssrf.js +9 -1
  28. package/dist/runtime/types.d.ts +13 -0
  29. package/dist/shared/{nuxt-og-image.DdbTs-xp.mjs → nuxt-og-image.CPSxZhVO.mjs} +122 -496
  30. package/dist/shared/nuxt-og-image.DM30kcDz.mjs +660 -0
  31. package/dist/shared/nuxt-og-image.RI9DgjgV.cjs +689 -0
  32. package/dist/shared/{nuxt-og-image.CfTPCtaS.cjs → nuxt-og-image.yDJBWXBt.cjs} +181 -555
  33. package/package.json +43 -39
  34. package/dist/shared/nuxt-og-image.CJa2KCie.mjs +0 -189
  35. package/dist/shared/nuxt-og-image.CMYbz66P.cjs +0 -193
@@ -172,15 +172,31 @@ export async function loadAllFonts(event, options) {
172
172
  }
173
173
  return loaded;
174
174
  }
175
+ const MAX_DEFINED_FONTS = 10;
176
+ const MAX_FONT_PATH_LENGTH = 2048;
177
+ function isDataUri(path) {
178
+ return path.trimStart().toLowerCase().startsWith("data:");
179
+ }
180
+ function normalizeFontWeight(weight) {
181
+ const n = typeof weight === "number" ? weight : typeof weight === "string" ? Number(weight) : Number.NaN;
182
+ if (!Number.isFinite(n))
183
+ return 400;
184
+ return Math.min(Math.max(Math.round(n), 1), 1e3);
185
+ }
175
186
  export async function loadDefinedFonts(event, fontDefs) {
176
187
  const results = [];
177
- for (const def of fontDefs) {
178
- if (!def || typeof def !== "object" || !def.path)
188
+ const defs = Array.isArray(fontDefs) ? fontDefs : [];
189
+ if (import.meta.dev && defs.length > MAX_DEFINED_FONTS)
190
+ logger.warn(`defineOgImage fonts capped at ${MAX_DEFINED_FONTS}; ${defs.length - MAX_DEFINED_FONTS} ignored.`);
191
+ for (const def of defs.slice(0, MAX_DEFINED_FONTS)) {
192
+ if (!def || typeof def !== "object")
193
+ continue;
194
+ const name = typeof def.name === "string" ? def.name.trim() : "";
195
+ const path = typeof def.path === "string" ? def.path : "";
196
+ if (!name || !path || path.length > MAX_FONT_PATH_LENGTH && !isDataUri(path))
179
197
  continue;
180
- const name = def.name;
181
- const weight = def.weight || 400;
198
+ const weight = normalizeFontWeight(def.weight);
182
199
  const style = def.style === "italic" ? "italic" : "normal";
183
- const path = def.path;
184
200
  const fontConfig = { family: name, weight, style, src: path, localPath: path };
185
201
  const data = await resolve(event.e, fontConfig).catch(() => null);
186
202
  if (data) {
@@ -1,5 +1,4 @@
1
1
  import type _satori from 'satori';
2
- import type _sharp from 'sharp';
3
2
  export declare function getResvg(): Promise<any>;
4
3
  export declare function getSatori(): Promise<typeof _satori>;
5
- export declare function getSharp(): Promise<typeof _sharp>;
4
+ export declare function getSharp(): Promise<import("sharp").SharpConstructor>;
@@ -1,5 +1,6 @@
1
1
  import { defu } from "defu";
2
2
  import { withBase } from "ufo";
3
+ import compatibility from "#og-image/compatibility";
3
4
  import { logger } from "../../../logger.js";
4
5
  import { fetchLocalAsset } from "../../util/fetchLocalAsset.js";
5
6
  import { getFetchTimeout } from "../../util/fetchTimeout.js";
@@ -98,25 +99,52 @@ function dedupeFontsByBinary(fonts) {
98
99
  }
99
100
  return result;
100
101
  }
101
- async function loadFontsIntoRenderer(state, fonts) {
102
- for (const entry of dedupeFontsByBinary(fonts)) {
102
+ function toUint8Array(data) {
103
+ if (data instanceof Uint8Array)
104
+ return data;
105
+ if (data instanceof ArrayBuffer)
106
+ return new Uint8Array(data);
107
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
108
+ }
109
+ function createFontDescriptor(entry, includeKey = false) {
110
+ return {
111
+ ...includeKey ? { key: entry.binaryKey } : {},
112
+ name: entry.family,
113
+ data: toUint8Array(entry.data),
114
+ ...entry.weight !== void 0 ? { weight: entry.weight } : {},
115
+ ...entry.style ? { style: entry.style } : {}
116
+ };
117
+ }
118
+ function addLoadedFamilies(state, entry, registeredFamilies) {
119
+ if (Array.isArray(registeredFamilies) && registeredFamilies.length) {
120
+ for (const family of registeredFamilies) {
121
+ const name = family?.name;
122
+ if (typeof name === "string")
123
+ state.loadedFamilies.add(name);
124
+ }
125
+ } else {
126
+ state.loadedFamilies.add(entry.family);
127
+ }
128
+ }
129
+ async function loadFontsIntoRenderer(state, entries) {
130
+ for (const entry of entries) {
103
131
  if (state.loadedFontKeys.has(entry.binaryKey))
104
132
  continue;
105
- const fontData = entry.data instanceof ArrayBuffer ? new Uint8Array(entry.data) : Uint8Array.from(entry.data);
106
133
  try {
107
- await state.renderer.loadFont({
108
- name: entry.family,
109
- data: fontData,
110
- ...entry.weight !== void 0 ? { weight: entry.weight } : {},
111
- style: entry.style
112
- });
113
- state.loadedFamilies.add(entry.family);
134
+ const registeredFamilies = await state.renderer.loadFont(createFontDescriptor(entry));
135
+ addLoadedFamilies(state, entry, registeredFamilies);
114
136
  } catch (err) {
115
- logger.warn(`Failed to load font "${entry.family}" into takumi renderer: ${err.message}`);
137
+ logger.warn(`Failed to load font "${entry.family}" with takumi renderer: ${err.message}`);
116
138
  }
117
139
  state.loadedFontKeys.add(entry.binaryKey);
118
140
  }
119
141
  }
142
+ function normalizeImageSources(images) {
143
+ return (images || []).map((image) => ({
144
+ src: image.src,
145
+ data: image.data instanceof Uint8Array ? image.data : new Uint8Array(image.data)
146
+ }));
147
+ }
120
148
  function lookupFontFamily(family, loadedFamilies) {
121
149
  if (loadedFamilies.has(family))
122
150
  return family;
@@ -155,10 +183,12 @@ function rewriteFontFamilies(node, loadedFamilies, subsetChains) {
155
183
  }
156
184
  async function createImage(event, format) {
157
185
  const { options, timings } = event;
186
+ const isTakumiV2 = compatibility.takumiVersion === 2;
158
187
  const { fontFamilyOverride, defaultFont } = getDefaultFontFamily(options);
159
188
  const nodes = await createTakumiNodes(event);
160
189
  const codepoints = extractCodepoints(nodes);
161
190
  const fonts = await timings.measure("font-load", () => loadFontsForRenderer(event, { supportedFormats: /* @__PURE__ */ new Set(["ttf", "woff2"]), preferStatic: true, component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints }));
191
+ const fontEntries = dedupeFontsByBinary(fonts);
162
192
  const hookTimeout = event.runtimeConfig.security?.renderTimeout ?? 15e3;
163
193
  await withTimeout(
164
194
  event._nitro.hooks.callHook("nuxt-og-image:takumi:nodes", nodes, event),
@@ -170,7 +200,12 @@ async function createImage(event, format) {
170
200
  const extractResourceUrls = await timings.measure("takumi-extract-init", () => getExtractResourceUrls());
171
201
  const resourceUrls = await extractResourceUrls(nodes);
172
202
  const baseURL = event.runtimeConfig.app.baseURL;
173
- const fetchedResources = [];
203
+ const { images: optionImages, fetchedResources: optionFetchedResources, persistentImages, ...takumiOptions } = options.takumi || {};
204
+ const images = [
205
+ ...normalizeImageSources(optionImages),
206
+ ...normalizeImageSources(optionFetchedResources),
207
+ ...normalizeImageSources(persistentImages)
208
+ ];
174
209
  if (resourceUrls.length) {
175
210
  const fetchTimeout = getFetchTimeout(event.runtimeConfig);
176
211
  const headers = { "x-nuxt-og-image": "1" };
@@ -200,17 +235,17 @@ async function createImage(event, format) {
200
235
  }) ?? void 0;
201
236
  }
202
237
  if (data)
203
- fetchedResources.push({ src, data: new Uint8Array(data) });
238
+ images.push({ src, data: new Uint8Array(data) });
204
239
  })));
205
240
  }
206
241
  const maxDpr = event.runtimeConfig.security?.maxDpr || 2;
207
242
  const maxDim = event.runtimeConfig.security?.maxDimension || 2048;
208
243
  const dpr = Math.min(Math.max(1, options.takumi?.devicePixelRatio ?? 1), maxDpr);
209
- const renderOptions = defu(options.takumi, {
244
+ const renderOptions = defu(takumiOptions, {
210
245
  width: Math.min(Number(options.width) * dpr, maxDim),
211
246
  height: Math.min(Number(options.height) * dpr, maxDim),
212
247
  format,
213
- fetchedResources,
248
+ ...isTakumiV2 ? { images } : { fetchedResources: images },
214
249
  devicePixelRatio: dpr
215
250
  });
216
251
  const lockTimeout = event.runtimeConfig.security?.renderTimeout ?? 15e3;
@@ -218,19 +253,26 @@ async function createImage(event, format) {
218
253
  return await withTakumiLock(state, lockTimeout, () => {
219
254
  endLockWait();
220
255
  return timings.measure("render-takumi", async () => {
221
- await loadFontsIntoRenderer(state, fonts);
256
+ let loadedFamilies = state.loadedFamilies;
257
+ if (isTakumiV2) {
258
+ loadedFamilies = new Set(fontEntries.map((entry) => entry.family));
259
+ renderOptions.fonts = fontEntries.map((entry) => createFontDescriptor(entry, true));
260
+ renderOptions.fontFamilies = [...loadedFamilies];
261
+ } else {
262
+ await loadFontsIntoRenderer(state, fontEntries);
263
+ }
222
264
  const rootStyle = nodes.style ?? {};
223
265
  if (fontFamilyOverride) {
224
266
  const chain = subsetChains.get(fontFamilyOverride);
225
267
  if (chain) {
226
268
  rootStyle.fontFamily = chain.map((f) => `"${f}"`).join(", ");
227
- } else if (state.loadedFamilies.has(fontFamilyOverride)) {
269
+ } else if (loadedFamilies.has(fontFamilyOverride)) {
228
270
  rootStyle.fontFamily = fontFamilyOverride;
229
271
  }
230
272
  }
231
273
  nodes.style = rootStyle;
232
- rewriteFontFamilies(nodes, state.loadedFamilies, subsetChains);
233
- return state.renderer.render(nodes, renderOptions);
274
+ rewriteFontFamilies(nodes, loadedFamilies, subsetChains);
275
+ return await state.renderer.render(nodes, renderOptions);
234
276
  });
235
277
  }, endLockWait);
236
278
  }
@@ -14,11 +14,21 @@ export interface SafeFetchOptions {
14
14
  timeout: number;
15
15
  headers?: Record<string, string>;
16
16
  signal?: AbortSignal;
17
+ /**
18
+ * A host (hostname + optional port) that is exempt from `isBlockedUrl`. Used
19
+ * for same-origin asset fetches whose own origin may legitimately be loopback
20
+ * (dev) or otherwise "private" — the initial host is trusted, but any redirect
21
+ * that leaves it is still re-validated, closing the open-redirect SSRF where
22
+ * the app's own origin 30x's to an internal target.
23
+ */
24
+ trustedHost?: string;
17
25
  }
18
26
  /**
19
27
  * Fetch a URL with manual redirect handling. Each hop (including the initial
20
28
  * URL) is run through `isBlockedUrl` before the request is dispatched, so an
21
29
  * allowed origin returning a 30x to an internal IP cannot complete the SSRF.
30
+ * A hop whose host matches `opts.trustedHost` skips the block check (see
31
+ * SafeFetchOptions.trustedHost).
22
32
  *
23
33
  * Returns `null` on any failure (block, network error, non-2xx, redirect
24
34
  * limit). The caller treats null as a soft failure and falls back to the
@@ -151,7 +151,15 @@ export async function fetchWithRedirectValidation(initialUrl, opts) {
151
151
  try {
152
152
  let url = initialUrl;
153
153
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
154
- if (isBlockedUrl(url))
154
+ let isTrusted = false;
155
+ if (opts.trustedHost) {
156
+ try {
157
+ isTrusted = new URL(url).host === opts.trustedHost;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+ if (!isTrusted && isBlockedUrl(url))
155
163
  return null;
156
164
  const res = await fetch(url, {
157
165
  redirect: "manual",
@@ -153,6 +153,15 @@ export interface OgImageOptions {
153
153
  sharp?: SharpOptions & JpegOptions;
154
154
  takumi?: {
155
155
  format?: 'png' | 'jpeg' | 'webp';
156
+ images?: Array<{
157
+ src: string;
158
+ data: ArrayBuffer | Uint8Array;
159
+ }>;
160
+ fetchedResources?: Array<{
161
+ src: string;
162
+ data: ArrayBuffer | Uint8Array;
163
+ }>;
164
+ /** @deprecated Use `images` instead. */
156
165
  persistentImages?: Array<{
157
166
  src: string;
158
167
  data: ArrayBuffer;
@@ -229,6 +238,10 @@ export interface RuntimeCompatibilitySchema {
229
238
  emoji?: 'local' | 'fetch';
230
239
  wasm?: any;
231
240
  }
241
+ export interface RuntimeCompatibilityMeta {
242
+ takumiVersion?: 1 | 2;
243
+ }
244
+ export type RuntimeCompatibilityPayload = Partial<Omit<RuntimeCompatibilitySchema, 'wasm'>> & RuntimeCompatibilityMeta;
232
245
  export type CompatibilityFlags = Partial<Omit<RuntimeCompatibilitySchema, 'wasm'>>;
233
246
  export interface CompatibilityFlagEnvOverrides {
234
247
  dev?: CompatibilityFlags;