fix(email-renderer): validate basic media and spacer dimensions
This commit is contained in:
1 parent
5e30d6a7d0
commit
c1f4f42b21
3 files changed
+46
-10
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.21",
|
"version": "0.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ export function ampLayoutCss(node: BlockNode): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "<br />");
|
const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "<br />");
|
||||||
const imageDimension = (fallback: string): ValueSchema<string> => ({
|
const imageDimension = (fallback: string, minimum = 1): ValueSchema<string> => ({
|
||||||
kind: "string",
|
kind: "string",
|
||||||
defaultValue: fallback,
|
defaultValue: fallback,
|
||||||
parse(value = fallback, path = "value") {
|
parse(value = fallback, path = "value") {
|
||||||
return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) > 0 && Number(value) <= 4000
|
return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) >= minimum && Number(value) <= 4000
|
||||||
? { ok: true, value }
|
? { ok: true, value }
|
||||||
: { ok: false, errors: [{code:"email.image-dimension",message:"Image dimensions must be between 1 and 4000 pixels.",severity:"error",path}] };
|
: { ok: false, errors: [{code:"email.image-dimension",message:`Dimensions must be between ${minimum} and 4000 pixels.`,severity:"error",path}] };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const marketingImage = (node: BlockNode, target: "html" | "amp" | "mjml") => {
|
const marketingImage = (node: BlockNode, target: "html" | "amp" | "mjml") => {
|
||||||
@@ -328,8 +328,8 @@ export const imageBlock = leaf(
|
|||||||
object({
|
object({
|
||||||
src: safeUrl({ schemes: ["https"] }),
|
src: safeUrl({ schemes: ["https"] }),
|
||||||
alt: string({ max: 1_000, default: "" }),
|
alt: string({ max: 1_000, default: "" }),
|
||||||
width: string({ default: "600" }),
|
width: imageDimension("600"),
|
||||||
height: string({ default: "300" }),
|
height: imageDimension("300"),
|
||||||
decorative: boolean(false),
|
decorative: boolean(false),
|
||||||
}) as never,
|
}) as never,
|
||||||
{
|
{
|
||||||
@@ -391,7 +391,7 @@ export const spacerBlock = leaf(
|
|||||||
"content.spacer",
|
"content.spacer",
|
||||||
"Spacer",
|
"Spacer",
|
||||||
"icon-[lucide--move-vertical]",
|
"icon-[lucide--move-vertical]",
|
||||||
object({ height: string({ default: "24" }) }) as never,
|
object({ height: imageDimension("24", 0) }) as never,
|
||||||
{
|
{
|
||||||
emailHtml: ({ node }) =>
|
emailHtml: ({ node }) =>
|
||||||
emailCell(
|
emailCell(
|
||||||
@@ -413,8 +413,8 @@ export const videoBlock = leaf(
|
|||||||
href: safeUrl({ schemes: ["https"] }),
|
href: safeUrl({ schemes: ["https"] }),
|
||||||
thumbnail: safeUrl({ schemes: ["https"] }),
|
thumbnail: safeUrl({ schemes: ["https"] }),
|
||||||
alt: string({ max: 1_000, default: "Video thumbnail" }),
|
alt: string({ max: 1_000, default: "Video thumbnail" }),
|
||||||
width: string({ default: "600" }),
|
width: imageDimension("600"),
|
||||||
height: string({ default: "338" }),
|
height: imageDimension("338"),
|
||||||
}) as never,
|
}) as never,
|
||||||
{
|
{
|
||||||
emailHtml: ({ node }) => emailCell(`<a href="${html(attrs(node).href)}" aria-label="${html(attrs(node).title)}"><img src="${html(attrs(node).thumbnail)}" alt="${html(attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" style="border:0;display:block;height:auto;width:100%;max-width:${html(attrs(node).width)}px;" /></a>`, node),
|
emailHtml: ({ node }) => emailCell(`<a href="${html(attrs(node).href)}" aria-label="${html(attrs(node).title)}"><img src="${html(attrs(node).thumbnail)}" alt="${html(attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" style="border:0;display:block;height:auto;width:100%;max-width:${html(attrs(node).width)}px;" /></a>`, node),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test("product and RSS images survive HTML, AMP and strict MJML exports", async (
|
|||||||
const result = await renderEmail(document(type, props), { amp: true, mjml: true });
|
const result = await renderEmail(document(type, props), { amp: true, mjml: true });
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) continue;
|
if (!result.ok) continue;
|
||||||
expect(result.value.parts.html.content).toContain('width:100%;max-width:320px;');
|
expect(result.value.parts.html.content).toContain("width:100%;max-width:320px;");
|
||||||
for (const output of [
|
for (const output of [
|
||||||
result.value.parts.html.content,
|
result.value.parts.html.content,
|
||||||
result.value.parts.amp?.content,
|
result.value.parts.amp?.content,
|
||||||
@@ -113,3 +113,39 @@ test("marketing image dimensions reject invalid or injected values", async () =>
|
|||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("basic images, video thumbnails and spacers validate dimensions before rendering", async () => {
|
||||||
|
const cases: Array<{ type: string; props: Record<string, string> }> = [
|
||||||
|
{ type: "content.image", props: { src: "https://example.com/image.png", alt: "Image" } },
|
||||||
|
{
|
||||||
|
type: "content.video",
|
||||||
|
props: {
|
||||||
|
href: "https://example.com/video",
|
||||||
|
thumbnail: "https://example.com/image.png",
|
||||||
|
title: "Video",
|
||||||
|
alt: "Video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "content.spacer", props: {} },
|
||||||
|
];
|
||||||
|
for (const block of cases) {
|
||||||
|
for (const height of ["-1", "4001", "100%", "24;position:fixed", '24" onload="bad', "1e3"]) {
|
||||||
|
const result = await renderEmail(
|
||||||
|
document(block.type, { ...block.props, height } as Record<string, string>),
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
}
|
||||||
|
const valid = await renderEmail(
|
||||||
|
document(block.type, { ...block.props, height: "4000" } as Record<string, string>),
|
||||||
|
);
|
||||||
|
expect(valid.ok).toBe(true);
|
||||||
|
}
|
||||||
|
expect((await renderEmail(document("content.spacer", { height: "0" }))).ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await renderEmail(
|
||||||
|
document("content.image", { src: "https://example.com/image.png", height: "0" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user