diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/docs/edit.html b/docs/edit.html index acd1cb6..3ef4dbb 100644 --- a/docs/edit.html +++ b/docs/edit.html @@ -446,7 +446,16 @@ function decodeURL(s) { s = decodeURIComponent(s) if (s.startsWith("http")) s; - return atob(s.replace(/=/g,'')); + return atou(s.replace(/=/g,'')); +} + +// UTF-8-safe base64. Plain btoa throws on any character above U+00FF, so a +// pasted SVG containing an emoji or a checkmark would fail outright, and one +// containing an accent would silently encode as Latin-1 and render as mojibake. +function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } +function atou(b64) { + const bytes = atob(b64); + try { return decodeURIComponent(escape(bytes)); } catch (e) { return bytes; } } @@ -455,7 +464,7 @@ let path = ["/" + encodePrettyComponent(data.title)]; if (data.description) path.push("d/" + encodePrettyComponent(data.description.substring(0,200).split(". ").shift())); if (data.favicon) path.push("f/" + encodeURIComponent(data.favicon)); - if (data.image) path.push("i/" + encodeURIComponent(btoa(data.image).replace(/=/g, ""))); + if (data.image) path.push("i/" + encodeURIComponent(utoa(data.image).replace(/=/g, ""))); return "/m" + path.join('/') + "/"; } diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index 7d9d699..5fa186b 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -8,7 +8,15 @@ function decodeURL(s) { if (s.startsWith(".")) return s; if (s.startsWith("/")) return s; try { - return atob(s.replace(/=/g,'')) + const bytes = atob(s.replace(/=/g,'')) + // atob yields Latin-1. Recover UTF-8 so pasted SVG containing accents or + // emoji survives; fall back to the raw bytes for genuinely Latin-1 payloads + // written by older versions of the editor. + try { + return decodeURIComponent(escape(bytes)) + } catch (e) { + return bytes + } } catch (e) { return s; } @@ -17,6 +25,52 @@ function decodeURL(s) { function atou(b64) { return decodeURIComponent(escape(atob(b64))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } +// Shared SVG->PNG renderer: https://github.com/arfct/og-svg +export const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev"; + +// Builds a render URL from an SVG payload. +// +// The payload is passed through byte-for-byte rather than re-encoded, because it +// may be base64, percent-encoded, or raw markup depending on who wrote the URL. +// og-svg tries base64 first and falls back to percent-decoding, and wraps a bare +// fragment in an root, so all of those work. +function renderUrl(payload) { + return `${RENDER_ORIGIN}/png?s=${encodeURIComponent(payload)}`; +} + +/** + * Resolves the `i` field to a final og:image URL. + * + * The editor base64-encodes whatever is in the image field with no marker + * (docs/edit.html), so a user who pastes SVG code — which the prompt invites — + * arrives here as raw markup. That used to fall through to the bare-hostname + * branch and produce `og:image="https:// { } if (info.i) { - info.i = decodeURL(info.i) - if (info.i.startsWith("svg:")) { - info.i = "/.netlify/functions/rasterize/" + info.i; - } else if (info.u && (info.i.startsWith(".") || info.i.startsWith("/"))) { - info.i = new URL(info.i, info.u).href - } else { - info.i = "https://" + info.i; - } + info.i = resolveImageUrl(info.i, info.u) - content.push(mProp("og:image", info.i)); + content.push(mProp("og:image", info.i)); if (info.iw) content.push(mProp("og:image:width", info.iw)); if (info.ih) content.push(mProp("og:image:width", info.ih)); content.push(mName("twitter:card", "summary_large_image")); diff --git a/package.json b/package.json index 984576c..c184045 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "doc": "docs" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run" }, "repository": { "type": "git", @@ -24,6 +24,7 @@ "tweetnacl": "^1.0.3" }, "devDependencies": { - "netlify-cli": "^27.0.1" + "netlify-cli": "^27.0.1", + "vitest": "^4.1.0" } } diff --git a/test/image-url.test.js b/test/image-url.test.js new file mode 100644 index 0000000..c5fa315 --- /dev/null +++ b/test/image-url.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { resolveImageUrl, RENDER_ORIGIN } from "../netlify/edge-functions/metadata.js"; + +const SVG = + ''; + +// What docs/edit.html writes into the path for an image value. +const asEditorEncodes = (value) => utf8Base64(value).replace(/=/g, ""); + +// UTF-8-safe base64, matching the repo's existing utoa() helper. +const utf8Base64 = (value) => btoa(unescape(encodeURIComponent(value))); + +describe("resolveImageUrl", () => { + it("sends SVG markup pasted into the editor to the renderer", () => { + // The editor base64s the value with no svg: marker, so after decoding we + // hold raw markup. This previously produced "https:// { + const out = resolveImageUrl(asEditorEncodes(SVG)); + expect(out).not.toContain("https:// { + const out = resolveImageUrl(`svg:${asEditorEncodes(SVG)}`); + expect(out.startsWith(`${RENDER_ORIGIN}/png?s=`)).toBe(true); + }); + + it("sends a bare fragment to the renderer", () => { + // og-svg wraps a fragment in an root with a default viewport. + const out = resolveImageUrl(asEditorEncodes('')); + expect(out.startsWith(`${RENDER_ORIGIN}/png?s=`)).toBe(true); + }); + + it("round-trips the markup through the renderer payload", () => { + const out = resolveImageUrl(asEditorEncodes(SVG)); + const payload = decodeURIComponent(new URL(out).searchParams.get("s")); + // The payload must decode back to the original markup, base64 or otherwise. + const decoded = /^ { + // btoa is Latin-1 only, so 'é' arrives as a raw 0xE9 byte that is not valid + // UTF-8. Decoding must recover the character, or resvg rejects the document. + const svg = + '' + + 'café'; + const out = resolveImageUrl(utf8Base64(svg)); + const payload = decodeURIComponent(new URL(out).searchParams.get("s")); + const decoded = /^ { + // Links built before the editor switched to UTF-8-safe base64. + const svg = 'café'; + const latin1 = btoa(svg).replace(/=/g, ""); + expect(() => resolveImageUrl(latin1)).not.toThrow(); + expect(resolveImageUrl(latin1)).toContain(`${RENDER_ORIGIN}/png?s=`); + }); + + it("leaves an absolute https url alone", () => { + const url = "https://cdn.example.com/a.jpg"; + expect(resolveImageUrl(url)).toBe(url); + }); + + it("leaves an absolute http url alone", () => { + const url = "http://cdn.example.com/a.jpg"; + expect(resolveImageUrl(url)).toBe(url); + }); + + it("resolves a relative path against the target url", () => { + expect(resolveImageUrl("/img/a.png", "https://example.com/page")).toBe( + "https://example.com/img/a.png", + ); + }); + + it("resolves a dot-relative path against the target url", () => { + expect(resolveImageUrl("./a.png", "https://example.com/dir/page")).toBe( + "https://example.com/dir/a.png", + ); + }); + + it("prefixes a bare hostname with https", () => { + expect(resolveImageUrl("cdn.example.com/a.jpg")).toBe("https://cdn.example.com/a.jpg"); + }); + + it("returns an empty string for no input", () => { + expect(resolveImageUrl(undefined)).toBe(""); + }); + + it("does not treat a base64 payload that decodes to a url as svg", () => { + const out = resolveImageUrl(asEditorEncodes("https://cdn.example.com/b.jpg")); + expect(out).toBe("https://cdn.example.com/b.jpg"); + }); +});