Merge pull request #1295 from PicPeak/fix/post-merge-followups
fix(gallery): image-loading follow-ups — pre-load band, decode release, sanitizer dedup
This commit is contained in:
@@ -12,7 +12,9 @@ jest.mock('../../src/database/db', () => ({ db: jest.fn(), logActivity: jest.fn(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
sanitizeCampaignBody, sanitizeCampaignCss, MAX_BODY_BYTES,
|
sanitizeCampaignBody, sanitizeCampaignCss, MAX_BODY_BYTES,
|
||||||
|
sanitizeInlineStylesAfterSubstitution,
|
||||||
} = require('../../src/services/newsletterService');
|
} = require('../../src/services/newsletterService');
|
||||||
|
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||||
|
|
||||||
describe('sanitizeCampaignBody', () => {
|
describe('sanitizeCampaignBody', () => {
|
||||||
it('returns empty string for empty input', () => {
|
it('returns empty string for empty input', () => {
|
||||||
@@ -92,6 +94,35 @@ describe('sanitizeCampaignBody', () => {
|
|||||||
expect(out).not.toContain('http://evil.example');
|
expect(out).not.toContain('http://evil.example');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks a tracking url() hidden behind " entities', () => {
|
||||||
|
// sanitize-html writes `"` inside an attribute as `"`, so the CSS
|
||||||
|
// scanner and the recipient's browser disagreed about where strings
|
||||||
|
// start: the browser decodes first and reads the apostrophe as ordinary
|
||||||
|
// text inside a real string, then fetches the background — while the
|
||||||
|
// scanner saw the apostrophe open a string and skipped past the url().
|
||||||
|
const out = sanitizeCampaignBody(
|
||||||
|
`<p style="font-family:"don't";background:url(https://evil.example/p.gif)">hi</p>`
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a tracking url() hidden behind an escaped quote', () => {
|
||||||
|
const out = sanitizeCampaignBody(
|
||||||
|
`<p style="--m:\\';background:url(https://evil.example/p.gif)">hi</p>`
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a legitimate quoted font stack, re-encoded for the attribute', () => {
|
||||||
|
const out = sanitizeCampaignBody(
|
||||||
|
`<p style="color:red;font-family:"Helvetica Neue",sans-serif">hi</p>`
|
||||||
|
);
|
||||||
|
expect(out).toContain('color:red');
|
||||||
|
expect(out).toContain('Helvetica Neue');
|
||||||
|
// Re-encoded, so the attribute stays well formed rather than being cut short.
|
||||||
|
expect(out).not.toMatch(/style="[^"]*"[^>]*"/);
|
||||||
|
});
|
||||||
|
|
||||||
it('strips expression() out of an inline style', () => {
|
it('strips expression() out of an inline style', () => {
|
||||||
const out = sanitizeCampaignBody('<p style="width:expression(alert(1))">hi</p>');
|
const out = sanitizeCampaignBody('<p style="width:expression(alert(1))">hi</p>');
|
||||||
expect(out).not.toContain('expression(');
|
expect(out).not.toContain('expression(');
|
||||||
@@ -159,3 +190,46 @@ describe('sanitizeCampaignCss', () => {
|
|||||||
expect(css).not.toContain('</style>');
|
expect(css).not.toContain('</style>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('inline CSS is re-checked after template substitution', () => {
|
||||||
|
// The stored body is sanitized, but safeTemplateReplace rewrites it
|
||||||
|
// afterwards — so the string that was validated is not the string that is
|
||||||
|
// sent. A conditional inside a style attribute can delete the quoting that
|
||||||
|
// made a url() inert, which no amount of lexer correctness can catch.
|
||||||
|
const payload =
|
||||||
|
`<p style="--x:x{{#if company_name}}'{{/if}};`
|
||||||
|
+ `background:url(https://evil.example/p.gif);`
|
||||||
|
+ `--y:x{{#if company_name}}'{{/if}}">hi</p>`;
|
||||||
|
|
||||||
|
it('neutralises a url() that substitution would activate', () => {
|
||||||
|
const stored = sanitizeCampaignBody(payload);
|
||||||
|
// Correctly left alone at write time: the url() really is inside a CSS
|
||||||
|
// string while the conditionals are still in place.
|
||||||
|
expect(stored).toContain('evil.example');
|
||||||
|
|
||||||
|
const substituted = safeTemplateReplace(stored, { company_name: '' }, { escapeHtml: true });
|
||||||
|
// Expansion removed the quotes, so without the recheck this ships live.
|
||||||
|
expect(substituted).toMatch(/background:url\(https:\/\/evil\.example/);
|
||||||
|
|
||||||
|
const rendered = sanitizeInlineStylesAfterSubstitution(substituted);
|
||||||
|
expect(rendered).not.toMatch(/url\(\s*['"]?https:\/\/evil\.example/);
|
||||||
|
expect(rendered).toContain('background:none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a body without style attributes untouched', () => {
|
||||||
|
const html = '<p>Hello {{first_name}}</p>';
|
||||||
|
expect(sanitizeInlineStylesAfterSubstitution(html)).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps legitimate inline styles through the recheck', () => {
|
||||||
|
const html = '<p style="color:red;font-size:14px">hi</p>';
|
||||||
|
const out = sanitizeInlineStylesAfterSubstitution(html);
|
||||||
|
expect(out).toContain('color:red');
|
||||||
|
expect(out).toContain('font-size:14px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is safe on empty and nullish input', () => {
|
||||||
|
expect(sanitizeInlineStylesAfterSubstitution('')).toBe('');
|
||||||
|
expect(sanitizeInlineStylesAfterSubstitution(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -221,6 +221,92 @@ describe('sanitizeCSS', () => {
|
|||||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a C0 control character', '\u0001'],
|
||||||
|
['a NUL byte', '\u0000'],
|
||||||
|
['a DEL byte', '\u007F'],
|
||||||
|
// Newlines are control characters for this strip, so the bypass did not
|
||||||
|
// need an exotic byte — ordinary-looking wrapped CSS was enough.
|
||||||
|
['a newline', '\n'],
|
||||||
|
])('blocks a url() that only becomes one after %s is removed', (_label, ch) => {
|
||||||
|
// Same token-joining hazard as the HTML-comment case above: the control
|
||||||
|
// strip used to run AFTER the URL scan, so `u\u0001rl(...)` was scanned
|
||||||
|
// as clean and then joined into a live remote request, with no warning.
|
||||||
|
const { sanitized, warnings } = sanitizeCSS(
|
||||||
|
`.a{background:u${ch}rl(https://evil.example/p.gif)}`
|
||||||
|
);
|
||||||
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
|
expect(warnings.join(' ')).toContain('external URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a url() hidden behind an escaped quote outside a string', () => {
|
||||||
|
// `\'` is an escaped identifier character, not a string opener. The
|
||||||
|
// scanner used to step onto the apostrophe, enter string mode, and copy
|
||||||
|
// the rest of the stylesheet — url() included — unexamined.
|
||||||
|
const { sanitized } = sanitizeCSS(
|
||||||
|
".hero{--marker:\\';background:url(https://evil.example/p.gif)}"
|
||||||
|
);
|
||||||
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading escape', '.a{background:\\75rl(https://evil.example/p.gif)}'],
|
||||||
|
['an escape mid-identifier', '.a{background:u\\72l(https://evil.example/p.gif)}'],
|
||||||
|
])('blocks a url() spelled with %s', (_label, css) => {
|
||||||
|
// `\75` is the CSS escape for `u`, so a browser reads `\75rl(` as
|
||||||
|
// url(). The escape-outside-a-string handling has to run AFTER the
|
||||||
|
// identifier check, or it eats the escape and hides the token.
|
||||||
|
const { sanitized } = sanitizeCSS(css);
|
||||||
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a url() the TAG strip would have un-quoted', () => {
|
||||||
|
// `<[^>]*>` deletes the span it matches, and `<">` takes a quote with it.
|
||||||
|
// Running that after the URL scan meant the scanner saw the url() safely
|
||||||
|
// inside a string and this pass then removed the quotes that made it so.
|
||||||
|
// URL validation has to be the last thing that looks at the text.
|
||||||
|
const { sanitized } = sanitizeCSS(
|
||||||
|
'--x:x<">;background:url(https://evil.example/p.gif);--y:x<">'
|
||||||
|
);
|
||||||
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat NBSP as CSS whitespace inside url()', () => {
|
||||||
|
// JS `\s` matches U+00A0; CSS whitespace does not. Skipping it let the
|
||||||
|
// scanner read the following quote as a legitimate data: URI and swallow
|
||||||
|
// a remote url() inside the "string", while a browser sees an unquoted
|
||||||
|
// url-token ending at the first `)` and fetches the remote background.
|
||||||
|
const NBSP = '\u00a0';
|
||||||
|
const { sanitized } = sanitizeCSS(
|
||||||
|
`.a{background:url(${NBSP}"data:image/png);background:url(https://evil.example/p.gif);--x:");}`
|
||||||
|
);
|
||||||
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows ordinary CSS whitespace around a data: URI', () => {
|
||||||
|
const spaced = sanitizeCSS('.a{background:url( "data:image/png;base64,iVBORw0KGgo=" )}');
|
||||||
|
expect(spaced.sanitized).toContain('data:image/png');
|
||||||
|
const tabbed = sanitizeCSS('.a{background:url(\t"data:image/png;base64,iVBORw0KGgo=")}');
|
||||||
|
expect(tabbed.sanitized).toContain('data:image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an unterminated quote hide everything after it', () => {
|
||||||
|
// An unclosed quote is a parse error. Trusting it meant one stray
|
||||||
|
// apostrophe disabled scanning for the remainder of the stylesheet, so
|
||||||
|
// the safe reading is to treat it as an ordinary character and continue.
|
||||||
|
const { sanitized } = sanitizeCSS(
|
||||||
|
"p{font-family:'don't;background:url(https://evil.example/p.gif)}"
|
||||||
|
);
|
||||||
|
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still keeps legitimate quoted values and data: images intact', () => {
|
||||||
|
const font = sanitizeCSS('p{font-family:"Helvetica Neue",sans-serif;color:red}');
|
||||||
|
expect(font.sanitized).toContain('"Helvetica Neue"');
|
||||||
|
const data = sanitizeCSS(".a{background:url('data:image/png;base64,iVBORw0KGgo=')}");
|
||||||
|
expect(data.sanitized).toContain('data:image/png');
|
||||||
|
});
|
||||||
|
|
||||||
it('still blocks the other forbidden patterns', () => {
|
it('still blocks the other forbidden patterns', () => {
|
||||||
const { sanitized } = sanitizeCSS(
|
const { sanitized } = sanitizeCSS(
|
||||||
'@import url("https://x/e.css"); .a{width:expression(alert(1));behavior:url(e.htc)}'
|
'@import url("https://x/e.css"); .a{width:expression(alert(1));behavior:url(e.htc)}'
|
||||||
|
|||||||
@@ -141,35 +141,81 @@ function sanitizeCampaignBody(html) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
// sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each.
|
// sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each.
|
||||||
.replace(/style="([^"]*)"/gi, (match, css) => {
|
// sanitizeCSS blocks remote url() properly as of #1290 — it lexes the CSS
|
||||||
const { sanitized } = sanitizeCSS(css);
|
// rather than pattern-matching it, so the local pass this used to need is
|
||||||
const cleaned = stripRemoteCssUrls(sanitized);
|
// gone. Keeping a second copy would mean two definitions of "disallowed"
|
||||||
return cleaned ? `style="${cleaned.replace(/"/g, '')}"` : '';
|
// drifting apart.
|
||||||
});
|
//
|
||||||
|
// Entities are decoded BEFORE the CSS is scanned, and re-encoded after.
|
||||||
|
// sanitize-html emits `"` inside an attribute as `"`, so the scanner
|
||||||
|
// and the recipient's browser otherwise disagree about where CSS strings
|
||||||
|
// begin: in `style="font-family:"don't";background:url(...)"`
|
||||||
|
// the browser decodes first and reads the apostrophe as ordinary text
|
||||||
|
// inside a real string, then makes the url() request — while the scanner
|
||||||
|
// saw the apostrophe open a string and skipped everything after it. The
|
||||||
|
// scanner has to be shown what the browser will actually parse.
|
||||||
|
.replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by the write-time sanitize and the post-substitution recheck. */
|
||||||
|
const STYLE_ATTRIBUTE = /style="([^"]*)"/gi;
|
||||||
|
|
||||||
|
function sanitizeStyleAttribute(match, css) {
|
||||||
|
const { sanitized } = sanitizeCSS(decodeHtmlEntities(css));
|
||||||
|
return sanitized ? `style="${encodeForAttribute(sanitized)}"` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove every `url(...)` that is not an inline data: image.
|
* Re-check inline CSS AFTER template substitution.
|
||||||
*
|
*
|
||||||
* The shared `sanitizeCSS` *detects* a remote url() and prefixes it with a
|
* Sanitizing runs on the stored body, but `safeTemplateReplace` rewrites it
|
||||||
* `/* BLOCKED URL *\/` comment — but a CSS comment is stripped during
|
* afterwards — so the string that was validated is not the string that gets
|
||||||
* tokenization, so the declaration a mail client actually parses still
|
* sent. A conditional inside a style attribute can delete the very characters
|
||||||
* carries the live URL. Verified:
|
* that made a URL inert:
|
||||||
*
|
*
|
||||||
* sanitizeCSS('.a{background:url(https://x/p.gif)}').sanitized
|
* style="--x:x{{#if company_name}}'{{/if}};background:url(https://evil…)"
|
||||||
* → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}'
|
|
||||||
*
|
*
|
||||||
* In a newsletter that is a tracking pixel delivered to every recipient, so
|
* At sanitize time the url() sits inside a CSS string and is correctly left
|
||||||
* this pass actually removes the token. Scoped to the newsletter path on
|
* alone; once the conditional is expanded the quotes are gone and the
|
||||||
* purpose: the same weakness affects gallery custom CSS, but changing shared
|
* background is live. No amount of lexer correctness fixes that, because the
|
||||||
* sanitizer behaviour is a separate change with its own blast radius.
|
* text being lexed is not the text being delivered — the check has to run
|
||||||
|
* again on the final output. Substitution cannot introduce a `"` (values are
|
||||||
|
* HTML-escaped), so the attribute regex still matches what it should.
|
||||||
*/
|
*/
|
||||||
function stripRemoteCssUrls(css) {
|
function sanitizeInlineStylesAfterSubstitution(html) {
|
||||||
if (!css) return '';
|
if (!html) return html;
|
||||||
return String(css)
|
return String(html).replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute);
|
||||||
.replace(/\/\*\s*BLOCKED URL\s*\*\//gi, '')
|
}
|
||||||
.replace(/url\s*\(\s*(['"]?)([^)'"]*)\1\s*\)/gi, (match, _quote, target) =>
|
|
||||||
(/^data:image\/(?:jpeg|jpg|png|gif|webp)/i.test(target.trim()) ? match : 'none'));
|
/**
|
||||||
|
* Decode the HTML entities sanitize-html emits inside attribute values, so
|
||||||
|
* CSS is scanned in the form the recipient's parser will see. One pass, so
|
||||||
|
* `&quot;` decodes to `"` and not to `"`.
|
||||||
|
*/
|
||||||
|
function decodeHtmlEntities(value) {
|
||||||
|
return String(value).replace(
|
||||||
|
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|(quot|apos|amp|lt|gt));/g,
|
||||||
|
(whole, dec, hex, name) => {
|
||||||
|
if (dec !== undefined) {
|
||||||
|
const code = Number(dec);
|
||||||
|
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
||||||
|
}
|
||||||
|
if (hex !== undefined) {
|
||||||
|
const code = parseInt(hex, 16);
|
||||||
|
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
||||||
|
}
|
||||||
|
return { quot: '"', apos: '\'', amp: '&', lt: '<', gt: '>' }[name];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-encode a sanitized value so it is safe inside a double-quoted attribute. */
|
||||||
|
function encodeForAttribute(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +224,8 @@ function stripRemoteCssUrls(css) {
|
|||||||
* `javascript:` and every `url()` that is not a `data:` image.
|
* `javascript:` and every `url()` that is not a `data:` image.
|
||||||
*
|
*
|
||||||
* That is STRICTER than the issue's "https: images only" note — the shared
|
* That is STRICTER than the issue's "https: images only" note — the shared
|
||||||
* sanitizer allows no remote `url()` at all. Kept as-is rather than loosened:
|
* sanitizer allows no remote `url()` at all, and since #1290 it enforces
|
||||||
|
* that by lexing rather than by pattern-matching. Kept as-is rather than loosened:
|
||||||
* a remote CSS url() in mail is a tracking pixel by another name, and a
|
* a remote CSS url() in mail is a tracking pixel by another name, and a
|
||||||
* campaign's images belong in `<img>` tags where the scheme filter sees them.
|
* campaign's images belong in `<img>` tags where the scheme filter sees them.
|
||||||
*
|
*
|
||||||
@@ -187,7 +234,7 @@ function stripRemoteCssUrls(css) {
|
|||||||
function sanitizeCampaignCss(css) {
|
function sanitizeCampaignCss(css) {
|
||||||
if (!css) return { css: '', warnings: [] };
|
if (!css) return { css: '', warnings: [] };
|
||||||
const { sanitized, warnings } = sanitizeCSS(String(css));
|
const { sanitized, warnings } = sanitizeCSS(String(css));
|
||||||
return { css: stripRemoteCssUrls(sanitized), warnings };
|
return { css: sanitized, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -310,7 +357,12 @@ async function renderForRecipient(campaign, customer, options = {}) {
|
|||||||
// Substitution happens AFTER sanitizing, with escaping on: a customer's own
|
// Substitution happens AFTER sanitizing, with escaping on: a customer's own
|
||||||
// company name is untrusted text and must not be able to inject markup by
|
// company name is untrusted text and must not be able to inject markup by
|
||||||
// riding in through a variable the sanitizer never saw.
|
// riding in through a variable the sanitizer never saw.
|
||||||
const body = safeTemplateReplace(safeBody, variables, { escapeHtml: true });
|
// Re-checked after substitution, not only before it: expansion can remove
|
||||||
|
// the quoting that made a url() inert at sanitize time. See
|
||||||
|
// sanitizeInlineStylesAfterSubstitution.
|
||||||
|
const body = sanitizeInlineStylesAfterSubstitution(
|
||||||
|
safeTemplateReplace(safeBody, variables, { escapeHtml: true })
|
||||||
|
);
|
||||||
const subject = safeTemplateReplace(campaign.subject || '', variables);
|
const subject = safeTemplateReplace(campaign.subject || '', variables);
|
||||||
|
|
||||||
const { css } = sanitizeCampaignCss(campaign.body_css);
|
const { css } = sanitizeCampaignCss(campaign.body_css);
|
||||||
@@ -922,6 +974,7 @@ async function sendTest(campaignId, toEmail, adminId) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sanitizeCampaignBody,
|
sanitizeCampaignBody,
|
||||||
|
sanitizeInlineStylesAfterSubstitution,
|
||||||
sanitizeCampaignCss,
|
sanitizeCampaignCss,
|
||||||
unsubscribeToken,
|
unsubscribeToken,
|
||||||
verifyUnsubscribeToken,
|
verifyUnsubscribeToken,
|
||||||
|
|||||||
@@ -147,11 +147,27 @@ function stripDisallowedUrls(css) {
|
|||||||
if (input[i] === '"' || input[i] === '\'') {
|
if (input[i] === '"' || input[i] === '\'') {
|
||||||
const quote = input[i];
|
const quote = input[i];
|
||||||
let j = i + 1;
|
let j = i + 1;
|
||||||
|
let closed = false;
|
||||||
while (j < input.length) {
|
while (j < input.length) {
|
||||||
if (input[j] === '\\') { j += 2; continue; }
|
if (input[j] === '\\') { j += 2; continue; }
|
||||||
if (input[j] === quote) { j += 1; break; }
|
// A newline ends a string in CSS (it produces a bad-string token), so
|
||||||
|
// an unclosed quote must not run past the end of its own line.
|
||||||
|
if (input[j] === '\n' || input[j] === '\r' || input[j] === '\f') break;
|
||||||
|
if (input[j] === quote) { j += 1; closed = true; break; }
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
|
// An UNTERMINATED quote is a parse error, and trusting it is how a
|
||||||
|
// stray apostrophe hid everything after it: `font-family:"don't`
|
||||||
|
// opened a string that swallowed the url() following it, while the
|
||||||
|
// recipient's browser — which decodes the entity first — saw the
|
||||||
|
// apostrophe safely inside a real string and made the request. Failing
|
||||||
|
// closed here means emitting the quote as an ordinary character and
|
||||||
|
// carrying on scanning, so a later url() is still examined.
|
||||||
|
if (!closed) {
|
||||||
|
out += input[i];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
out += input.slice(i, Math.min(j, input.length));
|
out += input.slice(i, Math.min(j, input.length));
|
||||||
i = Math.min(j, input.length);
|
i = Math.min(j, input.length);
|
||||||
continue;
|
continue;
|
||||||
@@ -161,7 +177,7 @@ function stripDisallowedUrls(css) {
|
|||||||
const ident = readIdentifier(input, i);
|
const ident = readIdentifier(input, i);
|
||||||
if (ident.end > i && decodeCssEscapes(ident.raw).toLowerCase() === 'url') {
|
if (ident.end > i && decodeCssEscapes(ident.raw).toLowerCase() === 'url') {
|
||||||
let j = ident.end;
|
let j = ident.end;
|
||||||
while (j < input.length && /\s/.test(input[j])) j += 1;
|
while (j < input.length && CSS_WS.test(input[j])) j += 1;
|
||||||
if (input[j] === '(') {
|
if (input[j] === '(') {
|
||||||
const token = readUrlToken(input, j);
|
const token = readUrlToken(input, j);
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -185,6 +201,19 @@ function stripDisallowedUrls(css) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- escape that does NOT begin an identifier -------------------------
|
||||||
|
// Ordered AFTER readIdentifier deliberately. `\75` is the escape for
|
||||||
|
// `u`, so `\75rl(...)` is url() to a browser — consuming the escape
|
||||||
|
// first would hide it from the check above, which is a bypass this
|
||||||
|
// branch introduced when it ran earlier. What is left for it is the
|
||||||
|
// `\'` case: an escaped quote that must not be read as opening a
|
||||||
|
// string, since that swallowed the rest of the stylesheet unscanned.
|
||||||
|
if (input[i] === '\\' && i + 1 < input.length) {
|
||||||
|
out += input.slice(i, i + 2);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
out += input[i];
|
out += input[i];
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
@@ -209,6 +238,15 @@ function matchEscape(input, start) {
|
|||||||
* escaped Tailwind selector) is emitted byte-identical rather than silently
|
* escaped Tailwind selector) is emitted byte-identical rather than silently
|
||||||
* rewritten to `.w-1/2`, which is a different selector.
|
* rewritten to `.w-1/2`, which is a different selector.
|
||||||
*/
|
*/
|
||||||
|
// CSS whitespace is exactly space, tab, LF, CR and FF. JavaScript's `\s`
|
||||||
|
// is NOT the same set — it also matches NBSP and the other Unicode spaces,
|
||||||
|
// and that difference was a bypass: in `url(\u00a0"data:image/png);...")`
|
||||||
|
// the scanner skipped the NBSP as whitespace and read the following quote as
|
||||||
|
// a legitimate quoted data: URI, swallowing a remote url() inside it. A
|
||||||
|
// browser treats NBSP as an ordinary character, making that an UNQUOTED
|
||||||
|
// url-token that ends at the first `)` — leaving the remote background live.
|
||||||
|
const CSS_WS = /[ \t\n\r\f]/;
|
||||||
|
|
||||||
function readIdentifier(input, start) {
|
function readIdentifier(input, start) {
|
||||||
let j = start;
|
let j = start;
|
||||||
let raw = '';
|
let raw = '';
|
||||||
@@ -228,7 +266,7 @@ function readIdentifier(input, start) {
|
|||||||
function readUrlToken(input, openParen) {
|
function readUrlToken(input, openParen) {
|
||||||
let j = openParen + 1;
|
let j = openParen + 1;
|
||||||
let target = '';
|
let target = '';
|
||||||
while (j < input.length && /\s/.test(input[j])) j += 1;
|
while (j < input.length && CSS_WS.test(input[j])) j += 1;
|
||||||
|
|
||||||
if (input[j] === '"' || input[j] === '\'') {
|
if (input[j] === '"' || input[j] === '\'') {
|
||||||
// Quoted: the quote closes the value, so ")" inside it is content.
|
// Quoted: the quote closes the value, so ")" inside it is content.
|
||||||
@@ -249,7 +287,7 @@ function readUrlToken(input, openParen) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (j < input.length && /\s/.test(input[j])) j += 1;
|
while (j < input.length && CSS_WS.test(input[j])) j += 1;
|
||||||
// Unterminated url( — malformed. Leave it alone rather than swallowing the
|
// Unterminated url( — malformed. Leave it alone rather than swallowing the
|
||||||
// remainder of the stylesheet.
|
// remainder of the stylesheet.
|
||||||
if (input[j] !== ')') return null;
|
if (input[j] !== ')') return null;
|
||||||
@@ -291,12 +329,35 @@ function sanitizeCSS(cssContent) {
|
|||||||
// Remove HTML comments that might be used for injection
|
// Remove HTML comments that might be used for injection
|
||||||
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
// URLs are scanned AFTER the comment strip, not before. Removing
|
// Remove control characters BEFORE the URL scan. This is the same
|
||||||
// `<!--x-->` from `u<!--x-->rl(https://evil.example/p.gif)` JOINS the
|
// token-joining hazard as the HTML-comment strip above: dropping the
|
||||||
// remaining characters into a live `url(...)` — so a scan that ran first
|
// \u0001 from `u\u0001rl(https://evil.example/p.gif)` joins the remainder
|
||||||
// saw no token, reported the input clean, and the transformation below it
|
// into a live `url(...)`, so a scan that ran first saw no token and
|
||||||
// then produced exactly the request the scan was there to prevent. Any
|
// reported the input clean. Newlines are control characters too, which
|
||||||
// pass that can join tokens has to happen before validation, not after.
|
// made `u\nrl(...)` the same bypass in ordinary-looking CSS.
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
|
||||||
|
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||||
|
|
||||||
|
// URLs are scanned AFTER the comment and control-character strips, not
|
||||||
|
// before. Removing `<!--x-->` from `u<!--x-->rl(https://evil.example/p.gif)`
|
||||||
|
// JOINS the remaining characters into a live `url(...)` — so a scan that
|
||||||
|
// ran first saw no token, reported the input clean, and the transformation
|
||||||
|
// below it then produced exactly the request the scan was there to
|
||||||
|
// prevent. Any pass that can join tokens has to happen before validation,
|
||||||
|
// not after.
|
||||||
|
// Remove any remaining script-like content. This is the LAST pass that can
|
||||||
|
// move text, and so it must run before the URL scan, not after: it deletes
|
||||||
|
// the matched span, and a span like `<">` takes a quote with it. That is
|
||||||
|
// how `--x:x<">;background:url(https://evil.example/p.gif);--y:x<">` shipped
|
||||||
|
// a live background — the scanner saw the url() safely inside a string, and
|
||||||
|
// this line then removed the quotes that made it so.
|
||||||
|
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
|
||||||
|
|
||||||
|
// URL validation runs LAST, deliberately. Every pass above rewrites the
|
||||||
|
// text, and each one that did so after this point has produced a bypass:
|
||||||
|
// the HTML-comment strip (#1290), the control-character strip, and the tag
|
||||||
|
// strip immediately above. Validating anything other than the final bytes
|
||||||
|
// means validating a string that is not the one that gets served.
|
||||||
const urlPass = stripDisallowedUrls(sanitized);
|
const urlPass = stripDisallowedUrls(sanitized);
|
||||||
if (urlPass.blocked > 0) {
|
if (urlPass.blocked > 0) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -306,13 +367,6 @@ function sanitizeCSS(cssContent) {
|
|||||||
sanitized = urlPass.sanitized;
|
sanitized = urlPass.sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove control characters
|
|
||||||
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
|
|
||||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
|
||||||
|
|
||||||
// Remove any remaining script-like content
|
|
||||||
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
|
|
||||||
|
|
||||||
return { sanitized: sanitized.trim(), warnings };
|
return { sanitized: sanitized.trim(), warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,14 +78,16 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// Draw image to canvas when canvas rendering is enabled
|
// Draw image to canvas when canvas rendering is enabled
|
||||||
|
// Returns whether the pixels actually made it onto the canvas, so the
|
||||||
|
// caller knows if the source image is still needed (#1287).
|
||||||
const drawToCanvas = useCallback(() => {
|
const drawToCanvas = useCallback(() => {
|
||||||
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
|
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return false;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const img = imageRef.current;
|
const img = imageRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
if (!ctx || !img.complete || img.naturalWidth === 0) return;
|
if (!ctx || !img.complete || img.naturalWidth === 0) return false;
|
||||||
|
|
||||||
// Set canvas dimensions to match image
|
// Set canvas dimensions to match image
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = img.naturalWidth;
|
||||||
@@ -95,6 +97,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
setCanvasReady(true);
|
setCanvasReady(true);
|
||||||
|
return true;
|
||||||
}, [useCanvasRendering]);
|
}, [useCanvasRendering]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,7 +254,26 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageRef.current = img;
|
imageRef.current = img;
|
||||||
drawToCanvas();
|
const drawn = drawToCanvas();
|
||||||
|
// Once drawImage has copied the pixels into the canvas the source
|
||||||
|
// decode is dead weight, so drop it here rather than at unmount. The
|
||||||
|
// grid is not virtualised — a 546-photo event mounts 546 of these and
|
||||||
|
// none of them unmount while the gallery is open — so a cleanup-only
|
||||||
|
// release never actually runs for the case it was meant to fix
|
||||||
|
// (#1287). Nothing redraws from `imageRef` afterwards: drawToCanvas
|
||||||
|
// has this one caller.
|
||||||
|
if (drawn) {
|
||||||
|
// Handlers off BEFORE the src goes. Measured in Chromium and WebKit:
|
||||||
|
// neither fires `error` when the attribute is removed after a
|
||||||
|
// successful load, so this is not fixing an observed bug — but if any
|
||||||
|
// engine ever did, `onerror` would set canvasFailed, swap the canvas
|
||||||
|
// for a plain <img>, and decode the image a second time, which is the
|
||||||
|
// exact opposite of what this release is for. The ordering is free.
|
||||||
|
img.onload = null;
|
||||||
|
img.onerror = null;
|
||||||
|
imageRef.current = null;
|
||||||
|
img.removeAttribute('src');
|
||||||
|
}
|
||||||
onLoad?.();
|
onLoad?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,6 +288,17 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
img.onload = null;
|
img.onload = null;
|
||||||
img.onerror = null;
|
img.onerror = null;
|
||||||
|
// Fallback release for the paths the onload handler above cannot
|
||||||
|
// cover: the draw failed, or the source changed / the component
|
||||||
|
// unmounted before onload ever fired. `imageRef` is what drawToCanvas
|
||||||
|
// reads and it was never cleared, so a detached Image — and the decode
|
||||||
|
// behind it — stayed pinned by a live JS reference. A decoded <img> in
|
||||||
|
// the document is evictable under memory pressure; one held by a ref
|
||||||
|
// is not.
|
||||||
|
if (imageRef.current === img) {
|
||||||
|
imageRef.current = null;
|
||||||
|
}
|
||||||
|
img.removeAttribute('src');
|
||||||
};
|
};
|
||||||
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Canvas-mode memory release (#1287).
|
||||||
|
*
|
||||||
|
* In canvas mode the component keeps a detached `Image` in `imageRef` so
|
||||||
|
* `drawToCanvas` can read it. The effect cleanup nulled `onload`/`onerror`
|
||||||
|
* but never cleared that ref, so the Image — and the decoded bitmap behind
|
||||||
|
* it — stayed pinned by a live JS reference for the component's lifetime.
|
||||||
|
*
|
||||||
|
* That is not academic at gallery scale. The photo grid is NOT virtualised:
|
||||||
|
* a 546-photo event mounts 546 of these and none ever unmount, so nothing was
|
||||||
|
* ever released. A decoded <img> in the document is evictable under memory
|
||||||
|
* pressure; one held by a ref is not.
|
||||||
|
*/
|
||||||
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../utils/galleryAuthStorage', () => ({
|
||||||
|
getActiveGallerySlug: () => 'demo',
|
||||||
|
getGalleryToken: () => 'token',
|
||||||
|
inferGallerySlugFromLocation: () => 'demo',
|
||||||
|
resolveSlugFromRequestUrl: () => 'demo',
|
||||||
|
}));
|
||||||
|
vi.mock('../../../utils/url', () => ({ buildResourceUrl: (u: string) => `http://localhost${u}` }));
|
||||||
|
|
||||||
|
import { AuthenticatedImage } from '../AuthenticatedImage';
|
||||||
|
|
||||||
|
/** Every Image the component constructs, so the test can inspect them. */
|
||||||
|
const created: HTMLImageElement[] = [];
|
||||||
|
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||||
|
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
created.length = 0;
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
blob: async () => new Blob(['x'], { type: 'image/png' }),
|
||||||
|
})));
|
||||||
|
// Patch the two methods rather than replacing URL — spreading the
|
||||||
|
// constructor loses its prototype and breaks every `new URL(...)`.
|
||||||
|
// Unique per call: the canvas effect keys off `imageSrc`, so a constant
|
||||||
|
// URL would make a src change look like no change at all.
|
||||||
|
let n = 0;
|
||||||
|
createObjectURL = vi.fn(() => `blob:mock-url-${++n}`);
|
||||||
|
revokeObjectURL = vi.fn();
|
||||||
|
URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
|
||||||
|
URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL;
|
||||||
|
|
||||||
|
const RealImage = globalThis.Image;
|
||||||
|
vi.stubGlobal('Image', class extends RealImage {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
created.push(this as unknown as HTMLImageElement);
|
||||||
|
// jsdom leaves these at 0/false for a blob: src, which makes
|
||||||
|
// drawToCanvas bail before it draws. Present a decoded image so the
|
||||||
|
// draw path is reachable.
|
||||||
|
Object.defineProperty(this, 'complete', { get: () => true });
|
||||||
|
Object.defineProperty(this, 'naturalWidth', { get: () => 10 });
|
||||||
|
Object.defineProperty(this, 'naturalHeight', { get: () => 10 });
|
||||||
|
// jsdom never fires load for a blob: src, so drive it manually.
|
||||||
|
setTimeout(() => this.onload?.(new Event('load')), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe('AuthenticatedImage canvas mode', () => {
|
||||||
|
it('releases the decoded image as soon as it is drawn, without waiting for unmount', async () => {
|
||||||
|
// The case this whole change exists for. Every other test here asserts
|
||||||
|
// release on unmount or src change — neither of which happens to a grid
|
||||||
|
// tile, because the grid is not virtualised and the tiles stay mounted
|
||||||
|
// for as long as the gallery is open. Once drawImage has copied the
|
||||||
|
// pixels the source decode is dead weight and must go immediately.
|
||||||
|
const ctx = { drawImage: vi.fn() };
|
||||||
|
const getContext = vi
|
||||||
|
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||||
|
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||||
|
const img = created[0];
|
||||||
|
|
||||||
|
await waitFor(() => expect(ctx.drawImage).toHaveBeenCalled());
|
||||||
|
// Still mounted, still the same src — and already released.
|
||||||
|
await waitFor(() => expect(img.getAttribute('src')).toBeNull());
|
||||||
|
|
||||||
|
getContext.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the decoded image on unmount', async () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||||
|
const img = created[0];
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// The src is dropped so the browser can reclaim the decode without
|
||||||
|
// waiting for GC, and the handlers are detached.
|
||||||
|
expect(img.getAttribute('src')).toBeNull();
|
||||||
|
expect(img.onload).toBeNull();
|
||||||
|
expect(img.onerror).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the blob URL on unmount', async () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the previous image when the src changes', async () => {
|
||||||
|
// A recycled tile (a layout reusing a component instance for a different
|
||||||
|
// photo) must not accumulate one pinned decode per photo it has shown.
|
||||||
|
const { rerender } = render(
|
||||||
|
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(created.length).toBe(1));
|
||||||
|
const first = created[0];
|
||||||
|
|
||||||
|
rerender(<AuthenticatedImage src="/api/gallery/demo/thumbnail/2" alt="t" useCanvasRendering />);
|
||||||
|
await waitFor(() => expect(created.length).toBe(2));
|
||||||
|
|
||||||
|
expect(first.getAttribute('src')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,6 +83,24 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
onToggleSelect={onToggleSelect}
|
onToggleSelect={onToggleSelect}
|
||||||
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
|
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
|
||||||
lazy
|
lazy
|
||||||
|
/*
|
||||||
|
* Pre-load band (#1287). Grid was the only lazy layout passing no
|
||||||
|
* `inViewRootMargin`, so PhotoCard ran the observer at the
|
||||||
|
* IntersectionObserver default of 0px with threshold 0.1 — a tile could
|
||||||
|
* not begin loading until a tenth of it was already on screen. The
|
||||||
|
* gallery owner's description of the symptom is that exact shape:
|
||||||
|
* spinning the wheel outran loading by ~50 images, then it caught up.
|
||||||
|
*
|
||||||
|
* Viewport-relative rather than a fixed 100px like Justified: a phone
|
||||||
|
* and a 4K desktop scroll past very different amounts of grid per
|
||||||
|
* gesture, and a band tuned to one is wrong for the other.
|
||||||
|
*
|
||||||
|
* `%`, not `vh` — rootMargin only accepts px and percentages, and an
|
||||||
|
* IntersectionObserver constructed with a vh value throws. A percentage
|
||||||
|
* resolves against the root's own box, so 100% is one viewport height
|
||||||
|
* of lead in each direction, which is what vh would have meant.
|
||||||
|
*/
|
||||||
|
inViewRootMargin="100% 0px"
|
||||||
fadeInWhenVisible={animationType === 'fade'}
|
fadeInWhenVisible={animationType === 'fade'}
|
||||||
skeletonClassName="skeleton aspect-square w-full rounded-lg"
|
skeletonClassName="skeleton aspect-square w-full rounded-lg"
|
||||||
imageProps={{
|
imageProps={{
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Grid's lazy-loading pre-load band (#1287).
|
||||||
|
*
|
||||||
|
* Grid was the only layout passing `lazy` without an `inViewRootMargin`, so
|
||||||
|
* PhotoCard ran its observer at the IntersectionObserver default of `0px`
|
||||||
|
* with `threshold: 0.1` — a tile could not begin loading until a tenth of it
|
||||||
|
* was already on screen. The gallery owner described exactly that: spinning
|
||||||
|
* the scroll wheel outran loading by ~50 images before it caught up.
|
||||||
|
*
|
||||||
|
* The unit matters as much as the value. `rootMargin` accepts only px and
|
||||||
|
* percentages; an IntersectionObserver constructed with a `vh` value throws
|
||||||
|
* SyntaxError, which would have broken every Grid gallery outright. Verified
|
||||||
|
* in Chrome:
|
||||||
|
*
|
||||||
|
* '100% 0px' → accepted
|
||||||
|
* '100px 0px' → accepted
|
||||||
|
* '100vh 0px' → SyntaxError: rootMargin must be specified in pixels or percent
|
||||||
|
*
|
||||||
|
* jsdom has no IntersectionObserver, so this asserts against the source
|
||||||
|
* rather than constructing one.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
const layouts = resolve(__dirname, '..');
|
||||||
|
const read = (f: string) => readFileSync(resolve(layouts, f), 'utf8');
|
||||||
|
|
||||||
|
/** Only px and % are legal rootMargin units. */
|
||||||
|
const LEGAL_ROOT_MARGIN = /^(-?\d+(px|%)|0)(\s+(-?\d+(px|%)|0)){0,3}$/;
|
||||||
|
|
||||||
|
describe('grid lazy pre-load band', () => {
|
||||||
|
it('Grid passes an inViewRootMargin', () => {
|
||||||
|
expect(read('GridGalleryLayout.tsx')).toMatch(/inViewRootMargin=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every inViewRootMargin in every layout uses a legal unit', () => {
|
||||||
|
// A vh value throws at IntersectionObserver construction and takes the
|
||||||
|
// whole gallery down with it, so this guards the unit, not just presence.
|
||||||
|
for (const file of ['GridGalleryLayout.tsx', 'JustifiedGalleryLayout.tsx']) {
|
||||||
|
const src = read(file);
|
||||||
|
for (const [, value] of src.matchAll(/inViewRootMargin="([^"]+)"/g)) {
|
||||||
|
expect(value, `${file}: "${value}"`).toMatch(LEGAL_ROOT_MARGIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every layout that lazy-renders also declares a pre-load band', () => {
|
||||||
|
// The defect was Grid being lazy with no margin. Any future layout that
|
||||||
|
// opts into `lazy` and forgets the margin reintroduces it.
|
||||||
|
for (const file of ['GridGalleryLayout.tsx', 'JustifiedGalleryLayout.tsx']) {
|
||||||
|
const src = read(file);
|
||||||
|
const isLazy = /^\s*lazy\s*$/m.test(src) || /\slazy=\{?true/.test(src);
|
||||||
|
if (!isLazy) continue;
|
||||||
|
expect(src, `${file} is lazy but declares no inViewRootMargin`)
|
||||||
|
.toMatch(/inViewRootMargin=/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user