From be8d79e9c4b6148a0b3f8a1f81f05fbf5fbf6880 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 4 Sep 2026 20:39:43 +0200 Subject: [PATCH 01/23] fix(gallery): release the canvas-mode decode, and drop a now-duplicate sanitizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to yesterday's merges. Both were already known; neither depends on the open question in #1287. 1. Canvas mode pinned every decoded image for the component's lifetime. `AuthenticatedImage` keeps a detached Image in `imageRef` so drawToCanvas can read it. The effect cleanup nulled onload/onerror and never cleared that ref, so the Image — and the decode behind it — stayed held by a live JS reference. A decoded in the document is evictable under memory pressure; one held by a ref is not. That is not academic at gallery scale. The photo grid is NOT virtualised, so a 546-photo event mounts 546 of these and none ever unmount — nothing was ever released. The ref is cleared and the src dropped, so the browser can reclaim without waiting for GC. This is NOT presented as the fix for #1287. That investigation is still open: the reporter has since shown the backend idle during a stall and the renderer itself unresponsive for 45s, which rules out the theories tried so far. This is a real leak on the same path, worth fixing on its own terms while that question is settled. 2. newsletterService no longer carries its own remote-url() stripper. It was added because the shared sanitizeCSS "blocked" remote URLs with a CSS comment that parsers discard. #1290 replaced that with a lexer, so the local copy is dead weight — and two definitions of "disallowed" would drift apart. Verified the shared function covers every case the local one did, including the quoted-paren and CSS-escape forms found in review. Three tests on the release path, two of which fail without the fix: unmount clears the ref and drops the src, the blob URL is revoked, and a src change releases the previous image rather than accumulating one pinned decode per photo a recycled tile has shown. --- backend/src/services/newsletterService.js | 36 ++---- .../components/common/AuthenticatedImage.tsx | 14 +++ .../AuthenticatedImage.canvasRelease.test.tsx | 104 ++++++++++++++++++ 3 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/common/__tests__/AuthenticatedImage.canvasRelease.test.tsx diff --git a/backend/src/services/newsletterService.js b/backend/src/services/newsletterService.js index 46da3528..eaec1f76 100644 --- a/backend/src/services/newsletterService.js +++ b/backend/src/services/newsletterService.js @@ -141,44 +141,24 @@ function sanitizeCampaignBody(html) { }, }) // sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each. + // sanitizeCSS blocks remote url() properly as of #1290 — it lexes the CSS + // rather than pattern-matching it, so the local pass this used to need is + // gone. Keeping a second copy would mean two definitions of "disallowed" + // drifting apart. .replace(/style="([^"]*)"/gi, (match, css) => { const { sanitized } = sanitizeCSS(css); - const cleaned = stripRemoteCssUrls(sanitized); - return cleaned ? `style="${cleaned.replace(/"/g, '')}"` : ''; + return sanitized ? `style="${sanitized.replace(/"/g, '')}"` : ''; }); } -/** - * Remove every `url(...)` that is not an inline data: image. - * - * The shared `sanitizeCSS` *detects* a remote url() and prefixes it with a - * `/* BLOCKED URL *\/` comment — but a CSS comment is stripped during - * tokenization, so the declaration a mail client actually parses still - * carries the live URL. Verified: - * - * sanitizeCSS('.a{background:url(https://x/p.gif)}').sanitized - * → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}' - * - * In a newsletter that is a tracking pixel delivered to every recipient, so - * this pass actually removes the token. Scoped to the newsletter path on - * purpose: the same weakness affects gallery custom CSS, but changing shared - * sanitizer behaviour is a separate change with its own blast radius. - */ -function stripRemoteCssUrls(css) { - if (!css) return ''; - return String(css) - .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')); -} - /** * Sanitize a campaign's optional `'); }); }); + +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 = + `

hi

