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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=3.16.0"
5
5
  },
6
6
  "configKey": "ogImage",
7
- "version": "6.10.3",
7
+ "version": "6.10.4",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
@@ -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, withoutLeadingSlash, withoutTrailingSlash, withQuery } from "ufo";
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 { hashKey } from "../../shared/hash.js";
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(`hash:${optionsHash}`);
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 basePath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
125
- if (!basePath) {
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: "[Nuxt OG Image] The _path option must be a same-origin path."
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 baseCacheKey = normalised.options.cacheKey || (hashMatch ? `hash:${hashMatch[1]}` : resolvePathCacheKey(e, basePathWithQuery, normalised.options));
178
- const key = componentHash ? `${baseCacheKey}:${componentHash}` : baseCacheKey;
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
- src.replace("_nuxt/@fs/", "").replace("_nuxt/", "").replace("./", "")
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 = ["1", emojiSet, iconName].join("|");
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
- const key = ["1", emojiSet, iconName].join("|");
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, cacheKey: string): Promise<H3Error | DevToolsExtractPayload>;
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, cacheKey) {
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(`hash:${opt._hash}`, { ...opt, _path: pagePath });
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, withLeadingSlash, withQuery } from "ufo";
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
- if (targetPath.includes("://") || !parseSameOriginPath(targetPath)) {
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
- if (isInternalRoute(targetPath)) {
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(withLeadingSlash(targetPath), forwardQuery);
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
- secret?: string;
7
- }): Promise<void | H3Error | {
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 { createError, handleCacheHeaders, setHeader, setHeaders } from "#nuxtseo/h3";
4
+ import { handleCacheHeaders, setHeader, setHeaders } from "#nuxtseo/h3";
5
5
  import { useStorage } from "#nuxtseo/nitro";
6
6
  import { logger } from "../../logger.js";
7
- import { getEventQuery } from "./query.js";
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
- const purgeValue = getEventQuery(ctx.e).purge;
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
- secret: security?.secret
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. 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`).
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): SameOriginPath | undefined;
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
- function hasUnsafePathChar(input) {
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 void 0;
167
- if (hasUnsafePathChar(input))
168
- return void 0;
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 void 0;
190
+ return err("The path is not a valid URL path.");
174
191
  }
175
- if (resolved.origin !== PROBE_ORIGIN)
176
- return void 0;
177
- return input;
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 {};
@@ -0,0 +1,4 @@
1
+ import { digestCanonical } from "../../shared/urlEncoding.js";
2
+ export function toStorageKey(namespace, identity) {
3
+ return `${namespace}:${digestCanonical(identity)}`;
4
+ }
@@ -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 base64UrlToHex(digest(canonicalJson(hashInput))).slice(0, 16);
120
+ return digestCanonical(hashInput).slice(0, 16);
118
121
  }
119
122
  export function encodeOgImageParams(options, defaults) {
120
123
  const parts = [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-og-image",
3
3
  "type": "module",
4
- "version": "6.10.3",
4
+ "version": "6.10.4",
5
5
  "description": "Enlightened OG Image generation for Nuxt.",
6
6
  "author": {
7
7
  "website": "https://harlanzw.com",