nuxt-og-image 6.8.0 → 6.9.1
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/chunks/tw4.mjs +1 -1
- package/dist/chunks/uno.mjs +2 -2
- package/dist/module.d.mts +1 -1
- package/dist/module.d.ts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/app/composables/defineOgImageScreenshot.js +4 -1
- package/dist/runtime/server/nitro.d.ts +9 -0
- package/dist/runtime/server/nitro.js +10 -0
- package/dist/runtime/server/og-image/bindings/fetch/node.d.ts +2 -0
- package/dist/runtime/server/og-image/bindings/fetch/node.js +42 -0
- package/dist/runtime/server/og-image/bindings/fetch/web.d.ts +7 -0
- package/dist/runtime/server/og-image/bindings/fetch/web.js +3 -0
- package/dist/runtime/server/og-image/bindings/font-assets/external-url.d.ts +0 -11
- package/dist/runtime/server/og-image/bindings/font-assets/external-url.js +2 -19
- package/dist/runtime/server/og-image/browser/screenshot.js +4 -1
- package/dist/runtime/server/og-image/context.js +9 -1
- package/dist/runtime/server/plugins/prerender.js +4 -3
- package/dist/runtime/server/routes/resolve.js +2 -2
- package/dist/runtime/server/util/cloudflare.js +2 -2
- package/dist/runtime/server/util/ssrf.d.ts +31 -0
- package/dist/runtime/server/util/ssrf.js +64 -5
- package/dist/runtime/server/utils.d.ts +1 -1
- package/dist/runtime/server/utils.js +3 -3
- package/dist/runtime/shared/urlEncoding.d.ts +9 -3
- package/dist/runtime/shared/urlEncoding.js +27 -10
- package/dist/runtime/types.d.ts +5 -2
- package/dist/shared/{nuxt-og-image.D936Fp6O.mjs → nuxt-og-image.CNS1IVAi.mjs} +82 -37
- package/package.json +38 -41
- package/types/virtual.d.ts +8 -0
package/dist/chunks/tw4.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { resolveModulePath } from 'exsolve';
|
|
4
4
|
import { dirname, join } from 'pathe';
|
|
5
|
-
import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.
|
|
5
|
+
import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.CNS1IVAi.mjs';
|
|
6
6
|
import 'node:crypto';
|
|
7
7
|
import 'node:fs';
|
|
8
8
|
import '@nuxt/kit';
|
package/dist/chunks/uno.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { defu } from 'defu';
|
|
3
3
|
import { logger } from '../../dist/runtime/logger.js';
|
|
4
|
-
import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.
|
|
5
|
-
export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.
|
|
4
|
+
import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.CNS1IVAi.mjs';
|
|
5
|
+
export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.CNS1IVAi.mjs';
|
|
6
6
|
import 'node:crypto';
|
|
7
7
|
import 'node:fs';
|
|
8
8
|
import 'node:url';
|
package/dist/module.d.mts
CHANGED
|
@@ -142,7 +142,7 @@ interface ModuleOptions {
|
|
|
142
142
|
/**
|
|
143
143
|
* Browser renderer configuration.
|
|
144
144
|
*
|
|
145
|
-
*
|
|
145
|
+
* Set to true to enable defineOgImageScreenshot(), or configure a browser provider.
|
|
146
146
|
* For Cloudflare deployments, specify the browser binding name.
|
|
147
147
|
*
|
|
148
148
|
* @example { provider: 'cloudflare', binding: 'BROWSER' }
|
package/dist/module.d.ts
CHANGED
|
@@ -142,7 +142,7 @@ interface ModuleOptions {
|
|
|
142
142
|
/**
|
|
143
143
|
* Browser renderer configuration.
|
|
144
144
|
*
|
|
145
|
-
*
|
|
145
|
+
* Set to true to enable defineOgImageScreenshot(), or configure a browser provider.
|
|
146
146
|
* For Cloudflare deployments, specify the browser binding name.
|
|
147
147
|
*
|
|
148
148
|
* @example { provider: 'cloudflare', binding: 'BROWSER' }
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import 'nuxtseo-shared/kit';
|
|
|
10
10
|
import 'pathe';
|
|
11
11
|
import 'pkg-types';
|
|
12
12
|
import 'std-env';
|
|
13
|
-
export { n as default } from './shared/nuxt-og-image.
|
|
13
|
+
export { n as default } from './shared/nuxt-og-image.CNS1IVAi.mjs';
|
|
14
14
|
import '../dist/runtime/logger.js';
|
|
15
15
|
import 'magic-string';
|
|
16
16
|
import 'oxc-walker';
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { useRouter } from "#app";
|
|
1
|
+
import { createError, useRouter } from "#app";
|
|
2
|
+
import { useOgImageRuntimeConfig } from "../utils.js";
|
|
2
3
|
import { defineOgImageRaw } from "./_defineOgImageRaw.js";
|
|
3
4
|
export function defineOgImageScreenshot(options = {}) {
|
|
5
|
+
if (!useOgImageRuntimeConfig().browserEnabled)
|
|
6
|
+
throw createError({ statusCode: 500, statusMessage: "[nuxt-og-image] Screenshots require browser opt-in. Set ogImage.browser to true or a browser provider in nuxt.config.ts." });
|
|
4
7
|
const router = useRouter();
|
|
5
8
|
const route = router.currentRoute.value?.path || "/";
|
|
6
9
|
const { delay, mask, selector, colorScheme, ...rest } = options;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { H3Event } from '#nuxtseo/h3';
|
|
2
|
+
import type { OgImageOptionsInternal } from '../types.js';
|
|
3
|
+
type Options = Partial<OgImageOptionsInternal>;
|
|
4
|
+
/**
|
|
5
|
+
* Build the absolute OG image URL for a page. Without a page path, uses the current request path.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getOgImageUrl(event: H3Event, options?: Options): string;
|
|
8
|
+
export declare function getOgImageUrl(event: H3Event, pagePath: string, options?: Options): string;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { parseURL, withoutBase } from "ufo";
|
|
2
|
+
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
|
+
import { withSiteUrl } from "#site-config/server/composables/utils";
|
|
4
|
+
import { getOgImagePath } from "./utils.js";
|
|
5
|
+
export function getOgImageUrl(event, pathOrOptions, maybeOptions) {
|
|
6
|
+
const pagePath = typeof pathOrOptions === "string" ? pathOrOptions : withoutBase(parseURL(event.path).pathname, useRuntimeConfig(event).app.baseURL) || "/";
|
|
7
|
+
const options = typeof pathOrOptions === "string" ? maybeOptions : pathOrOptions;
|
|
8
|
+
const { path } = getOgImagePath(event, pagePath, options);
|
|
9
|
+
return withSiteUrl(event, path, { canonical: !import.meta.dev });
|
|
10
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
function guardedLookup(isBlocked) {
|
|
6
|
+
return (hostname, options, callback) => {
|
|
7
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
8
|
+
if (err)
|
|
9
|
+
return callback(err, "", 0);
|
|
10
|
+
const blocked = addresses.find((a) => isBlocked(a.address));
|
|
11
|
+
if (blocked)
|
|
12
|
+
return callback(new Error(`[Nuxt OG Image] ${hostname} resolves to blocked address ${blocked.address}.`), "", 0);
|
|
13
|
+
if (options.all)
|
|
14
|
+
return callback(null, addresses);
|
|
15
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function guardedFetch(url, { headers, signal, isBlockedAddress }) {
|
|
20
|
+
const request = url.startsWith("https:") ? httpsRequest : httpRequest;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const req = request(url, {
|
|
23
|
+
// Match the defaults that `fetch` sent before this transport existed.
|
|
24
|
+
headers: { "accept": "*/*", "user-agent": "node", ...headers },
|
|
25
|
+
signal,
|
|
26
|
+
lookup: guardedLookup(isBlockedAddress ?? (() => false)),
|
|
27
|
+
// A fresh socket per hop: never reuse a connection nobody checked.
|
|
28
|
+
agent: false
|
|
29
|
+
}, (res) => {
|
|
30
|
+
const status = res.statusCode ?? 0;
|
|
31
|
+
const responseHeaders = new Headers();
|
|
32
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
33
|
+
for (const v of [value ?? []].flat())
|
|
34
|
+
responseHeaders.append(name, v);
|
|
35
|
+
}
|
|
36
|
+
const body = status === 204 || status === 304 ? null : Readable.toWeb(res);
|
|
37
|
+
resolve(new Response(body, { status, headers: responseHeaders }));
|
|
38
|
+
});
|
|
39
|
+
req.on("error", reject);
|
|
40
|
+
req.end();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface GuardedFetchInit {
|
|
2
|
+
headers?: Record<string, string>;
|
|
3
|
+
signal: AbortSignal;
|
|
4
|
+
/** Rejects the connection when a resolved address matches. Node only. */
|
|
5
|
+
isBlockedAddress?: (address: string) => boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function guardedFetch(url: string, { headers, signal }: GuardedFetchInit): Promise<Response>;
|
|
@@ -12,17 +12,6 @@ export declare function isDataFontUrl(path: string): boolean;
|
|
|
12
12
|
* Check `isDataFontUrl` first — `data:` is non-http and would report true here.
|
|
13
13
|
*/
|
|
14
14
|
export declare function isExternalFontUrl(path: string): boolean;
|
|
15
|
-
/**
|
|
16
|
-
* Resolve an authority-bearing font URL to the href to fetch, but only when it
|
|
17
|
-
* is same-origin with the configured site URL. Returns null otherwise.
|
|
18
|
-
*
|
|
19
|
-
* Runtime external font URLs are unsupported — `@nuxt/fonts` is the only
|
|
20
|
-
* supported way to load custom fonts, and it serves them same-origin. The single
|
|
21
|
-
* allowed exception is the site's own origin, gated on an explicitly configured
|
|
22
|
-
* site URL. Resolving against that origin also collapses canonicalization
|
|
23
|
-
* bypasses (a crafted " //127.0.0.1" resolves cross-origin → rejected).
|
|
24
|
-
*/
|
|
25
|
-
export declare function resolveSameOriginFontUrl(path: string, siteUrl: string | undefined): string | null;
|
|
26
15
|
/**
|
|
27
16
|
* Fetch a `data:` or authority-bearing font URL. `data:` is decoded directly
|
|
28
17
|
* (no network). An external URL is fetched only when same-origin with the site
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchWithRedirectValidation } from "../../../util/ssrf.js";
|
|
1
|
+
import { fetchWithRedirectValidation, resolveSameOriginUrl } from "../../../util/ssrf.js";
|
|
2
2
|
const SENTINEL_ORIGIN = "http://font-asset.invalid/";
|
|
3
3
|
const SENTINEL_HOST = "font-asset.invalid";
|
|
4
4
|
export function isDataFontUrl(path) {
|
|
@@ -13,23 +13,6 @@ export function isExternalFontUrl(path) {
|
|
|
13
13
|
}
|
|
14
14
|
return resolved.protocol !== "http:" || resolved.host !== SENTINEL_HOST;
|
|
15
15
|
}
|
|
16
|
-
export function resolveSameOriginFontUrl(path, siteUrl) {
|
|
17
|
-
if (!siteUrl)
|
|
18
|
-
return null;
|
|
19
|
-
let siteOrigin;
|
|
20
|
-
try {
|
|
21
|
-
siteOrigin = new URL(siteUrl).origin;
|
|
22
|
-
} catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
let target;
|
|
26
|
-
try {
|
|
27
|
-
target = new URL(path, siteOrigin);
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
return target.origin === siteOrigin ? target.href : null;
|
|
32
|
-
}
|
|
33
16
|
export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
|
|
34
17
|
if (isDataFontUrl(path)) {
|
|
35
18
|
const res = await fetch(path.trimStart(), { signal: AbortSignal.timeout(timeout) }).catch(() => {
|
|
@@ -39,7 +22,7 @@ export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
|
|
|
39
22
|
return Buffer.from(await res.arrayBuffer());
|
|
40
23
|
throw new Error("[Nuxt OG Image] Invalid data: font URL.");
|
|
41
24
|
}
|
|
42
|
-
const href =
|
|
25
|
+
const href = resolveSameOriginUrl(path, siteUrl);
|
|
43
26
|
if (!href)
|
|
44
27
|
throw new Error("[Nuxt OG Image] External font URLs are not supported. Load custom fonts via @nuxt/fonts.");
|
|
45
28
|
const ab = await fetchWithRedirectValidation(href, { timeout });
|
|
@@ -5,6 +5,7 @@ import { getNitroOrigin } from "#site-config/server/composables";
|
|
|
5
5
|
import { buildOgImageUrl } from "../../../shared.js";
|
|
6
6
|
import { getFetchTimeout } from "../../util/fetchTimeout.js";
|
|
7
7
|
import { logger } from "../../util/logger.js";
|
|
8
|
+
import { resolveSameOriginUrl } from "../../util/ssrf.js";
|
|
8
9
|
import { useOgImageRuntimeConfig } from "../../utils.js";
|
|
9
10
|
function isPlaywrightPage(page) {
|
|
10
11
|
return typeof page.setViewportSize === "function";
|
|
@@ -101,7 +102,9 @@ export async function createScreenshot({ basePath, e, options, extension, timing
|
|
|
101
102
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
|
102
103
|
}
|
|
103
104
|
} else {
|
|
104
|
-
const url =
|
|
105
|
+
const url = resolveSameOriginUrl(withQuery(path, options.props || {}), getNitroOrigin(e));
|
|
106
|
+
if (!url)
|
|
107
|
+
throw new Error("[Nuxt OG Image] The browser renderer can only navigate to a same-origin path.");
|
|
105
108
|
await gotoWithIdle(page, url, 1e4);
|
|
106
109
|
}
|
|
107
110
|
const screenshotOptions = {
|
|
@@ -13,6 +13,7 @@ import { autoEjectCommunityTemplate } from "../util/auto-eject.js";
|
|
|
13
13
|
import { createNitroRouteRuleMatcher } from "../util/kit.js";
|
|
14
14
|
import { normaliseOptions } from "../util/options.js";
|
|
15
15
|
import { getEventQuery } from "../util/query.js";
|
|
16
|
+
import { parseSameOriginPath } from "../util/ssrf.js";
|
|
16
17
|
import { createTimings, TIMING_CTX_KEY } from "../util/timings.js";
|
|
17
18
|
import { withTimeout } from "../util/withTimeout.js";
|
|
18
19
|
import { useOgImageRuntimeConfig } from "../utils.js";
|
|
@@ -120,7 +121,14 @@ export async function resolveContext(e) {
|
|
|
120
121
|
delete queryParams.html;
|
|
121
122
|
queryParams = separateProps(queryParams);
|
|
122
123
|
}
|
|
123
|
-
const
|
|
124
|
+
const rawPath = urlOptions._path ?? "/";
|
|
125
|
+
const basePath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
|
|
126
|
+
if (!basePath) {
|
|
127
|
+
return createError({
|
|
128
|
+
statusCode: 400,
|
|
129
|
+
statusMessage: "[Nuxt OG Image] The _path option must be a same-origin path."
|
|
130
|
+
});
|
|
131
|
+
}
|
|
124
132
|
const componentHash = urlOptions._componentHash || "";
|
|
125
133
|
delete urlOptions._path;
|
|
126
134
|
delete urlOptions._hash;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parse } from "devalue";
|
|
2
|
-
import { parseURL } from "ufo";
|
|
2
|
+
import { parseURL, withoutBase } from "ufo";
|
|
3
3
|
import { appendResponseHeader } from "#nuxtseo/h3";
|
|
4
|
-
import { defineNitroPlugin } from "#nuxtseo/nitro";
|
|
4
|
+
import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
5
5
|
import { prerenderOptionsCache } from "#og-image-cache";
|
|
6
6
|
import { createSitePathResolver } from "#site-config/server/composables/utils";
|
|
7
7
|
import { isInternalRoute } from "../../shared.js";
|
|
@@ -37,9 +37,10 @@ export default defineNitroPlugin(async (nitro) => {
|
|
|
37
37
|
});
|
|
38
38
|
const key = resolvePathCacheKey(ctx.event, resolvePathWithBase(path));
|
|
39
39
|
await prerenderOptionsCache.setItem(key, payloads);
|
|
40
|
+
const pagePath = withoutBase(path, useRuntimeConfig().app.baseURL);
|
|
40
41
|
for (const [_ogKey, opt] of payloads) {
|
|
41
42
|
if (opt._hash) {
|
|
42
|
-
await prerenderOptionsCache.setItem(`hash:${opt._hash}`, opt);
|
|
43
|
+
await prerenderOptionsCache.setItem(`hash:${opt._hash}`, { ...opt, _path: pagePath });
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
const prerenderPaths = ctx.event.context._ogImagePrerenderPaths;
|
|
@@ -4,6 +4,7 @@ import { fetchWithEvent } from "#nuxtseo/nitro";
|
|
|
4
4
|
import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
|
|
5
5
|
import { isInternalRoute } from "../../shared.js";
|
|
6
6
|
import { getEventQuery } from "../util/query.js";
|
|
7
|
+
import { parseSameOriginPath } from "../util/ssrf.js";
|
|
7
8
|
import { useOgImageRuntimeConfig } from "../utils.js";
|
|
8
9
|
const RE_META_TAG = /<meta\b[^>]*>/gi;
|
|
9
10
|
const RE_META_KEY = /\b(?:property|name)\s*=\s*(?:"([^"]+)"|'([^']+)')/i;
|
|
@@ -11,7 +12,6 @@ const RE_META_CONTENT = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
|
|
|
11
12
|
const MAX_RESOLVE_PATH_LENGTH = 2048;
|
|
12
13
|
const RE_STRIP_PREFIX = /^.*?\/_og\/r/;
|
|
13
14
|
const RE_IMAGE_EXT = /\.(?:png|jpe?g|webp|svg)$/i;
|
|
14
|
-
const RE_DOUBLE_LEADING_SLASH = /^\/{2,}/;
|
|
15
15
|
function extractMeta(html, key) {
|
|
16
16
|
for (const tagMatch of html.matchAll(RE_META_TAG)) {
|
|
17
17
|
const tag = tagMatch[0];
|
|
@@ -78,7 +78,7 @@ export default defineEventHandler(async (event) => {
|
|
|
78
78
|
statusMessage: `[Nuxt OG Image] Target path exceeds ${MAX_RESOLVE_PATH_LENGTH} characters.`
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
-
if (targetPath.includes("://") ||
|
|
81
|
+
if (targetPath.includes("://") || !parseSameOriginPath(targetPath)) {
|
|
82
82
|
throw createError({
|
|
83
83
|
statusCode: 400,
|
|
84
84
|
statusMessage: "[Nuxt OG Image] Target path must be a same-origin path."
|
|
@@ -7,11 +7,11 @@ export function getCloudflareEnv(event) {
|
|
|
7
7
|
const runtimeEnv = toRecord(runtimeCloudflare?.env);
|
|
8
8
|
if (runtimeEnv)
|
|
9
9
|
return runtimeEnv;
|
|
10
|
-
const contextCloudflare = toRecord(event?.context
|
|
10
|
+
const contextCloudflare = toRecord(event?.context?.cloudflare);
|
|
11
11
|
const contextEnv = toRecord(contextCloudflare?.env);
|
|
12
12
|
if (contextEnv)
|
|
13
13
|
return contextEnv;
|
|
14
|
-
const platform = toRecord(event?.context
|
|
14
|
+
const platform = toRecord(event?.context?._platform);
|
|
15
15
|
const platformCloudflare = toRecord(platform?.cloudflare);
|
|
16
16
|
return toRecord(platformCloudflare?.env);
|
|
17
17
|
}
|
|
@@ -10,6 +10,36 @@ export declare function expandIPv6(addr: string): number[] | null;
|
|
|
10
10
|
* blocked outright.
|
|
11
11
|
*/
|
|
12
12
|
export declare function isBlockedUrl(url: string): boolean;
|
|
13
|
+
/** True when a resolved IP address is internal, reserved, or unparseable. */
|
|
14
|
+
export declare function isBlockedAddress(address: string): boolean;
|
|
15
|
+
declare const sameOriginPathBrand: unique symbol;
|
|
16
|
+
/**
|
|
17
|
+
* A path that stays on the current origin when resolved against any base URL.
|
|
18
|
+
* Only `parseSameOriginPath` creates one, so a value of this type is safe to
|
|
19
|
+
* pass to a same-origin fetch or a headless browser navigation.
|
|
20
|
+
*/
|
|
21
|
+
export type SameOriginPath = string & {
|
|
22
|
+
readonly [sameOriginPathBrand]: true;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Parse an untrusted path (for example the `_path` URL option) into a
|
|
26
|
+
* same-origin path. Returns `undefined` for anything that could leave the
|
|
27
|
+
* origin: absolute URLs, other schemes, protocol-relative `//host`,
|
|
28
|
+
* backslash variants, control characters, and paths without a leading `/`
|
|
29
|
+
* (which a string concatenation with the origin turns into `origin@host`).
|
|
30
|
+
*
|
|
31
|
+
* GHSA-h5qf-97cw-2h86: an absolute `_path` navigated the browser renderer to
|
|
32
|
+
* an internal host and reflected the response into the image.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseSameOriginPath(input: unknown): SameOriginPath | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `path` against `origin` and return the href, but only when the result
|
|
37
|
+
* stays on that origin. Returns null for a missing or unparseable origin, and for
|
|
38
|
+
* anything that resolves elsewhere. The WHATWG parser normalizes leading
|
|
39
|
+
* controls, tabs, and backslashes first, so " //127.0.0.1" resolves cross-origin
|
|
40
|
+
* and is rejected (GHSA-q8hw-4fvp-9rwv).
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveSameOriginUrl(path: string, origin: string | undefined): string | null;
|
|
13
43
|
export interface SafeFetchOptions {
|
|
14
44
|
timeout: number;
|
|
15
45
|
headers?: Record<string, string>;
|
|
@@ -35,3 +65,4 @@ export interface SafeFetchOptions {
|
|
|
35
65
|
* usual missing-asset behaviour.
|
|
36
66
|
*/
|
|
37
67
|
export declare function fetchWithRedirectValidation(initialUrl: string, opts: SafeFetchOptions): Promise<ArrayBuffer | null>;
|
|
68
|
+
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { guardedFetch } from "#og-image/bindings/fetch";
|
|
1
2
|
const RE_IPV6_BRACKETS = /^\[|\]$/g;
|
|
3
|
+
const RE_TRAILING_DOTS = /\.+$/;
|
|
2
4
|
const RE_DIGIT_ONLY = /^\d+$/;
|
|
3
5
|
const RE_INT_IP = /^(?:0x[\da-f]+|\d+)$/i;
|
|
4
6
|
const RE_HEX_GROUP = /^[0-9a-f]{1,4}$/i;
|
|
@@ -18,6 +20,12 @@ function isPrivateIPv4(a, b) {
|
|
|
18
20
|
return true;
|
|
19
21
|
if (a === 0)
|
|
20
22
|
return true;
|
|
23
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
24
|
+
return true;
|
|
25
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
26
|
+
return true;
|
|
27
|
+
if (a >= 224)
|
|
28
|
+
return true;
|
|
21
29
|
return false;
|
|
22
30
|
}
|
|
23
31
|
export function expandIPv6(addr) {
|
|
@@ -88,6 +96,10 @@ function isPrivateIPv6(groups) {
|
|
|
88
96
|
}
|
|
89
97
|
if (g0 === 100 && g1 === 65435 && g2 === 1)
|
|
90
98
|
return true;
|
|
99
|
+
if (g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0)
|
|
100
|
+
return true;
|
|
101
|
+
if (g0 === 8193 && g1 === 0)
|
|
102
|
+
return true;
|
|
91
103
|
if ((g0 & 65024) === 64512)
|
|
92
104
|
return true;
|
|
93
105
|
if ((g0 & 65472) === 65152)
|
|
@@ -114,7 +126,7 @@ export function isBlockedUrl(url) {
|
|
|
114
126
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
115
127
|
return true;
|
|
116
128
|
const hostname = parsed.hostname.toLowerCase();
|
|
117
|
-
const bare = hostname.replace(RE_IPV6_BRACKETS, "");
|
|
129
|
+
const bare = hostname.replace(RE_IPV6_BRACKETS, "").replace(RE_TRAILING_DOTS, "");
|
|
118
130
|
if (bare === "localhost" || bare.endsWith(".localhost"))
|
|
119
131
|
return true;
|
|
120
132
|
const dottedParts = bare.split(".");
|
|
@@ -137,6 +149,50 @@ export function isBlockedUrl(url) {
|
|
|
137
149
|
}
|
|
138
150
|
return false;
|
|
139
151
|
}
|
|
152
|
+
export function isBlockedAddress(address) {
|
|
153
|
+
return isBlockedUrl(`http://${address.includes(":") ? `[${address}]` : address}/`);
|
|
154
|
+
}
|
|
155
|
+
const PROBE_ORIGIN = "http://og-image.invalid";
|
|
156
|
+
function hasUnsafePathChar(input) {
|
|
157
|
+
for (let i = 0; i < input.length; i++) {
|
|
158
|
+
const code = input.charCodeAt(i);
|
|
159
|
+
if (code <= 31 || code === 127 || code === 92)
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
export function parseSameOriginPath(input) {
|
|
165
|
+
if (typeof input !== "string" || input[0] !== "/" || input[1] === "/")
|
|
166
|
+
return void 0;
|
|
167
|
+
if (hasUnsafePathChar(input))
|
|
168
|
+
return void 0;
|
|
169
|
+
let resolved;
|
|
170
|
+
try {
|
|
171
|
+
resolved = new URL(input, PROBE_ORIGIN);
|
|
172
|
+
} catch {
|
|
173
|
+
return void 0;
|
|
174
|
+
}
|
|
175
|
+
if (resolved.origin !== PROBE_ORIGIN)
|
|
176
|
+
return void 0;
|
|
177
|
+
return input;
|
|
178
|
+
}
|
|
179
|
+
export function resolveSameOriginUrl(path, origin) {
|
|
180
|
+
if (!origin)
|
|
181
|
+
return null;
|
|
182
|
+
let base;
|
|
183
|
+
try {
|
|
184
|
+
base = new URL(origin).origin;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
let target;
|
|
189
|
+
try {
|
|
190
|
+
target = new URL(path, base);
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return target.origin === base ? target.href : null;
|
|
195
|
+
}
|
|
140
196
|
export async function fetchWithRedirectValidation(initialUrl, opts) {
|
|
141
197
|
const controller = new AbortController();
|
|
142
198
|
const externalAbort = opts.signal;
|
|
@@ -161,20 +217,23 @@ export async function fetchWithRedirectValidation(initialUrl, opts) {
|
|
|
161
217
|
}
|
|
162
218
|
if (!isTrusted && isBlockedUrl(url))
|
|
163
219
|
return null;
|
|
164
|
-
const res = await
|
|
165
|
-
redirect: "manual",
|
|
220
|
+
const res = await guardedFetch(url, {
|
|
166
221
|
signal: controller.signal,
|
|
167
|
-
headers: opts.headers
|
|
222
|
+
headers: opts.headers,
|
|
223
|
+
isBlockedAddress: isTrusted ? void 0 : isBlockedAddress
|
|
168
224
|
});
|
|
169
225
|
if (res.status >= 300 && res.status < 400) {
|
|
226
|
+
await res.body?.cancel();
|
|
170
227
|
const loc = res.headers.get("location");
|
|
171
228
|
if (!loc)
|
|
172
229
|
return null;
|
|
173
230
|
url = new URL(loc, url).toString();
|
|
174
231
|
continue;
|
|
175
232
|
}
|
|
176
|
-
if (!res.ok)
|
|
233
|
+
if (!res.ok) {
|
|
234
|
+
await res.body?.cancel();
|
|
177
235
|
return null;
|
|
236
|
+
}
|
|
178
237
|
return await res.arrayBuffer();
|
|
179
238
|
}
|
|
180
239
|
return null;
|
|
@@ -4,5 +4,5 @@ export interface GetOgImagePathResult {
|
|
|
4
4
|
path: string;
|
|
5
5
|
hash?: string;
|
|
6
6
|
}
|
|
7
|
-
export declare function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult;
|
|
7
|
+
export declare function getOgImagePath(event: H3Event, _pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult;
|
|
8
8
|
export declare function useOgImageRuntimeConfig(e?: H3Event): OgImageRuntimeConfig;
|
|
@@ -3,9 +3,9 @@ import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
|
3
3
|
import { componentNames } from "#og-image-virtual/component-names.mjs";
|
|
4
4
|
import { buildOgImageUrl } from "../shared.js";
|
|
5
5
|
import { getCloudflareEnv } from "./util/cloudflare.js";
|
|
6
|
-
export function getOgImagePath(_pagePath, _options) {
|
|
7
|
-
const baseURL = useRuntimeConfig().app.baseURL;
|
|
8
|
-
const { defaults, security } = useOgImageRuntimeConfig();
|
|
6
|
+
export function getOgImagePath(event, _pagePath, _options) {
|
|
7
|
+
const baseURL = useRuntimeConfig(event).app.baseURL;
|
|
8
|
+
const { defaults, security } = useOgImageRuntimeConfig(event);
|
|
9
9
|
const extension = _options?.extension || defaults.extension;
|
|
10
10
|
const isStatic = import.meta.prerender && !(security?.secret && security?.strict);
|
|
11
11
|
const options = { ..._options, _path: _pagePath };
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generate a deterministic hash from options object
|
|
3
|
-
* Excludes _path so images with same options can be cached across pages
|
|
3
|
+
* Excludes _path so images with same options can be cached across pages,
|
|
4
|
+
* except for PageScreenshot, which renders the page itself.
|
|
4
5
|
* Optionally includes componentHash and version for cache busting
|
|
6
|
+
*
|
|
7
|
+
* Uses the first 64 bits of a SHA-256 digest over a canonical (key-sorted)
|
|
8
|
+
* serialization. The previous 32-bit rolling hash collided on large sites,
|
|
9
|
+
* silently overwriting other pages' prerendered images.
|
|
5
10
|
*/
|
|
6
11
|
export declare function hashOgImageOptions(options: Record<string, any>, componentHash?: string, version?: string): string;
|
|
7
12
|
/**
|
|
@@ -25,7 +30,8 @@ export interface BuildOgImageUrlResult {
|
|
|
25
30
|
/**
|
|
26
31
|
* Build full OG image URL
|
|
27
32
|
*
|
|
28
|
-
*
|
|
33
|
+
* Static URLs fall back to hash mode when encoded params exceed MAX_PATH_LENGTH
|
|
34
|
+
* or hold a character outside [A-Za-z0-9_.~-]:
|
|
29
35
|
* - Returns short path: /_og/s/o_<hash>.png
|
|
30
36
|
* - Returns hash in result for cache storage
|
|
31
37
|
*
|
|
@@ -34,7 +40,7 @@ export interface BuildOgImageUrlResult {
|
|
|
34
40
|
* @param isStatic - Whether this is a static/prerendered image
|
|
35
41
|
* @param defaults - Optional defaults to skip from URL (keeps URLs shorter)
|
|
36
42
|
* @example buildOgImageUrl({ width: 1200, props: { title: 'Hello' } }, 'png', true)
|
|
37
|
-
* // Returns: { url: "/_og/s/
|
|
43
|
+
* // Returns: { url: "/_og/s/o_<hash>.png", hash: "<hash>" }
|
|
38
44
|
*/
|
|
39
45
|
export declare function buildOgImageUrl(options: Record<string, any>, extension?: string, isStatic?: boolean, defaults?: Record<string, any>, secret?: string): BuildOgImageUrlResult;
|
|
40
46
|
/**
|
|
@@ -19,6 +19,7 @@ const RE_COMMA_PARAM_SEPARATOR = /,(?=\w+_)/;
|
|
|
19
19
|
const RE_SIGNATURE_SUFFIX = /,s_[^,]+$/;
|
|
20
20
|
const RE_NON_ASCII = /[^\u0000-\u007F]/;
|
|
21
21
|
const RE_WINDOWS_RESERVED_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/;
|
|
22
|
+
const RE_NOT_UNRESERVED = /[^\w.~-]/;
|
|
22
23
|
const PARAM_ALIASES = {
|
|
23
24
|
w: "width",
|
|
24
25
|
h: "height",
|
|
@@ -85,19 +86,35 @@ function b64Decode(str) {
|
|
|
85
86
|
}
|
|
86
87
|
return Buffer.from(padded, "base64").toString("utf8");
|
|
87
88
|
}
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
function canonicalJson(value) {
|
|
90
|
+
if (value === null || typeof value !== "object")
|
|
91
|
+
return JSON.stringify(value) ?? "null";
|
|
92
|
+
if (Array.isArray(value))
|
|
93
|
+
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
94
|
+
const record = value;
|
|
95
|
+
const entries = Object.keys(record).sort().filter((key) => record[key] !== void 0).map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
|
|
96
|
+
return `{${entries.join(",")}}`;
|
|
97
|
+
}
|
|
98
|
+
const B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
99
|
+
function base64UrlToHex(input) {
|
|
100
|
+
let hex = "";
|
|
101
|
+
let buffer = 0;
|
|
102
|
+
let bits = 0;
|
|
103
|
+
for (const char of input) {
|
|
104
|
+
buffer = buffer << 6 | B64URL_ALPHABET.indexOf(char);
|
|
105
|
+
bits += 6;
|
|
106
|
+
if (bits >= 8) {
|
|
107
|
+
bits -= 8;
|
|
108
|
+
hex += (buffer >> bits & 255).toString(16).padStart(2, "0");
|
|
109
|
+
}
|
|
94
110
|
}
|
|
95
|
-
return
|
|
111
|
+
return hex;
|
|
96
112
|
}
|
|
97
113
|
export function hashOgImageOptions(options, componentHash, version) {
|
|
98
|
-
const { _path, _hash, ...
|
|
114
|
+
const { _path, _hash, ...rest } = options;
|
|
115
|
+
const hashableOptions = rest.component === "PageScreenshot" ? { ...rest, _path } : rest;
|
|
99
116
|
const hashInput = componentHash || version ? [hashableOptions, componentHash || "", version || ""] : hashableOptions;
|
|
100
|
-
return
|
|
117
|
+
return base64UrlToHex(digest(canonicalJson(hashInput))).slice(0, 16);
|
|
101
118
|
}
|
|
102
119
|
export function encodeOgImageParams(options, defaults) {
|
|
103
120
|
const parts = [];
|
|
@@ -233,7 +250,7 @@ export function decodeOgImageParams(encoded) {
|
|
|
233
250
|
export function buildOgImageUrl(options, extension = "png", isStatic = false, defaults, secret) {
|
|
234
251
|
const encoded = encodeOgImageParams(options, defaults);
|
|
235
252
|
const prefix = isStatic ? "/_og/s" : "/_og/d";
|
|
236
|
-
if (isStatic && (encoded.length > MAX_PATH_LENGTH ||
|
|
253
|
+
if (isStatic && (encoded.length > MAX_PATH_LENGTH || RE_NOT_UNRESERVED.test(encoded))) {
|
|
237
254
|
const hash = hashOgImageOptions(options);
|
|
238
255
|
return {
|
|
239
256
|
url: `${prefix}/o_${hash}.${extension}`,
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -4,12 +4,13 @@ import type { SatoriOptions } from 'satori';
|
|
|
4
4
|
import type { JpegOptions, SharpOptions } from 'sharp';
|
|
5
5
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
|
6
6
|
import type { H3Error, H3Event } from '#nuxtseo/h3';
|
|
7
|
+
import type { SameOriginPath } from './server/util/ssrf.js';
|
|
7
8
|
import type { Timings } from './server/util/timings.js';
|
|
8
9
|
export interface OgImageRenderEventContext {
|
|
9
10
|
e: H3Event;
|
|
10
11
|
extension: 'png' | 'jpeg' | 'jpg' | 'webp' | 'svg' | 'html' | 'json';
|
|
11
12
|
key: string;
|
|
12
|
-
basePath:
|
|
13
|
+
basePath: SameOriginPath;
|
|
13
14
|
renderer: Renderer;
|
|
14
15
|
options: OgImageOptionsInternal;
|
|
15
16
|
isDevToolsContextRequest: boolean;
|
|
@@ -30,6 +31,7 @@ export interface OgImageIconsData {
|
|
|
30
31
|
height: number;
|
|
31
32
|
}
|
|
32
33
|
export interface OgImageRuntimeConfig {
|
|
34
|
+
browserEnabled: boolean;
|
|
33
35
|
version: string;
|
|
34
36
|
satoriOptions: SatoriOptions;
|
|
35
37
|
resvgOptions: ResvgRenderOptions;
|
|
@@ -76,7 +78,7 @@ export interface OgImageRuntimeConfig {
|
|
|
76
78
|
}
|
|
77
79
|
export type RendererType = 'satori' | 'browser' | 'takumi';
|
|
78
80
|
export type BrowserProvider = 'playwright' | 'chrome-launcher' | 'on-demand' | 'cloudflare';
|
|
79
|
-
export type BrowserConfig =
|
|
81
|
+
export type BrowserConfig = boolean | {
|
|
80
82
|
provider: BrowserProvider;
|
|
81
83
|
/** Required when provider is 'cloudflare' - the wrangler binding name */
|
|
82
84
|
binding?: string;
|
|
@@ -241,6 +243,7 @@ export interface RuntimeCompatibilitySchema {
|
|
|
241
243
|
takumi: 'node' | 'node-dev' | 'wasm' | false;
|
|
242
244
|
sharp: 'node' | false;
|
|
243
245
|
emoji?: 'local' | 'fetch';
|
|
246
|
+
fetch?: 'node' | 'web';
|
|
244
247
|
wasm?: any;
|
|
245
248
|
}
|
|
246
249
|
export interface RuntimeCompatibilityMeta {
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, rmSync } from 'node:fs';
|
|
4
4
|
import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
|
|
6
|
+
import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addServerImports, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
|
|
7
7
|
import { defu } from 'defu';
|
|
8
8
|
import { fnv1a64Base36 } from 'fnv1a-64';
|
|
9
9
|
import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
|
|
@@ -119,6 +119,16 @@ async function hasResolvableDependency(dep) {
|
|
|
119
119
|
return null;
|
|
120
120
|
}).then((r) => r && r !== dep);
|
|
121
121
|
}
|
|
122
|
+
const MIN_SATORI_VERSION = "0.33.5";
|
|
123
|
+
function isSupportedSatoriVersion(version) {
|
|
124
|
+
const current = (version || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
125
|
+
const minimum = MIN_SATORI_VERSION.split(".").map(Number);
|
|
126
|
+
for (let i = 0; i < minimum.length; i++) {
|
|
127
|
+
if ((current[i] ?? 0) !== minimum[i])
|
|
128
|
+
return (current[i] ?? 0) > minimum[i];
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
122
132
|
const VALID_RENDERER_SUFFIXES = ["satori", "browser", "takumi"];
|
|
123
133
|
const RE_RENDERER_SUFFIX_CI = /\.?(satori|browser|takumi)$/i;
|
|
124
134
|
const RE_RENDERER_SUFFIX = /(Satori|Browser|Takumi)$/;
|
|
@@ -192,6 +202,8 @@ const cloudflare = {
|
|
|
192
202
|
sharp: false,
|
|
193
203
|
emoji: "fetch",
|
|
194
204
|
// edge size limits - use API instead of bundling 24MB icons
|
|
205
|
+
fetch: "web",
|
|
206
|
+
// no DNS API
|
|
195
207
|
wasm: {
|
|
196
208
|
esmImport: true
|
|
197
209
|
// Do NOT set lazy here — Nitro's cloudflare preset uses lazy: false because
|
|
@@ -214,8 +226,9 @@ const WebContainer = {
|
|
|
214
226
|
satori: "wasm-fs",
|
|
215
227
|
takumi: "wasm",
|
|
216
228
|
sharp: false,
|
|
217
|
-
emoji: "fetch"
|
|
229
|
+
emoji: "fetch",
|
|
218
230
|
// webcontainer has size constraints
|
|
231
|
+
fetch: "web"
|
|
219
232
|
};
|
|
220
233
|
const RuntimeCompatibility = {
|
|
221
234
|
"nitro-dev": NodeDevRuntime,
|
|
@@ -256,6 +269,7 @@ const RuntimeCompatibility = {
|
|
|
256
269
|
sharp: false,
|
|
257
270
|
emoji: "fetch",
|
|
258
271
|
// edge size limits
|
|
272
|
+
fetch: "web",
|
|
259
273
|
wasm: {
|
|
260
274
|
rollup: {
|
|
261
275
|
targetEnv: "auto-inline",
|
|
@@ -276,6 +290,7 @@ const RuntimeCompatibility = {
|
|
|
276
290
|
sharp: false,
|
|
277
291
|
emoji: "fetch",
|
|
278
292
|
// edge size limits - bundling 24MB icons not viable
|
|
293
|
+
fetch: "web",
|
|
279
294
|
wasm: {
|
|
280
295
|
// lowers workers kb size
|
|
281
296
|
esmImport: true,
|
|
@@ -351,32 +366,32 @@ async function applyNitroPresetCompatibility(nitroConfig, options) {
|
|
|
351
366
|
);
|
|
352
367
|
if (resolvedCompatibility.satori) {
|
|
353
368
|
const satoriPackage = await readPackageJSON(`${satoriPkgDir}/package.json`);
|
|
354
|
-
if (satoriPackage.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const base64 = (await readFile(harfbuzzWasmPath)).toString("base64");
|
|
368
|
-
nitroConfig.virtual["#og-image/harfbuzz-wasm"] = `export default imports => WebAssembly.instantiate(Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)), imports)`;
|
|
369
|
-
}
|
|
370
|
-
} else if (Number.parseInt(getNuxtVersion(useNuxt()), 10) >= 5) {
|
|
371
|
-
const config = nitroConfig;
|
|
372
|
-
config.traceDeps = [.../* @__PURE__ */ new Set([...config.traceDeps || [], "harfbuzzjs*"])];
|
|
369
|
+
if (!isSupportedSatoriVersion(satoriPackage.version))
|
|
370
|
+
throw new Error(`[Nuxt OG Image] Satori ${satoriPackage.version} is not supported. Satori ${MIN_SATORI_VERSION} or later is required (GHSA-wx4j-mvgx-mqwp). Run: npm install satori@latest`);
|
|
371
|
+
const harfbuzzWasmPath = resolveModulePath("harfbuzzjs/hb.wasm", { from: `${satoriPkgDir}/package.json` });
|
|
372
|
+
if (resolvedCompatibility.satori === "wasm" || target.replace(RE_LEGACY_SUFFIX, "") === "netlify-edge") {
|
|
373
|
+
const harfbuzzDir = harfbuzzWasmPath.slice(0, -"hb.wasm".length);
|
|
374
|
+
nitroConfig.virtual["#og-image/harfbuzz-callbacks"] = resolvedCompatibility.satori === "wasm" ? await prepareHarfBuzzCallbacks(`${harfbuzzDir}hbjs.js`, join(useNuxt().options.buildDir, "cache/og-image/harfbuzz")) : "export default undefined";
|
|
375
|
+
Object.assign(nitroConfig.alias, {
|
|
376
|
+
"harfbuzzjs": await resolve.resolvePath("./runtime/server/og-image/bindings/satori/harfbuzz"),
|
|
377
|
+
"#og-image/harfbuzz-factory": `${harfbuzzDir}hb.js`,
|
|
378
|
+
"#og-image/harfbuzz-adapter": `${harfbuzzDir}hbjs.js`
|
|
379
|
+
});
|
|
380
|
+
if (resolvedCompatibility.satori === "wasm") {
|
|
381
|
+
nitroConfig.alias["#og-image/harfbuzz-wasm"] = `${harfbuzzWasmPath}?module`;
|
|
373
382
|
} else {
|
|
374
|
-
|
|
375
|
-
nitroConfig.
|
|
376
|
-
...nitroConfig.externals.traceInclude || [],
|
|
377
|
-
harfbuzzWasmPath
|
|
378
|
-
])];
|
|
383
|
+
const base64 = (await readFile(harfbuzzWasmPath)).toString("base64");
|
|
384
|
+
nitroConfig.virtual["#og-image/harfbuzz-wasm"] = `export default imports => WebAssembly.instantiate(Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)), imports)`;
|
|
379
385
|
}
|
|
386
|
+
} else if (Number.parseInt(getNuxtVersion(useNuxt()), 10) >= 5) {
|
|
387
|
+
const config = nitroConfig;
|
|
388
|
+
config.traceDeps = [.../* @__PURE__ */ new Set([...config.traceDeps || [], "harfbuzzjs*"])];
|
|
389
|
+
} else {
|
|
390
|
+
nitroConfig.externals = nitroConfig.externals || {};
|
|
391
|
+
nitroConfig.externals.traceInclude = [.../* @__PURE__ */ new Set([
|
|
392
|
+
...nitroConfig.externals.traceInclude || [],
|
|
393
|
+
harfbuzzWasmPath
|
|
394
|
+
])];
|
|
380
395
|
}
|
|
381
396
|
}
|
|
382
397
|
if (Object.values(resolvedCompatibility).includes("wasm")) {
|
|
@@ -431,6 +446,12 @@ async function setupBuildHandler(config, resolve, getDetectedRenderers, getCompa
|
|
|
431
446
|
metadata: getCompatibilityMeta()
|
|
432
447
|
});
|
|
433
448
|
nuxt.hooks.hook("nitro:init", async (nitro) => {
|
|
449
|
+
nitro.hooks.hook("prerender:config", (prerenderConfig) => {
|
|
450
|
+
if (prerenderConfig.virtual?.["playwright-core"]) {
|
|
451
|
+
const { "playwright-core": _, ...virtual } = prerenderConfig.virtual;
|
|
452
|
+
prerenderConfig.virtual = virtual;
|
|
453
|
+
}
|
|
454
|
+
});
|
|
434
455
|
await applyNitroPresetCompatibility(nitro.options, {
|
|
435
456
|
compatibility: config.compatibility?.runtime,
|
|
436
457
|
resolve,
|
|
@@ -636,6 +657,14 @@ const PROVIDER_DEPENDENCIES = [
|
|
|
636
657
|
}
|
|
637
658
|
}
|
|
638
659
|
];
|
|
660
|
+
function resolveAutoDetectedProvider(input) {
|
|
661
|
+
if (input.hasUserComponents)
|
|
662
|
+
return { preferred: null, fallbackToDefault: false };
|
|
663
|
+
const preferred = input.installedProviders.find((p) => p === "takumi") ?? input.installedProviders[0] ?? null;
|
|
664
|
+
if (preferred)
|
|
665
|
+
return { preferred, fallbackToDefault: false };
|
|
666
|
+
return { preferred: null, fallbackToDefault: true };
|
|
667
|
+
}
|
|
639
668
|
async function getInstalledProviders() {
|
|
640
669
|
const installed = [];
|
|
641
670
|
for (const provider of PROVIDER_DEPENDENCIES) {
|
|
@@ -4928,6 +4957,10 @@ declare module '#og-image/renderers/takumi' {
|
|
|
4928
4957
|
export default _default
|
|
4929
4958
|
}
|
|
4930
4959
|
|
|
4960
|
+
declare module '#og-image/bindings/fetch' {
|
|
4961
|
+
export { guardedFetch } from '${typesPath.replace("/types", "")}/server/og-image/bindings/fetch/web'
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4931
4964
|
declare module '#og-image/emoji-transform' {
|
|
4932
4965
|
import type { OgImageRenderEventContext } from '${typesPath}'
|
|
4933
4966
|
export function getEmojiSvg(ctx: OgImageRenderEventContext, emoji: string): Promise<string | null>
|
|
@@ -5172,6 +5205,7 @@ Or pin to a specific version in package.json:
|
|
|
5172
5205
|
setRuntimeAlias("#og-image-cache", resolve("./runtime/server/og-image/cache/lru"));
|
|
5173
5206
|
const preset = resolveOgImagePreset(nuxt.options.nitro);
|
|
5174
5207
|
const targetCompatibility = getPresetNitroPresetCompatibility(preset);
|
|
5208
|
+
setRuntimeAlias("#og-image/bindings/fetch", resolve(`./runtime/server/og-image/bindings/fetch/${targetCompatibility.fetch ?? "node"}`));
|
|
5175
5209
|
const runtimeCompatibilityMeta = {};
|
|
5176
5210
|
const normalizedPreset = preset.replace(RE_LEGACY_SUFFIX, "");
|
|
5177
5211
|
const isCloudflareWorkers = ["cloudflare", "cloudflare-module", "cloudflare-durable"].includes(normalizedPreset);
|
|
@@ -5587,6 +5621,9 @@ ${familyInfo}`);
|
|
|
5587
5621
|
name: "getOgImagePath",
|
|
5588
5622
|
from: resolve("./runtime/app/utils")
|
|
5589
5623
|
});
|
|
5624
|
+
addServerImports([
|
|
5625
|
+
{ name: "getOgImageUrl", from: resolve("./runtime/server/nitro") }
|
|
5626
|
+
]);
|
|
5590
5627
|
const basePluginPath = `./runtime/app/plugins${config.zeroRuntime ? "/__zero-runtime" : ""}`;
|
|
5591
5628
|
addPlugin({ mode: "server", src: resolve(`${basePluginPath}/route-rule-og-image.server`) });
|
|
5592
5629
|
addPlugin({ mode: "server", src: resolve(`${basePluginPath}/og-image-canonical-urls.server`) });
|
|
@@ -5634,19 +5671,25 @@ ${familyInfo}`);
|
|
|
5634
5671
|
}
|
|
5635
5672
|
}
|
|
5636
5673
|
}
|
|
5674
|
+
if (config.browser)
|
|
5675
|
+
ogImageComponentCtx.detectedRenderers.add("browser");
|
|
5637
5676
|
if (!nuxt.options._prepare && !hasUserComponents) {
|
|
5638
|
-
const
|
|
5639
|
-
|
|
5677
|
+
const { preferred, fallbackToDefault } = resolveAutoDetectedProvider({
|
|
5678
|
+
hasUserComponents,
|
|
5679
|
+
installedProviders: (await getInstalledProviders()).map((p) => p.provider)
|
|
5680
|
+
});
|
|
5640
5681
|
if (preferred) {
|
|
5641
|
-
ogImageComponentCtx.detectedRenderers.add(preferred
|
|
5642
|
-
logger.debug(`Using ${preferred
|
|
5643
|
-
} else if (
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5682
|
+
ogImageComponentCtx.detectedRenderers.add(preferred);
|
|
5683
|
+
logger.debug(`Using ${preferred} renderer`);
|
|
5684
|
+
} else if (fallbackToDefault) {
|
|
5685
|
+
if (nuxt.options.dev && !nuxt.options._prepare && canPromptInteractively()) {
|
|
5686
|
+
const renderer = await promptForRendererSelection();
|
|
5687
|
+
ogImageComponentCtx.detectedRenderers.add(renderer);
|
|
5688
|
+
logger.debug(`Using ${renderer} renderer`);
|
|
5689
|
+
} else {
|
|
5690
|
+
ogImageComponentCtx.detectedRenderers.add("takumi");
|
|
5691
|
+
logger.warn(`No OG image renderer dependency detected. Defaulting to \`takumi\` (non-interactive environment). Install \`${TAKUMI_CORE_PACKAGE}\`, or add a renderer component (e.g. components/OgImage/Default.satori.vue) to choose explicitly.`);
|
|
5692
|
+
}
|
|
5650
5693
|
}
|
|
5651
5694
|
}
|
|
5652
5695
|
const availableRenderers = /* @__PURE__ */ new Set();
|
|
@@ -6052,6 +6095,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
|
|
|
6052
6095
|
// @ts-expect-error runtime type
|
|
6053
6096
|
isNuxtContentDocumentDriven: !!nuxt.options.content?.documentDriven,
|
|
6054
6097
|
cssFramework: cssFramework || "none",
|
|
6098
|
+
browserEnabled: !!config.browser,
|
|
6055
6099
|
// Browser renderer config for cloudflare binding access
|
|
6056
6100
|
browser: typeof config.browser === "object" ? {
|
|
6057
6101
|
provider: config.browser.provider,
|
|
@@ -6086,6 +6130,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
|
|
|
6086
6130
|
"nuxt-og-image": {
|
|
6087
6131
|
defaults: runtimeConfig.defaults,
|
|
6088
6132
|
includeTwitter: runtimeConfig.includeTwitter,
|
|
6133
|
+
browserEnabled: runtimeConfig.browserEnabled,
|
|
6089
6134
|
hasServerRuntime: !nuxt.options._generate && !nuxt.options.nitro?.static
|
|
6090
6135
|
}
|
|
6091
6136
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-og-image",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "6.
|
|
4
|
+
"version": "6.9.1",
|
|
5
5
|
"description": "Enlightened OG Image generation for Nuxt.",
|
|
6
6
|
"author": {
|
|
7
7
|
"website": "https://harlanzw.com",
|
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
"@takumi-rs/core": "^1.0.0-beta.3 || ^2.0.0",
|
|
54
54
|
"@takumi-rs/wasm": "^1.0.0-beta.3 || ^2.0.0",
|
|
55
55
|
"@unhead/vue": "^2.0.5 || ^3.0.0",
|
|
56
|
-
"@unocss/config": "^66.10.
|
|
57
|
-
"@unocss/core": "^66.10.
|
|
58
|
-
"fontless": "^0.2.0 || ^0.4.3",
|
|
56
|
+
"@unocss/config": "^66.10.5",
|
|
57
|
+
"@unocss/core": "^66.10.5",
|
|
58
|
+
"fontless": "^0.2.0 || ^0.4.3 || ^1.0.0",
|
|
59
59
|
"nitropack": "^2.13.4",
|
|
60
60
|
"playwright-core": "^1.50.0",
|
|
61
|
-
"satori": ">=0.
|
|
62
|
-
"sharp": "^0.
|
|
61
|
+
"satori": ">=0.33.5",
|
|
62
|
+
"sharp": "^0.35.4",
|
|
63
63
|
"tailwindcss": "^4.0.0",
|
|
64
64
|
"unifont": "^0.7.0 || ^0.8.3",
|
|
65
65
|
"unstorage": "^1.15.0",
|
|
@@ -110,9 +110,9 @@
|
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"dependencies": {
|
|
113
|
-
"@clack/prompts": "^1.8.
|
|
113
|
+
"@clack/prompts": "^1.8.1",
|
|
114
114
|
"@nuxt/kit": "^4.5.2",
|
|
115
|
-
"@vue/compiler-sfc": "^3.5.
|
|
115
|
+
"@vue/compiler-sfc": "^3.5.43",
|
|
116
116
|
"chrome-launcher": "^1.2.1",
|
|
117
117
|
"consola": "^3.4.2",
|
|
118
118
|
"defu": "^6.1.7",
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"exsolve": "^1.1.1",
|
|
121
121
|
"fnv1a-64": "^0.1.2",
|
|
122
122
|
"lightningcss": "^1.33.0",
|
|
123
|
-
"magic-string": "^1.
|
|
123
|
+
"magic-string": "^1.4.1",
|
|
124
124
|
"magicast": "^0.5.5",
|
|
125
125
|
"mocked-exports": "^0.1.1",
|
|
126
126
|
"nuxt-site-config": "^4.2.3",
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"object-identity": "^0.2.3",
|
|
130
130
|
"ofetch": "^1.5.1",
|
|
131
131
|
"ohash": "^2.0.12",
|
|
132
|
-
"oxc-parser": "^0.
|
|
132
|
+
"oxc-parser": "^0.151.0",
|
|
133
133
|
"oxc-walker": "^1.1.1",
|
|
134
134
|
"pathe": "^2.0.3",
|
|
135
135
|
"pkg-types": "^2.3.3",
|
|
@@ -139,21 +139,21 @@
|
|
|
139
139
|
"tinyexec": "^1.3.1",
|
|
140
140
|
"ufo": "^1.6.4",
|
|
141
141
|
"ultrahtml": "^1.7.0",
|
|
142
|
-
"unplugin": "^3.
|
|
142
|
+
"unplugin": "^3.4.0"
|
|
143
143
|
},
|
|
144
144
|
"devDependencies": {
|
|
145
145
|
"@antfu/eslint-config": "^9.5.1",
|
|
146
146
|
"@fontsource/noto-sans-sc": "^5.3.0",
|
|
147
147
|
"@iconify-json/carbon": "^1.2.27",
|
|
148
148
|
"@iconify-json/logos": "^1.2.14",
|
|
149
|
-
"@iconify-json/noto": "^1.2.
|
|
149
|
+
"@iconify-json/noto": "^1.2.8",
|
|
150
150
|
"@iconify-json/ri": "^1.2.10",
|
|
151
|
-
"@iconify-json/tabler": "^1.2.
|
|
151
|
+
"@iconify-json/tabler": "^1.2.39",
|
|
152
152
|
"@iconify/types": "^2.0.0",
|
|
153
153
|
"@img/sharp-linux-x64": "^0.35.4",
|
|
154
|
-
"@nuxt/content": "^3.16.
|
|
155
|
-
"@nuxt/devtools": "4.0.0-
|
|
156
|
-
"@nuxt/devtools-kit": "4.0.0-
|
|
154
|
+
"@nuxt/content": "^3.16.1",
|
|
155
|
+
"@nuxt/devtools": "4.0.0-beta.1",
|
|
156
|
+
"@nuxt/devtools-kit": "4.0.0-beta.1",
|
|
157
157
|
"@nuxt/fonts": "^0.14.0",
|
|
158
158
|
"@nuxt/icon": "^2.5.1",
|
|
159
159
|
"@nuxt/image": "^2.1.0",
|
|
@@ -167,30 +167,30 @@
|
|
|
167
167
|
"@resvg/resvg-js": "^2.6.2",
|
|
168
168
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
169
169
|
"@tailwindcss/vite": "^4.3.3",
|
|
170
|
-
"@takumi-rs/core": "^2.
|
|
170
|
+
"@takumi-rs/core": "^2.14.0",
|
|
171
171
|
"@takumi-rs/core-v1": "npm:@takumi-rs/core@1.8.7",
|
|
172
|
-
"@takumi-rs/helpers": "^2.
|
|
172
|
+
"@takumi-rs/helpers": "^2.14.0",
|
|
173
173
|
"@takumi-rs/helpers-v1": "npm:@takumi-rs/helpers@1.8.7",
|
|
174
|
-
"@takumi-rs/wasm": "^2.
|
|
174
|
+
"@takumi-rs/wasm": "^2.14.0",
|
|
175
175
|
"@takumi-rs/wasm-v1": "npm:@takumi-rs/wasm@1.8.7",
|
|
176
|
-
"@unocss/config": "^66.10.
|
|
177
|
-
"@unocss/core": "^66.10.
|
|
178
|
-
"@unocss/nuxt": "^66.10.
|
|
179
|
-
"@unocss/preset-icons": "^66.10.
|
|
180
|
-
"@unocss/preset-wind": "^66.10.
|
|
181
|
-
"@unocss/preset-wind4": "^66.10.
|
|
182
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
183
|
-
"@vue/compiler-core": "^3.5.
|
|
184
|
-
"@vueuse/nuxt": "^
|
|
176
|
+
"@unocss/config": "^66.10.5",
|
|
177
|
+
"@unocss/core": "^66.10.5",
|
|
178
|
+
"@unocss/nuxt": "^66.10.5",
|
|
179
|
+
"@unocss/preset-icons": "^66.10.5",
|
|
180
|
+
"@unocss/preset-wind": "^66.10.5",
|
|
181
|
+
"@unocss/preset-wind4": "^66.10.5",
|
|
182
|
+
"@vitejs/plugin-vue": "^6.0.9",
|
|
183
|
+
"@vue/compiler-core": "^3.5.43",
|
|
184
|
+
"@vueuse/nuxt": "^15.0.0",
|
|
185
185
|
"birpc": "^4.2.0",
|
|
186
186
|
"bumpp": "^12.3.0",
|
|
187
|
-
"eslint": "^10.
|
|
188
|
-
"eslint-plugin-harlanzw": "^0.
|
|
189
|
-
"fontless": "^0.
|
|
187
|
+
"eslint": "^10.11.0",
|
|
188
|
+
"eslint-plugin-harlanzw": "^0.22.0",
|
|
189
|
+
"fontless": "^1.0.0",
|
|
190
190
|
"fontless-v0-2": "npm:fontless@0.2.0",
|
|
191
191
|
"globby": "^16.2.4",
|
|
192
192
|
"happy-dom": "^20.14.5",
|
|
193
|
-
"hookable": "^6.1.
|
|
193
|
+
"hookable": "^6.1.2",
|
|
194
194
|
"jest-image-snapshot": "^6.5.2",
|
|
195
195
|
"lightningcss": "^1.33.0",
|
|
196
196
|
"nitropack": "^2.13.4",
|
|
@@ -199,24 +199,21 @@
|
|
|
199
199
|
"playwright": "^1.63.0",
|
|
200
200
|
"playwright-core": "^1.63.0",
|
|
201
201
|
"sass": "^1.104.1",
|
|
202
|
-
"satori": "^0.33.
|
|
203
|
-
"satori-v0-19": "npm:satori@0.19.2",
|
|
204
|
-
"satori-v0-32": "npm:satori@0.32.0",
|
|
202
|
+
"satori": "^0.33.5",
|
|
205
203
|
"sharp": "^0.35.4",
|
|
206
|
-
"sharp-v0-34": "npm:sharp@0.34.0",
|
|
207
204
|
"sirv": "^3.0.2",
|
|
208
205
|
"tailwindcss": "^4.3.3",
|
|
209
206
|
"typescript": "6.0.3",
|
|
210
207
|
"unbuild": "^3.6.1",
|
|
211
208
|
"unifont": "^0.8.3",
|
|
212
209
|
"unifont-v0-7": "npm:unifont@0.7.0",
|
|
213
|
-
"unocss": "^66.10.
|
|
214
|
-
"vitest": "^5.0.
|
|
215
|
-
"vue": "^3.5.
|
|
210
|
+
"unocss": "^66.10.5",
|
|
211
|
+
"vitest": "^5.0.1",
|
|
212
|
+
"vue": "^3.5.43",
|
|
216
213
|
"vue-tsc": "^3.3.11",
|
|
217
|
-
"wrangler": "^4.
|
|
214
|
+
"wrangler": "^4.136.1",
|
|
218
215
|
"yoga-wasm-web": "^0.3.3",
|
|
219
|
-
"zod": "^4.6.
|
|
216
|
+
"zod": "^4.6.5"
|
|
220
217
|
},
|
|
221
218
|
"scripts": {
|
|
222
219
|
"bench:edge-startup": "nuxt build bench/fixtures/edge-startup && node bench/edge-startup.mjs bench/fixtures/edge-startup/.netlify/edge-functions/server/server.js",
|
package/types/virtual.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ declare module '#og-image/renderers/browser' {
|
|
|
29
29
|
export default renderer
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
declare module '#og-image/bindings/fetch' {
|
|
33
|
+
export function guardedFetch(url: string, init: {
|
|
34
|
+
headers?: Record<string, string>
|
|
35
|
+
signal: AbortSignal
|
|
36
|
+
isBlockedAddress?: (address: string) => boolean
|
|
37
|
+
}): Promise<Response>
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
declare module '#og-image/bindings/satori' {
|
|
33
41
|
const satori: typeof import('satori').satori
|
|
34
42
|
export default satori
|