`; + + 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 = '

Hello {{first_name}}

'; + expect(sanitizeInlineStylesAfterSubstitution(html)).toBe(html); + }); + + it('keeps legitimate inline styles through the recheck', () => { + const html = '

hi

'; + 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(); + }); +}); diff --git a/backend/src/services/newsletterService.js b/backend/src/services/newsletterService.js index 8a29f79a..9c991ef2 100644 --- a/backend/src/services/newsletterService.js +++ b/backend/src/services/newsletterService.js @@ -154,10 +154,37 @@ function sanitizeCampaignBody(html) { // 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="([^"]*)"/gi, (match, css) => { - const { sanitized } = sanitizeCSS(decodeHtmlEntities(css)); - return sanitized ? `style="${encodeForAttribute(sanitized)}"` : ''; - }); + .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)}"` : ''; +} + +/** + * Re-check inline CSS AFTER template substitution. + * + * Sanitizing runs on the stored body, but `safeTemplateReplace` rewrites it + * afterwards — so the string that was validated is not the string that gets + * sent. A conditional inside a style attribute can delete the very characters + * that made a URL inert: + * + * style="--x:x{{#if company_name}}'{{/if}};background:url(https://evil…)" + * + * At sanitize time the url() sits inside a CSS string and is correctly left + * alone; once the conditional is expanded the quotes are gone and the + * background is live. No amount of lexer correctness fixes that, because the + * 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 sanitizeInlineStylesAfterSubstitution(html) { + if (!html) return html; + return String(html).replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute); } /** @@ -330,7 +357,12 @@ async function renderForRecipient(campaign, customer, options = {}) { // 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 // 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 { css } = sanitizeCampaignCss(campaign.body_css); @@ -942,6 +974,7 @@ async function sendTest(campaignId, toEmail, adminId) { module.exports = { sanitizeCampaignBody, + sanitizeInlineStylesAfterSubstitution, sanitizeCampaignCss, unsubscribeToken, verifyUnsubscribeToken, From 1151e96144a9ada7170461e829d2f3d5dcc8edb2 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 5 Sep 2026 15:30:04 +0200 Subject: [PATCH 20/23] fix(security): validate CSS urls last, after every pass that moves text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth bypass, and the same root cause as the first: sanitizeCSS validated, then kept rewriting. `<[^>]*>` deletes the span it matches, and `<">` takes a quote with it. So `--x:x<">;background:url(https://evil.example/p.gif);--y:x<">` was scanned with the url() safely inside a string, and the tag strip below then removed the quotes that made it so — shipping a live remote background with no warning. The file already carried the rule: "any pass that can join tokens has to happen before validation, not after." It has now been broken three separate times — by the HTML-comment strip (#1290), the control- character strip, and the tag strip. Rather than fix a third instance in place, the URL scan is now the LAST step, so what is validated is always the bytes that get served. All eight known bypass classes are pinned, together with the legitimate data: URI, quoted font stack and escaped selector that must survive untouched. Refs #1264 --- .../utils/cssSanitizer.remoteUrls.test.js | 11 +++++++++++ backend/src/utils/cssSanitizer.js | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/utils/cssSanitizer.remoteUrls.test.js b/backend/__tests__/utils/cssSanitizer.remoteUrls.test.js index be8c39fe..30d1e035 100644 --- a/backend/__tests__/utils/cssSanitizer.remoteUrls.test.js +++ b/backend/__tests__/utils/cssSanitizer.remoteUrls.test.js @@ -260,6 +260,17 @@ describe('sanitizeCSS', () => { 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 diff --git a/backend/src/utils/cssSanitizer.js b/backend/src/utils/cssSanitizer.js index 98df28ea..b8780d68 100644 --- a/backend/src/utils/cssSanitizer.js +++ b/backend/src/utils/cssSanitizer.js @@ -345,6 +345,19 @@ function sanitizeCSS(cssContent) { // 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); if (urlPass.blocked > 0) { warnings.push( @@ -354,9 +367,6 @@ function sanitizeCSS(cssContent) { sanitized = urlPass.sanitized; } - // Remove any remaining script-like content - sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */'); - return { sanitized: sanitized.trim(), warnings }; } From 7ff8caf9d7e601dff2472b1ad57b3c83ba9d3cdc Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 5 Sep 2026 23:41:17 +0200 Subject: [PATCH 21/23] fix: remove the fragmentation handling stranded by #1303 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1298 and #1303 merged together. #1298 taught the creation paths to resolve a fragmentation_level default; #1303 removed everything that consumed it. Neither conflicted textually, so main ended up validating the field on create and update, copying it on duplicate, resolving default_fragmentation_level for it, and advertising it in the v1 API docs — for a value nothing reads and a setting the Image Security tab no longer exposes. Inert rather than broken, which is exactly why it needed removing on purpose: dead code that contradicts the PR that just deleted the feature is how the next reader concludes fragmentation still works. The events.fragmentation_level column and the app_settings row stay, as #1303 decided — dropping a column is irreversible and the stored values are harmless once nothing reads them. Refs #1300 --- .../integration/imageSecurityDefaults.test.js | 12 +++--------- backend/__tests__/routes/adminEvents.smoke.test.js | 1 - backend/src/routes/adminEvents/crud.js | 2 -- backend/src/routes/adminEvents/helpers.js | 8 -------- backend/src/routes/v1/events.js | 2 -- 5 files changed, 3 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/integration/imageSecurityDefaults.test.js b/backend/__tests__/integration/imageSecurityDefaults.test.js index b2998705..b923fac1 100644 --- a/backend/__tests__/integration/imageSecurityDefaults.test.js +++ b/backend/__tests__/integration/imageSecurityDefaults.test.js @@ -5,7 +5,7 @@ * rendered as toggles, and read by nothing: * * default_protection_level, default_image_quality, - * enable_canvas_rendering, default_fragmentation_level + * enable_canvas_rendering * * Each maps onto an `events` column migration 038 already created, and each * is labelled "… by default". `enable_devtools_protection` was the only one @@ -46,7 +46,7 @@ describe('image-security creation defaults', () => { beforeEach(async () => { await db('app_settings').whereIn('setting_key', [ 'default_protection_level', 'default_image_quality', - 'enable_canvas_rendering', 'default_fragmentation_level', + 'enable_canvas_rendering', ]).del(); }); @@ -60,13 +60,11 @@ describe('image-security creation defaults', () => { await setSetting('default_protection_level', 'enhanced'); await setSetting('default_image_quality', 72); await setSetting('enable_canvas_rendering', true); - await setSetting('default_fragmentation_level', 5); expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'enhanced', image_quality: 72, use_canvas_rendering: true, - fragmentation_level: 5, }); }); @@ -83,7 +81,6 @@ describe('image-security creation defaults', () => { ['image quality above 100', 'default_image_quality', 250], ['image quality of zero', 'default_image_quality', 0], ['a non-numeric quality', 'default_image_quality', 'high'], - ['fragmentation above the range', 'default_fragmentation_level', 99], ['a non-boolean canvas value', 'enable_canvas_rendering', 'yes'], // parseInt would have rescued each of these into a valid-looking // integer. The settings PUT stores values without validating them, so @@ -91,8 +88,6 @@ describe('image-security creation defaults', () => { ['a numeric prefix with trailing junk', 'default_image_quality', '72oops'], ['a fractional quality', 'default_image_quality', 72.5], ['a single-element array', 'default_image_quality', [72]], - ['a fractional fragmentation level', 'default_fragmentation_level', 3.7], - ['a fragmentation level with trailing junk', 'default_fragmentation_level', '3x'], ])('ignores %s and falls through to the column default', async (_label, key, value) => { await setSetting(key, value); expect(await getImageSecurityDefaults()).toEqual({}); @@ -210,11 +205,10 @@ describe('image-security creation defaults', () => { it('resolves each column independently', () => { expect(resolveImageSecurityColumns( { image_quality: 60 }, - { protection_level: 'enhanced', fragmentation_level: 4 }, + { protection_level: 'enhanced' }, )).toEqual({ protection_level: 'enhanced', image_quality: 60, - fragmentation_level: 4, }); }); diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js index 4f857406..e15bf3b3 100644 --- a/backend/__tests__/routes/adminEvents.smoke.test.js +++ b/backend/__tests__/routes/adminEvents.smoke.test.js @@ -224,7 +224,6 @@ describe('admin events CRUD endpoints (smoke)', () => { ['image_quality', [72]], ['protection_level', ['basic']], ['use_canvas_rendering', [false]], - ['fragmentation_level', [3]], // Not a protection field: the guard is not scoped to that block. ['event_name', ['Arrayed']], ['allow_downloads', [false]], diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index a9ed22d8..c80e96ba 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -235,7 +235,6 @@ module.exports = (router) => { body('protection_level').optional().not().isArray().isIn(['basic', 'standard', 'enhanced', 'maximum']), body('use_canvas_rendering').optional().not().isArray().isBoolean().toBoolean(), body('image_quality').optional().not().isArray().isInt({ min: 1, max: 100 }).toInt(), - body('fragmentation_level').optional().not().isArray().isInt({ min: 1, max: 10 }).toInt(), body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), // #328 follow-up: per-event opt-in for presigned-URL "Download All". @@ -1427,7 +1426,6 @@ module.exports = (router) => { protection_level: source.protection_level, image_quality: source.image_quality, use_canvas_rendering: source.use_canvas_rendering, - fragmentation_level: source.fragmentation_level, watermark_downloads: source.watermark_downloads, watermark_text: source.watermark_text, allow_presigned_download: source.allow_presigned_download, diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js index 82e1af16..98fe9a9a 100644 --- a/backend/src/routes/adminEvents/helpers.js +++ b/backend/src/routes/adminEvents/helpers.js @@ -127,7 +127,6 @@ const getDownloadProtectionDefaults = async () => { * default_protection_level → events.protection_level * default_image_quality → events.image_quality * enable_canvas_rendering → events.use_canvas_rendering - * default_fragmentation_level → events.fragmentation_level * * Each maps onto a column migration 038 already created, and each is * labelled "… by default", so applying them at creation is what the panel @@ -172,7 +171,6 @@ const getImageSecurityDefaults = async (trx = null) => { 'default_protection_level', 'default_image_quality', 'enable_canvas_rendering', - 'default_fragmentation_level', ]) .select('setting_key', 'setting_value'); @@ -214,10 +212,6 @@ const getImageSecurityDefaults = async (trx = null) => { defaults.use_canvas_rendering = canvas; } - const fragmentation = toInteger(read('default_fragmentation_level')); - if (fragmentation !== undefined && fragmentation >= 1 && fragmentation <= 10) { - defaults.fragmentation_level = fragmentation; - } } catch (error) { // A settings read must never block event creation; the column defaults // are a correct fallback. @@ -262,8 +256,6 @@ const resolveImageSecurityColumns = (body = {}, defaults = {}) => { const canvas = pick('use_canvas_rendering'); if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas); - const fragmentation = pick('fragmentation_level'); - if (fragmentation !== undefined) columns.fragmentation_level = fragmentation; return columns; }; diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 121c22ea..982c5bc4 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -138,7 +138,6 @@ const photoUpload = async (req, res, next) => { * protection_level: { type: string, nullable: true, enum: [basic, standard, enhanced, maximum], description: "Image protection level. When omitted, falls back to the global default_protection_level setting." } * use_canvas_rendering: { type: boolean, nullable: true, description: "Render gallery images to a canvas instead of an img tag. When omitted, falls back to the global enable_canvas_rendering setting." } * image_quality: { type: integer, minimum: 1, maximum: 100, nullable: true, description: "Served image quality percentage. When omitted, falls back to the global default_image_quality setting." } - * fragmentation_level: { type: integer, minimum: 1, maximum: 10, nullable: true, description: "Stored for future use; no renderer consumes it yet. When omitted, falls back to the global default_fragmentation_level setting." } * hero_logo_visible: { type: boolean, nullable: true, description: "Show event logo in the hero block. When omitted, falls back to the global branding_logo_display_hero setting." } * hero_logo_size: { type: string, nullable: true, enum: [small, medium, large, xlarge], description: "Hero logo size. When omitted, falls back to the global branding_logo_size setting." } * hero_logo_position: { type: string, nullable: true, enum: [top, center, bottom], description: "Hero logo position. Defaults to 'top' (not settings-backed — see migration 084)." } @@ -187,7 +186,6 @@ router.post( body('protection_level').optional().not().isArray().isIn(['basic', 'standard', 'enhanced', 'maximum']), body('use_canvas_rendering').optional().not().isArray().isBoolean().toBoolean(), body('image_quality').optional().not().isArray().isInt({ min: 1, max: 100 }).toInt(), - body('fragmentation_level').optional().not().isArray().isInt({ min: 1, max: 10 }).toInt(), body('hero_logo_visible').optional().isBoolean(), body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) From b4c2c4055073f029eae94b59a59f740332244ec6 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:45:17 +0200 Subject: [PATCH 22/23] chore(main): release 3.124.0-beta.0 (#1305) --- .release-please-manifest-beta.json | 2 +- CHANGELOG.md | 35 ++++++++++++++++++++++++++++++ backend/package.json | 2 +- frontend/package.json | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 03d11272..8a425dc7 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.123.0-beta.0" + ".": "3.124.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9630f7c6..b1de7677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.124.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.123.0-beta.0...v3.124.0-beta.0) (2026-09-05) + + +### Features + +* **newsletters:** warn about deliverability before a large send ([0536c86](https://github.com/PicPeak/picpeak/commit/0536c86ec9014bf3b64c6590841e863525d90326)) +* **newsletters:** warn about deliverability before a large send ([49197be](https://github.com/PicPeak/picpeak/commit/49197be3293bac4c312352b3915d9d7fd9973c29)) + + +### Bug Fixes + +* **gallery:** give the Grid layout a lazy-loading pre-load band ([#1287](https://github.com/PicPeak/picpeak/issues/1287)) ([b1e5287](https://github.com/PicPeak/picpeak/commit/b1e5287351b43a347957e0b7a32d4c81d00ba11b)) +* **gallery:** image-loading follow-ups — pre-load band, decode release, sanitizer dedup ([905fc59](https://github.com/PicPeak/picpeak/commit/905fc595e3c15d55e00347807047acbe58c9b5bc)) +* **gallery:** release the canvas decode when it is drawn, not at unmount ([fbe9757](https://github.com/PicPeak/picpeak/commit/fbe9757a53b1cbe460837534cc3319d990180b61)), closes [#1287](https://github.com/PicPeak/picpeak/issues/1287) +* **gallery:** release the canvas-mode decode, and drop a now-duplicate sanitizer ([be8d79e](https://github.com/PicPeak/picpeak/commit/be8d79e9c4b6148a0b3f8a1f81f05fbf5fbf6880)) +* **gallery:** remove the inert image-protection prop surface from AuthenticatedImage ([1f316ef](https://github.com/PicPeak/picpeak/commit/1f316ef91cd2f74ac7ae68751fc00b9a3ccff410)) +* **gallery:** remove the inert image-protection prop surface from AuthenticatedImage ([e734e41](https://github.com/PicPeak/picpeak/commit/e734e41c412cede1be5efbe27aab617d59faee9d)), closes [#1297](https://github.com/PicPeak/picpeak/issues/1297) +* **newsletters:** make the warning's duration and queue claim honest ([7b4a65e](https://github.com/PicPeak/picpeak/commit/7b4a65ecc79d93715c52d82e9d7adb2665540536)) +* remove the image-fragmentation surface ([ae23b1a](https://github.com/PicPeak/picpeak/commit/ae23b1adea03fb6b46aac0079f6e523b97e3594e)) +* remove the image-fragmentation surface ([967224c](https://github.com/PicPeak/picpeak/commit/967224c030b9cd721fb0217d0763e1ec1978c51e)) +* **security:** apply image-security defaults on every creation path ([ab6c33d](https://github.com/PicPeak/picpeak/commit/ab6c33d9eb485cc3ed05187a98a22f51926cc125)), closes [#1296](https://github.com/PicPeak/picpeak/issues/1296) +* **security:** apply the Image-security defaults instead of storing them ([#1296](https://github.com/PicPeak/picpeak/issues/1296)) ([2e9bd54](https://github.com/PicPeak/picpeak/commit/2e9bd540c97001d274c6288800a86a164174ae9c)) +* **security:** apply the Image-security defaults instead of storing them ([#1296](https://github.com/PicPeak/picpeak/issues/1296)) ([8ca3610](https://github.com/PicPeak/picpeak/commit/8ca3610514dc9e16c1c14c82fce529042b728e47)) +* **security:** check for an escaped identifier before consuming the escape ([b6dc099](https://github.com/PicPeak/picpeak/commit/b6dc0991ce04b574a03aff9cd579f0333b11048e)), closes [#1264](https://github.com/PicPeak/picpeak/issues/1264) +* **security:** close the remaining image-security default gaps ([19c518a](https://github.com/PicPeak/picpeak/commit/19c518aaa50f1bd8fb7cef260a55bf3d5f3eb7f3)), closes [#1296](https://github.com/PicPeak/picpeak/issues/1296) +* **security:** close two CSS url() bypasses the sanitizer dedup exposed ([1cf8274](https://github.com/PicPeak/picpeak/commit/1cf82746b72c2639547871e4e479d390760b7c1d)) +* **security:** decode settings at the API boundary and honour the transaction ([0e560eb](https://github.com/PicPeak/picpeak/commit/0e560ebb193d8243ed4de059ea150f3f64fa1409)), closes [#1296](https://github.com/PicPeak/picpeak/issues/1296) +* **security:** one settings decoder, and the last creation path ([0deef25](https://github.com/PicPeak/picpeak/commit/0deef2584f4a6287bb947bdc545f585ae30aab67)), closes [#1296](https://github.com/PicPeak/picpeak/issues/1296) +* **security:** re-check inline CSS after template substitution ([027afb6](https://github.com/PicPeak/picpeak/commit/027afb608667ae072465fdf173751fa79f75110d)), closes [#1264](https://github.com/PicPeak/picpeak/issues/1264) +* **security:** reject array values for every field on the event update ([933f2d8](https://github.com/PicPeak/picpeak/commit/933f2d8e0ee0685128f2e8f8bed5169a06b4116c)), closes [#1296](https://github.com/PicPeak/picpeak/issues/1296) +* **security:** reject array values on the event update route too ([8f3436f](https://github.com/PicPeak/picpeak/commit/8f3436f17d6390a776c4258c53475d4c6038a63e)), closes [#1296](https://github.com/PicPeak/picpeak/issues/1296) +* **security:** strip control characters before scanning CSS for url() ([99f54a3](https://github.com/PicPeak/picpeak/commit/99f54a39546591d21df5ca11149770a161c447d9)), closes [#1264](https://github.com/PicPeak/picpeak/issues/1264) +* **security:** use CSS whitespace, not JavaScript's, in the url() reader ([4196e83](https://github.com/PicPeak/picpeak/commit/4196e83a5f0427984fc16538783a1f7418a10837)), closes [#1264](https://github.com/PicPeak/picpeak/issues/1264) +* **security:** validate CSS urls last, after every pass that moves text ([1151e96](https://github.com/PicPeak/picpeak/commit/1151e96144a9ada7170461e829d2f3d5dcc8edb2)), closes [#1264](https://github.com/PicPeak/picpeak/issues/1264) + ## [3.123.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.122.7-beta.0...v3.123.0-beta.0) (2026-09-04) diff --git a/backend/package.json b/backend/package.json index 99a437b1..adcd92be 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.123.0-beta.0", + "version": "3.124.0-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 08c24ee6..0f4b961e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.123.0-beta.0", + "version": "3.124.0-beta.0", "type": "module", "scripts": { "dev": "vite", From a5ff9264091bcc1727244565d62e22701971144e Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:55:53 +0200 Subject: [PATCH 23/23] chore(main): release 3.124.1-beta.0 (#1307) --- .release-please-manifest-beta.json | 2 +- CHANGELOG.md | 7 +++++++ backend/package.json | 2 +- frontend/package.json | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 8a425dc7..22c7b2e1 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.124.0-beta.0" + ".": "3.124.1-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b1de7677..48b5fbac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.124.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.124.0-beta.0...v3.124.1-beta.0) (2026-09-05) + + +### Bug Fixes + +* remove the fragmentation handling stranded by [#1303](https://github.com/PicPeak/picpeak/issues/1303) ([5dda14f](https://github.com/PicPeak/picpeak/commit/5dda14f7271265506a17acec5d253d9912784692)) + ## [3.124.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.123.0-beta.0...v3.124.0-beta.0) (2026-09-05) diff --git a/backend/package.json b/backend/package.json index adcd92be..be3035f3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.124.0-beta.0", + "version": "3.124.1-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 0f4b961e..7574ec1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.124.0-beta.0", + "version": "3.124.1-beta.0", "type": "module", "scripts": { "dev": "vite",