24101 Commits

Author SHA1 Message Date
Elena Torró
4faac8f746
🐛 Fix text decoration selection on v3 editor (#12255) 2026-10-09 15:48:03 +02:00
Elena Torró
c4068a2256
🐛 Fix additional suffix on new exporter files (#12256) 2026-10-09 15:47:08 +02:00
andrés gonzález
9931d82379
🐛 Show the themes permission message only to non-editors (#12203)
The theme selector showed "You need to be an editor to use themes"
whenever tokens could not be edited. That is also the case for editors
when the tokens source is a library, and they can still switch themes.
Base the message on file edit permission instead.

Closes #12177

AI-assisted-by: claude-opus-5-5
2026-10-09 14:14:51 +02:00
andrés gonzález
f9515a69ca
🐛 Translate the This file tab texts in the libraries modal (#12204)
* 🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)

* 🐛 Fix inner shadow covering the shape in the SVG renderer

The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.

Closes #11967

AI-assisted-by: claude-opus-5-5

* ♻️ Build filter entry props with mf/props

Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.

AI-assisted-by: claude-opus-5-5

* 🐛 Translate the This file tab texts in the libraries modal

The tab name, the section title and the empty state button of the
This file tab were hardcoded in English. Add translation keys with
English and Spanish texts.

Closes #12178

AI-assisted-by: claude-opus-5-5

---------

Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
2026-10-09 14:12:17 +02:00
Alejandro Alonso
92bcc0e635
🐛 Show live text size in Design tab while editing (#12250)
Auto-grow text geometry is finalize-only to avoid typing lag, so the
Design tab kept the committed selrect (0.01 for new texts) until Esc.
Patch display W/H from live WASM layout while editing, matching the
viewport outline, without restoring per-keystroke modifiers.

Closes #12244
2026-10-09 13:56:39 +02:00
Eva Marco
57bbe2112c 🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)
* 🐛 Fix inner shadow covering the shape in the SVG renderer

The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.

Closes #11967

AI-assisted-by: claude-opus-5-5

* ♻️ Build filter entry props with mf/props

Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.

AI-assisted-by: claude-opus-5-5
2026-10-09 13:03:21 +02:00
Alejandro Alonso
dd76faf17b Merge remote-tracking branch 'origin/main' into staging 2026-10-09 10:00:00 +02:00
Andrey Antukh
9b8dde2954
✨ Add rust toolchain setup on rust scripts (#12237) 2026-10-09 09:54:55 +02:00
Andrey Antukh
a36193e54b 🐛 Pass team-id in binfile v3 import tests
`resolve-and-link-libraries` asserts `::bfc/team-id`, and
`enable-backend-asserts` is now enabled in `scripts/ci` and
`tests-backend.yml`. Without a team-id, the v3 import tests
error instead of passing. Production always provides the
team (RPC import, management and debug routes); mirror that
in the tests.

Update `mem:backend/testing` and `mem:scripts/ci` with the
`enable-backend-asserts` behavior.

Verified with `./scripts/ci backend`: 1235 tests, 6384
assertions, 0 failures.

AI-assisted-by: deepseek-v4.1-flash
2026-10-08 17:33:40 +00:00
Andrey Antukh
ff4c4a4a34 📎 Backport .github and scripts/ci from develop 2026-10-08 19:24:58 +02:00
Luis de Dios
fad083314d
🎉 Expose per-side stroke width in the plugin API (#11994)
* ✨ Add stroke side-width helpers to common types

Add set-width-to-all-sides, set-width-to-single-side and side-width to
app.common.types.stroke, mirroring the border-radius helpers in
app.common.types.shape.radius. They back the per-side stroke width API
and are covered by unit tests.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Round-trip per-side stroke widths through the plugin API

Add strokeWidthTop/Right/Bottom/Left to format-stroke and parse-stroke so
reading shape.strokes exposes the per-side widths and writing a stroke
back preserves them instead of collapsing to the uniform width.

parse-stroke prefers a live stroke proxy's hidden raw state when present,
so a commit only rewrites the attributes the plugin touched.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add per-side stroke widths to the plugin API types

Add the four per-side members to the Stroke interface and to the
borderWidth and dimension token property lists, and refresh the generated
API surface used by the plugin test suite.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Expose per-side stroke width on the stroke proxy

Add strokeWidthTop/Right/Bottom/Left to StrokeProxy. Reads fall back to
the uniform strokeWidth; strokeWidth now writes all four sides, like
borderRadius writes all four corners; each side setter writes only its
own key and the top side also updates strokeWidth.

Reject per-side writes on shapes other than boards and rectangles with a
validation error naming the property, and expose the raw stroke map
through a hidden proxy member so a commit preserves untouched attributes.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Support stroke width tokens per side in the plugin API

Token attribute expansion now accepts the uniform strokeWidth property and
expands it to the four per-side attributes, mirroring the design tab.
shape.tokens exposes the per-side keys and reports strokeWidth when all
four sides share one token, and applyToken routes through the shared
expander.

AI-assisted-by: deepseek-v4.1-flash

* 📚 Document per-side stroke width in the plugins changelog

Note the new Stroke members, the restored strokeWidth token property and
the shape-type validation in plugins/CHANGELOG.md.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix some minor issues

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-08 18:09:06 +02:00
Eva Marco
9218448b29 🐛 Keep selection handles visible when editing measures
Changing position, size or rotation from the measures menu triggered
the bounding box cloaking, which sets the transform to :move for one
second. While it lasts the selection outline and handles are hidden,
and since the size badge is now shown during :move, only the badge
stayed on screen. Stop cloaking from these inputs so the selection
stays visible while its geometry is edited.

Closes #12162

AI-assisted-by: claude-opus-5-5
2026-10-08 17:58:36 +02:00
Eva Marco
dc6fc46a36 🐛 Refresh X/Y inputs when a flex child toggles absolute position
The measures menu is memoized and only re-renders when one of the
compared values changes. :layout-item-absolute was not among them, so
switching a flex child between static and absolute kept the X/Y inputs
in their previous disabled/enabled state until another compared value
changed or the shape was selected again.

AI-assisted-by: claude-opus-5-5
2026-10-08 17:58:36 +02:00
Alejandro Alonso
11bf8d1f67
🐛 Collapse text selection to edge on Left/Right (#12226)
With a range selected, Left and Right were moving one character from
the focus instead of collapsing to the selection start or end. Match
standard text-field behavior and keep Shift+arrows extending.

Closes #12207
2026-10-08 16:50:10 +02:00
Elena Torró
91b30d387f
🐛 Fix Ctrl+Home/End moving only to the paragraph edge (#12225) 2026-10-08 16:37:05 +02:00
Alejandro Alonso
78d26243c7
🐛 Sync Design tab styles on text-editor click caret (#12205)
Click caret placement only moved the selection and never refreshed
current styles, so the Design tab kept showing the previous fill and
typography. Place the caret through place_caret_from_position so
StylesChanged is emitted like keyboard navigation.

Closes #12195
2026-10-08 15:42:21 +02:00
Belén Albeza
2ffdd16038
🐛 Fix typing after a triple-click in the text editor (#12218)
A triple-click can leave the browser selection outside the hidden input
surface (it reads as "\n"). That selection is not editable, so typed
keys fired keydown but no input, and never reached the WASM editor.

Move the selection back into the surface before plain keys reach the
browser, and after double and triple clicks.

Closes #12187

AI-assisted-by: claude-opus-5-5
2026-10-08 15:35:28 +02:00
Belén Albeza
c2a1d70476
🐛 Fix strikethrough tooltip shortcut (#12221)
The Strikethrough button tooltip showed Alt+Shift+Ctrl+5, but the
shortcut is Alt+Shift+5. The tooltip used meta-shift, which adds
Ctrl (Cmd on Mac). Build it with shift so it matches the command.

Closes #12217

AI-assisted-by: claude-opus-5-5
2026-10-08 15:26:11 +02:00
Elena Torró
29ea6e49b6
🐛 Step over whole emojis in the text editor (#12202)
Caret moves, deletes and caret rects use grapheme boundaries.
Closes #12188
2026-10-08 13:42:25 +02:00
Andrey Antukh
d6f62d8d7d ⬆️ Update opencode on devenv dockerfile 2026-10-08 13:13:10 +02:00
Andrey Antukh
c17eb2a804
📚 Document disable-email-verification team auto-add behavior (#11425)
The configuration docs claimed it is not possible to add someone to a
team without them accepting an invitation email, but the backend
auto-adds existing profiles to the team when the email verification
is disabled (create-invitation in app.rpc.commands.teams-invitations).

Replace the incorrect sentence with an accurate exception note, add
the missing disable-email-verification entry to the "Other flags"
reference, document the CLI-created profile auto-add behavior in the
docker getting-started guide, and describe the shortcut in the
developer authentication docs.

Closes #11424

Reported in #11376

AI-assisted-by: glm-5.3-flash
2026-10-08 12:44:39 +02:00
andrés gonzález
3779dd99ae
💄 Remove the duplicated MCP Server link from Key Features (#12193)
Product already links to the same page.

Refs #12132

AI-assisted-by: claude-opus-5-5
2026-10-08 12:00:54 +02:00
andrés gonzález
85ba670278
💄 Unify copy in the shortcuts settings, modals and sidebar (#12174)
* 💄 Unify copy in the shortcuts settings, modals and sidebar

Use one term per concept across the customizable shortcuts UI: "Custom"
and "Customize" for user changes, "shortcut" for the keys, and "Reset"
with "default" to go back to the defaults.

Rewrite the conflict message so it says what saving does, fix typos and
grammar, unify the empty states and rename the font size actions.

Give the reset all button, the search placeholder, the import column,
the invalid import error and the edit button aria-label their own keys,
and update the e2e tests to the new copy.

Closes #12173

AI-assisted-by: claude-opus-5-5

* 💄 Reword the shortcuts sidebar link and custom empty state

The sidebar link no longer names a settings section that does not exist
in Penpot. The empty state of the custom tab now says there are no
custom shortcuts and points to the All tab by name.

AI-assisted-by: claude-opus-5-5
2026-10-08 12:00:17 +02:00
Eva Marco
c9146a30ab
🐛 Keep layer search and tree scroll across sidebar tab switches (#12117)
Switching sidebar tabs unmounts the layers panel. The search text and
filters lived only in component state, so on return the still open
search bar was treated as a new search and cleared. Keep that state per
file in a session atom, as the assets panel already does.

Closing or clearing the search mounts the tree container again, but the
restore hook only ran on mount or page change, so the tree went back to
the top. Let use-restore-scroll take an active? flag so each list
restores its own position whenever it is shown again, and drop the
saved search position on close so a new search starts at the top.

Closes #11972

AI-assisted-by: claude-opus-5-5
2026-10-08 11:45:35 +02:00
andrés gonzález
df0402b626
💄 Align the Help Center footer with penpot.app (#12179)
The footer had a hardcoded 2025 copyright and links that no longer match
penpot.app. The year is now set at build time. Sections, links and
social icons follow the current penpot.app footer, and the grid wraps
into two rows to fit the new sections.

Closes #12132

AI-assisted-by: claude-opus-5-5
2026-10-08 10:59:36 +02:00
Eva Marco
31a079a7a2
🐛 Allow creating tokens when the file is its own tokens source (#12182)
Setting the local file back as the tokens source, or unlinking the
library that was the source, stored the file's own id in
:tokens-source. ensure-tokens-lib only created a tokens-lib when
:tokens-source was nil, so a file without a tokens-lib never got one,
and every token change was dropped without any error.

Now ensure-tokens-lib also creates the tokens-lib when the source is
the file itself, which fixes files already in that state, and
set-tokens-source removes the attribute instead of storing the file's
own id.

Closes #12180

AI-assisted-by: claude-opus-5-5
2026-10-08 09:55:18 +02:00
Eva Marco
41efc3558a
🐛 Fix nested libraries vanishing from the libraries modal (#12031)
* 🐛 Fix nested libraries vanishing from the libraries modal

The "This file" tab nests each linked library under a linked library
that uses it, taken from the team shared files. A library with such a
parent was removed from the top level and only drawn under its parent
if the parent was at the top level itself. So:

- In a chain of two or more levels (file -> A -> B -> C), C was lost.
- In a cycle (A uses B and B uses A), both had a parent, none was at
  the top level, and both were lost.

The libraries showed while the shared files were loading, and vanished
when they arrived.

Build the list as a tree instead: direct libraries and libraries not
used by any other linked library are roots, nested libraries follow
their parent at any depth, and each library is added only once, even
when the dependencies form a cycle.

AI-assisted-by: claude-opus-5-5

* ♻️ Move the linked libraries tree to sort-linked-libraries

Extract the code that orders the linked libraries as a tree from the
file tab to a public function, so it can be tested on its own. Add
tests for a cycle with a direct root, a cycle with no root, a deep
chain and no dependencies yet.

AI-assisted-by: claude-opus-5-5

* ♻️ Use connected-to to find library parents when sorting

The caller already computes :connected-to for each linked library, the
reverse index of the dependencies map. sort-linked-libraries rebuilt
the same relation from dependencies. Read it from :connected-to
instead, so the function takes only the libraries and the component
memo no longer depends on dependencies.

Reword the cycle-without-root test to say it models the unknown direct
flag, and add tests for a diamond, a self-dependency, a cycle of three
without root, a parent that is not a linked library, and for no
library being lost or duplicated.

AI-assisted-by: claude-sonnet-5-5

---------

Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
2026-10-08 09:54:46 +02:00
Elena Torró
b3a62eeb4b
🐛 Paint layout children in canvas z-order in vector exports (#12171) 2026-10-08 09:07:33 +02:00
Alejandro Alonso
30127e57a0
🐛 Skip spurious save when missing font cannot be remapped (#12164)
fix-deleted-fonts committed text and typography changes even when no
replacement font existed, so opening a page with a missing custom font
rewrote identical content and cleared position-data on every visit.

Only emit changes when the remapped content or typography actually
differs. Add focused unit coverage for the no-replacement and remapped
paths.

Closes #12151
2026-10-07 18:03:51 +02:00
Eva Marco
ddce3c2c95
🐛 Open the import dialog again from template links (#12131)
Template links (#?template=<url>) keep their params in a bare "#?"
fragment. Since routing moved to the query string, only "#/" legacy
hashes are translated, and the root route was an empty query string,
so the template param was dropped and the dashboard opened without the
import dialog. Route a "#?" fragment as the query token, and treat a
query without a screen param as the root, as before.

Logged out users also lost the template: get-teams requires a session
and its 401 ended the flow before the params were kept for after login.
Skip it for the anonymous profile. This part already failed on main.

Closes #12127

AI-assisted-by: claude-opus-5-5
2026-10-07 14:03:08 +02:00
Danny Shirely
6defe26a78 🐛 Keep per-side stroke widths out of inspect geometry (#12055)
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.

Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.

Closes #12026

AI-assisted-by: GPT-6

Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
2026-10-07 13:09:04 +02:00
Alonso Torres
29072e4c6b
🐛 Fix path editor crash on shortcut (#11996) 2026-10-07 13:07:07 +02:00
Alonso Torres
0d8bcf9637
🐛 Fix crash when drawing (#12101) 2026-10-07 12:32:14 +02:00
Luis de Dios
f962cf487c
🐛 Migrate legacy stroke width token references (#12018)
Stroke width tokens used to be applied to the single `:stroke-width`
token attribute. Applying tokens per side replaced it with the four
`:stroke-width-*` attributes, but no migration moved the existing
references, so files written before 2.19 look unapplied: the token
pill stays off, the Design tab shows a plain number and propagation
finds no update action.

Add migration 0031, which copies the legacy reference to every side,
keeps any side reference already present and drops the old key. It
covers page and component shapes.

Closes #12016

AI-assisted-by: deepseek-v4.1-flash
2026-10-07 11:17:40 +02:00
andrés gonzález
3f6b7a7959
✨ Take changelog highlights from the release highlight label (#12154)
* ✨ Take changelog highlights from the release highlight label

The 🚀 section was picked by the agent on each run and kept
forever, so a regeneration could add highlights product did not choose
and nobody could drop one. Make the `release highlight` label the only
source, as `breaking change` already is for 💥, and have the report
flag 🚀 entries that do not match the label.

AI-assisted-by: claude-opus-5-5

* 📚 Update changelog for 2.19.0

Regenerate the 2.19.0 section from the milestone with the
update-changelog skill. Add the 79 closed issues that were missing,
drop entries labelled `no changelog` and #9263, which is not in the
milestone, and take 🚀 from the `release highlight` label.

AI-assisted-by: claude-opus-5-5
2026-10-07 10:42:54 +02:00
Andrey Antukh
806a9ee98e
⬆️ Update dependencies (#12150) 2026-10-07 10:41:29 +02:00
Andrey Antukh
7f0aa4f38e Merge remote-tracking branch 'origin/main' into staging 2026-10-07 10:23:37 +02:00
Andrey Antukh
b2d688a04c 💄 Stringify ids in comp-processors log props
Wrap every id prop in the comp-processors warn logs
with str so the log shows plain text instead of
the #uuid reader form. Also use :hint instead of
:msg and lowercase text, as the rest of the system
does. No logic changes, logging only.

AI-assisted-by: muse-spark-1.3
2026-10-07 08:17:33 +00:00
Eva Marco
2f47be6c15
🐛 Show the size badge on paths and straight lines (#12066)
* 🐛 Show the size badge on paths and straight lines

The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.

Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.

The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.

AI-assisted-by: claude-opus-5-5

* ♻️ Extract the size badge layout and add tests for it

Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.

Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.

The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.

AI-assisted-by: claude-opus-5-5

* ✨ Show the length of straight lines in the size badge

A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.

The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.

Relates to #12064

AI-assisted-by: claude-opus-5-5

* 🐛 Hide the path size badge when the path is lower than it

Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.

AI-assisted-by: claude-opus-5-5
2026-10-07 09:18:57 +02:00
Andrey Antukh
e7041234f3 📎 Set library (sdk) version to 1.2.0 2026-10-07 08:49:32 +02:00
Andrey Antukh
84f068aca7 Merge remote-tracking branch 'origin/main' into staging 2026-10-06 18:59:36 +02:00
Andrey Antukh
5b02e4d898 ✨ Add find-project-anomalies skill and helper
Check a milestone against the Main project board with
scripts/project-anomalies.py (check writes
tmp/<MILESTONE>-ANOMALIES.md): open issues with merged PRs,
milestone mismatches either way, needs triage labels and
unassigned non-community issues. Outsiders resolve in bulk;
only a missing milestone auto-fixes, on confirmation.

Teach scripts/gh.py batched issue lookup (issue subcommand),
milestone on prs output, plus assignees and projects fields,
and retry transient HTTP 504s.

AI-assisted-by: muse-spark-1.3
2026-10-06 16:58:08 +00:00
Andrey Antukh
4bdded86be ♻️ Extract changelog checks into scripts/changelog.py
Move the inline programs out of the update-changelog skill into
scripts/changelog.py (check-merged, cross-ref, report) and shrink
the skill to the workflow alone.

Teach scripts/gh.py to retry transient HTTP 504s, resolve lookups
in batches of 50 (new issue subcommand, milestone on prs output),
and report 💥 entries without the breaking change label.

Add the nine missing 2.17.0 changelog entries found while
trying the new flow.

AI-assisted-by: muse-spark-1.3
2026-10-06 16:26:37 +00:00
Eva Marco
d046f6997d
🐛 Keep assets list view scroll position across sidebar tabs (#12115)
In list view the height of each component row depended on its
thumbnail, which renders deferred. When the Assets tab mounted again,
rows were shorter until the thumbnails appeared, so the restored
scrollTop landed several rows further down and scroll anchoring kept
it there. Reserve the thumbnail height in the row grid so rows keep
their size from the first render.

Closes #11971

AI-assisted-by: claude-opus-5-5
2026-10-06 16:55:16 +02:00
Eva Marco
fcbb8f1fc0
🐛 Keep derived layout paddings non-negative (#12129)
Adding a flex or grid layout to a board with children derives the
paddings from the distance between the board and its children. A child
that overflows the board, or floating point noise after "Resize board
to fit content", gave negative paddings. The shape schema rejects them
since paddings must be non-negative, so update-file failed and autosave
stopped. Clamp the derived paddings to 0, as the derived gaps already
are.

Closes #12107

AI-assisted-by: claude-opus-5-5
2026-10-06 16:34:28 +02:00
Andrey Antukh
51ae83202e Merge remote-tracking branch 'origin/main' into staging 2026-10-06 15:56:55 +02:00
Andrey Antukh
0ccd75487f ⬆️ Update dependencies on devenv 2026-10-06 15:43:38 +02:00
Eva Marco
5981d1ee66
🐛 Open a never scrolled token set at the top of the list (#12119)
The tokens list container stays mounted when another token set is
selected; only the restore key changes. With no saved position for the
new set, use-restore-scroll did nothing, so the list kept the scroll
position of the previous set. Scroll to the top when nothing is saved
for the key. Freshly mounted panels are already at the top, so Layers
and Assets do not change.

Closes #11973

AI-assisted-by: claude-opus-5-5
2026-10-06 15:40:24 +02:00
Andrey Antukh
ff7a66f519 📚 Update changelog 2.18.3 2026-10-06 14:25:50 +02:00
Andrey Antukh
10504ff2bf
🐛 Sanitize SVG on binfile import (#12105)
* 🐛 Sanitize smuggled SVG on binfile import

Route imported SVG storage objects through the SVG sanitizer on
all three binfile paths (v3, v1, v2), closing the second bypass
of GHSA-ffhp-m958-qxvr. Integrity checks still run on the raw
bundle bytes first; only the persisted copy is the sanitized one.

Adds a shared sanitize-imported-svg helper with unit tests and a
v3 export-tamper-import test proving smuggled scripts no longer
survive the import.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Normalize SVG content-type on binfile import

Close the residual GHSA-ffhp-m958-qxvr bypass where a crafted
content-type spelling (uppercase, parameters) skipped the import
sanitizer. Detection now uses a shared case-insensitive predicate,
stored values are canonicalized, and the v3 re-read honors the
import size limit.

Extends the tamper tests to obfuscated spellings and adds
exhaustive v1 coverage, including the tempfile branch.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Gate binfile content-type on known storage types

Define the complete set of storage content-types in
app.common.media and enforce it on the binfile import schema, so
unknown types fail closed instead of passing through. Detection
keeps a single normalization at the boundary with an exact
predicate, and the string helpers use cuerdas.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Keep only produced types in storage-object-types

Drop apng, avif, penpot and plain text from the set: no flow
stores them, they only exist in the extension mapping table.
Every member must have a producing code path; unproduced types
stay out until some flow actually stores them.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Reject unknown content-type on v1 binfile import

Enforce the storage content-type allowlist on the v1 import
path, like v3 already does via schema. Bundles declaring types
outside the set, or none at all, fail closed with the same
media-type-not-allowed error as uploads. The sanitize branch is
explicit: SVG bytes are sanitized, anything else passes through.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-06 14:21:12 +02:00