nuxt-og-image 6.10.3 → 6.10.4
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/module.json +1 -1
- package/dist/runtime/server/og-image/context.d.ts +0 -1
- package/dist/runtime/server/og-image/context.js +10 -25
- package/dist/runtime/server/og-image/core/plugins/imageSrc.js +6 -3
- package/dist/runtime/server/og-image/core/transforms/emojis/fetch.js +4 -4
- package/dist/runtime/server/og-image/devtools.d.ts +1 -1
- package/dist/runtime/server/og-image/devtools.js +3 -1
- package/dist/runtime/server/plugins/prerender.js +2 -9
- package/dist/runtime/server/routes/resolve.js +6 -4
- package/dist/runtime/server/util/cache.d.ts +3 -3
- package/dist/runtime/server/util/cache.js +4 -19
- package/dist/runtime/server/util/eventHandlers.js +10 -4
- package/dist/runtime/server/util/purge.d.ts +14 -0
- package/dist/runtime/server/util/purge.js +15 -0
- package/dist/runtime/server/util/ssrf.d.ts +12 -5
- package/dist/runtime/server/util/ssrf.js +26 -9
- package/dist/runtime/server/util/storageKey.d.ts +14 -0
- package/dist/runtime/server/util/storageKey.js +4 -0
- package/dist/runtime/shared/urlEncoding.d.ts +5 -0
- package/dist/runtime/shared/urlEncoding.js +4 -1
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import type { H3Error, H3Event } from '#nuxtseo/h3';
|
|
2
2
|
import type { OgImageRenderEventContext } from '../../types.js';
|
|
3
|
-
export declare function resolvePathCacheKey(e: H3Event, path: string, resolvedOptions?: Record<string, any>): string;
|
|
4
3
|
export declare function resolveContext(e: H3Event): Promise<H3Error | OgImageRenderEventContext>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defu } from "defu";
|
|
2
|
-
import { parseURL,
|
|
3
|
-
import { normalizeKey } from "unstorage";
|
|
2
|
+
import { parseURL, withoutTrailingSlash, withQuery } from "ufo";
|
|
4
3
|
import { createError } from "#nuxtseo/h3";
|
|
5
4
|
import { useNitroApp } from "#nuxtseo/nitro";
|
|
6
5
|
import { prerenderOptionsCache } from "#og-image-cache";
|
|
@@ -8,33 +7,18 @@ import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
|
|
|
8
7
|
import { createSitePathResolver } from "#site-config/server/composables/utils";
|
|
9
8
|
import { logger } from "../../logger.js";
|
|
10
9
|
import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps, verifyOgImageSignature } from "../../shared.js";
|
|
11
|
-
import {
|
|
10
|
+
import { digestCanonical } from "../../shared/urlEncoding.js";
|
|
12
11
|
import { createNitroRouteRuleMatcher } from "../util/kit.js";
|
|
13
12
|
import { normaliseOptions } from "../util/options.js";
|
|
14
13
|
import { getEventQuery } from "../util/query.js";
|
|
15
14
|
import { parseSameOriginPath } from "../util/ssrf.js";
|
|
15
|
+
import { toStorageKey } from "../util/storageKey.js";
|
|
16
16
|
import { createTimings, TIMING_CTX_KEY } from "../util/timings.js";
|
|
17
17
|
import { withTimeout } from "../util/withTimeout.js";
|
|
18
18
|
import { useOgImageRuntimeConfig } from "../utils.js";
|
|
19
19
|
import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from "./instances.js";
|
|
20
20
|
const RE_HASH_MODE = /^o_([a-z0-9]+)$/i;
|
|
21
21
|
const RE_SIGNATURE_SUFFIX = /,s_([^,]+)$/;
|
|
22
|
-
export function resolvePathCacheKey(e, path, resolvedOptions) {
|
|
23
|
-
const siteConfig = getSiteConfig(e, {
|
|
24
|
-
resolveRefs: true
|
|
25
|
-
});
|
|
26
|
-
const basePath = withoutTrailingSlash(withoutLeadingSlash(normalizeKey(path)));
|
|
27
|
-
const hashParts = [
|
|
28
|
-
basePath,
|
|
29
|
-
import.meta.prerender ? "" : siteConfig.url
|
|
30
|
-
];
|
|
31
|
-
if (resolvedOptions)
|
|
32
|
-
hashParts.push(hashKey(resolvedOptions));
|
|
33
|
-
return [
|
|
34
|
-
!basePath || basePath === "/" ? "index" : basePath,
|
|
35
|
-
hashKey(hashParts)
|
|
36
|
-
].join(":");
|
|
37
|
-
}
|
|
38
22
|
export async function resolveContext(e) {
|
|
39
23
|
const runtimeConfig = useOgImageRuntimeConfig(e);
|
|
40
24
|
const resolvePathWithBase = createSitePathResolver(e, {
|
|
@@ -73,7 +57,7 @@ export async function resolveContext(e) {
|
|
|
73
57
|
if (hashMatch) {
|
|
74
58
|
const optionsHash = hashMatch[1];
|
|
75
59
|
if (import.meta.prerender && prerenderOptionsCache) {
|
|
76
|
-
const cached = await prerenderOptionsCache.getItem(
|
|
60
|
+
const cached = await prerenderOptionsCache.getItem(toStorageKey("prerender", optionsHash));
|
|
77
61
|
if (cached && typeof cached === "object") {
|
|
78
62
|
urlOptions = cached;
|
|
79
63
|
} else {
|
|
@@ -121,13 +105,14 @@ export async function resolveContext(e) {
|
|
|
121
105
|
queryParams = separateProps(queryParams);
|
|
122
106
|
}
|
|
123
107
|
const rawPath = urlOptions._path ?? "/";
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
108
|
+
const parsedPath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
|
|
109
|
+
if (parsedPath._tag === "Err") {
|
|
126
110
|
return createError({
|
|
127
111
|
statusCode: 400,
|
|
128
|
-
statusMessage:
|
|
112
|
+
statusMessage: `[Nuxt OG Image] The _path option must be a same-origin path. ${parsedPath.reason}`
|
|
129
113
|
});
|
|
130
114
|
}
|
|
115
|
+
const basePath = parsedPath.value;
|
|
131
116
|
const componentHash = urlOptions._componentHash || "";
|
|
132
117
|
delete urlOptions._path;
|
|
133
118
|
delete urlOptions._hash;
|
|
@@ -174,8 +159,8 @@ export async function resolveContext(e) {
|
|
|
174
159
|
normalised.options.props = filtered;
|
|
175
160
|
}
|
|
176
161
|
const rendererType = normalised.renderer;
|
|
177
|
-
const
|
|
178
|
-
const key =
|
|
162
|
+
const identity = normalised.options.cacheKey ? ["cacheKey", String(normalised.options.cacheKey)] : hashMatch ? ["hash", hashMatch[1]] : ["page", basePathWithQuery, import.meta.prerender ? "" : getSiteConfig(e, { resolveRefs: true }).url, normalised.options];
|
|
163
|
+
const key = digestCanonical([...identity, componentHash]);
|
|
179
164
|
let renderer;
|
|
180
165
|
switch (rendererType) {
|
|
181
166
|
case "satori":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { withBase, withoutLeadingSlash } from "ufo";
|
|
1
|
+
import { parseURL, withBase, withoutLeadingSlash } from "ufo";
|
|
2
2
|
import { useStorage } from "#nuxtseo/nitro";
|
|
3
3
|
import { getNitroOrigin } from "#site-config/server/composables/getNitroOrigin";
|
|
4
4
|
import { toBase64Image } from "../../../../shared.js";
|
|
@@ -6,15 +6,18 @@ import { decodeHtml } from "../../../util/encoding.js";
|
|
|
6
6
|
import { fetchLocalAsset } from "../../../util/fetchLocalAsset.js";
|
|
7
7
|
import { getFetchTimeout } from "../../../util/fetchTimeout.js";
|
|
8
8
|
import { logger } from "../../../util/logger.js";
|
|
9
|
-
import { fetchWithRedirectValidation, isBlockedUrl } from "../../../util/ssrf.js";
|
|
9
|
+
import { fetchWithRedirectValidation, isBlockedUrl, parseSameOriginPath } from "../../../util/ssrf.js";
|
|
10
10
|
import { getImageDimensions } from "../../utils/image-detector.js";
|
|
11
11
|
import { defineTransformer } from "../plugins.js";
|
|
12
12
|
const RE_URL_LEADING = /^url\(['"]?/;
|
|
13
13
|
const RE_URL_TRAILING = /['"]?\)$/;
|
|
14
14
|
const SUBREQUEST_HEADERS = { "x-nuxt-og-image": "1" };
|
|
15
15
|
async function resolveLocalFilePathImage(publicStoragePath, src) {
|
|
16
|
+
const path = parseSameOriginPath(parseURL(src).pathname);
|
|
17
|
+
if (path._tag === "Err")
|
|
18
|
+
return;
|
|
16
19
|
const normalizedSrc = withoutLeadingSlash(
|
|
17
|
-
|
|
20
|
+
decodeURIComponent(path.value).replace("_nuxt/@fs/", "").replace("_nuxt/", "").replace("./", "")
|
|
18
21
|
).replaceAll("/", ":");
|
|
19
22
|
const key = `${publicStoragePath}:${normalizedSrc}`;
|
|
20
23
|
if (await useStorage().hasItem(key))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { $fetch } from "ofetch";
|
|
2
2
|
import { emojiCache } from "#og-image-cache";
|
|
3
3
|
import { getFetchTimeout } from "../../../../util/fetchTimeout.js";
|
|
4
|
+
import { toStorageKey } from "../../../../util/storageKey.js";
|
|
4
5
|
import { getEmojiCodePoint, getEmojiIconNames } from "./emoji-utils.js";
|
|
5
6
|
export async function getEmojiSvg(ctx, emojiChar) {
|
|
6
7
|
const emojiSet = ctx.options.emojis;
|
|
@@ -8,7 +9,7 @@ export async function getEmojiSvg(ctx, emojiChar) {
|
|
|
8
9
|
const possibleNames = getEmojiIconNames(codePoint, emojiSet);
|
|
9
10
|
let svg = null;
|
|
10
11
|
for (const iconName of possibleNames) {
|
|
11
|
-
const key =
|
|
12
|
+
const key = toStorageKey("emoji", [emojiSet, iconName]);
|
|
12
13
|
if (await emojiCache.hasItem(key)) {
|
|
13
14
|
svg = await emojiCache.getItem(key);
|
|
14
15
|
if (svg)
|
|
@@ -24,15 +25,14 @@ export async function getEmojiSvg(ctx, emojiChar) {
|
|
|
24
25
|
if (deadline.aborted)
|
|
25
26
|
break;
|
|
26
27
|
try {
|
|
27
|
-
svg = await $fetch(`https://api.iconify.design/${emojiSet}/${iconName}.svg`, {
|
|
28
|
+
svg = await $fetch(`https://api.iconify.design/${encodeURIComponent(emojiSet)}/${encodeURIComponent(iconName)}.svg`, {
|
|
28
29
|
responseType: "text",
|
|
29
30
|
retry: 0,
|
|
30
31
|
signal: deadline,
|
|
31
32
|
timeout
|
|
32
33
|
});
|
|
33
34
|
if (svg && svg !== "404") {
|
|
34
|
-
|
|
35
|
-
await emojiCache.setItem(key, svg);
|
|
35
|
+
await emojiCache.setItem(toStorageKey("emoji", [emojiSet, iconName]), svg);
|
|
36
36
|
break;
|
|
37
37
|
}
|
|
38
38
|
} catch {
|
|
@@ -7,4 +7,4 @@ export interface DevToolsExtractPayload {
|
|
|
7
7
|
images: DevToolsMetaDataExtraction[];
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
|
-
export declare function fetchPathHtmlAndExtractOptions(e: H3Event, path: string,
|
|
10
|
+
export declare function fetchPathHtmlAndExtractOptions(e: H3Event, path: string, key: string): Promise<H3Error | DevToolsExtractPayload>;
|
|
@@ -3,6 +3,7 @@ import { createError } from "#nuxtseo/h3";
|
|
|
3
3
|
import { htmlPayloadCache } from "#og-image-cache";
|
|
4
4
|
import { extractSocialPreviewTags } from "../../pure.js";
|
|
5
5
|
import { logger } from "../util/logger.js";
|
|
6
|
+
import { toStorageKey } from "../util/storageKey.js";
|
|
6
7
|
const PAYLOAD_REGEX = /<script.+id="nuxt-og-image-options"[^>]*>(.+?)<\/script>/;
|
|
7
8
|
function extractOptionsFromHtml(html) {
|
|
8
9
|
const match = String(html).match(PAYLOAD_REGEX);
|
|
@@ -36,7 +37,8 @@ async function doFetchWithErrorHandling(fetch, path) {
|
|
|
36
37
|
}
|
|
37
38
|
return [res._data || await res.text(), null];
|
|
38
39
|
}
|
|
39
|
-
export async function fetchPathHtmlAndExtractOptions(e, path,
|
|
40
|
+
export async function fetchPathHtmlAndExtractOptions(e, path, key) {
|
|
41
|
+
const cacheKey = toStorageKey("devtools", key);
|
|
40
42
|
const cachedHtmlPayload = await htmlPayloadCache.getItem(cacheKey);
|
|
41
43
|
if (!import.meta.dev && cachedHtmlPayload && cachedHtmlPayload.expiresAt > Date.now())
|
|
42
44
|
return cachedHtmlPayload.value;
|
|
@@ -3,10 +3,9 @@ import { parseURL, withoutBase } from "ufo";
|
|
|
3
3
|
import { appendResponseHeader } from "#nuxtseo/h3";
|
|
4
4
|
import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
5
5
|
import { prerenderOptionsCache } from "#og-image-cache";
|
|
6
|
-
import { createSitePathResolver } from "#site-config/server/composables/utils";
|
|
7
6
|
import { isInternalRoute } from "../../shared.js";
|
|
8
|
-
import { resolvePathCacheKey } from "../og-image/context.js";
|
|
9
7
|
import { createNitroRouteRuleMatcher } from "../util/kit.js";
|
|
8
|
+
import { toStorageKey } from "../util/storageKey.js";
|
|
10
9
|
const PAYLOAD_REGEX = /<script.+id="nuxt-og-image-options"[^>]*>(.+?)<\/script>/;
|
|
11
10
|
const RE_SCRIPT_OPTIONS = /<script id="nuxt-og-image-options" type="application\/json">[\s\S]*?<\/script>/;
|
|
12
11
|
const RE_SCRIPT_OVERRIDES = /<script id="nuxt-og-image-overrides" type="application\/json">[\s\S]*?<\/script>/;
|
|
@@ -31,16 +30,10 @@ export default defineNitroPlugin(async (nitro) => {
|
|
|
31
30
|
return;
|
|
32
31
|
const parsed = parse(_payload);
|
|
33
32
|
const payloads = parsed.map((opt) => [opt.key || "og", opt]);
|
|
34
|
-
const resolvePathWithBase = createSitePathResolver(ctx.event, {
|
|
35
|
-
absolute: false,
|
|
36
|
-
withBase: true
|
|
37
|
-
});
|
|
38
|
-
const key = resolvePathCacheKey(ctx.event, resolvePathWithBase(path));
|
|
39
|
-
await prerenderOptionsCache.setItem(key, payloads);
|
|
40
33
|
const pagePath = withoutBase(path, useRuntimeConfig().app.baseURL);
|
|
41
34
|
for (const [_ogKey, opt] of payloads) {
|
|
42
35
|
if (opt._hash) {
|
|
43
|
-
await prerenderOptionsCache.setItem(
|
|
36
|
+
await prerenderOptionsCache.setItem(toStorageKey("prerender", opt._hash), { ...opt, _path: pagePath });
|
|
44
37
|
}
|
|
45
38
|
}
|
|
46
39
|
const prerenderPaths = ctx.event.context._ogImagePrerenderPaths;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseURL,
|
|
1
|
+
import { parseURL, withQuery } from "ufo";
|
|
2
2
|
import { createError, defineEventHandler, getRequestHost, sendRedirect } from "#nuxtseo/h3";
|
|
3
3
|
import { fetchWithEvent } from "#nuxtseo/nitro";
|
|
4
4
|
import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
|
|
@@ -78,13 +78,15 @@ export default defineEventHandler(async (event) => {
|
|
|
78
78
|
statusMessage: `[Nuxt OG Image] Target path exceeds ${MAX_RESOLVE_PATH_LENGTH} characters.`
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
const parsedPath = parseSameOriginPath(targetPath);
|
|
82
|
+
if (targetPath.includes("://") || parsedPath._tag === "Err") {
|
|
82
83
|
throw createError({
|
|
83
84
|
statusCode: 400,
|
|
84
85
|
statusMessage: "[Nuxt OG Image] Target path must be a same-origin path."
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
+
const pagePath = parsedPath.value;
|
|
89
|
+
if (isInternalRoute(pagePath)) {
|
|
88
90
|
throw createError({
|
|
89
91
|
statusCode: 400,
|
|
90
92
|
statusMessage: "[Nuxt OG Image] Cannot resolve og:image for internal route."
|
|
@@ -98,7 +100,7 @@ export default defineEventHandler(async (event) => {
|
|
|
98
100
|
if (!k.startsWith("_og_"))
|
|
99
101
|
forwardQuery[k] = v;
|
|
100
102
|
}
|
|
101
|
-
const fetchPath = withQuery(
|
|
103
|
+
const fetchPath = withQuery(pagePath, forwardQuery);
|
|
102
104
|
const html = await fetchWithEvent(event, fetchPath, {
|
|
103
105
|
headers: { accept: "text/html" },
|
|
104
106
|
responseType: "text"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { H3Error } from '#nuxtseo/h3';
|
|
2
1
|
import type { OgImageRenderEventContext } from '../../types.js';
|
|
3
2
|
export declare function useOgImageBufferCache(ctx: OgImageRenderEventContext, options: {
|
|
4
3
|
baseCacheKey: string | false;
|
|
5
4
|
cacheMaxAgeSeconds?: number;
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** The caller has checked the purge token. Drop the stored entry and render again. */
|
|
6
|
+
purge?: boolean;
|
|
7
|
+
}): Promise<void | {
|
|
8
8
|
cachedItem: false | BufferSource;
|
|
9
9
|
enabled: boolean;
|
|
10
10
|
update: (image: BufferSource | Buffer | Uint8Array) => Promise<void>;
|
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import { fnv1a64Base36 } from "fnv1a-64";
|
|
2
2
|
import { withTrailingSlash } from "ufo";
|
|
3
3
|
import { prefixStorage } from "unstorage";
|
|
4
|
-
import {
|
|
4
|
+
import { handleCacheHeaders, setHeader, setHeaders } from "#nuxtseo/h3";
|
|
5
5
|
import { useStorage } from "#nuxtseo/nitro";
|
|
6
6
|
import { logger } from "../../logger.js";
|
|
7
|
-
import {
|
|
8
|
-
function safeCompare(a, b) {
|
|
9
|
-
if (a.length !== b.length)
|
|
10
|
-
return false;
|
|
11
|
-
let mismatch = 0;
|
|
12
|
-
for (let i = 0; i < a.length; i++)
|
|
13
|
-
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
14
|
-
return mismatch === 0;
|
|
15
|
-
}
|
|
7
|
+
import { toStorageKey } from "./storageKey.js";
|
|
16
8
|
function warnCacheBackendUnreachable(ctx, baseCacheKey, e) {
|
|
17
9
|
if (ctx._nitro._ogImageCacheBackendWarned)
|
|
18
10
|
return;
|
|
@@ -24,7 +16,7 @@ export async function useOgImageBufferCache(ctx, options) {
|
|
|
24
16
|
const intentionallyEnabled = !import.meta.dev && !import.meta.prerender && maxAge > 0;
|
|
25
17
|
let enabled = intentionallyEnabled;
|
|
26
18
|
const cache = prefixStorage(useStorage(), withTrailingSlash(options.baseCacheKey || "/"));
|
|
27
|
-
const key = ctx.key;
|
|
19
|
+
const key = toStorageKey("image", ctx.key);
|
|
28
20
|
let cachedItem = false;
|
|
29
21
|
if (enabled) {
|
|
30
22
|
const hasItem = await cache.hasItem(key).catch((e) => {
|
|
@@ -39,14 +31,7 @@ export async function useOgImageBufferCache(ctx, options) {
|
|
|
39
31
|
}) : null;
|
|
40
32
|
if (entry) {
|
|
41
33
|
const { value, expiresAt, headers } = entry;
|
|
42
|
-
|
|
43
|
-
if (typeof purgeValue !== "undefined") {
|
|
44
|
-
if (options.secret && !safeCompare(String(purgeValue), options.secret)) {
|
|
45
|
-
return createError({
|
|
46
|
-
statusCode: 403,
|
|
47
|
-
statusMessage: "[Nuxt OG Image] Invalid purge token. Provide the signing secret as ?purge=<secret>."
|
|
48
|
-
});
|
|
49
|
-
}
|
|
34
|
+
if (options.purge) {
|
|
50
35
|
await cache.removeItem(key).catch((err) => {
|
|
51
36
|
void err;
|
|
52
37
|
});
|
|
@@ -7,6 +7,8 @@ import { fetchPathHtmlAndExtractOptions } from "../og-image/devtools.js";
|
|
|
7
7
|
import { html } from "../og-image/templates/html.js";
|
|
8
8
|
import { useOgImageRuntimeConfig } from "../utils.js";
|
|
9
9
|
import { useOgImageBufferCache } from "./cache.js";
|
|
10
|
+
import { parsePurgeRequest } from "./purge.js";
|
|
11
|
+
import { getEventQuery } from "./query.js";
|
|
10
12
|
export async function imageEventHandler(e) {
|
|
11
13
|
const reqStart = performance.now();
|
|
12
14
|
const ctx = await resolveContext(e).catch((err) => {
|
|
@@ -58,6 +60,13 @@ async function renderOgImage(e, ctx) {
|
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
}
|
|
63
|
+
const purgeRequest = import.meta.dev || import.meta.prerender ? { _tag: "None" } : parsePurgeRequest(getEventQuery(e).purge, security?.secret);
|
|
64
|
+
if (purgeRequest._tag === "Denied") {
|
|
65
|
+
return createError({
|
|
66
|
+
statusCode: 403,
|
|
67
|
+
statusMessage: "[Nuxt OG Image] Invalid purge token. Provide the signing secret as ?purge=<secret>."
|
|
68
|
+
});
|
|
69
|
+
}
|
|
61
70
|
if ((import.meta.dev || debug) && isDevToolsContextRequest) {
|
|
62
71
|
setHeader(e, "Content-Type", "application/json");
|
|
63
72
|
const [extract, rendererDebug] = await Promise.all([
|
|
@@ -120,14 +129,11 @@ async function renderOgImage(e, ctx) {
|
|
|
120
129
|
const cacheApi = await useOgImageBufferCache(ctx, {
|
|
121
130
|
cacheMaxAgeSeconds: ctx.options.cacheMaxAgeSeconds,
|
|
122
131
|
baseCacheKey,
|
|
123
|
-
|
|
132
|
+
purge: purgeRequest._tag === "Authorized"
|
|
124
133
|
}).finally(endCacheLookup);
|
|
125
134
|
if (typeof cacheApi === "undefined") {
|
|
126
135
|
return;
|
|
127
136
|
}
|
|
128
|
-
if (cacheApi instanceof H3Error) {
|
|
129
|
-
return cacheApi;
|
|
130
|
-
}
|
|
131
137
|
let image = cacheApi.cachedItem;
|
|
132
138
|
if (image) {
|
|
133
139
|
timings.record("cache-hit", 0);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** What a request asks of the runtime cache through its `purge` query value. */
|
|
2
|
+
export type PurgeRequest = {
|
|
3
|
+
_tag: 'None';
|
|
4
|
+
} | {
|
|
5
|
+
_tag: 'Authorized';
|
|
6
|
+
} | {
|
|
7
|
+
_tag: 'Denied';
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Parse the `purge` query value. A purge needs the signing secret as its
|
|
11
|
+
* value, the same secret that signs image URLs. Without a secret, no purge
|
|
12
|
+
* can pass.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parsePurgeRequest(value: unknown, secret: string | false | undefined): PurgeRequest;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function safeCompare(a, b) {
|
|
2
|
+
if (a.length !== b.length)
|
|
3
|
+
return false;
|
|
4
|
+
let mismatch = 0;
|
|
5
|
+
for (let i = 0; i < a.length; i++)
|
|
6
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
7
|
+
return mismatch === 0;
|
|
8
|
+
}
|
|
9
|
+
export function parsePurgeRequest(value, secret) {
|
|
10
|
+
if (value === void 0)
|
|
11
|
+
return { _tag: "None" };
|
|
12
|
+
if (typeof secret !== "string" || !secret || typeof value !== "string")
|
|
13
|
+
return { _tag: "Denied" };
|
|
14
|
+
return safeCompare(value, secret) ? { _tag: "Authorized" } : { _tag: "Denied" };
|
|
15
|
+
}
|
|
@@ -21,17 +21,24 @@ declare const sameOriginPathBrand: unique symbol;
|
|
|
21
21
|
export type SameOriginPath = string & {
|
|
22
22
|
readonly [sameOriginPathBrand]: true;
|
|
23
23
|
};
|
|
24
|
+
export type SameOriginPathResult = {
|
|
25
|
+
_tag: 'Ok';
|
|
26
|
+
value: SameOriginPath;
|
|
27
|
+
} | {
|
|
28
|
+
_tag: 'Err';
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
24
31
|
/**
|
|
25
32
|
* Parse an untrusted path (for example the `_path` URL option) into a
|
|
26
|
-
* same-origin path.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
33
|
+
* normalized same-origin path. The input is decoded once and must then be a
|
|
34
|
+
* plain absolute path: one leading `/`, no `.` or `..` segments, no
|
|
35
|
+
* encoded separators, no `?` or `#`, and no control characters. The value
|
|
36
|
+
* is the percent-encoded pathname, so it resolves the same way everywhere.
|
|
30
37
|
*
|
|
31
38
|
* GHSA-h5qf-97cw-2h86: an absolute `_path` navigated the browser renderer to
|
|
32
39
|
* an internal host and reflected the response into the image.
|
|
33
40
|
*/
|
|
34
|
-
export declare function parseSameOriginPath(input: unknown):
|
|
41
|
+
export declare function parseSameOriginPath(input: unknown): SameOriginPathResult;
|
|
35
42
|
/**
|
|
36
43
|
* Resolve `path` against `origin` and return the href, but only when the result
|
|
37
44
|
* stays on that origin. Returns null for a missing or unparseable origin, and for
|
|
@@ -153,28 +153,45 @@ export function isBlockedAddress(address) {
|
|
|
153
153
|
return isBlockedUrl(`http://${address.includes(":") ? `[${address}]` : address}/`);
|
|
154
154
|
}
|
|
155
155
|
const PROBE_ORIGIN = "http://og-image.invalid";
|
|
156
|
-
|
|
156
|
+
const RE_PERCENT_ESCAPE = /%[\da-f]{2}/i;
|
|
157
|
+
const RE_ENCODED_SEPARATOR = /%(?:2f|5c)/i;
|
|
158
|
+
function hasReservedPathChar(input) {
|
|
157
159
|
for (let i = 0; i < input.length; i++) {
|
|
158
160
|
const code = input.charCodeAt(i);
|
|
159
|
-
if (code <= 31 || code === 127 || code === 92)
|
|
161
|
+
if (code <= 31 || code === 127 || code === 92 || code === 63 || code === 35)
|
|
160
162
|
return true;
|
|
161
163
|
}
|
|
162
164
|
return false;
|
|
163
165
|
}
|
|
166
|
+
function err(reason) {
|
|
167
|
+
return { _tag: "Err", reason };
|
|
168
|
+
}
|
|
164
169
|
export function parseSameOriginPath(input) {
|
|
165
170
|
if (typeof input !== "string" || input[0] !== "/" || input[1] === "/")
|
|
166
|
-
return
|
|
167
|
-
if (
|
|
168
|
-
return
|
|
171
|
+
return err("The path must start with a single /.");
|
|
172
|
+
if (RE_ENCODED_SEPARATOR.test(input))
|
|
173
|
+
return err("The path must not encode a / or \\.");
|
|
174
|
+
let decoded;
|
|
175
|
+
try {
|
|
176
|
+
decoded = decodeURIComponent(input);
|
|
177
|
+
} catch {
|
|
178
|
+
return err("The path has an invalid percent-encoding.");
|
|
179
|
+
}
|
|
180
|
+
if (RE_PERCENT_ESCAPE.test(decoded))
|
|
181
|
+
return err("The path must be percent-encoded once.");
|
|
182
|
+
if (hasReservedPathChar(decoded))
|
|
183
|
+
return err("The path must not contain ?, #, \\ or control characters.");
|
|
184
|
+
if (decoded.split("/").some((segment) => segment === "." || segment === ".."))
|
|
185
|
+
return err("The path must not contain . or .. segments.");
|
|
169
186
|
let resolved;
|
|
170
187
|
try {
|
|
171
188
|
resolved = new URL(input, PROBE_ORIGIN);
|
|
172
189
|
} catch {
|
|
173
|
-
return
|
|
190
|
+
return err("The path is not a valid URL path.");
|
|
174
191
|
}
|
|
175
|
-
if (resolved.origin !== PROBE_ORIGIN)
|
|
176
|
-
return
|
|
177
|
-
return
|
|
192
|
+
if (resolved.origin !== PROBE_ORIGIN || resolved.search || resolved.hash)
|
|
193
|
+
return err("The path must stay on the same origin.");
|
|
194
|
+
return { _tag: "Ok", value: resolved.pathname };
|
|
178
195
|
}
|
|
179
196
|
export function resolveSameOriginUrl(path, origin) {
|
|
180
197
|
if (!origin)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare const storageKeyBrand: unique symbol;
|
|
2
|
+
/**
|
|
3
|
+
* A storage key made of a fixed namespace and a SHA-256 hex digest. Only
|
|
4
|
+
* `toStorageKey` creates one, so every key has the same short, fixed format
|
|
5
|
+
* whatever the identity it stands for.
|
|
6
|
+
*/
|
|
7
|
+
export type StorageKey = string & {
|
|
8
|
+
readonly [storageKeyBrand]: true;
|
|
9
|
+
};
|
|
10
|
+
/** Each namespace keeps one kind of entry apart from the others. */
|
|
11
|
+
export type StorageKeyNamespace = 'image' | 'devtools' | 'emoji' | 'prerender';
|
|
12
|
+
/** Derive the storage key for an identity. The same identity always gives the same key. */
|
|
13
|
+
export declare function toStorageKey(namespace: StorageKeyNamespace, identity: unknown): StorageKey;
|
|
14
|
+
export {};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 hex digest (64 chars of [0-9a-f]) of a canonical, key-sorted
|
|
3
|
+
* serialization. Equal structures produce equal digests in every runtime.
|
|
4
|
+
*/
|
|
5
|
+
export declare function digestCanonical(value: unknown): string;
|
|
1
6
|
/**
|
|
2
7
|
* Generate a deterministic hash from options object
|
|
3
8
|
* Excludes _path so images with same options can be cached across pages,
|
|
@@ -110,11 +110,14 @@ function base64UrlToHex(input) {
|
|
|
110
110
|
}
|
|
111
111
|
return hex;
|
|
112
112
|
}
|
|
113
|
+
export function digestCanonical(value) {
|
|
114
|
+
return base64UrlToHex(digest(canonicalJson(value)));
|
|
115
|
+
}
|
|
113
116
|
export function hashOgImageOptions(options, componentHash, version) {
|
|
114
117
|
const { _path, _hash, ...rest } = options;
|
|
115
118
|
const hashableOptions = rest.component === "PageScreenshot" ? { ...rest, _path } : rest;
|
|
116
119
|
const hashInput = componentHash || version ? [hashableOptions, componentHash || "", version || ""] : hashableOptions;
|
|
117
|
-
return
|
|
120
|
+
return digestCanonical(hashInput).slice(0, 16);
|
|
118
121
|
}
|
|
119
122
|
export function encodeOgImageParams(options, defaults) {
|
|
120
123
|
const parts = [];
|