Merge branch 'develop' into manage-sh-safe-directory

Bring manage-sh-safe-directory up to develop b438676d6c. The merge is
clean: manage.sh auto-merged and the branch's safe.directory guard
survives unchanged.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-11-11 17:38:55 +01:00
commit c473d358f0
No known key found for this signature in database
863 changed files with 85368 additions and 48130 deletions

View File

@ -1,6 +1,6 @@
---
name: local-ci
description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors.
description: Run local CI-style checks with ./scripts/ci (lint, tests, format, and on request production builds) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, repairing Clojure delimiter errors, or checking that a module still compiles.
---
# Local CI
@ -29,10 +29,12 @@ Run from the repo root:
```
Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp`
`plugins` `library`, or `--all` for every module.
`plugins` `library` `media-processor`, or `--all` for every module.
With no task flags it runs three tasks per module, in order: **lint**,
**test**, **fmt** (format check; `--fix` formats files instead).
**test**, **fmt** (format check; `--fix` formats files instead). **build**
is opt-in: it compiles the module the way production does and is the slow
one, so it never runs unless you ask for it.
| Flag | Effect |
|------|--------|
@ -41,6 +43,7 @@ With no task flags it runs three tasks per module, in order: **lint**,
| `--lint` / `--no-lint` | Run only lint / drop lint |
| `--test` / `--no-test` | Run only tests / drop tests |
| `--fmt` / `--no-fmt` | Run only format check / drop it |
| `--build` / `--no-build` | Run only the production build / drop it |
| `--fix` | Format files instead of checking (other tasks unaffected) |
| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files |
| `--fail-fast` | Stop at the first failure |
@ -72,6 +75,12 @@ log path.
# Format the module without running the test suite
./scripts/ci --fix --no-test frontend
# Check that the module still compiles (slow: minutes)
./scripts/ci --build frontend
# Compile every module (8 builds, ~4 min with warm caches)
./scripts/ci --build --all
# Broke delimiters in Clojure/CLJS files: repair first, then lint
./scripts/ci --paren-repair frontend
./scripts/ci --lint frontend
@ -89,7 +98,12 @@ log path.
- Test tasks are long-running (backend runs `clojure -M:dev:test`); give
the bash call a generous timeout (10–20 minutes) instead of letting it
time out mid-run.
- `mcp` has no lint task — it shows as skipped, not failed.
- `mcp` and `media-processor` have no lint task — they show as skipped,
not failed. `common` has no build task for the same reason.
- Build runs are the slow ones (frontend ~2 min even with warm caches;
cold caches roughly double it). Give the shell a generous timeout and
do not run two builds at once in the same checkout: `render-wasm` takes
a build lock.
- `--paren-repair` only fixes delimiters; run lint afterwards to catch
what remains. See `mem:scripts/paren-repair`.
- What to run and how to read test results: `mem:testing`.

View File

@ -0,0 +1,330 @@
---
name: migrate-ds-area
description: Migrate one Penpot UI area to the Design System components for one group of the DS migration epic (#12375): buttons and icons (icon-button*, button*, icon*), selects (select*) and later groups. Moves the area's SCSS off the deprecated styles, proves every change with a zero-tolerance visual test, then opens the draft PR and updates the group issue (PRs table row, QA comment). Use when the user asks to migrate an area or module to the DS, continue a DS migration group, or names an area of a group issue.
---
# Skill: migrate-ds-area
Move one UI area (one `.cljs` + `.scss` pair, or a few related modules)
from legacy components and raw HTML to the Design System components,
prove with pixels what changed, and ship it as a draft PR that QA can
test.
The epic is #12375. Each control type is a group with its own issue
(buttons and icons #12376, selects #12377, radios #12378, …). The group
issue holds the plan (order, waiting areas), the changes QA should
expect, and the PRs table where people claim areas. Read it first.
## Input
- The group issue (e.g. `#12376`) and the area: a module path (e.g.
`dashboard/fonts`) or an area named in the group issue.
- No area given: take the next unclaimed area in the group issue's plan
order, and say which one.
## Ground rules
- Never `git push`. The user pushes; then you open the PR.
- PRs go to `develop`; branches start from `origin/develop`.
- **Stay in the group.** Only the controls of this group change. In the
buttons/icons group, selects, radios, numeric/text inputs, checkboxes,
menus (`%menu-*`, `%dropdown-*`) and modal structure (`%modal-*`) stay;
a checkbox tick or an input label icon stays legacy.
- **Accept the DS look.** Do not add CSS to imitate legacy sizes or
colors. Do keep positions: icons and labels must not move unless the
DS size forces it, and then say so.
- No negative margins to compensate; use positive DS spacing or
`px2rem()`.
- **Gaps in the DS wait.** An area that needs an icon or a feature the
DS lacks keeps its legacy control; say so in the PR.
- Skip legacy shared components that another group will replace
(`context_menu_a11y`, `forms`, `select`, `dropdown`, `radio_buttons`,
`search_bar`, `title_bar`, …): they wait for that group's cleanup,
where they are updated if still used or deleted if not.
- Debug-only UI gets no QA comment and no compare image.
## Devenv
Run every test, lint and `pnpm` command inside the workspace container,
never on the host (a host `pnpm` relinks `node_modules` and breaks the
container's watcher). The container is `penpot-devenv-ws<N>-main`, where
`<N>` is the workspace number (`ws0` is the main repo; `wsN` lives in
`~/.penpot/penpot_workspaces/wsN`). Write output to a file, then read it:
```bash
C=penpot-devenv-ws1-main # your workspace
docker exec -u penpot -w /home/penpot/penpot/frontend $C \
bash -lc '<command>' > <scratch>/out.txt 2>&1
```
When the app does not behave as the code says, check the build first:
- **Stale build after a branch switch** ("is not a function", or a page
with empty `stl/css` classes): `touch` the stale `.cljs`/`.scss`, wait
for the compiled file under `frontend/resources/public/` to update,
hard-reload.
- **A whole area unstyled:** a new `.scss` added after the watcher
started is missing from `main.css` (grep `main.css` for the module
prefix), or `main.css` was written corrupted after many `.scss`
changed at once. Touch a `.scss`; if the module is still missing,
restart the watcher in the workspace's tmux session (`penpot:0`,
`./scripts/watch app`).
- **Blank app after switching between `develop` and `staging`:**
dependencies differ. Run `pnpm install --frozen-lockfile` in the
container and restart the watcher.
## Workflow
### 1. Claim the area and branch
1. Read the group issue: plan, waiting areas, expected changes, PRs table.
2. Add a row to the PRs table with no PR number, the user's @login as
Owner and the state `Claimed` (`gh issue view/edit <n> --body-file`).
Read the body right before editing, and read it again after saving:
when two people edit at the same time, GitHub keeps only the last
edit, so a row can vanish. Put yours back if it is gone.
Skip an area someone else already claimed, unless the claim has had
no PR for 7 days and its owner did not answer a comment asking.
3. `git switch -c <user>-ds-<group>-<area> origin/develop`.
4. Survey the area: every control of the group in the `.cljs`, the
legacy icons, the placeholders and mixins the `.scss` uses, the
`div`/`span` with `:on-click`.
### 2. Visual baseline
Add a `test.describe("<Area> controls", …)` block to the matching spec
in `frontend/playwright/ui/visual-specs/` (project `ds`, dark theme).
Put it next to the blocks of the same screen, not always at the end of
the file: two PRs that both append at the end conflict.
- Every shot: `toHaveScreenshot({ maxDiffPixelRatio: 0, threshold: 0 })`.
- Show every changed control at rest, on hover and open or pressed.
Capture the element or the smallest stable container.
- Locators must work before and after the change: text, role, testid,
`[class*='…']`, or `a.or(b)` when the element type changes (raw
`[class*='menu-btn']` before, `getByRole("button", { name: "Options" })`
after). Scope `.or()` to the area so it cannot match elsewhere.
- Missing data? Add a fixture under `frontend/playwright/data/` and mock
it (`dashboardPage.mockRPC(...)`, `DashboardPage.mockConfigFlags` for
flags). Watch for expired or inconsistent fixtures (dates in the past,
wrong file for a mock).
- Capture with `--update-snapshots=all`:
```bash
npx playwright test --project=ds <spec> -g "<describe>" --update-snapshots=all
```
Plain `--update-snapshots` keeps a snapshot within the project's 0.5%
tolerance, so a real diff looks like 0.
- Copy the snapshots (`<spec>.spec.js-snapshots/<Describe>-*`) to
`<scratch>/diffs/<area>/before/` and look at them: the state you
wanted must really be on screen.
### 3. Controls commit
#### Buttons and icons
| Legacy | DS |
|---|---|
| icon-only raw button | `[:> icon-button* {:variant "ghost" :aria-label (tr …) :icon i/x :on-click f}]` (always pass `:variant`; `"action"` for 24px icon toggles) |
| pressed/selected | `:aria-pressed (boolean x)` |
| text button | `[:> button* {:variant "primary"/"secondary"/"ghost"/"destructive" :type "button"} label]` |
| `[:input {:type "button"}]` | `button*` |
| modal close | `icon-button*` ghost, `:aria-label (tr "labels.close")`, `:tooltip-class` carrying the corner position |
| legacy icon outside a button | `[:> icon* {:icon-id i/x :class …}]` |
- Not every raw button changes: rows, tabs, swatches, avatar widgets,
full-height segments, round custom buttons and link-style text buttons
stay raw (their legacy icon still changes). List the kept ones in the
PR.
- A `div`/`span` with `:on-click` in the area becomes a `[:button]`
(with `aria-expanded`/`aria-haspopup` when it opens something). An
`[:a]` without `href` stays; list it in the PR.
- **Tooltip wrapper.** `icon-button*` renders inside a tooltip trigger
wrapper. Position (`position: absolute`, grid placement, margins
against siblings) goes on `:tooltip-class`; look (`--button-height`,
colors) goes on `:class`.
- **Tooltip at the right edge.** Small icon buttons at the right end of
a sidebar row or title bar get `:tooltip-placement "top-left"`; list
it as an expected change.
- **Sizes through custom properties:** `--button-height: #{px2rem(64)}` /
`--button-width`. Sass does not evaluate functions inside custom
properties without `#{}`.
- **A grown `button*` keeps its label at the start**: add
`justify-content: center` when it is wider than its content.
- **Icon colors:** DS icons use `stroke: currentColor`. Set `color` on
the icon class, not `stroke`/`fill`. A legacy `color: transparent` left
on the button or wrapper makes the DS icon invisible: drop it. A class
that redefines the color variable on the icon breaks the hover that
changes it on the button: define the variable on the button.
- **Every DS icon needs a color of its own** (on its class or a parent):
one that inherits none shows in the devenv debug yellow.
- **Icon sizes:** `"s"` draws 12px inside a 16px box (compensate the
margin by 2px per side to keep the drawing in place); `"m"` 16px; `"l"`
24px; `"xl"` 32px.
- **Stroke width:** DS icons keep the DS stroke width. Never set
`stroke-width` on a DS icon to match the legacy one; a thinner or
thicker line is part of the DS look.
- **Legacy placeholders that style `svg` descendants** (e.g.
`%dropdown-element-base` → `span svg { 12px box; stroke }`) override a
DS icon placed inside them: give the icon class its own box
(`inline-size`/`block-size` 16px) and `stroke: currentcolor` with more
specificity.
- **Flex shrink:** a DS svg inside a small flex button can shrink and be
cut; add `flex-shrink: 0` to the icon class.
- **Rotated legacy arrows:** prefer `i/arrow-left`/`-right`/`-up`/`-down`
over rotating `i/arrow` (same path), unless a rotation class drives the
open/closed state.
- **Menus anchored by the button size** move when the button grows; open
them from the top (`inset-block-start: calc(100% + var(--sp-xs))`) or
fix the context menu offset, and say so.
#### Selects
`[:& select {:default-value v :options [{:value :label}] :on-change f}]`
→ `[:> select* {:default-selected <id> :options <memoized vector of {:id :label}> :on-change f}]`
(`app.main.ui.ds.controls.select`).
- **Ids are non-blank strings.** A blank id shows a dimmed "--", and an
id that matches no option falls back to the first one. Give mixed,
none and auto their own ids (`"multiple"`, `"none"`, `"auto"`) and map
them back in the handler. Use `""` only where "--" is the wanted look.
- **Convert the value.** The legacy select ran `d/read-string`, so
handlers got keywords, numbers or uuids; `select*` passes the `:id`
string. Convert with `keyword`, `d/parse-integer` or `uuid/parse`.
- **Memoized options, live selection.** `select*` resets its selection
when `:default-selected` or the `:options` identity changes. Build
options with `mf/with-memo` and `mapv` (the schema wants a non-empty
vector) and pass `:default-selected` from the live value, never a
constant.
- **Never pass nil.** In dev builds a present prop with value nil
(`:class`, `:wrapper-class`, `:disabled`, `:default-selected`) throws
`invalid props on component select*`. Use `(boolean x)`, `d/nilv`, or
add the prop only when it has a value.
- **Props the legacy select ignored stay out**: `select*` passes rest
props to its button, so they would reach the DOM.
- **Classes:** `:wrapper-class` for layout, `:class` for look.
- **Portal:** add `:has-portal true` only where the visual test shows the
list cut at the bottom of a panel, and say so in the QA comment.
- **Locators:** the button is a `combobox` with no accessible name; use
`getByRole("combobox").filter({ hasText: "Linear" })` (it matches the
legacy select too) and `getByRole("option", { name: … })`.
- **Keyboard:** the arrows no longer change the value while the list is
closed. It is an expected change, already listed in #12377.
#### All groups
- New labels: add the key to `en.po` and `es.po` by hand, in sorted
position, with a `#:` reference to the cljs line. Never run the
translations sync.
- The `.scss` only gets what the new controls need in this commit.
- Some e2e specs are not Prettier-clean on develop: never run
`prettier --write` on a whole spec; format only your block.
Then wait for the watcher (grep the compiled JS/CSS under
`frontend/resources/public/` for the change), recapture with `=all`,
copy to `after/`, diff:
```bash
SK=.agents/skills/migrate-ds-area/scripts
python3 -I $SK/diff.py <before> <after> # per-file pixel count + bbox
python3 -I $SK/trip.py <b> <a> <out.png> <scale> # before | after | red diff
```
`trip.py` pads images of different sizes, so a taller "after" still
renders.
Explain every diff. Fix what is not a DS-look change (moved icons, cut
icons, lost hover color). Rerun a shot that changes on unchanged code
before trusting it; flaky spots seen so far: the invite modal textarea
border, the search input caret, the user account page, the path handler
menu trigger.
Lint, then commit (read `mem:workflow/creating-commits` first):
```bash
npx prettier --check <spec> <fixtures>; npx stylelint <scss>; clj-kondo --lint <cljs>; cljfmt check <cljs>
git add -f <cljs> <scss> <po> <spec> <fixtures> # -f: visual-specs/ is gitignored
./scripts/check-commit
```
A plain `git add` with an ignored path in the list adds nothing; always
`-f`, and check `git show --stat`. Never commit the screenshots.
Subject: `:recycle: Use DS <controls> in <area>`. A pre-existing bug
found and fixed on the way goes in its own `:bug:` commit.
### 4. SCSS commit
Run the `refactor-scss` skill on every `.scss` the PR touched (only
those), in its own commit. Keep `refactor/` only for component-family
placeholders (`%modal-*`, `%menu-*`, `%input-*`, `%checkbox-*`) and say
why in a comment above the `@use`. Unused selectors are deleted, not
commented out (list them in the commit body). Typography without an
exact match uses the closest DS typography. Run the skill's
**Self-review** before committing: no bare element selectors (add
classes in the `.cljs`), no copied properties that do nothing, z-index
tokens by purpose. Compare against the controls commit: it should be
0 px; explain anything else (typically a typography or border change).
For files that several modules share or that are huge, stop and propose
a separate PR instead.
### 5. Hand off, PR and tracking
1. Report to the user: commits, visible changes, kept raw controls,
pre-existing bugs, and the compare image path
(`<scratch>/diffs/<area>/compare.png`, before | after | diff). Give
the push command: `git push -u origin <branch>` (the branch tracks
`origin/develop`, so a bare `git push` is refused).
2. After the user pushes, check `git rev-parse HEAD origin/<branch>`,
then (read `mem:workflow/creating-prs` first):
```bash
gh pr create --repo penpot/penpot --draft --base develop --head <branch> \
--title "<commit subject>" --body-file <body> \
--label "WG adoption" --label "no issue required" \
--milestone <group issue milestone> --project Main
gh pr edit <n> --repo penpot/penpot --remove-label "needs triage"
```
Never add `no changelog` to a PR: that label goes on issues.
Body: AI note, `Part of #<group> (DS migration epic #12375).`, a
short What / How, the kept raw controls, an empty `## Screenshots`
when something is visible (the user attaches the image). Never
`Closes #<group>`. Keep it short: reviewers find long PRs hard to read.
3. In the group issue's PRs table, fill your row:
`| #<n> | <area>: <controls> (<visible changes>) | @<owner> | Draft |`.
4. Post a QA comment on the group issue: `**#<n>: <area>**`, *Where* (how
to reach it), *Expected changes* (every visible change; QA treats
anything not listed as a bug), *No other change* (what still works
the same).
Do not mark the draft ready or ask for review: the user decides when.
### 6. Review fixes
When the reviewer comments on a PR:
1. Fix each comment in a new commit on the branch (never amend pushed
commits). A comment you do not apply gets a short reason the user can
post, with an image when it is visual.
2. Rerun the area's visual spec before and after the fix with `=all`.
Build a compare image for every visible change and give the user its
full path, saying which PR it goes to.
3. Update everything the fix makes stale, in the same turn: the QA
comment (*Expected changes* must list the new visible changes), the
PRs table row, and the PR body.
4. When working on several PRs in one go, report per PR: branch, new
commit, what changed, image path. Never mix two branches in one list.
5. Give the push command per branch. If a rebase is coming soon, say
that pushing now costs one extra CI run; the user decides.
6. After a round of review fixes, check whether the same issue exists in
the user's other open PRs of the group, and fix it there too.
### 7. After merges
Whenever PRs merge (or go from draft to ready), sync the state column of
the group's PRs table with GitHub for **every** row, not only your own:
read each row's PR (`gh pr view <n> --json state,isDraft`) and set
Merged / In review / Draft. The table drifts when other people merge.
## Output
- Branch with 2 commits (controls, SCSS) plus any `:bug:` commit.
- Visual spec block with zero-tolerance shots.
- Compare image in the scratch folder.
- After the push: draft PR, row and QA comment in the group issue.

View File

@ -0,0 +1,25 @@
"""Pixel diff of two folders of PNG snapshots with the same file names.
Usage: python3 -I diff.py <before-dir> <after-dir>
Prints, per file, the number of differing pixels and their bounding box,
or the two sizes when they differ.
"""
import os
import sys
from PIL import Image, ImageChops
before, after = sys.argv[1], sys.argv[2]
for name in sorted(os.listdir(before)):
other = os.path.join(after, name)
if not os.path.exists(other):
print(name, "missing in", after)
continue
a = Image.open(os.path.join(before, name)).convert("RGB")
b = Image.open(other).convert("RGB")
if a.size != b.size:
print(name, "size", a.size, b.size)
continue
diff = ImageChops.difference(a, b)
count = sum(1 for p in diff.getdata() if p != (0, 0, 0))
print(name, count, diff.getbbox())

View File

@ -0,0 +1,33 @@
"""Before | after | red diff image, one row per snapshot.
Usage: python3 -I trip.py <before-dir> <after-dir> <out.png> <scale>
Both folders hold PNGs with the same names (crop them first if needed).
"""
import os
import sys
from PIL import Image, ImageChops
before, after, out, scale = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
rows = []
for name in sorted(os.listdir(before)):
a = Image.open(os.path.join(before, name)).convert("RGB")
b = Image.open(os.path.join(after, name)).convert("RGB")
w, h = max(a.width, b.width), max(a.height, b.height)
pad_a = Image.new("RGB", (w, h), (200, 200, 200)); pad_a.paste(a, (0, 0))
pad_b = Image.new("RGB", (w, h), (200, 200, 200)); pad_b.paste(b, (0, 0))
mask = ImageChops.difference(pad_a, pad_b).convert("L").point(lambda v: 255 if v else 0)
red = Image.new("RGB", (w, h), (255, 255, 255))
red.paste((255, 0, 0), mask=mask)
row = Image.new("RGB", (w * 3 + 20, h), (200, 200, 200))
for i, im in enumerate([pad_a, pad_b, red]):
row.paste(im, (i * (w + 10), 0))
rows.append(row.resize((row.width * scale, row.height * scale), Image.NEAREST))
width = max(r.width for r in rows)
height = sum(r.height + 10 for r in rows)
canvas = Image.new("RGB", (width, height), (255, 255, 255))
y = 0
for r in rows:
canvas.paste(r, (0, y))
y += r.height + 10
canvas.save(out)

View File

@ -0,0 +1,421 @@
---
name: refactor-scss
description: Migrate one Penpot SCSS module off the deprecated styles under frontend/resources/styles/common/refactor/ to the current Design System (ds/ tokens, mixins and px2rem). Pure refactor, no visual change. Use when the user asks to refactor, migrate or clean up a .scss file to the DS.
---
# Skill: refactor-scss
Refactor a single SCSS file so that it no longer depends on the deprecated
styling system, using the current Design System instead.
This is a **pure refactor**. Do NOT introduce visual or behavioral changes.
Preserve the generated CSS behavior exactly unless a rule below requires
otherwise. The rules that allow a small visible change (closest DS
typography, DS border width) want it shown in the visual check and listed
in the report and the commit body.
Reviewers read every line of the result. A line that is
copied but does nothing, a bare element selector or a raw z-index comes
back as a review comment, so the rules below are strict about them.
## Input
- The path to one `.scss` file (e.g. `frontend/src/app/main/ui/foo/bar.scss`).
- If no path is given, ask for it. Do not pick a file yourself.
## Workflow
1. Read the target `.scss` file and the `.cljs` file with the same name next
to it.
2. Read every deprecated source the file uses, so you inline real values,
never remembered ones:
- `frontend/resources/styles/common/refactor/mixins.scss`
- `frontend/resources/styles/common/refactor/design-tokens.scss`
- `frontend/resources/styles/common/refactor/spacing.scss`
- any other file under `frontend/resources/styles/common/refactor/` that
the stylesheet pulls in (placeholders, borders, z-index, fonts...).
3. Read the DS modules you will map to:
- `frontend/src/app/main/ui/ds/_utils.scss` (`px2rem()`)
- `frontend/src/app/main/ui/ds/spacing.scss`
- `frontend/src/app/main/ui/ds/_sizes.scss`
- `frontend/src/app/main/ui/ds/typography.scss` (`use-typography()`)
- `frontend/src/app/main/ui/ds/_borders.scss`
- `frontend/src/app/main/ui/ds/z-index.scss`
4. Apply the rules below and edit the file in place.
5. Verify (see **Verification** and **Visual check**).
6. Report (see **Output**). Do not commit. When the refactor goes with other
changes, it belongs in its own commit, after the task's commits.
## Primary goal
After the refactor, the SCSS file must not depend on anything under
`frontend/resources/styles/common/refactor/`. This includes imports, mixins,
placeholder selectors, functions, deprecated variables and design-token
aliases.
### Partial refactor
Some placeholders stand for a whole component family that will move to the DS
in its own PR: `%input-element`, `%input-with-label`, `%input-checkbox`,
`%modal-*`, `%menu-dropdown`, `%menu-item-base`, `%shortcut-*` and similar.
Inlining them in a small task produces big, risky diffs. In that case:
- migrate everything else (tokens, sizes, spacing, small mixins);
- keep the `refactor/` import only for those placeholders;
- list each remaining dependency and the selectors that use it in the report,
so the user can plan it as a separate PR.
## General rules
- Keep the resulting SCSS as small and readable as possible.
- Avoid duplicated declarations and duplicated selectors.
- Do not change class names.
- Do not change DOM structure.
- Do not change specificity unless required.
- Follow the project's SCSS formatting (Prettier, see **Verification**).
## Imports
Use the DS modules. Import them with `as *` when they expose mixins,
variables or functions:
```scss
@use "ds/_utils.scss" as *;
```
not
```scss
@use "ds/_utils.scss" as utils;
```
Do not introduce namespaces such as `utils.`, `typography.`, `borders.`,
`spacing.` or `sizes.` unless a module explicitly requires one.
Remove every unused import.
## Deprecated mixins
If the stylesheet uses mixins from
`frontend/resources/styles/common/refactor/mixins.scss`:
- locate the mixin;
- inline its generated CSS (then apply the rest of these rules to it);
- remove the `@include`;
- remove the import.
Do not leave deprecated mixins.
## Placeholders
Replace every `@extend %placeholder` with the generated CSS. Remove every
deprecated placeholder, except the component-family ones described in
**Partial refactor**.
Inline only what does something on that element. Do not paste a
placeholder (or mixin) body as it is. For each property, check:
- **It applies to the element.** `fill`, `stroke` and `stroke-width` only
act on SVG shapes: drop them from a `span`, `div` or `button`.
`color: transparent` on a wrapper that only holds an icon does nothing
useful: drop it and set the real color.
- **It is not the default or already given.** `stroke-width: 1px` is the
SVG default; `fill: none` and `stroke: currentColor` are already set by
the DS `icon*` (`.icon`). Drop them.
- **A later rule does not override it.**
- **It is not a stroke width on a DS icon.** DS icons keep the DS
stroke width; drop any `stroke-width` that only imitates the legacy
icon (1.33px, 2.25px...).
`%button-icon` is the usual case: on a wrapper it leaves `color:
transparent; fill: none; stroke-width: 1px`, and none of the three is
needed.
## Element selectors
Do not style HTML elements directly (`svg {}`, `h3 {}`, `button {}`,
`input.x {}`, or `.component { svg {} }`).
When no class exists, **add one in the `.cljs`**: a DS component takes
`:class (stl/css :x)` (`icon*`, `heading*`, `button*`…), and a plain
element takes `{:class (stl/css :x)}`. Touching the `.cljs` for this is
part of the refactor. A comment like "the icon has no class" is not an
answer.
Prefer the DS component when one fits: `heading*` for `h1`–`h6`
(`:level`, `:typography`), `text*` for text, `icon*` for icons.
`element.class` (e.g. `input.element-title`) is not needed to beat the
input defaults: try the class alone and check it in the visual check.
An element selector stays only for markup you cannot reach (injected
HTML, third-party widgets), with a comment saying so.
**Icon colors.** A DS icon draws with `stroke: currentColor`. Set
`color` on the icon class or its parent, never `stroke`/`fill` on an
`svg` selector.
## Color variables
Whenever a color changes across states, introduce a component-local CSS
variable.
Instead of
```scss
.button {
color: var(--color-foreground-primary);
&:hover {
color: var(--color-accent-primary);
}
&:disabled {
color: var(--color-foreground-disabled);
}
}
```
write
```scss
.button {
--button-foreground-color: var(--color-foreground-primary);
color: var(--button-foreground-color);
&:hover {
--button-foreground-color: var(--color-accent-primary);
}
&:disabled {
--button-foreground-color: var(--color-foreground-disabled);
}
}
```
Use this pattern for `color`, `background-color`, `border-color`,
`outline-color`, `fill`, `stroke` and any similar property.
## Design token aliases
Remove every alias defined in
`frontend/resources/styles/common/refactor/design-tokens.scss`. Resolve each
alias to its final DS token. Do not keep intermediate aliases.
Instead of `color: var(--button-foreground-hover);` write
`color: var(--color-accent-primary);`.
## Spacing
Replace deprecated spacing variables using
`frontend/src/app/main/ui/ds/spacing.scss` for `margin`, `padding`, `gap` and
`inset`.
If no exact spacing token exists, use `px2rem()`. Do not approximate with the
closest token.
## Sizes
Replace deprecated size variables using
`frontend/src/app/main/ui/ds/_sizes.scss` for `inline-size`, `block-size`,
`width`, `height` and min/max sizes.
If no exact size exists, use `px2rem()` from
`frontend/src/app/main/ui/ds/_utils.scss`. Do not leave hardcoded pixel values
unless absolutely necessary.
## Typography
When any of `font-size`, `font-weight`, `line-height`, `font-family` or
`letter-spacing` appear, check whether together they match one of the
definitions in `frontend/src/app/main/ui/ds/typography.scss`.
If they match, replace all the individual declarations with:
```scss
@include use-typography("<typography-name>");
```
If there is no exact match, use the **closest DS typography** (same role:
a panel title in uppercase → `headline-small`, body text → `body-*`),
even when more than one detail differs. Show the change in the visual
check and state it in the report and in the commit body (e.g. "11px
regular → 12px medium").
Keep the original declarations only when no DS typography fits the role
(e.g. a 32px display number in a 14px-only area); then leave a TODO
naming the closest one and ask the user.
## Borders
Replace deprecated border radius and border width values using
`frontend/src/app/main/ui/ds/_borders.scss`. A raw width close to a token
(`1.75px`) takes the token (`$b-2`); say so in the report. If nothing is
close, use `px2rem()`.
## Logical properties
Prefer logical properties whenever they preserve the existing behavior:
- `width` → `inline-size`
- `height` → `block-size`
- `max-width` → `max-inline-size`
- `margin-left` → `margin-inline-start`
- `padding-right` → `padding-inline-end`
- `top` → `inset-block-start`
- `left` → `inset-inline-start`
## Z-index
Never leave magic z-index values. Use tokens from
`frontend/src/app/main/ui/ds/z-index.scss`, and pick the token **by what
the element is, not by its number**: a dropdown, menu or select →
`--z-index-dropdown`; a toolbar, palette, sidebar or panel →
`--z-index-panels`; and so on (read the comments in that file). A raw `4`
on a dropdown becomes `var(--z-index-dropdown)` even though the number
changes. Check the visual check for overlap.
Leave a TODO only when the element fits no token's purpose.
Legacy z-index values between sibling elements that do not overlap can be
removed, but only when the **Visual check** shows no pixel change in every
state that matters (open menus, collapsed, hidden). Say so in the report.
## Duplicated properties
Within the same selector, avoid duplicated properties, duplicated CSS
variables and duplicated declarations introduced by the refactor. Allow
duplicates only when they are intentional browser fallbacks.
## Unused classes
Open the `.cljs` file with the same name as the SCSS file and check whether
every CSS class is referenced (as `(stl/css :class-name)`,
`(stl/css-case :class-name ...)` or similar).
CSS modules prefix every class with its module
(`main_ui_<path>__<class>`), so only the `.cljs` of the same module can use
it. Before calling a selector unused, also search the whole `frontend/src`
for the class name as a string, a `:global` use, or a dynamic class built
at runtime.
If a selector is unused after that check:
- delete it (do not leave it commented out, do not add a TODO);
- list every deleted selector in the report and in the commit body.
A rule that matches nothing because the markup changed (e.g. `.icon svg`
when the DS icon is the `svg` itself) is deleted the same way. Before
deleting a descendant rule, check every element it matched, not only
the one you changed: `.placeholder .icon svg` can also color a second
icon, which then has no color (in the devenv it shows in the debug
yellow).
Every DS icon needs a color of its own (`color` on its class or on a
parent with a defined color): an icon that inherits nothing shows in
the debug yellow in the devenv.
Classes used by markup injected from outside (e.g. an external widget)
stay, with a comment saying so.
Also check the other direction: every class the `.cljs` passes to
`stl/css` / `stl/css-case` must exist in the `.scss`. A missing one silently
does nothing; report it.
## Sass and CSS pitfalls
- Sass does not evaluate functions inside CSS custom properties. Write
`--button-height: calc(var(--height) - #{px2rem(16)});`, not
`... - px2rem(16))`, or the browser gets the literal `px2rem(16)`.
- A `/` between Sass size variables is read as a division
(`grid-template: $sz-32 / auto` breaks). Split it into
`grid-template-rows` / `grid-template-columns` (or areas).
- A CSS-wide keyword cannot be part of a shorthand: `inset-inline: unset 0`
is invalid and stylelint does not catch it. Use the longhands.
- After editing, check the compiled CSS for any of these
(`frontend/resources/public/css/main.css`, search the module class).
## Uncertainty
If any transformation is uncertain, never guess. Leave a TODO in the code
and explain why in the report.
## Self-review
Before the report, read the final diff as the reviewer would and search
the file for these; each hit must be fixed or have a real reason:
```bash
rg -n 'svg \{|^\s*(h[1-6]|p|hr|input|button|span|a)\b[^-_]|[a-z]\.[a-z-]+ \{' <file>
rg -n 'stroke-width|color: transparent|fill: none|z-index: [0-9]|TODO' <file>
rg -n '[0-9]\.[0-9]+px|[^(]\b[0-9]+px' <file>
```
- an element selector → add a class (see **Element selectors**);
- a property that does not apply or is a default → drop it (see
**Placeholders**);
- a raw z-index → token by purpose;
- a typography TODO → closest DS typography;
- a raw px value → token or `px2rem()`.
## Verification
Run the project scripts from `frontend/` inside the devenv container
(`penpot-devenv-ws<N>-main` for workspace `wsN`; never run `pnpm` on the
host), writing output to a file first (never pipe lint output into
filters):
```bash
docker exec -u penpot -w /home/penpot/penpot/frontend penpot-devenv-ws<N>-main \
bash -lc 'pnpm run lint:scss; echo LINT=$?; pnpm run check-fmt:scss; echo FMT=$?' \
> <scratch>/scss-check.txt 2>&1
```
If the format check fails, run `pnpm run fmt:scss` and check again.
**Dependency check.** A grep for `refactor/` is not enough. For the final
file, check:
- `@use` / `@import` sources: all must be `ds/...` (or the sidebar common
module, which is DS-only);
- every `@include`, `@extend` and `%placeholder`;
- every `$variable`: which imported module defines it (`$br-*` exists in both
`ds/_borders.scss` and `refactor/borders.scss`; the `@use` decides);
- every `var(--token)`: defined in the DS (colors, spacing, sizes) or only as a
`refactor/` alias.
Report any failure as it is; do not hide it.
## Visual check
"No visual change" must be shown, not assumed. When a visual spec covers the
component (`frontend/playwright/ui/visual-specs/`, project `ds`), or one can be
added:
1. Capture before (base branch styles) and after with
`pnpm exec playwright test --project=ds -g "<test>" --update-snapshots=all`
in the container. `=all` is required: plain `--update-snapshots` only
rewrites past the 0.5% tolerance, so a real diff can look like 0.
2. Before each capture, wait until the compiled CSS/JS shows the expected
version (search `main.css` for the changed rule).
3. Compare with zero tolerance. If pixels change, build the before | after |
diff (red) image and explain every change in the report.
## Output
1. The diff of the file (`git diff -- <file>`).
2. A summary with:
- removed imports;
- removed deprecated mixins;
- removed placeholders;
- typography replacements;
- design-token aliases removed;
- logical properties introduced;
- deleted unused selectors;
- TODOs requiring manual review, each with its reason;
- remaining `refactor/` dependencies (partial refactor) and where they are
used;
- classes missing from the `.scss`, if any.
3. The verification results, including the visual check (pixel count, or why
it was not possible).

View File

@ -5,16 +5,18 @@ description: Update the project CHANGES.md with issues from a given GitHub miles
# Skill: update-changelog
Update `CHANGES.md` with entries for all issues and PRs in a given GitHub
milestone. Each entry references the user-facing issue (not the PR) as the
primary link, with the fix PR inline on the same line.
Entry point for all changelog work. All rules — sections, exclusions, entry
format, the `:boom:`, `:rocket:` and `:lock:` contracts, pre-flight, and the
anomaly report — live in `mem:workflow/updating-changelog` (file:
`.serena/memories/workflow/updating-changelog.md`). This skill routes to that
memory; it does not repeat it.
## When to Use
- Before a new release, to populate the changelog with all fixed issues
- When new issues are added to an existing milestone and the changelog needs
to be refreshed
- To ensure every entry follows the correct format for the changelog
- To audit or repair an existing version section
## Prerequisites
@ -23,289 +25,33 @@ primary link, with the fix PR inline on the same line.
- `scripts/gh.py` for GitHub queries, `scripts/changelog.py` for changelog
checks (run `python3 scripts/changelog.py <command> --help` for usage)
## Workflow
## What to Read
### 1. Determine the target version
1. `mem:workflow/updating-changelog` — the canonical flow: sources of truth,
layout, entry rules, the `## Breaking change note` comment contract, the
advisory (`:lock:`) contract, pre-flight checks, anomaly types, and pitfalls.
2. `mem:scripts/changelog` — `boom`, `security`, `check-merged`, `cross-ref`,
`report`.
3. `mem:scripts/gh` — `issues`, `issue`, `comments`, `prs`, `advisories`.
The version is typically a semver string like `2.15.3`. Confirm with the user
if not specified.
### 2. Fetch all issues in the milestone
## Commands
```bash
# All closed issues (default)
python3 scripts/gh.py issues "2.16.0"
# Include open issues too
python3 scripts/gh.py issues "2.16.0" --state all
# Exclude entries that should not go in the changelog
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
python3 scripts/gh.py issues "<VERSION>" --exclude "release blocker,no changelog"
python3 scripts/gh.py advisories --patched-version "<VERSION>"
python3 scripts/changelog.py check-merged <PR numbers...>
python3 scripts/changelog.py boom "<VERSION>" # print the :boom: section
python3 scripts/changelog.py boom "<VERSION>" --write # replace it in CHANGES.md
python3 scripts/changelog.py security "<VERSION>" # print the :lock: section
python3 scripts/changelog.py security "<VERSION>" --write # replace it in CHANGES.md
python3 scripts/changelog.py cross-ref "<VERSION>"
python3 scripts/changelog.py report "<VERSION>"
```
**Exclusion rules (issue-level):**
- `no changelog` label — chore/refactor work, no entry needed
- `release blocker` label — blocked issues not yet ready for changelog
- `Task` issue type — internal chores, not user-facing; excluded by `gh.py`
(use `--include-tasks` to override)
- **Rejected project status** — issues with "Rejected" status on the "Main"
project board are excluded by `gh.py` (use `--include-rejected` to override).
This status is independent of the GitHub issue `state`.
## Hard Rules
**Exclusion rules (PR-level):** PRs with these labels stay out regardless of
their linked issue's labels: `release blocker`, `no issue required`.
Each entry carries `number`, `title`, `state`, `issue_type`, `labels`,
`closing_prs`, and `project_status`.
### 3. Identify missing entries (optional)
```bash
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
```
Returns only milestone issues not yet referenced in the changelog. Note: it
compares **issues** only. For unreferenced merged **PRs**, use the
cross-reference in step 8.
### 4. Fetch additional PR details when needed
```bash
# One or more PR numbers (also: --file prs.txt, or --stdin)
python3 scripts/gh.py prs 9179 9204 9311
# All merged PRs in a milestone (default); --state all/open/closed for others
python3 scripts/gh.py prs --milestone "2.16.0"
```
Returns `number`, `title`, `body`, `state`, `merged_at`, `author`, `labels`,
and `closing_issues`. Milestone mode uses paginated GraphQL (100 per page).
### 5. Categorize entries — strictly by issue type, never by labels or emoji
Use the **Issue Type** field (`issue_type` in the `gh.py` output). No separate
query is needed.
> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.**
> Labels like `bug`/`enhancement` and prefixes like `:bug:`/`:sparkles:` are
> often wrong or missing. `issue_type` is the single source of truth.
| `issue_type` value | Changelog section |
|--------------------|-------------------|
| `Bug` | `### :bug: Bugs fixed` |
| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` |
| `Task` | **Exclude** — internal chores, not user-facing |
| `null` (not set) | Fallback to labels: `bug` label → bugs, otherwise enhancements |
**Breaking changes override everything:** an issue with the `breaking change`
label goes under `### :boom: Breaking changes & Deprecations`, no matter its
issue type. This and `release highlight` below are the only label-based
rules — explicit exceptions to the "never by labels" principle above. The
`:boom:` subsection goes first in the version, before `:rocket:` (matching
the existing precedent).
**Highlights come from the `release highlight` label:** an issue with that
label also gets an entry under `### :rocket: Epics and highlights`, on top of
its regular entry (usually `:sparkles:`). In the section being refreshed,
`:rocket:` lists exactly the labelled issues: never pick highlights yourself
and never keep an unlabelled entry there. Product decides the highlights by
labelling issues on GitHub, so a regeneration cannot undo that choice. Older
version sections are left as they are.
**Community attribution:** if the issue or its fix PR has the
`community contribution` label, add `(by @<github_username>)` on the entry
line, **before** the issue/PR references. Use the **PR author** (the `author`
field from step 4), not the issue author:
```markdown
- Fix description of the bug (by @username) [#<ISSUE>](...) (PR: [#<PR>](...))
```
**Only closed issues are included**, even if open ones are tracked in the
milestone.
**Pairing rules:**
| Pattern | Changelog format |
|---------|-----------------|
| Closed issue + one or more fix PRs | Primary link = issue, PRs inline comma-separated |
| PR with no linked issue | Link the issue if a matching closed one exists in the milestone; otherwise skip (the issue is the changelog unit) |
| Closed issue with no fix PR in milestone | Link the issue directly, no PR reference |
> **False-positive associations:** a PR may wrongly claim to close an issue
> from another context (ancient PR, cross-project reference). If titles are
> clearly unrelated or the PR predates the issue by years, treat it as a data
> glitch and skip it.
### 5a. Verify PR merge status before writing
A closed issue may list closing PRs that were **closed without merging**
(e.g. a superseded community PR). Only **merged** PRs go in the changelog:
```bash
python3 scripts/changelog.py check-merged <ALL_PR_NUMBERS>
# also accepts: --file prs.txt, or numbers via --stdin
```
If a closing PR is closed-unmerged, find the merged PR that superseded it
(other PRs in the issue's closing list, similar titles, or pointers in the
closed PR's timeline) and reference that one instead.
### 5b. Security advisory (GHSA) entries
Advisories fixed in a release go in the changelog even though they are
**neither milestone issues nor PRs**. The GHSA ID and description come from
the user or the release notes — never from the milestone fetch.
```markdown
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
```
Rules: place under `### :bug: Bugs fixed` with **no issue or PR link**; do
**not** fetch or verify the URL (it may be draft/unpublished and 404); write
the description in imperative mood from the advisory title. These entries are
invisible to the automation — add them by hand, and in step 9 apply only the
backport/duplicate check to them.
### 6. Read the current CHANGES.md and run pre-flight checks
Newest version goes at the top, right after the `# CHANGELOG` header:
```markdown
## <VERSION>
### :boom: Breaking changes & Deprecations
- <breaking change or deprecation> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
### :bug: Bugs fixed
- Fix description of the bug [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- Fix another bug (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
### :sparkles: New features & Enhancements
- Add new feature description [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
Format details: entries start with `- ` plus a short imperative description;
PR refs stay inline (`(PR: [#<N>](<url>))`, comma-separated for several);
`(by @<username>)` goes before the issue link; only include non-empty
sections; blank line between a section's last entry and the next title; never
duplicate an entry from an earlier version section (the earlier version wins
for backports).
**Pre-flight checks — fix violations directly in `CHANGES.md` before writing
the new section.** Reconcile every existing entry (any version section) and
every milestone candidate against the current milestone state (re-fetch, do
not trust cached data):
1. **Duplicate across versions** → remove from the current section.
2. **Stale milestone assignment** (issue moved out of this milestone) →
remove the entry (or drop it if the section does not exist yet).
3. **Newly applied exclusion labels** (`no changelog`, `release blocker`) →
remove the entry.
4. **Issue no longer closed/deleted/Rejected** → remove the entry.
5. **Unmerged or moved PR reference** → fix the reference or remove the
entry (a PR merged in a *different* milestone is a step-9 anomaly, do not
silently remove it).
6. **Issue type changed to `Task`** → remove the entry.
7. **Breaking change misplaced** (issue has the `breaking change` label but
sits in another section) → move the entry to `:boom:`.
8. **Highlight out of sync** (`:rocket:` entry without the `release
highlight` label, or a labelled issue missing from `:rocket:`) → remove
the `:rocket:` entry or add it (step 7b). The regular entry stays.
9. **Missing valid issues** (closed, non-excluded, unreferenced anywhere) →
add them to the current section per step 5.
### 7. Build the description text
Derive it from the **issue title**, not the PR title. Strip leading emoji
prefixes (`:bug:`, `:sparkles:`, `:tada:`) and describe the user-facing
behavior:
| Issue title | Changelog description |
|-------------|----------------------|
| `Plugin API token methods fail with schema validation error on PRO` | `Fix Plugin API token methods failing with schema validation error on PRO` |
| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` |
| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` |
Insert the new version section right after the `# CHANGELOG` header with the
`edit` tool and enough context for a unique match.
### 7b. Populate `:rocket: Epics and highlights` from the label
List the milestone issues that pass step 2 exclusions and carry the
`release highlight` label:
```bash
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" \
| python3 -c "import sys,json; [print(i['number'], i['title']) for i in json.load(sys.stdin) if 'release highlight' in i['labels']]"
```
Create the subsection if missing (place it before `### :sparkles:`) with one
entry per labelled issue, same text and references as its regular entry.
Every `:rocket:` entry MUST carry issue AND PR references. If no issue is
labelled, leave `:rocket:` out and tell the user: the highlights are
product's call, not the agent's. To change them, add or remove the label on
GitHub and re-run; never edit `:rocket:` by hand.
### 8. Cross-reference milestone PRs against the changelog
```bash
python3 scripts/changelog.py cross-ref "<MILESTONE>" [--changes CHANGES.md]
```
Lists merged milestone PRs missing from the changelog section (decide per PR:
add it or confirm its exclusion labels) and warns about CLOSED (unmerged) PRs
in the milestone. GHSA entries never appear here — that absence is expected.
### 9. Generate the anomaly report
```bash
python3 scripts/changelog.py report "<MILESTONE>" [--changes CHANGES.md --output CHANGES-ISSUES.md]
```
Overwrites `CHANGES-ISSUES.md` with the current state. Every number renders as
a full `[#N](https://github.com/penpot/penpot/issues/N)` or
`[#N](https://github.com/penpot/penpot/pull/N)` link.
**An anomaly is a milestone mismatch or a `:boom:` mislabel** (the changelog
pairing is misleading and a human must judge intent):
1. Issue in this milestone, referenced PR in another milestone (or none).
2. PR in this milestone, closed issue in another milestone — except an issue
with *no* milestone, which belongs to another (probably private) project
and is neither anomaly nor changelog candidate.
3. `:boom:` entry whose issue lacks the `breaking change` label. These stay
listed in the report **and** in the changelog: either label the issue or
move the entry to its regular section.
4. `:rocket:` out of sync with the `release highlight` label. Step 6
pre-flight should have fixed it: if it shows up, re-run the workflow.
**Highlight gaps are warnings, not anomalies:** missing `:rocket:` on a
released X.Y.0 (patches never carry highlights); `:rocket:` entry without
issue AND PR references. Gaps never count toward the anomaly total.
**Anything else is a rule violation**, not a report item: fix it in step 6
pre-flight. If one shows up in the report, re-run the workflow.
## Key Principles
- **Issue = changelog unit**, PR = implementation detail inline.
- **Latest version first**, below the `# CHANGELOG` header.
- **Issue Type decides the section — exclusively**, except `breaking change`
label → `:boom:` first, and `release highlight` label → also `:rocket:`.
- **User-facing descriptions** in imperative mood.
- **Community attribution** uses the PR author, placed before the issue link.
- **Only closed issues**; **Rejected** project status excludes.
- **`no changelog` and `Task`** stay out.
- **Multiple fix PRs** go comma-separated inline.
- **Duplicates**: the earlier version section wins.
- **Taiga references**: resolve to the GitHub issue via description text or
PRs mentioning the Taiga URL, then link issue + PR.
- **GHSA entries** live under `:bug:` with only the advisory URL (see 5b).
- **Re-fetch before editing**; prefer `scripts/gh.py` over raw `gh api`.
- **Verify PR merge status** (`check-merged`); PR-level exclusions apply.
- **Cross-reference PRs, not just issues** (`cross-ref`); watch for
false-positive PR-to-issue links.
- `CHANGES.md` is edited only on explicit request or during the release process.
- Only closed, non-excluded milestone issues are included; the issue is the changelog unit and the PR is inline.
- `:boom:` and `:rocket:` are label-generated and `:lock:` is advisory-generated: never hand-edit them; edit the label, the `## Breaking change note` comment or the advisory and re-run the command.
- Verify PR merge status before writing; only merged PRs are referenced.
- Fill `patched_versions` in the advisories before running `security`; unpublished advisories are valid.

View File

@ -85,6 +85,10 @@ target instance is running and its MCP config has been generated:
# 'main', 'ws1' and similar spellings are rejected.
./manage.sh start-coding-agent claude --ws 1
./manage.sh start-coding-agent opencode --ws 2
# Run a different executable (e.g. a wrapper script) with the same MCP
# wiring. --ws and --bin may come in any order.
./manage.sh start-coding-agent claude --bin claude-alt
```
Equivalents by hand (run from inside the workspace directory):

View File

@ -38,13 +38,13 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Registry (push destination)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Login to Docker Hardened Images registry (base image pull)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}

View File

@ -200,7 +200,7 @@ jobs:
ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }}
- name: Login to Docker Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
@ -210,7 +210,7 @@ jobs:
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
@ -222,7 +222,7 @@ jobs:
# reuses the same PUB_DOCKER_* credentials as the DockerHub
# login above.
- name: Login to Docker Hardened Images registry (base image pull)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
@ -301,7 +301,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
@ -371,7 +371,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}

View File

@ -114,20 +114,20 @@ jobs:
persist-credentials: false
- name: Login to Docker Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to DockerHub Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Login to Docker Hardened Images registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
@ -191,13 +191,13 @@ jobs:
persist-credentials: false
- name: Login to DockerHub Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Login to Docker Hardened Images registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
@ -242,7 +242,7 @@ jobs:
persist-credentials: false
- name: Login to Docker Hardened Images registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}

View File

@ -34,7 +34,7 @@ permissions: {}
env:
ACTIONLINT_VERSION: '1.7.12'
ZIZMOR_VERSION: '1.31.1'
ZIZMOR_VERSION: '1.32.0'
jobs:
lint:

View File

@ -48,10 +48,10 @@ jobs:
# No dependency cache here: a deploy must not restore a cache that
# other workflows can write to.
- name: Setup pnpm + Node.js
uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1
uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2
with:
working-directory: plugins
runtime: node@24.22.1
runtime: node@26.13.0
install: false
- name: Install deps

View File

@ -46,10 +46,10 @@ jobs:
# No dependency cache here: a deploy must not restore a cache that
# other workflows can write to.
- name: Setup pnpm + Node.js
uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1
uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2
with:
working-directory: plugins
runtime: node@24.22.1
runtime: node@26.13.0
install: false
- name: Install deps

View File

@ -49,7 +49,7 @@ jobs:
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
./fmt --check
- name: Lint
working-directory: ./render-wasm

2
.nvmrc
View File

@ -1 +1 @@
v24.22.1
v24.22.2

View File

@ -182,7 +182,7 @@ Who uses it:
Failure policy: every send is best-effort. A failure never rolls back the business action; it is only recorded for debugging.
Backend side (`backend/src/app/rpc/management/nitrate.clj` `::push-audit-events`): `::audit/skip`, `::rpc/auth false` (trust comes from the shared key, not from a user session). It builds context with `audit/prepare-context-from-request` + `request-id`, takes the given `profile-id` as-is (no coercion here, must already be a uuid), sets `tracked-at = request-at`, defaults `type` to `"action"`, and calls `audit/submit` (which defaults `source` to `"backend"`). So these rows land as **`source = backend`**: they are audited like backend events because a trusted service set the author.
Backend side (`backend/src/app/rpc/management/nitrate.clj` `::push-audit-events`): `::audit/skip`, `::rpc/auth false` (trust comes from the shared key, not from a user session; only the `admin-console` key id is allowed). It builds context with `audit/prepare-context-from-request` + `request-id`, takes the given `profile-id` as-is (no coercion here, must already be a uuid), sets `tracked-at = request-at`, defaults `type` to `"action"`, and calls `audit/submit` (which defaults `source` to `"backend"`). So these rows land as **`source = backend`**: they are audited like backend events because a trusted service set the author.
### 7b. Browser channel (best-effort, like frontend events)
@ -252,9 +252,9 @@ Result: crash reports only exist if the frontend collector runs and `push-audit-
## 11. Consumers III: telemetry (shadow rows)
Telemetry reuses the same table with **anonymized shadow rows** (`source LIKE 'telemetry:%'`): day-truncated times, `0.0.0.0` IPs, props reduced to uuid/boolean/number values plus a few allowed fields (`lang`, `auth-backend`, derived `email-domain` — never raw emails), and a minimal context allowlist. Full + shadow rows can exist together per event when both flags are on; that duplication is on purpose.
Telemetry reuses the same table with **anonymized shadow rows** (`source LIKE 'telemetry:%'`): day-truncated times, `0.0.0.0` IPs, props reduced to uuid/boolean/number values plus a few allowed fields (`source`, `lang`, `auth-backend`, `activation`, derived `email-domain` — never raw emails), and a minimal context allowlist. Full + shadow rows can exist together per event when both flags are on; that duplication is on purpose.
Filters (`filter-telemetry-props` / `filter-telemetry-context`): by default only uuid/boolean/number with a simple key; exceptions: login/register/update-profile and frontend `identify` keep `lang`/`auth-backend`/`email-domain`; `organization-sso-auth-failed` keeps `failure-reason` if it is a known one; `instance-start` passes whole; frontend `navigate` keeps `[:route :file-id :team-id :page-id]`. Free text drops from telemetry but **stays in the full row and in Nexus**.
Filters (`filter-telemetry-props` / `filter-telemetry-context`): by default only uuid/boolean/number with a simple key; exceptions: login/register/update-profile and frontend `identify` keep `lang`/`auth-backend`/`source`/`activation`/`email-domain`; `organization-sso-auth-failed` keeps `failure-reason` if it is a known one; `instance-start` passes whole; frontend `navigate` keeps `[:route :file-id :team-id :page-id]`. Subscription actions have event-specific contracts: backend and frontend `create-subscription` / `create-trial-subscription` preserve the default uuid/boolean/number props plus `type`, `deployment`, `quantity` and `subscription-mode`; backend `update-subscription` preserves only `type`, `deployment`, `previous-status`, `status`, `previous-cancel-at-period-end`, `cancel-at-period-end`, `previous-cancel-at`, `cancel-at` and `changed-fields`; backend `subscription-summary` preserves only `type`, `deployment`, `instance-id`, `snapshot-date`, the per-status `*-count` values, `total-count`, `sync-succeeded`, `sync-failed` and `snapshot-complete`. These subscription exceptions apply only to action events. Other free text drops from telemetry but **stays in the full row and in Nexus**. In `register-profile`, `activation` is a prop (it is what reaches PostHog). `action` exists only in the `context` of the full `register-profile-retry` rows; `filter-telemetry-context` drops it from the anonymised copy, which keeps only `:version`, `:initiator`, `:client-version` and `:client-user-agent`.
Shipping (`backend/src/app/tasks/telemetry.clj`): the cron sends shadows to `:telemetry-uri` as JSON in 10k batches and **deletes** them on success; it purges leftovers older than 7 days. It also sends a legacy report (counters, teams, email domains...). On official hosts nothing is collected or sent (`telemetry-excluded?` covers `penpot.app`/`penpot.dev`).
@ -301,7 +301,7 @@ So the full journey is: Penpot buffer → Nexus `audit_log` (via either archive
Penpot → admin-console archiving lives in its own job (`:audit-log-consumer-admin-console`, consumer `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving):
- **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user.
- **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`; only the `admin-console` shared-key id can call it). Section 7a is the main user.
- **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b).
- **Penpot frontend Nitrate bits**: membership/subscription events (`frontend/src/app/main/data/nitrate.cljs`, `nitrate-audit/*`) through the same public endpoint.

View File

@ -13,6 +13,10 @@
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
- When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile.
- Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous.
- Management RPC (`/api/management/methods/:name`) is shared-key only. The middleware authenticates `<id> <secret>`; the RPC wrapper `wrap-shared-key-authorization` authorizes the key id against the method's `::rpc/allowed-shared-keys` set. Absent metadata accepts any configured key; all current methods declare one (`exporter`/`media-processor` for `upload-tempfile`, `nexus` for file analysis, `exporter` for jobs, `admin-console` for Nitrate). A valid key outside the set answers 403 `:shared-key-not-allowed`; denied calls do not reach the audit wrapper.
- Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous.
- On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves.
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`.
## Permission model
@ -21,12 +25,13 @@
- Team role flags are normalized as owner > admin > editor > viewer. Owner/admin imply edit; any membership row implies read.
- File/project role rows only count with a live team membership: the file/project permission queries require a matching `team_profile_rel` row, and leaving or removing a member deletes their team-scoped `file`/`project`/`team-project-profile-rel` rows (GHSA-v9r9-h77c-55m2).
- File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics.
- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries.
- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. RPCs that accept `share-id` enforce the link's page scope before mutating (`share-link-pages` + `check-page-scope`): a thread or page outside the link raises `:not-found`, and that check runs before the ownership checks. `get-profiles-for-file-comments` filters its query by the same pages. `mark-all-threads-as-read` does not take `share-id`.
## Teams, projects, and invitations
- Team/project commands mix DB changes, email, message bus notifications, media/storage cleanup, feature flags, quotas, and audit metadata. Keep mutations transactional when the existing command does so.
- Invitation flows validate muted/bounced emails before sending and use tokenized invitation state. Accepting an invitation is tied to the invited member identity, not just possession of a token.
- The whole invitation family is team-admin only: creating, updating and deleting invitations, minting the invitation token (`get-team-invitation-token`) and listing the pending ones (`get-team-invitations`, which carries the invited email and the offered role). The token of an invitation that grants the `owner` role is restricted to the team owner, because an admin cannot create nor promote an owner invitation (`cant-promote-to-owner`). This family is the exception to the rule above: it answers `:validation` / `:insufficient-permissions` instead of `:not-found`, so the five commands speak one language.
- Logical deletion is used for many product objects; prefer existing logical-deletion helpers over hard deletes unless the command already performs permanent cleanup.
- Bounced/spam-complaint emails can mute/block a profile for login/registration and email sending. Devenv Mailpit is the normal local path for registration/email-flow testing.

View File

@ -32,7 +32,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
RPC commands are defined with `app.util.services/defmethod` and schemas. Use `get-` prefixes for read operations. Command metadata usually includes auth, docs version, params schema, and result schema. Return plain maps/vectors or raise structured exceptions from `app.common.exceptions`.
Backend RPC command areas without focused memories include access tokens, binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them.
Backend RPC command areas without focused memories include access tokens (auth rules, incl. MCP tokens, in `mem:backend/auth-permissions-product-domains`), binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them.
## DB conventions
@ -67,6 +67,7 @@ Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` i
- Job resources (`app.jobs.storage`) are the objects a job owns through `job.resource_id`: they live in the `job-resource` bucket, are written with the profile of the job as owner (only that profile can read them back from `/assets/by-id`) and are never deduplicated. They carry no expiry of their own, because `expired-at` is stored as `deleted_at` and an object with it is unreadable from birth: the retention of the artifact is the retention of its job row. The writer marks the object as touched right away, so an artifact that no row references (a crash between the upload and the completion) is reclaimed by `storage-gc-touched`, and a row that does reference it freezes it until the jobs GC deletes the row and touches the object.
- The binfile jobs (`:export-binfile` and `:import-binfile`) run the core of `bf.v1`/`bf.v3` through `app.binfile.jobs`, which turns the `:progress` taps of the core into `progress` events of the job with `jobs/heartbeat` (the `modified_at` touch at most once per second, progress events at up to ~200ms). Each event is a self-contained milestone: a `:stage` keyword of one vocabulary shared by the two formats (the same work has a different name in v1 and v3) and an optional `:counters` map, with the counter of the activity named after its own stage and the outer scopes in course as context, which the tap carries itself (the reader puts `::outer-counters` in every tap inside them — today only `:files`, extendable to `:teams`/`:projects` for multi-scope exports — so the adapter remembers nothing between taps and a tap that happens outside any scope has no context); the ids and names the taps carry never reach the event. Every tap is reported, so a tap with no units to count still keeps the job alive, and a milestone the throttle drops loses nothing because the next one carries the same state. The adapter checks that the job is still active before the run, and the beat raises an `:interrupt` right there on the run thread when the job is no longer active, so the work stops at its next event point inside the transaction of its handler, which rolls back. A run whose last tap predates the interruption is discarded by the check once it returns. The adapter passes the job id from the handler context. Their job-def components carry pool, storage, metrics and msgbus, because a job with a `profile_id` cannot store an event without a msgbus on its cfg. Both readers answer with the same shape (`:file-ids` and, in version 3, the resolution of the libraries), because the version 1 reader returns a bare set of file ids.
- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it (the import job-def declares that same schema as its result schema, which is what types the resolution when the row is read).
- The v3 import only materializes a library relation when both ids belong to the package and differ; a relation that points outside the package or a self link is ignored with a warning. Migration `0158-del-forbidden-file-library-rels` deletes the legacy cross-team and self rows from `file_library_rel` and their `file_library_sync` rows, sync rows first.
- The domain cfg of a binfile request lives in `app.binfile.common` (`export-cfg`, `import-cfg`), so the legacy RPC and the jobs freeze the same ids, resolve the same team features and apply the same anti-zip-bomb limits. `resolve-export-type` is the translation the legacy RPC needs for its deprecated boolean flags; the creation command asks the caller for the type instead of guessing a default.
- Two retention knobs, easy to confuse: `:jobs-retention` sweeps terminal internal rows (the ones without a profile), while `:jobs-user-ttl` is the default expiry a user-facing job gets when it is created; the jobs GC deletes the row once `expires_at` passes and touches its resource. The heavy work of a user job runs on its own `:binfile` queue (`:worker-binfile-parallelism`, default 1) so it does not block the default runner.
- The quote of a family of jobs (`::export-jobs-per-profile`, `::import-jobs-per-profile`) counts the jobs of the profile that are still on their way (`new`/`scheduled`/`running`/`retry`) whose name the registry gives for that family, so a new job of the family is covered without touching the quote.

View File

@ -18,9 +18,10 @@
- The waiting counters are a field of the `ConcurrencyLimiter` component (a `ConcurrentHashMap` of name to `AtomicLong`), so they start at zero, die with the component, and the sampler that closes over that map republishes only its own names every 15 s; the sampler stops with the component in `halt-key!`. `impl-invoke` pairs one decrement with one increment through a per-call flag: the flag turns a second call into a no-op and the `finally` guarantees that there is one. The increment sits inside the `try` and the decrement lives only in the acquired callback and the `finally`, so a handler that throws, a rejection and a throwable from the publishing path all leave the count where it was and the thread without the limiter marked as held (a leftover mark would skip that limiter for that key for ever). Concurrent `inc`/`dec` publications of two threads can land out of order and the value can read low for a moment; the republish corrects it.
- The `app.rpc.climit` log lines carry `queue`, `queue-max`, `permits`, `elapsed`, `code` and `reason`, and their `id` carries the key (`name/<profile or key>`). That is the only place where the profile behind a queue is visible: a pile-up is chased there, on demand, not in the metrics. The `queue` of the line is the calls inside the limiter minus its permits, so it reads 0 for a lone caller that times out: that line goes out at TRACE instead of WARN, and the rejection count has to come from the metric.
- Nothing in the limiter path has a timeout unless the config sets one, and `psm/acquire` then calls `Semaphore.acquire`, which parks the calling thread forever. A parked thread still counts as in-system for every outer limiter of its chain, so the outermost one (the method's own by-profile limiter) stops granting permits and answers 429 to everything. A limiter without `:queue` gets an unlimited queue and therefore never rejects: its pile-up is silent, with no 429 and no log line except the WARN `enqueued`/`acquired` lines of the keyed id.
- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204.
- `climit/wrap` now appends the root limiters after the command's own, so `root/global`/`root/by-profile` end up outermost and every path meets them first; the nested `climit/invoke!` chains carry only the command's own limits and run inside them, which puts both paths in the same order. `impl-invoke` also keeps, per thread, the set of limiters that thread is inside of and runs the handler inline when a call asks for one it already holds, instead of queueing behind itself. That check relies on `pbh/invoke!` running the handler on the calling thread; the mark is removed in a `finally`. Commands declare the by-profile limit before the global one, which makes it the outermost of the pair: keep that declaration order, the reverse one would put two paths back in inverted order. The two nested call sites (`create-file-media-object-from-url`, `upload-photo`) still share `process-image` with the enclosing call instead of getting a limiter of their own, so the comment there about keeping the download out of that limit no longer holds.
- The shipped `backend/resources/climit.edn` sets a timeout on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy.
- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204. Both commands declare their limits now, so the shape cannot happen any more: the root limits are taken last and every path takes the limiters in the same order.
- `climit/wrap` takes the command's own limits before the root ones: the root limiters are appended to the sequence first and the last element of the sequence is the outermost one, so `root/global`/`root/by-profile` end up innermost. A request waiting for its own limits no longer holds the root permits of its profile, which is what kept a pile-up of heavy requests from blocking the other commands of that profile. Every acquisition goes through that wrapper, so every path takes the limiters in the same order: there is no way to enter the limiter from inside a handler, and a command that calls another command internally would take the limiters twice (the second call waits for a permit the first one holds and answers 429 when the timeout runs out). The key of a limit is a function of the params (a keyword is looked up in them, a function is applied to them, any other value is the key itself). Commands declare the by-profile limit before the global one, which makes it the outermost of the pair, and a test fails when the relation between all the limits the commands declare cannot be sorted: two commands that take the same limits in opposite order, or a ring of three, leave the paths of one profile holding them in opposite order.
- The two commands that used to enter climit from inside their handler declare their limits now, so the whole command runs inside them: the download of an external image takes a `process-image` permit, and that limit has one permit per profile, so a slow download holds the image processing of that profile for as long as it takes. It takes the global permit of that limit too (8 permits, queue 20), so eight slow downloads of eight different profiles leave the image processing of every profile waiting. The copy of the body has no timeout (the 30 s of the request covers the connection and the headers), so a server that stops sending keeps the permits until the operating system gives up. The storage write of the profile photo runs inside its limit too.
- The shipped `backend/resources/climit.edn` sets a timeout and a queue on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The queue matters because the root limits no longer bound the waiters: a command takes its own limits first, so a pile-up waits in the queue of that limit. The file and the declarations have to list the same ids: a `::climit/id` without an entry makes `wrap` log `no config found for specified queue` and run the handler with no limiter at all, and a test fails when a command declares an id the file does not list. Only key a limit by profile when the work contends on something shared (`update-file`, images, fonts, thumbnails, the profile row): a global cap on a cheap command adds a rejection path nobody needs, which is why `profile-plugin-ops` and `submit-audit-events` declare only `by-profile`. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy.
## DB helpers
@ -55,7 +56,7 @@
- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. A body the parser cannot read is a client error, classified at the parse site and never by exception class (`clojure.data.json` signals its syntax errors with a bare `java.lang.Exception`): 400 `:validation`/`:malformed-json`, one `warn` line (`malformed request body`) carrying path, peer address and content-type instead of a stack trace, and `penpot_http_malformed_requests_total`. `RequestTooBigException` keeps its 413.
- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling.
- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`. Only `kid=1`/`ver=1` tokens are decoded as session tokens; anything else is left unauthenticated (legacy v1 tokens were removed).
- Shared-key auth requires `x-shared-key` as `<key-id> <key>` and stores the lowercased key id on the request. If no shared keys are configured it always rejects.
- Shared-key auth requires `x-shared-key` as `<key-id> <key>` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. Management methods declare the key ids they accept with `::rpc/allowed-shared-keys`; a valid key outside that set answers 403 `:shared-key-not-allowed`, and a method without the metadata accepts any configured key.
- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. Sessions use only the v2 UUID model (`http_session_v2`); legacy string ids were removed.
- Session cookies are renewed when `modified-at` is older than the 6h renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. Session lifetime config and GC: `mem:backend/session-expiration`.
@ -83,6 +84,8 @@
- The `media-storage-bytes-per-team` quota check on `upload-file-media-object` is TEMPORARILY DISABLED (2026-10-19, kept commented in `app.rpc.commands.media` with its `sql:get-team-id-for-file` lookup still active for the audit props): the quote performs a full `SUM` over `storage_object` per upload and was too slow in production. The `upload-sessions-per-profile` quota is unaffected and stays active. Re-enable only with a cheaper approximation.
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
- The SVG sanitizer (`app.media.svg`) is a **blocklist**, not an allowlist: it drops dangerous elements and attributes by name and escapes every text and attribute value when it writes the tree back, so a payload hidden in a value cannot become live markup again. The allowlist rework is [#12190](https://github.com/penpot/penpot/issues/12190) and lands in media-processor, not here. `sanitize-svg` also tracks the namespace bindings through the recursion and drops any element that is not in the SVG namespace, so a document whose root is not an `svg` element in the SVG namespace is rejected and a child that rebinds `xmlns` to XHTML (`iframe`/`srcdoc`/`meta`) is dropped with its subtree; it compares the scheme of an URL the way the browser does (dropping tab/CR/LF/spaces), so `java&#x09;script:` does not slip through the `javascript:` filter. The paths that store an uploaded SVG as its final object go through `app.media.svg/sanitize-svg-upload` (media upload, the URL download, both thumbnail writers and the organization logo); the three binfile imports go through `app.binfile.common/sanitize-imported-svg`, which calls `sanitize-svg` directly. The profile picture is not an SVG: `import-profile-picture` ignores an `image/svg+xml` download, because the avatar is always rendered as an image. The caller checks the size before calling it: the helper reads the whole file, and the size limit applies to the upload, not to the sanitized copy, which can be up to six times bigger.
- Upload handlers run the `media.v` checks in a fixed order: `check-upload-path`, then `check-media-type`, then `check-media-size` (or `check-font-size` for fonts). The path check gates every later read: `check-media-type` sniffs the bytes at `:path` and `check-media-size` reads `fs/size`, so a path the client named outside `allowed-upload-prefixes` must be rejected before either touches the disk. `check-media-size` leans on that and answers a file it cannot read with the path code (`:params-validation`), not `:media-max-file-size-reached`. Keep the order when touching or adding a handler; two branches that reorder these lines conflict.
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
- Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts.
@ -91,13 +94,22 @@
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. A pointer keeps its id while nothing changes; content (`assoc`/`without`) and metadata (`with-meta`) changes produce a fresh id, so a fragment row is never rewritten after it is written. Re-assoc with an identical value returns the same map in array and hash maps alike, so a save keeps the fragment id of every page it does not change.
- `enable-objects-map` skips unloaded pointer pages: their fragment was already wrapped when it was written, and loading it only to wrap it again costs one page read per untouched page on every save. A page with a plain fragment is wrapped when it is first loaded or edited; a data migration that enables `fdata/objects-map` must rewrite the data itself.
- Snapshots share fragment rows with the live file (they are stored with pointers, not materialized). `file-gc` marks every fragment referenced neither by the current main file nor by an unpurged snapshot; the file is read unrealized and unchanged pages keep their ids via reattach, so a pass over an unchanged file writes no new fragments.
- A save attaches its automatic snapshot before applying pending file migrations: the snapshot keeps the pre-migration pointers, data and migration set, and restoring it leaves the next read to migrate the file. This also holds for files without the feature. The preview (`get-file-snapshot`) applies the pending migrations in memory with the live file's libraries and never persists them: the stored version keeps its data, the payload carries the snapshot's `:migrations` (an ordered set) and `fdata/objects-map` is realized for clients that do not declare it. The historical feature set is deliberately not checked against the enabled one: a version may predate a feature change. The window is narrow: the `get-file` RPC reads with `migrate?` on and its ETag hashes `available-migrations`, so a post-deploy read cannot be a 304 from the pre-deploy state and migrates before any save in the normal flow; the pre-migration snapshot only appears when the save is the first request to touch the file after the deploy (an open tab that saves without re-reading, or a non-UI writer). `create-file-snapshot` and the restore backup read with `migrate?` on, so those versions are stored already migrated.
- `:migrations` is always a `d/ordered-set`: `file-migrations/resolve-applied-migrations` builds one, `fmg/migrate` re-wraps the union after applying, and `file-snapshots/decode-snapshot` converts the `file_change` pgarray (as a plain vector, `set/difference` and `contains?` would compare indexes instead of names). A snapshot with NULL `:migrations` and an old `:version` predates the `file_change.migrations` column (SQL migration 0140): the preview regenerates the set from the stored version and applies that full chain, while `restore!` uses a fixed 67 baseline, so preview and restore can disagree for very old snapshots.
- The migration read path (`binfile.common/migrate-file`) captures `fdata/original-pointers` before realizing, reattaches unchanged positions after applying the migrations, and persists the reattached file; it returns the realized file because reads expose plain data. A migration that adds `fdata/objects-map` skips the reattach so the pages get wrapped.
- `fdata/load-pointer` reads rows that a previous `file-gc` pass marked on purpose (explicit `::db/remove-deleted false`): the fragment is only gone once `objects-gc` purges it. The `get-file-fragment` RPC keeps rejecting marked rows.
- Bind the fragment loader through `fdata/with-pointers` (reads), `fdata/with-tracked-pointers` (writes with a loader) or `fdata/with-tracked` (writes with nothing to load yet, e.g. file creation), and `fdata/with-untracked` (validation passes that must track nothing) instead of hand-rolled `binding` forms. Two direct bindings remain on purpose: the fine-grained tracked toggles of `app.rpc.commands.files-update` (validation/repair turn tracking off, restore the existing atom and read it before repairing), and `binfile.common/encode-file` (a fresh tracked set inheriting the current one). Converge them only if atom-scoped macros (`with-tracking`, `with-inherited-tracking`) are added; today the extra indirection buys nothing.
- Snapshot creation takes `FOR SHARE` on the file row: both `create-file-snapshot` and the backup read of `restore-file-snapshot` hold it from the read to the insert, so they serialize with a `file-gc` pass (`FOR UPDATE ... SKIP LOCKED`) instead of pinning fragments the pass is about to mark. `get-file-snapshot` realizes the snapshot pointers with the live file id (fragment rows are keyed by file, not by snapshot).
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
- A `file_data` row is one of three classes, told apart by `type`: `main` is the file's current state and carries `id = file_id`; `snapshot` is a point-in-time copy whose `id` is the one its `file_change` row shares; `fragment` is a piece of the pointer-map, referenced from the file's data.
- Their lifecycles differ: `main` and `snapshot` live with the file (dated when it is deleted, undated when it is restored), while a `fragment` carries no date and is collected by `file_gc` once the file stops referencing it. Neither is a session buffer, so do not read a `fragment` as an ephemeral revision.
- File snapshots (versions) keep their metadata row in `file_change` and their payload in `file_data` (`type = 'snapshot'`). With the `db` backend `file_change.data` stays NULL: that column is only written by the `legacy-db` backend, plus the rows written before the 2025-07 file storage refactor.
- Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label.
- Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. `0157-migrate-legacy-file-data` moved the labelled leftovers to `file_data` and deleted the unlabelled payload rows, so from 2.19 on the label is the only marker there is.
- The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again.
- Revisions live only in `file_change` and have no `file_data` counterpart. They are born already scheduled for deletion one hour later (`app.rpc.commands.files-update`) and the snapshot paths cannot reach one, because they resolve their row through a `label IS NOT NULL` filter.
- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again.
- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, which is `label IS NOT NULL` (the unlabelled rows that still carry a legacy payload are removed by `0157-migrate-legacy-file-data`, so restore leaves them dated), the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again.
- Binfile v1/v3 import and the v3 export `get-file` step run inside one long transaction. They raise `idle_in_transaction_session_timeout` to 30 min (`app.binfile.common/transaction-idle-timeout-ms`) instead of disabling it, so a connection left idle while the ZIP is decoded or the file data is realized is bounded; `statement_timeout` stays at 300 s.
- `update-file` with `:changes-with-metadata` groups that all carry `:commit-id` is partial: fast path applies and validates everything once; on a change failure it bisects the commits for the longest prefix that applies and validates (each attempt applies a prefix from the original data and runs referential integrity + file schema; ~log2 K attempts, none for a single commit, the first attempt's error reused), keeping that prefix (a commit that repairs an earlier one can keep both) and answering 200 with `:rejected {:commit-ids :failed-commit-id :error}`, plus `:preexisting-damage true` when the data the first rejected commit touches already fails validation without it (`preexisting-damage?`: one more referential + schema check of its pages/components on the base file, the accepted prefix or the original; the frontend then says the page data is damaged). Only accepted changes reach `file_change`, persistence and the msgbus; a fully rejected request writes and broadcasts nothing (revn unchanged). Flat `:changes` or groups without ids stay all-or-nothing. Before each attempt (`apply-prefix`, which returns `{:file :tracked :refs}` or the cause) `pmap/*tracked*` and the `cpc/*state*` atom are reset to their pre-attempt values (at the end `apply-commits` sets those of the kept prefix): a pointer map already `modified?` keeps its id when changed again, so the failed version would be persisted. DB/IO/interrupt errors and `:not-found` fragments (`change-failure?`) fail the whole request, never reject a commit. A rejection whose ex-info has no `:type` reports `:internal :unexpected-error`. `app.http.errors` answers 503 (`:server-error` + code) for transient SQLSTATEs (classes 08/53, 57P01–3, 40001, 40P01), `SQLTransientException`, `InterruptedException` and only the `IOException`s that pass on their own (`transient-io-exception?`: `SocketException`, `InterruptedIOException` incl. socket timeouts, `UnknownHostException`, `HttpTimeoutException`), which the client's save retries (and GETs retry by themselves); any other `IOException` (EOF from a corrupt blob, Jackson parse errors, missing files) stays 500 `:io-exception`, as do other SQL errors. With the `:debug-save-faults` flag, a `:set-plugin-data` file change in namespace `:penpot-debug` keyed `save-fault:<kind>` raises that fault in `apply-changes` (used by `debug.failNextSave`/`commitInvalidChange`). With a schema check running (hard schema validation without skip-validate, or soft) and 2+ commits, the touched pages' objects are decoded on the original file first (`decode-objects`; referential validation alone reads few objects, so no pre-decode then): objects-map caches decoded values per instance and a derived map copies the cache, so otherwise every attempt re-decodes the page (~260 ms at 10k shapes). Validation step timings (`with-validation-timing`, upstream `penpot_rpc_update_file_validation_timing` + the `update-file` debug log) come from the first attempt only (retries run with a cfg without metrics and timings); the whole bisection is one `retry` step sample, and the rejection log carries `:attempts`/`:elapsed`.

View File

@ -8,6 +8,7 @@ Behaviors confirmed against the language/stdlib — do not re-derive from assump
- The analyzer reads `:async` only from the fn name meta and the `fn` operator meta; list-level meta is ignored (would make a MetaFn). `(fn ^:async [] …)` puts the meta on argv (pre/post only, NOT async).
- Valid: `(defn ^:async f)`, `(defn- ^:async f)`, `(^:async fn [] …)` (the latter is what stock `t/async` generates itself).
- `^:async` fns never throw synchronously (rejected promises instead); `try/catch/finally` supported; continuations are microtasks, timers and RxJS schedulers are macrotasks (FIFO).
- Inside an `^:async` fn, a nested call that needs a temp binding (e.g. calling a local fn `(g (f x))`, whose arg gets a `G__` binding) compiles to `(await (async function(){…})())`: `await` unwraps a returned promise, so `(settled (upload (make-ctx)))` blocks until the upload promise settles instead of passing the promise on (a test that pushes the answer later hangs, idle). Bind each step in its own `let` binding.
- `!` suffix allowed cases (only exceptions to the no-new-`!` rule in `mem:clojure/design-rules`): mutating public `app.db` API (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, `exec!`, ...) and fns whose contract is to work over transients (stdlib `conj!`, `assoc!`, `dissoc!`, `disj!`, `persistent!` and custom helpers over them); any further case must be registered here with justification before use.
- Penpot extends `clojure.core/Inst` in `app.common.time` ( JVM-only section), so `inst-ms` also accepts `Duration` (returns `.toMillis`, NOT epoch millis), `FileTime` and `OffsetDateTime`. It only works where that namespace is loaded; on a bare `Duration`, prefer explicit `.toMillis`, which reads as what it is (an amount, not an instant).
- `cuerdas.core/index-of` returns `nil` (not `-1`) when the substring is absent, like `clojure.string/index-of`. `(pos? nil)` **throws on the JVM** but evaluates to `false` in CLJS (JS coercion), so `(if (pos? (str/index-of s "\n")) …)` works in the browser and crashes the backend. Guard with `some?` before `pos?` (this was `app.common.exceptions/first-line`).

View File

@ -0,0 +1,9 @@
# Promesa
Promise library of the monorepo (frontend CLJS and common, `funcool/promesa` 12.0.1 in `common/deps.edn`). Its API has two call styles and mixing them in one expression is the trap: `fmap`/`mcat`/`merr` are **fn first, promise last** (`->>` friendly) and `then`/`then'`/`catch`/`handle` are **promise first, fn last** (`->` friendly). Convention: `fmap`/`mcat`/`merr` inside `->>` pipelines, `then`/`catch` with `->`. A `then` written inside a `->>` reads backwards and hides whether the step carries a value or a promise.
- `fmap` (alias `map`, both `(f p)`) is functor map: `f` returns a plain value.
- `mcat` (alias `mapcat`, both `(f p)`) is monadic bind: `f` must return a promise, and the result mirrors it.
- `then` also unwraps a promise returned by `f`, which is why it covers both cases at the cost of not naming which one it is; `then'` maps without unwrapping.
- `merr` (fn first) and `catch` (promise first) are the error side.
- `all` takes a collection of promises (a lazy seq works); `let` chains sequential bindings.
- Seam with the rx pipelines of the frontend: `beicon.v2.core/from` builds an observable from "arrays, collections, and promise instance" (`external/beicon`), so `(->> p (p/mcat f) (rx/from) (rx/map g))` is the usual bridge (`app.main.data.imports`, `app.util.zip`).

View File

@ -6,7 +6,7 @@
- Do not treat nil as a distinct persisted state from absence. Import/export and cleanup paths may filter nil attrs away.
- Avoid Clojure-special naming in exported object attrs, especially boolean names ending in `?`; exported/imported data must survive JSON/SVG/Transit and external tooling.
- Any new shape attr that participates in component sync must be listed in `app.common.types.component/sync-attrs` with the correct touched group. Attrs absent from `sync-attrs` are ignored by component synchronization.
- Shape `:svg-attrs` is the one map whose keys are camelCase React prop names (`fillRule`, not `fill-rule`), because the SVG import path runs them through `app.common.svg/attrs->props`. The `.penpot` reader (`app.common.json/read-kebab-key`) rewrites every json key of every entry to kebab-case, nested maps included, so any code that decodes a binary file must run `:svg-attrs` back through `attrs->props` or the names are lost. `:svg-defs` and the `:content` tree of `svg-raw` shapes keep their source names and round-trip on their own.
- Shape `:svg-attrs` keys are kebab-case (`fill-rule`, not `fillRule`): birth (`app.common.svg/attrs->kebab-props`, applied everywhere data lands on a shape) normalizes any outside spelling through the `svg-props` whitelist, and render boundaries (`get-svg-props`, `render-xml*`, `svg-node*`) translate back to camelCase for React. `:svg-defs` node attrs follow the same rule recursively; svg-raw `:content` attrs are kebab-ized spelling-only, never filtered — the sidebar reads `:stroke-style`, which is outside the whitelist. Non-spec attrs Penpot keeps live in `penpot-extra-attrs` (seeded with `:stroke-style`) and travel in source spelling. Excluded, each with its reason: `:tag` values (React and tag sets need them verbatim; the schema decoder restores keywords from stored strings), outer `:svg-defs` keys (id strings at birth, keywords or strings after JSON decode — accept both), all values (the kebab reader never touches values). Repair sites: birth plus migration `0035-fix-shape-svg-attrs-and-defs` (replaces the withdrawn `0021`); the binfile cleaner carries no svg repair.
## Cross-module update checklist

View File

@ -20,7 +20,10 @@
- Full referential/semantic validation currently runs only when file features contain `"components/v2"`.
- Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details.
- `update-file` validates per save with the change-scoped variants: `validate-file-affected!` (referential, whole touched pages/components) and `validate-file-schema-affected!` (file schema with `:pages-index` reduced to the touched pages; non-page data checked whole). The schema variant maps change types to pages itself (`touched-page-ids`): a new change type that writes a page must be added there, or to `file-level-change-types` if it touches no page; an unknown type makes it check the whole file. The whole-file schema check loads and decodes every page, ~75–80% of a save on multi-page files.
- `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes.
- `repair-file-until-valid` stops on: no errors; unchanged `:data` after apply (backend page/`PointerMap` `=` is pointer identity); stable error fingerprint `(frequencies (map #(dissoc % :shape) errors))` vs previous pass (check fingerprint before `repair-file`/`process-changes`); or `max-iterations`. Each `repair-error` builds from the original `file-data`, so N `:set`-based errors on one shape take N passes.
- `:ref-shape-not-found` repair: when the copy root's local component has a `:main-instance-id` that resolves to no shape but a `:main-instance` shape of that component sits on its `:main-instance-page`, it emits `mod-component` pointing the id back to that main (keeps the copy linked; a repair scoped to the copy's page never sees the main's `:invalid-main-instance-id`). Otherwise near-main reassign, else detach the copy subtree.
- `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule.
## Migrations
@ -29,4 +32,5 @@
- Migrations are an ordered set mixing legacy version-derived ids and newer named ids. Keep append order stable; `migrate` applies the set difference between available migrations and file migrations.
- `migrate-file` synthesizes legacy migration ids from old numeric versions when `:migrations` is absent, migrates legacy features, and records feature flags created through `cfeat/*new*`.
- When a file had no previous `:migrations`, `migrate-file` marks all migrations as migrated in metadata so callers persist the complete migration set, not only transformations that changed data.
- Closed schemas (`schema:stroke-attrs` and others) reject a key that was dropped from the declaration, so removing a declared attr needs a migration in the same change, for data already written with it.
- Closed schemas (`schema:stroke-attrs` and others) reject a key that was dropped from the declaration, so removing a declared attr needs a migration in the same change, for data already written with it.
- Rebase collision on a migration number: upstream keeps the id. Renumber the branch migration to the next free one, keep it last in `available-migrations`, and rename everything that spells the number — its test file and namespace (`files_migrations_00NN_test.cljc`), the test names, and comments in production code or memories that name it.

View File

@ -38,6 +38,10 @@ Useful builders:
`add-variant-with-copy` does not accept position params for children; use `gsh/absolute-move` after creation if positions matter.
## Generative sampling caution
- `sg/sample` is unseeded per run. If a test filters samples by type (e.g. keep only `:svg-raw`) then `first` can yield nil on unlucky seeds; `assoc` onto nil builds a typeless map that fails `:multi` dispatch on `:type`. Pin `:seed` in `sg/sample` opts whenever a test depends on a filtered subtype being present (verified seed 42 deals svg-raw shapes on JVM and JS).
## Driving production paths
For shape mutations, prefer production-path helpers such as `cls/generate-update-shapes` plus `thf/apply-changes`. For component swaps with keep-touched behavior, use `tho/swap-component-in-shape` with `{:keep-touched? true}`.

View File

@ -8,6 +8,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`.
- Verified Clojure language behaviors that contradict common assumptions (e.g. `int?` covers `Long`; `integer?` is the general predicate): `mem:clojure/idioms` — read before assuming stdlib predicate semantics.
- Read `mem:clojure/design-rules` (function shape, boundary checks, naming) before writing or modifying Clojure/ClojureScript code — not only when designing a solution or an API.
- Promises: which call style to use where (`fmap`/`mcat`/`merr` vs `then`/`catch`) and how they bridge to rx: `mem:clojure/promesa` — read before chaining promises or mixing them with observables.
# Development workflow
@ -15,16 +16,19 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 80-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer)
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
- Before updating `CHANGES.md` → `mem:workflow/updating-changelog` (sources of truth, entry rules, the `:boom:`/`:rocket:` label contracts, pre-flight checks, anomaly report)
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace layout, `scripts/sync-pnpm-version` flow, the stamp-missing-field and ignored-builds gotchas, verification steps)
- Before a Node version update → `mem:workflow/updating-node` (the six pin sites in `.nvmrc`, the devenv and image Dockerfiles and the plugins-deploy workflows, the DHI tag and digest rules, what to verify)
- Before bumping a non-JS dependency pinned in `docker/images/` (JDK, ImageMagick) → `mem:workflow/docker-images`
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request.
- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via `mem:workflow/updating-changelog` (the `update-changelog` skill flow) or on explicit user request.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`).
- Align `let` binding values: when a `let` form has multiple bindings spanning several lines, align the value forms to the same column with spaces.
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, fix them with `scripts/paren-repair` BEFORE running lint/format checks. See `mem:scripts/paren-repair` for usage.
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
- **Never wait for background work with a sleep.** A long command moved to the background reports its own completion: do not poll it with `sleep 150` (or any other wait) inside a foreground command, and do not re-read its output file to check whether it finished. Keep working on something else, or end the response and wait for the notification.
# Project modules
@ -57,11 +61,11 @@ The memory is structured in a way that you can get the critical information abou
- `scripts/taiga.py` — Fetch public issues, user stories, and tasks from the Penpot Taiga project without authentication. See `mem:scripts/taiga`.
- `scripts/gh.py` — GitHub operations helper: list milestone issues, fetch PR details, compare against CHANGES.md. Requires `gh` CLI. See `mem:scripts/gh`.
- `scripts/project-anomalies.py` — Main-board anomaly check for a milestone (`check` writes `tmp/<MILESTONE>-ANOMALIES.md`). Bulk resolution via `scripts/gh.py`. See `mem:scripts/project-anomalies`.
- `scripts/changelog.py` — Changelog checks for the `update-changelog` skill: `check-merged` (PR merge status), `cross-ref` (milestone PRs vs changelog section), `report` (anomaly report to CHANGES-ISSUES.md). Calls `scripts/gh.py` via subprocess. See `mem:scripts/changelog`.
- `scripts/changelog.py` — Changelog checks for `mem:workflow/updating-changelog`: `boom` (generate the `:boom:` section from `breaking change` issues), `check-merged` (PR merge status), `cross-ref` (milestone PRs vs changelog section), `report` (anomaly report to CHANGES-ISSUES.md). Calls `scripts/gh.py` via subprocess. See `mem:scripts/changelog`.
- `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`.
- `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm workspaces (root, modules, member packages). Keeps the shared pnpm store at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
- `scripts/setup-opencode` — Install the opencode CLI in-container as a global pnpm package in `$PNPM_HOME`. Not part of the devenv image; see `mem:devenv/core`.
- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`.
- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module, plus the production build on request (`--build`). Modules: `frontend backend common render-wasm exporter mcp plugins library media-processor`. Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`.
# Dependency graph

View File

@ -5,7 +5,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Compose project layout
- `penpotdev-infra`: shared `postgres`, `rustfs`, `valkey`, `mailer`, `ldap`. File: `docker-compose.infra.yml`.
- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer.
- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `${XDG_DATA_HOME:-~/.local/share}/penpot-devenv/workspaces/`, or the former `~/.penpot/penpot_workspaces/` when it already exists), maintained by the developer.
- Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount.
- All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands.

View File

@ -0,0 +1,106 @@
# Auth Events and the Session Origin
`signin` is the frontend `identify` event emitted once per session from
`logged-in` (`app.main.data.auth`). It carries the profile plus a closed
vocabulary that says why the session started.
## Vocabulary
- `source`: `login` | `register`. Always present.
- `auth-backend`: `penpot` | `ldap` | `google` | `github` | `gitlab` | `oidc`.
Same key the profile already uses. Always present.
- `activation`: `immediate` | `email-verification`. Only on signups.
- `from-invitation`: boolean. Always present in `signin`
(`(some? (:invitation-token profile))`); `auth-origin` omits it when `nil`.
`auth-origin` (public, validated against `schema:auth-origin`) drops the nil
fields and checks with `(assert (sm/check ...))`: it throws in development and
tests, and `:elide-asserts true` removes it from the release build, so a bad
value can never break a real login. Emitters never call it directly.
## Where each value comes from
- `source` and `activation` travel as profile metadata (`::ev/source`,
`::ev/activation`), written by `origin-meta`. `login-from-token` derives both
from the token issuer with `iss->origin`: `:auth` → `login`; `:verify-email` →
`register` + `email-verification`. An unknown `:iss` produces no metadata, so
the assertion fires in development and the event goes without `:source` in
production.
- `auth-backend` and `from-invitation` are read from the profile.
- `profile-origin` composes the four and `logged-in` merges them into the event;
the emitters only declare the fact.
| Flow | `source` | `activation` | `from-invitation` |
|---|---|---|---|
| Password login | login | | false |
| Password login from an invitation link | login | | true |
| LDAP login | login | | false |
| Social login, existing active profile | login | | false |
| Signup, email verification disabled | register | immediate | false |
| Signup, email verification enabled | register | email-verification | false |
| Social signup, active profile | register | immediate | false |
| Social signup, inactive profile | register | email-verification | false |
| Signup from an invitation link, email verification disabled | register | immediate | true |
| Signup from an invitation link, email verification enabled | register | email-verification | true |
| Social login from an invitation link | login | | true |
| Social signup from an invitation link, email verification enabled | register | email-verification | true |
| LDAP login from an invitation link | login | | true |
LDAP is the explicit exception: the directory provisions the account on the
first login, but there is no user signup, so the flow is always `login` and
never carries `activation`. Do not "fix" it by inferring a signup.
## Invitations go through `logged-in`
- `login-from-register` accepts an optional `:invitation-token`;
`with-register-origin` decorates the profile and `logged-in` does the rest.
The four places that used to navigate by hand to `:auth-verify-token`
(`ui/auth/register.cljs` twice, `ui/static.cljs`, `ui/auth/login_modal.cljs`)
emit `login-from-register` with the token. The modal used to live in
`ui/viewer/login.cljs`; it was extracted to the shared
`ui/auth/login_modal.cljs` (`login-register-modal*`, also used by
`ui/mcp_authorize.cljs`).
- The invitation navigates through `get-invitation-nav`, which does not wait for
`::dtm/teams-fetched`: `fetch-teams` has no error handling, so depending on it
would leave the invitation unaccepted when `get-teams` fails. For invited
profiles `get-redirect-events` emits `rx/empty`, so the teams path cannot move
the user away from the token verification screen.
- The OIDC callback with an invitation mints an `:auth` token that carries the
invitation JWE in its claims (`backend/src/app/auth/oidc.clj`), so the
frontend follows the regular path (`login-from-token` → `logged-in`). That
flow visits the token verification screen twice.
- `ui/auth/login_modal.cljs` (the register modal: `:is-active` branch calls
`on-close`, or `on-register` when the caller passes one, as in the MCP
authorize flow) and `ui/static.cljs` (`rt/reload true`) `:is-active`
branches still bypass `logged-in`; they are a separate decision, not an
oversight.
## Backend side
- The `register-profile` success branches
(`backend/src/app/rpc/commands/auth.clj`) put `activation` in the event props
and emit **no** `action`: the name already says it is a signup, and a constant
action would only add noise. The retry branches
(`register-profile-retry`) keep `action` in `context`
(`ignore-because-blocked`, `ignore`, `resend-email-verification`,
`ignore-because-complaints`), where it distinguishes outcomes.
- The LDAP invitation branch of `login-with-ldap` returns the full profile plus
`:invitation-token`; a partial map would break the `auth-origin` assertion.
- `register-profile` does not accept the invitation: it mints the session and
returns the token. The acceptance is `accept-team-invitation`, emitted from
`verify-token`.
- `filter-telemetry-props` keeps `source` and `activation` in the anonymised
row; `:action` is not on the allowlist.
## Analytics
- `register-profile` is a track event, so the props adapter forwards
`activation` with no extra work.
- `signin` is an identify event built from a closed list of profile keys. It
only reaches analytics if the identify branch of Nexus forwards `source`,
`activation` and `from_invitation` (`external/nexus`, another repository).
## `::ev/source` is no longer dead metadata
It is read by `profile-origin`. Do not remove it as unused, and keep the
vocabulary in sync with `schema:auth-origin` and the table above.

View File

@ -49,16 +49,23 @@ Get `team-id` from `(:current-team-id @app.main.store/state)`. Get file ids from
## Runtime patching with `set!`
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as `app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`, `app.main.errors/last-report`, or `app.main.errors/last-exception`. These patches affect only the live browser runtime and disappear on reload or recompilation.
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`, `app.main.errors/last-report`, or `app.main.errors/last-exception`. These patches affect only the live browser runtime and disappear on reload or recompilation.
`set!` on `app.main.store/on-event` does nothing at runtime: the store captured the function when it was created. To watch events, subscribe to the store stream and dispose the subscription after:
```clojure
;; Log non-noisy Potok events temporarily.
(set! app.main.store/on-event
(fn [event]
(when (potok.v2.core/event? event)
(.log js/console (potok.v2.core/repr-event event)))))
(def sub
(beicon.v2.core/subs!
(fn [event]
(when (potok.v2.core/event? event)
(.log js/console (potok.v2.core/repr-event event))))
app.main.store/stream))
;; (beicon.v2.core/dispose! sub)
```
`cljs_repl` starts every call in `cljs.user`; begin a call with `(in-ns 'my-ns)` to reuse helpers defined in another namespace.
Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure; it is not the normal way to patch live CLJS browser vars.
## Browser-console debug namespace

View File

@ -46,6 +46,7 @@ Workspace behavior:
App shell and product flows:
- Routing, root app shell, websocket, and global errors: `mem:frontend/routing-app-shell-subtleties`.
- Authentication events, the session origin vocabulary, and the invitation flow: `mem:frontend/auth-events`.
- Dashboard and viewer flows: `mem:frontend/dashboard-viewer-subtleties`.
- Plugin JS API runtime inside the frontend app: `mem:frontend/plugin-api-to-cljs-binding`.

View File

@ -5,14 +5,15 @@
- Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id.
- Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly.
- Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications.
- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients.
- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its header paints the milestone of the job (the stage and its counter) instead of the messages the old SSE stream sent; every file of a container reports the same milestone, so `jp/current-milestone-text` writes it once and the rows never repeat it. Before the job exists, the header paints a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and it writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, no row says anything: the header says the import waits for a slot while no job of it has started. The bar of the header measures the whole import, never a file: indeterminate while checking, waiting for a slot or rebuilding, because the client cannot know a global index and total of operations. The upload is the exception — the chunked upload knows its chunks, so the bar is determinate while the containers are sent, weighted by their size (`imp/upload-pct` over `:container-size`, the only percentage the client really knows). The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line on the rows any more: the rows of the import wizard carry only what is not progress — the name, the size, `Ready` once their container finished uploading, and the error text —, and the button that drops a file only shows while the wizard still offers its import step (the confirmation screen). Above the rows, the import wizard paints a stepper (Check/Upload/Import) plus one phase line: `imp/import-step` derives the step from the dialog status and the entries (upload holds while any file is pending, either `:import-ready` or carrying its own `:upload` milestone), so Check covers analyze, Upload covers the chunked client-side upload, and Import covers queue plus job. A file of a version 3 package shows what it occupies inside its container, computed while analyzing (`imp/file-sizes`): its own `files/<id>…` entries plus the storage objects its media and thumbnails reference, whose metadata is the only link to `objects/<id>…`. The container's own size stays in `:container-size` (what the dropped file weighs) and is what the upload bar weighs, never what a row shows. A file the manifest lists but the container holds no entry of has no size at all, and its row then shows no subtitle: what the package weighs never stands in for it. The subtitle is the size alone, with no extension. While analyzing with no failures nothing renders below the phase header — no rows, no spinner (`imp/awaiting-analysis?`); the header text is the only "Checking…" message. Rows appear once the analysis resolves (one per file inside the package), and a failed analysis takes the error-summary path instead. The ready step and its Continue wait for the end of the analysis (`imp/ready-to-import?`, fed with the completion of `imp/analyze`): a ready file is not enough, because the files still to arrive would never be imported. While rebuilding, the phase title counts the files and `imp/imported-count` counts them done with the job's own `:files` counter, so the number moves while a container rebuilds its files one by one. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients.
- File creation/duplication strips file `:data` before putting file summaries into dashboard state.
- The pending invitations are a team-admin only list (backend `:is-admin` already includes the owner). The dashboard hides the invitations entry from the header tabs and the team-options dropdown for non-admins, `invitations-section-content` keys the section on `:is-admin`, and `fetch-invitations` answers `insufficient-permissions` by emitting `invitations-fetched` with `[]`, so a demoted admin does not keep the cached emails and the header does not paint the invite button from a stale list. The organization `send-invitations` policy only gates inviting and resending, not the list.
## Viewer
- `app.main.ui.ui` parses the viewer's `file-id` query param with `uuid/parse*`, so a missing or malformed id arrives as nil. `viewer-page*` calls `check-file-id!` (same namespace) during render, before mounting `viewer*`: it raises `ex/raise :type :not-found :code :missing-file-id`, which the error boundary turns into the not-found page. Without the guard the `dv/initialize` assertion fails on the nil file-id and the user gets an internal-error page. Test: `frontend-tests.ui.viewer-test/check-file-id-raises-not-found-for-invalid-file-id`.
- Viewer initialization sets `:current-file-id`, `:current-share-id`, and `:viewer-local`, then fetches the view-only bundle. Comment threads are fetched only for logged-in users.
- Viewer bundle fetch sends the full supported feature set because anonymous shared viewers may not know team-enabled features.
- View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle.
- View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle, through `app.main.features.pointer-map/resolve-pointer` (share-link scoped; the workspace and library loaders use `resolve-file`, which resolves the same two levels). Both fail loudly: a fragment that cannot be resolved rejects the stream and is logged with file id and fragment id instead of silently dropping the page.
- `bundle-fetched` indexes pages and precomputes viewer frames/all-frames, stores libraries/users/thumbnails/permissions under `:viewer`, then navigates to frame id, query index, or auto-selected frame.
- Viewer zoom and interaction mode changes update both `:viewer-local` and the `:viewer` route query params. `update-zoom-querystring` guards with a query-param comparison (`not= current expected`) and uses `::rt/replace`; without that guard the load sequence (`bundle-fetched` → `zoom-to-fill` → `update-zoom-querystring` → `rt/nav`) re-runs on every navigation and crashes with React "maximum update depth exceeded" (2.18.0-RC5 regression, fixed in `31b73460c3`; regression test: `bundle-fetched-with-zoom-fill-url-does-not-navigate`).

View File

@ -23,6 +23,9 @@
- Public Plugin API objects are lightweight handles, not durable snapshots. Most getters locate fresh state from `app.main.store/state` using hidden `$id`, `$file`, `$page`, etc.
- `not-valid` logs by default but throws when the plugin flag `throwValidationErrors` is enabled. The MCP execute-code handler deliberately enables that flag while running code.
- A write to a removed shape always throws `Shape not found`, whatever that flag: setters through the shape proxy's `:on-set u/check-shape-writable` (runs outside the error handling), and methods that must not vanish silently (`resize`, `rotate`) call `u/check-shape-located` first. Methods run inside `:on-error (u/handle-error ...)`, which logs instead of throwing when the flag is off, so it rethrows errors whose `ex-data :type` is `::u/shape-not-found` or `::r/read-only`.
- A write while the workspace cannot take it (`r/read-only-reason`: save-failure lock `[:workspace-global :persistence-lock]`, `[:permissions :can-edit]` false, version preview `:preview-id`, lost render context `[:render-state :lost]`) always throws, whatever that flag. The `[:workspace-global :read-only?]` flag alone does not block: the Inspect tab sets it and plugins may still write. `r/check-permission` with `content:write`/`library:write` calls `r/check-writable` first, which covers most writers; writers without a permission check (most top-level context methods in `api.cljs`: `create*`, `group`, `align*`, …; z-order methods; text range `applyTypography`; ruler guide `remove`) call `r/check-writable` themselves, and shape setters get it through `u/check-shape-writable`. A new writer without a write-permission check must call it too. An async writer calls it again after each server await, before the next server write or commit (`rx/tap` on the response stream; `dwm/create-svg-shape-with-images` takes it as `:check-writable`), so a lock that lands mid-way rejects the promise with the read-only error. `r/has-permission?` is the check without it, used by the undo blocks so a block opened before a lock still closes. Comments (`comment:write`) stay writable: they do not go through file saves.
- `penpotMgmt` (`app.plugins.management`) checks permissions with `r/has-permission?` (`granted?`), never `r/check-permission`: its calls act on files through the backend, often from the dashboard with no file open, so the workspace read-only check (`check-writable`) must not block them.
- `naturalChildOrdering` and `throwValidationErrors` are stored per plugin under `[:plugins :flags plugin-id ...]`; changing default behavior affects automation and MCP diagnostics.
- Plugin data is stored under keyword namespaces: private data uses `(keyword "plugin" plugin-id)`, shared data uses `(keyword "shared" namespace)`.
@ -31,4 +34,5 @@
- Plugin listeners are watches on the global store and callbacks are debounced about 10ms. Callback exceptions are caught and logged so plugin code does not crash the app.
- `selectionchange` callbacks receive arrays of shape id strings, while `filechange`, `pagechange`, and `shapechange` return proxies.
- `contentsave` fires only when persistence status transitions to `:saved`; it calls the callback with no value.
- `contentrollback` is not a state watch: it subscribes to `st/stream` for `::dps/rolled-back` (kept in `subscriptions`, disposed by `remove-listener`), not debounced, delivered async so the reverted state is in place. Payload `{fileIds pageIds shapeIds}` from `pcm/rollback-targets` (page shapes only, from the reverted commits' redo changes). When the rollback falls back to reloading the file, `::dps/rolled-back` waits for the reloaded data, so the callback never reads the rejected data. The runtime's `validEvents` must list every event name, or `penpot.on` rejects it; a stale runtime build rejects new events. The MCP plugin forwards these rollbacks with the next task response and the server puts a warning first in that tool response (`RollbackNotices`). Unsent ones survive a tab or plugin reload in `penpot.localStorage` (key `rollbacks/<file-id>`, `LocalStorageRollbackStore`; the MCP manifest in `app.main.data.workspace.mcp` and `mcp/packages/plugin/public/manifest.json` grant `allow:localstorage`, which the runtime checks on access); the entry goes once a response carried them.
- Plugin history `undoBlockBegin` creates a workspace undo transaction with a JS `Symbol`; `undoBlockFinish` commits that symbol. Missing finish eventually relies on the workspace transaction timeout.

View File

@ -39,6 +39,7 @@ To assert over emitted event sequences, observe termination: subscribe through `
- Auto-answering mocks lose deadline expressiveness (can't time answers), need teardown timer-cancellation, and post-teardown deliveries hit real implementations — explicit pushes + async delivery + `wait-for` won on every axis.
- Teardown belongs to the terminal continuation, never to `finally`-around-triggers (it would dispose in-flight flows).
- A body that awaits must be `(^:async fn …)` even if the rest is sync; sync sequences are atomic vs the event loop.
- The `:esm` test build cannot load the CommonJS `sax` module behind `tubax`, so SVG parsing fails in tests (`dwm/valid-svg-string?` answers false, `dwm/svg->clj` errors): mock both with the parsed data.
From `frontend/`:
- Full unit test run (always builds, suppressed output): `pnpm run test:quiet`.

View File

@ -12,7 +12,7 @@
- Co-located SCSS modules are preferred. Use `app.main.style/stl` helpers from CLJS and design-system SCSS tokens/mixins instead of legacy global selectors or high-specificity nesting.
- Keep CSS specificity low. Avoid nested selectors unless they target elements the component owns; CSS Modules already prevent class-name collisions.
- Prefer CSS logical properties for directional spacing/layout (`padding-inline-start`, etc.). Physical `width`/`height` are still acceptable where they are clearer.
- Prefer CSS logical properties for directional spacing/layout (`padding-inline-start`, etc.). Physical `width`/`height` are still acceptable where they are clearer. Never mix: a stylesheet logical property (`inline-size`) and an inline physical `width` on the same element cascade separately, and the logical mapping wins — a dynamically driven width must be `width` on both sides (import modal progress bar hit this). And an inline style passed from CLJS must be a JS object (`#js {:width …}`): for plain `[:div …]` elements the Rumext macro does not convert the `:style` map at runtime — a plain CLJS map reaches React raw and React never finds the keys (static style literals are converted at compile time, which is why `:style` maps seem to work elsewhere).
- Use named design-system variables/tokens for spacing, borders, fixed dimensions, colors, and typography. Avoid hardcoded px/rem values and deprecated `resources/styles/common/refactor/spacing.scss` variables.
- Use component-local CSS custom properties for variants and theming instead of one-off Sass variables when a component has multiple visual states.
- Prefer DS typography components (`heading*`, `text*`) and typography mixins instead of plain text wrappers or deprecated typography mixins.

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@
- `apply-modifiers*` is the lower-level commit path once object/text modifiers are ready. It updates frame guides, frame comment threads, and then emits `update-shapes` with `:reg-objects? true`.
- Transform commits restrict diff attrs to `transform-attrs` to avoid scanning unrelated shape attrs.
- Text transforms may carry derived `:position-data`; `assoc-position-data` attaches it while preserving the original text shape context.
- A drag commits when its `mse/drag-stopper` fires, `:interrupt` (Escape) included. The save-failure lock emits `::plk/cancel-gestures` first: drags built on `plk/gesture-stopper` (start-move, start-resize, start-rotate, start-move-line-point, box/curve/line drawing) see a `cancelled?` flag set before the stop and emit `dwt/cancel-transform` (`clear-local-transform` + `finish-transform`) or `cancel-drawing` instead. A new drag gesture must use it (or, in a component, drop the drag when read-only turns on): `commit-changes` refuses the commit anyway, but the preview (WASM modifiers/props) would stay.
## Component-copy touched suppression

View File

@ -13,19 +13,26 @@ enabling advanced AI-driven features in Penpot.
- Build Tool: TypeScript Compiler (tsc) + esbuild
- Package Manager: pnpm
## General Principles
## Principles & Code Style
IMPORTANT: Use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
* You keep each concern in exactly one home.
A mechanism whose parts are only correct in combination is implemented as a single component/class, and its parts are private to it;
expose a minimal public surface. Java-like encapsulation: If helper functions and constants are only used by one abstraction,
make them internal to the respective class.
* For any non-trivial interfaces, you use functions/methods that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
* Prefer declaring and instantiating concrete classes over defining only interfaces and using object literals.
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
* When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
* When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
Syntax:
* Never use if blocks (or other blocks) without braces, even for single-line cases.
## Project Structure (Excerpt)
@ -50,7 +57,7 @@ mcp/
│ ├── src/
│ │ ├── main.ts # handles communication
│ │ └── plugin.ts # plugin implementation
│ └── package.json # Includes @penpot-mcp/common dependency
│ └── package.json # Plugin deps: published plugin-styles, linked plugin-types
└── prepare-api-docs # Python project for the generation of API docs
```
@ -100,8 +107,28 @@ From the `mcp/` directory, run
(node reports `Promise resolution is still pending but the event loop has
already resolved` for the file's root test). Inside tests the logger writes to
stdout directly. `NODE_TEST_CONTEXT` is the env var `node --test` sets.
* `packages/plugin` takes `@penpot/plugin-styles` from the registry but links
`@penpot/plugin-types` to `plugins/libs/plugin-types` (`link:` in the manifest
and in `mcp/pnpm-lock.yaml`, with no registry entry, because a linked package
has no resolution): the published types do not declare the `penpotMgmt`
global the plugin needs. On a plugins libs version bump, only the styles
version moves. After editing the manifest, `pnpm install --lockfile-only` in
`mcp/` must leave the lock untouched.
* Cross-cutting testing principles and anti-patterns: `mem:testing`.
## OAuth for remote MCP clients
- Authorization/discovery URLs, self-contained client IDs, consent and PKCE/code binding, backend Redis state, deployment wiring, and compatibility limits: `mem:mcp/oauth`.
## Backend tools (`submit_feedback`)
- Tools that call the Penpot backend directly (not through the plugin) register only when `PENPOT_MCP_BACKEND_URI` is nonblank (`PenpotMcpServer.initTools`). Currently only `SubmitFeedbackTool`.
- `PenpotBackendClient` owns the HTTP call and the status-to-message mapping; it posts to `api/main/methods/send-mcp-feedback` with `Authorization: Token <MCP user token>`. The token comes from the session context (OAuth bearer or `?userToken=`); without one the tool fails before calling the backend.
- Tool zod schemas mirror the backend malli schema (lengths, blank, control characters); keep both in sync. The backend is the authority.
- Success means the mail job was queued, not delivered; the tool text says so. `formatArgs` hides the feedback from logs.
- Backend side: `send-mcp-feedback` in `app.rpc.commands.feedback`, configured by `PENPOT_MCP_FEEDBACK_EMAIL`; MCP-token auth rules in `mem:backend/auth-permissions-product-domains`.
- User docs: `mcp/README.md` (env tables), `docs/mcp/index.md` (tool list), `docs/technical-guide/configuration.md` (MCP feedback section).
## Devenv plugin/server wiring
In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `<public-uri>/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter.
@ -116,3 +143,7 @@ For parallel devenvs, prefer same-origin MCP routing: each Penpot instance shoul
- The MCP server emits `1008` for a missing `userToken` in multi-user mode, for missing/invalid connection metadata, and to close a connection displaced by a newer one with the same user and session ID.
- Session IDs are deterministic per tab and file, so a reconnect reuses its ID while the old socket may be half-open: the newest connection wins (`PluginBridge.displaceConnection`). Across instances, each new connection publishes a session claim on a per-user Redis channel; an instance displaces its local connection only on a foreign claim that arrives after its own claim (channel order, no clocks). Discovery deduplicates by session ID.
- A tab rejected with `1008` never reaches `connected`, so the frontend's 60s reconnect watcher (`start-reconnect-watcher` in `app.main.data.workspace.mcp`, started only on `connected`) does not engage; recovery is manual via the MCP menu.
## Rollback warnings
- The plugin's `RollbackLog` (`packages/plugin/src/RollbackLog.ts`) holds the `contentrollback` events not yet sent with a task response; `Task.sendResponse` attaches them and acknowledges them once sent, and the server's `unclaimed-rollbacks` message (a response that came after its task timed out) puts them back. With a `RollbackStore` (`LocalStorageRollbackStore` over `penpot.localStorage`, one entry per file) every change is written through, so a reload of the tab or plugin keeps them; storage failures fall back to memory. Plugin tests run with `node --experimental-strip-types`, which rejects TypeScript parameter properties: declare fields explicitly.

View File

@ -0,0 +1,47 @@
# MCP OAuth
## Contract and scope
- MCP-specific authorization; existing Penpot login identifies the user on the approval page. No application vetting or persistent consent policy. Access requires user approval; registration alone grants nothing.
- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no usable one exists; preserve an existing usable token. A token is unusable, and replaced, when expired (settings can give MCP tokens an expiry), when its JWT issuer is not `urn:penpot:mcp-token` (tokens issued before MCP tokens had their own issuer), or when this instance cannot decrypt it (e.g. secret key changed); the backend rejects all three. No refresh token, `expires_in`, or new token lifetime.
- No new client database table or schema migration. Client IDs must remain usable across separate authorization flows without server-side registration storage. Do not replace them with expiring Redis registrations.
- MCP transport continues matching user tokens to connected Penpot plugins; no new per-request token introspection or independent semantic validation. The logged-in Penpot instance remains the content-access boundary. Do not silently add resource-server validation or change token semantics.
- README scope: user/developer usage instructions; document `PENPOT_MCP_AUTH_URI` in its environment-variable table. Keep OAuth architecture and protocol details here and non-obvious invariants in source comments.
## Deployment and discovery
- MCP process: `PENPOT_MCP_AUTH_URI=https://penpot.example/api/mcp/oauth` with `--multi-user`. Unset/empty disables OAuth; configured OAuth requires multi-user. Absolute HTTPS, or HTTP on loopback for development; no userinfo, query, or fragment. Invalid configuration fails startup; startup logs OAuth status.
- Devenv: `mcp/scripts/start-mcp-devenv-oauth` starts multi-user mode with `PENPOT_MCP_AUTH_URI` derived from `PENPOT_PUBLIC_URI`. Manual opt-in only: agentic tooling needs the single-user `start-mcp-devenv`.
- Public MCP transport: `<Penpot public URI>/mcp/stream`, proxied to Node `/mcp`. Missing credentials with OAuth enabled: 401 plus `WWW-Authenticate` referencing `<auth base>/resource` and scope `mcp`.
- Bearer header takes precedence over legacy `?userToken=`. Malformed headers do not fall back to the query token. Existing query-token clients remain supported.
- Protected-resource metadata: `/api/mcp/oauth/resource`; issuer: `/api/mcp/oauth`; authorization-server metadata: `/.well-known/oauth-authorization-server/api/mcp/oauth`. All reach the backend. Bundled devenv/production nginx configs forward the well-known path; custom proxies must do likewise. Keep Penpot public URI and external URLs consistent.
- Node implementation: `mcp/packages/server/src/McpAuthentication.ts`, integrated by `PenpotMcpServer.ts`; OAuth-enabled tools advertise `_meta.securitySchemes`.
## Client identification
- `backend/src/app/mcp/oauth_clients.clj`: dynamic registration at `/api/mcp/oauth/register`, HTTPS client metadata documents (CIMD), and registrations obtained in advance via `(app.mcp.oauth-clients/register! {} metadata)`.
- DCR returns `penpot-mcp-v1.` plus unpadded base64url JSON: normalized callback URLs, optional name, authentication method, random nonce, optional secret hash. Fixed grant/response/scope values are reconstructed. Nonce distinguishes registrations with otherwise identical metadata.
- IDs are public, unsigned, unencrypted, non-expiring metadata; validate on every resolution. Client secrets are generated randomly and returned separately; only the hash enters the ID. No signing/encryption key or client record in PostgreSQL/Redis. Old experimental database-backed UUID IDs require registration again.
- An edited payload changes the ID. Authorization-code exchange compares the exact ID string against the approved request before resolving metadata. Do not replace this with comparison of decoded fields; code ownership and PKCE binding must survive metadata edits.
- Supported authentication: `none`, `client_secret_basic`, `client_secret_post`; no `private_key_jwt`. CIMD selects public-client `none` from the document's supported methods; other listed methods do not imply support here. Scope `mcp`; grant `authorization_code`; response `code`. CIMD may advertise additional grants (e.g. Codex lists `refresh_token`); require `authorization_code` support and normalize to the grant Penpot implements, rather than reject broader client capabilities.
- Entire ID capped at 2,048 ASCII characters. DCR returns `invalid_client_metadata` if encoded metadata exceeds this; shorter/fewer callback URLs reduce size. OAuth defines no fixed ID-length maximum (RFC 6749 §2.2); stateless IDs are explicitly described by RFC 7591 §A.5.2. Installed MCP client SDK 2.3.1 accepts a 2,048-character ID; this does not establish Codex/Claude compatibility.
- Redirect matching is exact except loopback HTTP port changes for native clients (`127.0.0.1`, `[::1]`, exact `localhost`). Host, path and query must still match; reject fragments/userinfo and lookalike localhost domains. Allow HTTPS, loopback HTTP, and reverse-domain native schemes.
- CIMD: fetch with existing SSRF-checked `app.http.client/req`, no redirect following, require matching document `client_id`, bounded 64-KiB body and request/body-read timeouts. Cache max 256 entries/one minute, further limited by HTTP max-age; no-store/no-cache/no max-age means no reuse.
## Approval and exchange
- `backend/src/app/http/mcp_oauth.clj`: separate router branch from ordinary RPC middleware; preserve OAuth snake_case JSON and form encoding. Session auth applies to approval request/decision only. Public metadata/register/token endpoints allow noncredentialed CORS; MCP transport CORS was not added.
- Errors: every failure answers with an OAuth `{"error": ...}` body, never the shared Penpot error shape. Failures without `:oauth-error` are also passed to `app.http.errors/handle` for logging only; its response is discarded. `wrap-errors` also wraps the session middleware of `/request` and `/decision`. Safe to log because these routes never set request `:params`.
- `/authorize`: validate client/callback, `code`, PKCE S256, scope/resource/state; store pending request for 600 seconds; redirect to `?screen=mcp-authorize&request=<opaque handle>`.
- `frontend/src/app/main/ui/mcp_authorize.cljs`: existing login with return URL; show application and account details; submit explicit Allow/Cancel. No fixed MCP token in frontend state or browser redirect. Approval refreshes profile and broadcasts MCP enablement; does not connect a plugin automatically.
- `/request`: first authenticated read binds request to profile plus hashed login-session ID and CSRF value, using compare-and-replace. Re-read after the attempt: concurrent GETs may win/lose binding; only the matching session may proceed.
- `/decision`: check exact Origin, session binding and CSRF, then atomically consume the request. Denial creates no token/enables nothing. Approval calls transactional `access-token/ensure-mcp-access!`, then stores code for 120 seconds. Code state holds token row ID, never the fixed token. Callback echoes OAuth `state` and RFC 9207 `iss`.
- `/token`: validate grant, exact client ID/callback/resource and PKCE; resolve current client metadata/authentication method and verify any secret; atomically consume validated code; fetch the unexpired token by original row ID/profile/type. Regeneration deletes the old token row, so a pending code cannot silently yield the replacement token. Return fixed `access_token`, `token_type: Bearer`, scope `mcp`.
- `backend/src/app/mcp/oauth_state.clj` and Lua: tenant/kind/hash-prefixed keys; random handles; SET NX EX must succeed before returning handle. Reads retain raw JSON for atomic comparison. Consume/delete and bind/replace compare the validated raw record in Lua; replacement preserves TTL. Redis failures fail closed. Backend Redis is required independently of Node's optional Redis plugin bridge.
- `backend/src/app/rpc/commands/access_token.clj`: `ensure-mcp-access!` decodes the stored token with `ex/ignoring` to apply the usable-token rule above. Lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction.
## Verification and limits
- Tests: `mcp_oauth_clients_test.clj`, `mcp_oauth_http_test.clj`, `mcp_oauth_state_test.clj`, OAuth provisioning in `rpc_access_tokens_test.clj`; MCP authentication/HTTP tests; frontend approval tests. Cover no-storage registration, size boundaries, edited-ID rejection, code replay/concurrency, session/CSRF binding, and fixed-token reuse.
- Live OpenAI/Codex/Claude compatibility and marketplace acceptance remain unverified. In particular, verify token persistence without `expires_in` and acceptance of the existing matching-only resource boundary; do not claim protocol conformance from SDK tests alone.
- Protocol references: https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2 ; https://www.rfc-editor.org/rfc/rfc7591.html#appendix-A.5.2 ; https://www.rfc-editor.org/rfc/rfc7636.html ; https://www.rfc-editor.org/rfc/rfc9207.html .

View File

@ -26,6 +26,7 @@ No manual line wrapping: one line per bullet or paragraph, however long. Memorie
## Add/update threshold
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
Do not restate what the code already says (comments included): keep only what code cannot represent — decisions and their reasons, cross-file invariants, ordering constraints, environment and tool behavior, and traps that break builds.
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
## Maintenance Actions

View File

@ -36,6 +36,7 @@ From `render-wasm/`:
- Rust tests: `./test` or `cargo test <name>`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Lint: `./lint`.
- Format check: `cargo fmt --check`.
- Format: `./fmt`; check only: `./fmt --check`.
- `./build`, `./test`, `./lint` and `./fmt` all run `setup`, which selects the Rust version pinned in `_build_env` (skip with `PENPOT_WASM_PREPARED=1`).
Do not change exported WASM function signatures without updating the corresponding frontend bridge and verifying the frontend renderer path.

View File

@ -1,12 +1,14 @@
# Changelog helper
`scripts/changelog.py` holds the checks used by the `update-changelog` skill (extracted from the skill file so the skill stays workflow-only). Calls `scripts/gh.py` via subprocess; never call the GitHub API directly from this script.
`scripts/changelog.py` holds the checks used by the changelog flow. The rules and the step-by-step live in `mem:workflow/updating-changelog`; this memory documents the subcommands. Calls `scripts/gh.py` via subprocess; never call the GitHub API directly from this script.
## Subcommands
- `check-merged <PR...>` (`--file`, `--stdin` accepted) — warn on any non-merged PR, exit 1 when found; run before writing changelog entries.
- `cross-ref <MILESTONE> [--changes CHANGES.md]` — fetch milestone PRs with `--state all` once, report merged PRs missing from the version section plus CLOSED (unmerged) warnings; GHSA entries never appear here by design.
- `report <MILESTONE> [--changes CHANGES.md --output CHANGES-ISSUES.md]` — overwrite the anomaly report (milestone mismatches type A/B plus `:rocket:` gaps C/D); rule violations never belong in the report, they are fixed in CHANGES.md first.
- `boom <MILESTONE> [--changes CHANGES.md --write]` — generate `### :boom: Breaking changes & Deprecations` from the closed, non-excluded milestone issues labelled `breaking change`, sorted by issue number. Each issue becomes a `#### <issue title>` block: content = body of the issue's last `## Breaking change note` comment (whole markdown, headings demoted to `#####` outside code fences; fallback = heading plus refs with a stderr warning), refs = issue link plus merged closing PRs on their own line, with `(by @author)` for `community contribution`. `--write` replaces only that subsection of the version (inserts it after the version header when missing, keeps the heading when there are no entries; idempotent). Print-only by default. Uses `gh.py comments`.
- `security <MILESTONE> [--changes CHANGES.md --write]` — generate `### :lock: Security fixes` from the advisories whose `patched_versions` include the version (via `gh.py advisories --patched-version`), sorted by GHSA id. Entry: `- Fix <description> (<advisory URL>) (PR: [#N](...))`; description = `Fix` + advisory summary, first word decapitalized unless it starts with an acronym; PRs = merged milestone PRs labelled `pr pt` whose body contains the GHSA id, comma-separated, and no PR is legal. Warns on stderr when a `pr pt` PR references an advisory with no matching `patched_versions`. `--write` replaces only that subsection, placed after `:rocket:` (or `:boom:`, or the version header when neither exists); empty entries remove the subsection; idempotent. Print-only by default.
- `cross-ref <MILESTONE> [--changes CHANGES.md]` — fetch milestone PRs with `--state all` once, report merged PRs missing from the version section plus CLOSED (unmerged) warnings. PR links inside `:lock:` entries count as referenced; advisory URLs never appear here by design.
- `report <MILESTONE> [--changes CHANGES.md --output CHANGES-ISSUES.md]` — overwrite the anomaly report: A/B milestone mismatches, E `:boom:` entry without the label, F `:rocket:` sync, G `:boom:` sync, plus the C/D `:rocket:` gaps (warnings). `:boom:`/`:rocket:` rule violations are fixed by regenerating or relabelling, not by editing entries in place.
## Conventions
@ -15,4 +17,5 @@
- Version-section lookup accepts an optional `(suffix)` after the version header (e.g. `(Unreleased)`); a missing section is an error for `cross-ref` and an empty section for `report`.
- PR/issue reference parsing covers standard `[#N](.../pull/N)` / `[#N](.../issues/N)` links plus legacy `PR:[N]` / `[Github #N]` forms.
- Shared parsing helpers (`extract_version_section`, `extract_subsection`, `collect_changelog_prs/issues`) live at module top so future subcommands reuse them instead of duplicating regexes.
- Report anomaly types: A/B milestone mismatches, E `:boom:` entry whose issue lacks the `breaking change` label (counted, entries preserved — human labels the issue or moves the entry); C/D are `:rocket:` gaps (warnings, never counted).
- Report anomaly types: A/B milestone mismatches, E `:boom:` entry whose issue lacks the `breaking change` label (counted, entries preserved — human labels the issue or moves the entry), G `breaking change` issue missing from `:boom:` (counted, same contract as F); C/D are `:rocket:` gaps (warnings, never counted).
- `issue_excluded` (closed, non-Task, non-Rejected, no `release blocker`/`no changelog` label) is module-level and shared by `report` and `boom` so the exclusions cannot drift.

View File

@ -1,8 +1,9 @@
# CI (scripts/ci)
`scripts/ci` runs CI-style checks — lint, tests, format — for one or more
monorepo modules and prints a per-task summary. It is the local equivalent
of CI; use it to verify changes before declaring work done.
`scripts/ci` runs CI-style checks — lint, tests, format — and, on request,
the production build of each module, for one or more monorepo modules, and
prints a per-task summary. It is the local equivalent of CI; use it to
verify changes before declaring work done.
It also exports `PENPOT_FLAGS` with `enable-backend-asserts` appended (keeping
whatever the caller already set), which is what puts `clojure.core/assert`
@ -25,6 +26,7 @@ Run from the repo root:
```bash
./scripts/ci MODULE... # lint + test + fmt per module
./scripts/ci --build MODULE # production build only
./scripts/ci --all --no-test # lint + fmt on all modules
./scripts/ci --lint frontend # lint only
./scripts/ci --fix --no-test frontend # format files, skip tests
@ -32,14 +34,15 @@ Run from the repo root:
./scripts/ci --dry-run --all # preview what would run
```
Modules: `frontend backend common render-wasm exporter mcp plugins library`.
Modules: `frontend backend common render-wasm exporter mcp plugins library
media-processor`.
Flags:
- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats
instead).
- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` /
`--no-test` / `--no-fmt` drop one task from the default set.
instead). Build is **not** in the default set.
- `--lint` / `--test` / `--fmt` / `--build` run one task only; `--no-lint` /
`--no-test` / `--no-fmt` / `--no-build` drop one task from the default set.
- `--paren-repair` runs only the delimiter repair — it wraps
`scripts/paren-repair` over each module's Clojure/CLJS sources; see
`mem:scripts/paren-repair`.
@ -48,6 +51,49 @@ Flags:
output; `--dry-run` prints commands without running; `--clean` removes
the log directory.
## Build task
`--build` compiles each module the way production does, reusing caches
(`.shadow-cljs`, `target/`, `node_modules`) instead of clearing them. It
does not package bundles: `manage.sh build-bundle` remains the way to
produce deployable artifacts.
| Module | Command | Output |
|---|---|---|
| `frontend` | `NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets` | `frontend/resources/public/`, `frontend/target/` |
| `backend` | `clojure -T:build jar` | `backend/target/penpot.jar` |
| `common` | *(none; skipped)* | — |
| `render-wasm` | `./build` (targets `frontend` and `export`) | `render-wasm/target/` |
| `exporter` | `NODE_ENV=production pnpm run build:app` | `exporter/target/` |
| `mcp` | `pnpm run build` | `mcp/*/dist` |
| `plugins` | `pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc` | `plugins/dist/` |
| `library` | `NODE_ENV=production clojure -M:dev:shadow-cljs release library` | `library/target/library/` |
| `media-processor` | `pnpm run types:check && pnpm run build` | `media-processor/dist/` |
- Every output path above is gitignored, so a build leaves `git status`
clean.
- The module `build` scripts in each `scripts/build` are **not** used: they
delete `node_modules`, wipe `resources/public` and run `pnpm install`,
which belongs to the bundle pipeline, not to a local check.
- `frontend` and `library` have a `build` script that clears the shadow-cljs
cache first; the task calls the inner commands to stay incremental.
`exporter` uses `build:app` for the same reason.
- `common` has no artifact of its own: its compilation is exercised by its
`test` task and by the builds of `frontend`, `backend` and `exporter`.
- `media-processor` is the ninth module. It has no lint, so that task shows
as skipped; its build runs `tsc --noEmit` before esbuild because esbuild
does not type-check.
- Cold-cache cost measured on the devenv: `library` ~120 s, `frontend` ~120 s,
`exporter` ~80 s, `backend` ~35 s. With warm caches the whole set (8
builds) takes ~4 min. The first run of `clojure -T:build jar` downloads
`tools.build` if it is not in `~/.m2`.
- `pnpm run` may trigger a dependency check/install when `node_modules`
drifts from the lockfile (`verify-deps-before-run`), so the first run
after a dependency change can need network.
- `render-wasm/build` takes a lock (`_build_lock`): never run two builds at
once in the same checkout.
## Logs and exit codes
- Full output of every task: `.ci-logs/<module>-<task>.log`.
@ -59,12 +105,16 @@ Flags:
## Notes
- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`,
`./test`, and `cargo fmt`.
- `mcp` has no lint task and `media-processor` has no lint task; both show
as skipped. `render-wasm` uses `./lint`, `./test`, and `./fmt`.
- `scripts/test_ci.py` (unittest, `python3 scripts/test_ci.py`) covers the
flag wiring, one build command per module and the help text through
`--dry-run`; it executes no real build.
- Exports `PENPOT_FLAGS` with `enable-backend-asserts` appended to any
existing value, so backend test runs compile in and exercise
precondition asserts; see `mem:backend/testing`.
- Test tasks are long-running (backend: `clojure -M:dev:test`); use a
generous timeout when calling it from an agent shell.
- Test and build tasks are long-running (backend: `clojure -M:dev:test`;
frontend build: ~2 min warm); use a generous timeout when calling it from
an agent shell.
- Skill entry point: `.agents/skills/local-ci/SKILL.md`.
- Testing principles and output discipline: `mem:testing`.

View File

@ -11,6 +11,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Comparing milestone issues against CHANGES.md to find missing entries.
- Explicitly linking a GitHub issue to a pull request.
- Listing or inspecting GitHub Security Advisories (GHSA).
- Fetching issue comments (the `## Breaking change note` markdown for the changelog).
## Prerequisites
@ -82,6 +83,16 @@ Fetch issue details by number in batches of 50 (same input styles as `prs`: numb
**Output**: JSON array to stdout; progress to stderr.
### `comments`
Fetch every comment of one or more issues via REST (paginated, 100 per page), oldest first. Same input styles as `issue`: numbers, `--file`, `--stdin`.
```bash
python3 scripts/gh.py comments 11235 11236
```
**Output fields per comment**: `issue`, `id`, `author`, `created_at`, `body`. JSON array to stdout; progress to stderr. `changelog.py boom` reads the `## Breaking change note` comment from this output.
### `link-issue`
Explicitly assign a GitHub issue to a pull request:
@ -91,7 +102,7 @@ python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
# Short alias: python3 scripts/gh.py link <ISSUE_NUMBER> <PR_NUMBER>
```
The command resolves both node IDs and calls `addCloseIssueReferences`. It trusts the successful mutation instead of re-querying, because GitHub does not reliably report mutation-created links through `closedByPullRequestsReferences(userLinkedOnly: true)`. It is safe to rerun, works for merged PRs, and does not close an issue retroactively. JSON goes to stdout; progress and errors go to stderr; a missing issue/PR or a failed mutation exits non-zero.
The command resolves both node IDs and calls `addCloseIssueReferences`. It trusts the successful mutation instead of re-querying, because GitHub does not reliably report mutation-created links through `closedByPullRequestsReferences(userLinkedOnly: true)`. It is safe to rerun and works for merged PRs. It does not guarantee the issue stays open: adding `Closes #N` to the body of an already-merged PR closed the issue immediately when tried (#12896 / PR #12067, 2026-11-09). JSON goes to stdout; progress and errors go to stderr; a missing issue/PR or a failed mutation exits non-zero.
### `advisories`
@ -107,6 +118,9 @@ python3 scripts/gh.py advisories --severity critical
# Filter by state
python3 scripts/gh.py advisories --state triage
# Filter by patched version declared in the advisory
python3 scripts/gh.py advisories --patched-version 2.19.1
# Get full detail for a single advisory
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
```
@ -115,6 +129,8 @@ python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps.
`--patched-version` filters the raw advisories before formatting: the match is an exact token of `vulnerabilities[*].patched_versions` (split on `,`/`;`), so `2.19.1` never matches `2.19.10`. Every state is included (`triage`, `draft`, `published`, `closed`, `withdrawn`). The raw `vulnerabilities` stay internal to the filter: the list output keeps the summary schema.
**Output**: JSON to stdout; progress to stderr.
## Key principles

View File

@ -35,11 +35,36 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
| Field | Rule |
|-------|------|
| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **Labels** | `community contribution` (PRs from non-core) · `breaking change` when the change breaks compatibility (see below) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
## Breaking changes
When the issue introduces a breaking change or a deprecation, it is the unit
`mem:workflow/updating-changelog` turns into a block under
`### :boom: Breaking changes & Deprecations`. The section is generated from
the issue (`scripts/changelog.py boom`), so the issue must carry both:
1. The `breaking change` label.
2. A `## Breaking change note` **comment** (not a body section). Everything
after that marker line is copied to the changelog: paragraphs, bullet
lists, code blocks and headings are kept (headings are demoted to level 5
in `CHANGES.md`). Write it for self-hosters: what changes, what they must
do, and what breaks if they do nothing. Keep it short: explain the
implications and link the runbook or docs for the detail, never paste the
runbook itself. Links must point to a long-lived branch (`staging` or
`main`), never to a feature branch.
Post the note as a new comment. Do not add a breaking-change section to the
issue body and do not hand-write the changelog block. When the issue comes
from a PR, reuse the text of the PR's breaking-change section.
Do not hard-wrap the note: one line per paragraph or bullet.
The full changelog rules (sections, exclusions, `:rocket:`, pre-flight and
anomaly report) live in `mem:workflow/updating-changelog`.
## Issue Body Template
Write the body to a temp file to avoid shell quoting issues:
@ -237,6 +262,9 @@ Identify:
description). Omit internal details. Use the templates above.
- **Issue Type:** use the mapping table above (also handles `:bug:` /
`:sparkles:` / `:tada:` title prefixes).
- **Breaking changes:** when the PR body has a breaking-change section, post
its user-facing text as a `## Breaking change note` comment on the issue and
add the `breaking change` label (see **Breaking changes** above).
### Create the issue
@ -287,7 +315,7 @@ Add `Closes #<ISSUE_NUMBER>` to the PR body for readable context, then run the e
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
The command creates the GitHub Development link by calling `addCloseIssueReferences` and trusts the successful mutation (GitHub does not reliably report mutation-created links back through the API). It is safe to rerun and does not close an issue retroactively when the PR is already merged. Do not rely on the body keyword as the assignment operation.
The command creates the GitHub Development link by calling `addCloseIssueReferences` and trusts the successful mutation (GitHub does not reliably report mutation-created links back through the API). It is safe to rerun. Do not rely on it to keep the issue open: adding `Closes #N` to the body of an already-merged PR closed the issue immediately when tried (#12896 / PR #12067, 2026-11-09; the timeline shows a cross-reference plus a ClosedEvent with no closer). For changelog units that outcome is fine — a merged fix means a closed issue — but be aware when linking old merged PRs. Do not rely on the body keyword as the assignment operation.
### Clean up
@ -303,6 +331,8 @@ rm -f /tmp/issue-body.md /tmp/pr-body.md
label or the author is not a core team member, add the label to the issue.
- **Don't put implementation details in the issue body** — the issue is for
users, QA, and changelog readers.
- **Drop the PR's `no issue required` label** once the issue exists: it only
marks PRs that need no changelog unit, and it is stale from then on.
## Creating Issues from Draft Body

View File

@ -66,7 +66,7 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- **Write for humans.** The diff shows what changed. The description explains why.
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
- **Prefer bullets over paragraphs.** Short bullet items, grouped by area with bold lead-ins where helpful, are far easier to digest than prose; keep any remaining paragraph to a few sentences.
- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long.
- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long. This applies to PR descriptions and to PR/issue comments and review replies alike.
- **Skip the obvious.** Don't explain what `git diff` already shows.
### What NOT to Include
@ -80,7 +80,7 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
## Explicit Issue Assignment
- For each GitHub issue that a PR resolves, run `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` after creating or editing the PR. Do not rely on `Closes #NNNN` in the body; it is only human-readable context.
- The command calls `addCloseIssueReferences` and trusts the successful mutation: GitHub does not reliably report mutation-created links back through `closedByPullRequestsReferences(userLinkedOnly: true)`, so the command exits non-zero only when a link target is missing or the mutation fails. It is safe to rerun and also works for an already merged PR; it does not close an issue retroactively.
- The command calls `addCloseIssueReferences` and trusts the successful mutation: GitHub does not reliably report mutation-created links back through `closedByPullRequestsReferences(userLinkedOnly: true)`, so the command exits non-zero only when a link target is missing or the mutation fails. It is safe to rerun and also works for an already merged PR. Do not rely on it to keep the issue open: adding `Closes #N` to the body of an already-merged PR closed the issue immediately when tried (#12896 / PR #12067, 2026-11-09).
- Skip this process for `Relates to #NNNN` and Taiga references, which do not represent a closing relationship.
## Before Opening

View File

@ -0,0 +1,6 @@
# Non-JS dependencies in the production images
Node and pnpm pins: `mem:workflow/updating-node` and `mem:workflow/updating-pnpm`. Only the parts the Dockerfiles cannot show:
- JDK: the image tracks a non-LTS line, so there is no minor to pick — the last build of the line is the ceiling; moving line is a major and needs a real build to validate the `jlink` module list.
- ImageMagick: publish `penpotapp/imagemagick:<ver>` **before** bumping the `COPY --from` refs in the backend, exporter and devenv Dockerfiles; otherwise both builds break on the missing tag.

View File

@ -0,0 +1,208 @@
# Updating the changelog
> Note about this memory: unlike other Serena memories (dense notes written for LLM agents, see `mem:memory-maintenance`), this document is also written for human release managers who need full context. That is why it uses plain language and examples. It is the canonical home of the changelog flow; the `update-changelog` skill is only an entry point that routes here.
`CHANGES.md` (repository root) is the release notes file. Every entry comes from GitHub: a milestone issue, its merged PR, or a security advisory whose `patched_versions` include the version. It is never written from scratch by hand; the only manual parts are the wording of a description when the title is not enough and the advisory summaries (polished on GitHub, the source of the generated security entries).
The commands live in `scripts/gh.py` (GitHub queries) and `scripts/changelog.py` (changelog checks). Their flags and internals are documented in `mem:scripts/gh` and `mem:scripts/changelog`.
## 1. When to run it
- Before a release, to fill the section of the version being released.
- When new issues are added to an existing milestone.
- When asked to audit or refresh `CHANGES.md`.
Editing `CHANGES.md` happens only on explicit request or as part of the release process; see `mem:critical-info`.
## 2. Sources of truth
| Question | Source |
|---|---|
| Which issues belong to the version? | GitHub milestone |
| Which security fixes? | Advisories whose `patched_versions` include the version |
| Which section? | Issue Type |
| Is it a breaking change? | `breaking change` label |
| Is it a highlight? | `release highlight` label |
- **Issue Type decides the section**: `Bug` → `:bug:`, `Feature`/`Enhancement` → `:sparkles:`, `Task` is excluded (internal chores). Labels `bug`/`enhancement` and title emoji prefixes are often wrong or missing; do not use them.
- **Security fixes come from advisories**: an advisory whose `patched_versions` include the version becomes a `:lock:` entry (section 7). `triage`, `draft` and `published` are all valid states; the advisory may still be unpublished when the changelog is written. A security fix may also carry a regular bug issue; both entries are legal and independent (the issue lives in `:bug:` as any other bug).
- **Two labels override the Issue Type**: `breaking change` → an entry only under `:boom:`, never in its regular section; `release highlight` → an extra entry under `:rocket:` on top of its regular entry.
- **Only closed issues**. An open issue in the milestone is not generated, even if someone added a provisional entry by hand before the merge.
- **Excluded issues**: `no changelog`, `release blocker`, `Task` issue type, `Rejected` project status.
- **Excluded PRs**: a PR with `release blocker` or `no issue required` does not bring an entry of its own.
- **An issue with no milestone** belongs to another (probably private) project: it is neither changelog candidate nor anomaly.
## 3. Layout of `CHANGES.md`
The newest version goes at the top, right after the `# CHANGELOG` header. Inside a version, the section order is fixed:
```markdown
# CHANGELOG
## 2.19.0 (Unreleased)
### :boom: Breaking changes & Deprecations
#### <Breaking change title>
<content>
[#ISSUE](https://github.com/penpot/penpot/issues/ISSUE) (PR: [#PR](https://github.com/penpot/penpot/pull/PR))
### :rocket: Epics and highlights
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
### :lock: Security fixes
- Fix <user-facing description of the vulnerability> (<advisory URL>) (PR: [#PR](https://github.com/penpot/penpot/pull/PR))
### :bug: Bugs fixed
- Fix <user-facing description of the bug> [#ISSUE](https://github.com/penpot/penpot/issues/ISSUE) (PR: [#PR](https://github.com/penpot/penpot/pull/PR))
### :sparkles: New features & Enhancements
- Add <user-facing description of the feature> [#ISSUE](https://github.com/penpot/penpot/issues/ISSUE) (PR: [#PR](https://github.com/penpot/penpot/pull/PR))
```
- `:boom:` first, then `:rocket:`, `:lock:`, `:bug:`, `:sparkles:`. Other headings exist in old versions (`:heart:` community contributions); do not add them to new versions.
- Include a section only when it has entries. Historical versions may have an empty `:boom:` or `:rocket:` heading; `boom --write` keeps the `:boom:` heading even when there are no entries. `:lock:` is omitted (and removed on `--write`) when the version has no security advisories.
- One blank line between the last entry of a section and the next heading.
- No manual line wrapping: one line per bullet or paragraph, however long.
## 4. Regular entries (`:bug:`, `:sparkles:`)
The entry shape is:
```markdown
- <description> (by @github_username) [#ISSUE](https://github.com/penpot/penpot/issues/ISSUE) (PR: [#PR](https://github.com/penpot/penpot/pull/PR), [#PR2](https://github.com/penpot/penpot/pull/PR2))
```
- **Description**: derived from the issue title, not the PR title. Strip emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and write the user-facing behaviour in imperative mood. Examples: `Plugin API token methods fail with schema validation error` → `Fix Plugin API token methods failing with schema validation error`; `Custom uploaded font family names are not sanitized` → `Sanitize font family names on custom uploaded fonts`.
- **Issue is the primary link**: the issue is the changelog unit, the PR is the implementation. Multiple fix PRs go comma-separated inline.
- **Community attribution**: when the issue or its PR carries `community contribution`, add `(by @username)` before the issue link. Use the PR author, not the issue author.
- **Closed issue with no PR** in the milestone: link the issue only, no PR reference.
- **PR with no linked issue**: link a matching closed issue in the milestone when one exists; otherwise skip it, because the issue is the changelog unit.
- **False-positive PR links**: a PR may claim to close an unrelated issue (ancient PR, cross-project reference). If the titles are clearly unrelated or the PR predates the issue by years, treat it as a data glitch and ignore it.
## 5. `:boom:`: breaking changes and deprecations
Each breaking change is a `####` block, not a bullet. The title is the issue title without its emoji prefix; the content comes from a comment on the issue; the references go on their own line at the end.
The **issue** must carry:
1. The `breaking change` label.
2. A comment whose first line is `## Breaking change note`. Everything after that marker line is the changelog content: paragraphs, bullet lists, code blocks, tables, headings (they are demoted to `#####` on write), and so on. The marker itself is case-insensitive and accepts `#`, `##` or `###`; use `##`.
Example comment:
```markdown
## Breaking change note
The Admin Console is now enabled by default and the `enable-admin-console` flag is no longer needed.
Self-hosted instances must deploy the `penpot-admin-console` service and set `PENPOT_ADMIN_CONSOLE_URI` in frontend and backend (check the [upgrade guide](https://help.penpot.app/technical-guide/getting-started/docker/#upgrade-to-2.19)).
```
Generated block:
```markdown
#### Enable the Admin Console by default
The Admin Console is now enabled by default and the `enable-admin-console` flag is no longer needed.
Self-hosted instances must deploy the `penpot-admin-console` service and set `PENPOT_ADMIN_CONSOLE_URI` in frontend and backend (check the [upgrade guide](https://help.penpot.app/technical-guide/getting-started/docker/#upgrade-to-2.19)).
[#12807](https://github.com/penpot/penpot/issues/12807) (PR: [#12475](https://github.com/penpot/penpot/pull/12475))
```
Rules for the note:
- **Write it for admins**: what changes, what they must do, and what breaks if they do nothing. Do not paste the full runbook; explain the implications and link the runbook or docs.
- **Link a long-lived branch**, `staging` or `main`, never a feature branch (`issue-1234`). The link may 404 until the PR merges; that is expected.
- **Do not add issue or PR links**: the generator appends them.
- **Do not hard-wrap the text**: one line per paragraph or bullet. The generator copies lines verbatim, so wrapped lines end up wrapped in `CHANGES.md`.
- **One note per issue**. If several comments carry the marker, the generator uses the last one and warns.
- **No note**: the generator warns on stderr and emits only the heading and references; fix the issue, not `CHANGES.md`.
- **`####`, not `###`**: `###` is the level of the changelog sections. An entry at `###` would render as a sibling section and the scripts that read and rewrite `:boom:` would cut the section there. The note author never writes the `####`: the generator adds it.
- **Only merged closing PRs** are referenced; unmerged or superseded PRs are skipped.
- **Never hand-edit the section**. Generate it with `boom` (section 8). The note lives on GitHub; edit it there.
## 6. `:rocket:`: epics and highlights
- Lists exactly the milestone issues with the `release highlight` label, and nothing else. Product decides highlights by labelling issues; a regeneration cannot undo that choice.
- Every entry copies the text and references of the issue's regular entry and MUST carry both an issue link and a PR reference.
- If no issue is labelled, leave the section out; do not pick highlights yourself.
- Never edit `:rocket:` by hand: add or remove the label and regenerate.
## 7. `:lock:`: security fixes
Generated from the advisories that declare the version in `patched_versions`; never written by hand.
- **Source and filter**: `python3 scripts/gh.py advisories --patched-version <VERSION>` keeps the advisories whose `vulnerabilities[*].patched_versions` contain the version as an exact token (so `2.19.1` never matches `2.19.10`). Any state is valid — `triage`, `draft`, `published` — because the advisory may still be unpublished when the changelog is written.
- **Fill the advisories first**: `patched_versions` must be filled before generating the changelog. The release process publishes the advisories with (or after) the release; the changelog does not wait for publication.
- **PR reference**: a merged PR in the milestone labelled `pr pt` whose body contains the GHSA id contributes its number inline; several PRs go comma-separated. An advisory with no PR is legal: the entry links only the advisory.
- **Missing `patched_versions`**: when a `pr pt` PR references an advisory that declares no matching `patched_versions`, `security` prints a warning on stderr and leaves it out. Fix the advisory, not `CHANGES.md`.
- **Entry format**: `- Fix <description> (<advisory URL>) (PR: [#PR](...))`. The description is `Fix` plus the advisory summary, with the first word decapitalized unless it starts with an acronym (`SVG`, `CVE-...`).
- **Wording**: the flow may polish the description. Re-running `security --write` rewrites it from the summary, so the durable place for wording is the advisory summary on GitHub; keep it a problem statement, because the generator adds `Fix`.
- **Empty version**: no matching advisories → the subsection is not inserted (and is removed by `--write`).
- **Never hand-edit the section**: generate it with `security` (section 8).
## 8. Flow
1. **Confirm the version** (a semver string such as `2.19.0`) with the user when it is not obvious.
2. **Fetch the milestone issues**: `python3 scripts/gh.py issues "<VERSION>" --exclude "release blocker,no changelog"` (closed by default; add `--state all` only to inspect open issues). `gh.py` already drops `Task` and `Rejected`; pass `--include-tasks` / `--include-rejected` only to inspect them. To find what is missing from an existing section, add `--compare CHANGES.md`.
3. **Fetch PR details when needed**: `python3 scripts/gh.py prs <numbers...>` (also `--milestone`). Use it for the PR author, labels and merge state.
4. **Categorize** by Issue Type and the two labels (section 2), and **build the descriptions** (section 4).
5. **Verify PR merge status** before writing: `python3 scripts/changelog.py check-merged <PR numbers...>`. Only merged PRs go in the changelog; if a closing PR was closed unmerged, look for the PR that superseded it.
6. **Reconcile the existing text** (section 9).
7. **Generate `:boom:`**: `python3 scripts/changelog.py boom "<VERSION>"` prints the section; add `--write` to replace it in `CHANGES.md`. The write replaces only that subsection, inserts it after the version header when missing, keeps the heading when there are no entries, and is idempotent.
8. **Generate `:rocket:`** from the `release highlight` label (section 6).
9. **Generate `:lock:`**: `python3 scripts/changelog.py security "<VERSION>"` prints the section; add `--write` to replace it in `CHANGES.md`. The contract lives in section 7.
10. **Cross-check PRs**: `python3 scripts/changelog.py cross-ref "<VERSION>"`. It lists merged milestone PRs missing from the section and warns about closed (unmerged) PRs. PRs linked from `:lock:` count as referenced; an advisory with no PR is invisible here by design.
11. **Generate the anomaly report**: `python3 scripts/changelog.py report "<VERSION>"`. It overwrites `CHANGES-ISSUES.md` with the current state (section 10).
## 9. Pre-flight: reconcile before writing
Re-fetch the milestone (do not trust cached data) and fix these in `CHANGES.md` before adding the new section:
1. **Duplicate across versions** → remove it from the current section; the earlier version wins for backports. The same holds after merging a patch branch into `staging`: the fix keeps its released patch entry and leaves the newer, unreleased section (the merge conflict in `CHANGES.md` is resolved by keeping both sections, newest first, minus the duplicate).
2. **Stale milestone assignment** (the issue moved out of this milestone) → remove the entry.
3. **Newly applied exclusion labels** (`no changelog`, `release blocker`) → remove the entry.
4. **Issue no longer closed, deleted, or now `Rejected`** → remove the entry.
5. **Unmerged or moved PR reference** → fix the reference or remove the entry. A PR merged in a different milestone is an anomaly (section 10); do not remove it silently.
6. **Issue Type changed to `Task`** → remove the entry.
7. **Breaking change out of sync** (a `breaking change` issue outside `:boom:`, or a `:boom:` entry without the label) → regenerate the section; a labelled issue has no regular entry.
8. **Highlight out of sync** (`:rocket:` entry without the label, or a labelled issue missing) → remove it or add it.
9. **Missing valid issues** (closed, non-excluded, referenced nowhere) → add them to their section.
## 10. Anomaly report
`report` classifies problems it cannot decide alone. The total counts A, B, E, F and G; C and D are warnings.
- **A — issue in this milestone, referenced PR in another milestone (or none).** The claim "this issue is fixed by this PR in this milestone" is inconsistent. Fix the milestone on the issue or the PR, or remove the entry.
- **B — PR in this milestone whose closing issue is in another milestone.** Same fix. An issue with no milestone is not an anomaly (another, probably private, project).
- **E — `:boom:` entry whose issue lacks the `breaking change` label.** The entry stays in the changelog: either label the issue or move the entry to its regular section.
- **F — `:rocket:` out of sync with the `release highlight` label.** A rule violation: `:rocket:` must list exactly the labelled issues. Fix the label or re-run the flow.
- **G — `breaking change` issue missing from `:boom:`.** A rule violation: `:boom:` must list exactly the labelled issues. Regenerate the section or relabel.
- **C — released X.Y.0 version without a `:rocket:` section** (gap; patches never carry highlights).
- **D — `:rocket:` entry without issue AND PR references** (gap).
Anything else is a rule violation, not a report item: fix it in the pre-flight and re-run.
## 11. Pitfalls
- **Open issues with provisional entries**: a PR author may add the entry before the merge. `boom` will not generate it until the issue closes; keep the hand-written block identical to what the generator would produce.
- **Labels, not emoji**: title prefixes are cosmetic and often missing; the section comes from Issue Type and the two labels.
- **Taiga references**: old entries link to Taiga. Resolve them to the GitHub issue via the description text or the PRs that mention the Taiga URL, then link issue and PR.
- **Long-lived links only**: runbooks and docs referenced from a note must point to `staging` or `main`, never to a feature branch that will be deleted.
- **Generated sections**: `:boom:`, `:rocket:` and `:lock:` are generated. Never patch them by hand; edit the label, the note comment or the advisory and re-run.
- **Deleted or transferred issues**: an issue referenced in the changelog that returns 410/404 (deleted or moved out of the repo) must lose its entry in the pre-flight. Its security fixes, if any, stay in `:lock:` through the advisory.
## 12. Where the code is
- `scripts/gh.py`: `issues`, `issue`, `comments`, `prs`, `link-issue`, `advisories` (with `--patched-version`). Details: `mem:scripts/gh`.
- `scripts/changelog.py`: `check-merged`, `boom`, `security`, `cross-ref`, `report`. Details: `mem:scripts/changelog`.
- Tests: `scripts/test_gh.py`, `scripts/test_changelog.py`.
- The note contract for issue authors: `mem:workflow/creating-issues`, section **Breaking changes**.

View File

@ -5,7 +5,7 @@ No single source of truth: nothing reads `.nvmrc` at build time, and the version
## Pin sites
- `.nvmrc` → `v<X.Y.Z>`, with the leading `v`.
- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v<X.Y.Z>`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two.
- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v<X.Y.Z>`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two; only the arch SHAs change, the structure stays.
- `docker/images/Dockerfile.mcp` → two stages: `FROM dhi.io/node:<X.Y.Z>-debian13-dev AS build` and `FROM dhi.io/node:<X.Y.Z>-debian13 AS image`. The release stage carries no `-dev`.
- `docker/images/Dockerfile.media-processor` and `docker/images/Dockerfile.exporter` → single `FROM dhi.io/node:<X.Y.Z>-debian13-dev`.
- `.github/workflows/plugins-deploy-api-doc.yml` and `.github/workflows/plugins-deploy-styles-doc.yml` → `runtime: node@<X.Y.Z>` in the `pnpm/setup` step. Its `working-directory: plugins` makes pnpm's own version come from the manifest, so this pin is the Node one; keep it equal to `.nvmrc` minus the `v`.
@ -18,6 +18,7 @@ No single source of truth: nothing reads `.nvmrc` at build time, and the version
- Node 24.x is the Krypton LTS line: take the newest patch of that major unless a major bump is the point.
- Renovate also opens these bumps, on `renovate-*` branches and with its config living outside this repo; a manual bump and Renovate's must not diverge on any of the six sites.
- A backport merge can deliver a newer Node into `docker/` and the plugins-deploy workflows while main's `.nvmrc` stays behind, because main receives partial backports of `.github/` and `docker/` from develop. Merging main into staging therefore conflicts on the pin lines: take main's set and finish the bump (`.nvmrc` plus the `packageManager` fields via `mem:workflow/updating-pnpm`), or the six sites drift.
- The newer version wins in both directions. A maintenance→main merge (`main-2.18.x` into `main`) can carry the newer Node while `.nvmrc` and the `packageManager` fields stay on the older pair; then adopt the newer set and finish the bump on all six sites plus the 35 `packageManager` fields.
- Both workflows must stay valid YAML. The `python3` in the devenv has no `yaml` module, so validate with `uv run --with pyyaml python3 -c ...` instead of a bare import.
## Verification

View File

@ -27,8 +27,8 @@ file (never pipe tool output through filters).
members) must carry a `packageManager` field with the identical
`pnpm@<version>+sha512.<hash>` value. Do not let them drift.
`external/` (vendored trees with their own lifecycles), `.opencode/`,
`.pnpm-store/`, `bundles/` and `docker/images/bundle-*` copies are
never stamped.
`.pnpm-store/`, `bundles/`, `target/` build output and
`docker/images/bundle-*` copies are never stamped.
- pnpm is a system binary everywhere (devenv image, CI runners, Docker
images). Nothing may call corepack: it is gone from Node 25+. pnpm
auto-downloads the `packageManager` version on mismatch
@ -71,6 +71,7 @@ file (never pipe tool output through filters).
4. Run `scripts/sync-pnpm-version --install` (plain `pnpm install` in
each of the 11 workspace roots) to refresh lockfile metadata, then
fix any failing workspace (see gotchas) and re-run that directory.
For a version-only bump, `pnpm install --lockfile-only` in each root is enough: it rewrites only the pnpm self-reference entries and needs no node_modules. The repo-root `pnpm-lock.yaml` is rewritten by itself on the first `pnpm` call that self-installs the target version.
## Gotchas
@ -96,6 +97,9 @@ file (never pipe tool output through filters).
section in lockfiles last written by older pnpm. Large diffs mean
re-resolution; inspect them before accepting.
- A backport merge of main into staging can bring a newer `PNPM_VERSION` in `docker/` while every `packageManager` field stays behind, since main receives partial `.github/`+`docker/` backports from develop. Resolve those conflicts with main's version and run `scripts/sync-pnpm-version <version>` in the same merge.
- Direction does not decide the winner, the version does. A maintenance→main merge (`main-2.18.x` into `main`) can carry the newer Node and pnpm pins in `docker/` and the `plugins-deploy-*` workflows while main's `.nvmrc` and 35 `packageManager` fields stay on the older pair. Take the newer pair on every pin site (`docker/`, workflows, `.nvmrc`, `packageManager`) and run `scripts/sync-pnpm-version <version>` in the same merge; check with `--check` plus `pnpm install --lockfile-only --frozen-lockfile` per workspace.
- Those merges conflict on the pnpm self-reference block in all 11 lockfiles (importers `packageManagerDependencies`, `packages/@pnpm/exe.*@<ver>`, `pnpm@<ver>` and the matching `snapshots` entries). Resolve each workspace's lockfile keeping its own importer/package resolution and swapping only that block to the newer version. Do not start from the older side and regenerate with `pnpm install --lockfile-only`: pnpm re-resolves every changed range upward (this merge took root `@playwright/test` from 1.63.0 to 1.64.1 and `@modelcontextprotocol/server` from 2.3.1 to 2.4.0), which pollutes the merge with unrelated bumps. If a workspace later needs a real re-resolution after a manifest bump, `pnpm install --lockfile-only --fix-lockfile` accepts the hand-merged file and only fills what the manifests demand.
- `pnpm install` cannot run in `frontend` or `exporter` when `github.com` does not resolve: the `@penpot/svgo` git dependency triggers a `git fetch`. Frontend's lockfile entry carries no integrity, so pnpm always refetches; exporter's has one and still works. For frontend, resolve the self-reference hunks only.
## Verification

View File

@ -10,7 +10,8 @@
If the user pushes, treat that commit as final from the agent's side.
- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is
generated from GitHub milestones during the release process; update it only
via the `update-changelog` skill flow or on explicit user request.
via `mem:workflow/updating-changelog` (the `update-changelog` skill flow) or
on explicit user request.
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
This prevents hiding test failures. See `mem:testing` for details.
@ -155,6 +156,6 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`.
- `scripts/ci` — CI orchestration script for running lint, tests, format checks and, on request (`--build`), production builds across modules. See `mem:scripts/ci`.
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.

View File

@ -1,6 +1,95 @@
# CHANGELOG
## 2.19.0 (Unreleased)
## 2.20.0
### :bug: Bugs fixed
- Fix dashboard file thumbnails loading slow for large multi-page files [#12053](https://github.com/penpot/penpot/issues/12053) (PR: [#12054](https://github.com/penpot/penpot/pull/12054))
- Fix Plugin API resolving a font family token to its separate words when the family name has several words [#11748](https://github.com/penpot/penpot/issues/11748) (PR: [#11750](https://github.com/penpot/penpot/pull/11750))
- Fix asset redirect responses advertising a cache lifetime of about 1000 days instead of 24 hours (by @ners) [#12761](https://github.com/penpot/penpot/issues/12761) (PR: [#12758](https://github.com/penpot/penpot/pull/12758))
- Fix an internal error on typography tokens due to circular dependencies on other token types [#9580](https://github.com/penpot/penpot/issues/9580)
- Fix the MCP server rejecting request bodies over 100kb (express.json default) with PayloadTooLargeError (by @GitNimay) [#12080](https://github.com/penpot/penpot/issues/12080) (PR: [#12450](https://github.com/penpot/penpot/pull/12450))
- Fix library component, color and typography counters showing zero when opening files from the dashboard (by @lukel97) [#12826](https://github.com/penpot/penpot/issues/12826) (PR: [#12787](https://github.com/penpot/penpot/pull/12787))
- Fix workspace crash with Maximum call stack size exceeded when applying a library update [#12576](https://github.com/penpot/penpot/issues/12576) (PR: [#12661](https://github.com/penpot/penpot/pull/12661))
- Fix canvas rendering an imported shape without its hole when the SVG fill rule is evenodd [#11947](https://github.com/penpot/penpot/issues/11947) (PR: [#12006](https://github.com/penpot/penpot/pull/12006))
- Fix Plugin API component.instance() and createShapeFromSvg() nesting the new shape into whatever board is at (0, 0) of the current page (by @makesomethingshit) [#12086](https://github.com/penpot/penpot/issues/12086) (PR: [#12499](https://github.com/penpot/penpot/pull/12499))
- Fix view mode breaking zoom, board navigation, and comments when the URL has no page id [#12684](https://github.com/penpot/penpot/issues/12684) (PR: [#12687](https://github.com/penpot/penpot/pull/12687))
- Fix autosave stopping and valid edits being lost when part of a save fails validation [#12553](https://github.com/penpot/penpot/issues/12553) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Fix dashboard duplicate copying a file without the edits that are still saving [#12603](https://github.com/penpot/penpot/issues/12603) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Fix Alt measurements still showing a shape that was deleted [#12605](https://github.com/penpot/penpot/issues/12605) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Fix Plugin API ignoring changes to a deleted shape without an error [#12606](https://github.com/penpot/penpot/issues/12606) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Fix typography sample overflowing its row in locales with a wider sample (ko, ar) (by @desty) [#11486](https://github.com/penpot/penpot/issues/11486) (PR: [#11487](https://github.com/penpot/penpot/pull/11487))
- Fix token not showing when selecting two text layers (by @ShreyashAgare26) [#11924](https://github.com/penpot/penpot/issues/11924) (PR: [#11944](https://github.com/penpot/penpot/pull/11944))
- Fix inner shadow fill not working in the SVG render while it works in WebGL [#11967](https://github.com/penpot/penpot/issues/11967) (PR: [#12141](https://github.com/penpot/penpot/pull/12141))
- Fix @penpot/plugin-styles loading Work Sans from a third-party CDN (by @ump45nose) [#12023](https://github.com/penpot/penpot/issues/12023) (PR: [#12087](https://github.com/penpot/penpot/pull/12087))
### :sparkles: New features & Enhancements
- Add a feedback channel to the MCP server [#12490](https://github.com/penpot/penpot/issues/12490) (PR: [#12551](https://github.com/penpot/penpot/pull/12551))
- Auto-scroll sortable lists when dragging an item near their top or bottom edge (by @ShreyashAgare26) [#10478](https://github.com/penpot/penpot/issues/10478) (PR: [#11595](https://github.com/penpot/penpot/pull/11595))
- Make WebGL the default renderer for new accounts [#11855](https://github.com/penpot/penpot/issues/11855) (PR: [#12880](https://github.com/penpot/penpot/pull/12880))
- Allow MCP to work with multiple open Penpot files [#11759](https://github.com/penpot/penpot/issues/11759)
- Complete the Russian translations [#11955](https://github.com/penpot/penpot/issues/11955) (PR: [#11956](https://github.com/penpot/penpot/pull/11956))
- Implement full text format paste within Penpot [#12047](https://github.com/penpot/penpot/issues/12047) (PR: [#12137](https://github.com/penpot/penpot/pull/12137))
- Prevent the hosted MCP server from running execute_code twice when the plugin is open in two tabs [#12008](https://github.com/penpot/penpot/issues/12008) (PR: [#11927](https://github.com/penpot/penpot/pull/11927))
- Allow detaching typography tokens [#11887](https://github.com/penpot/penpot/issues/11887) (PR: [#11969](https://github.com/penpot/penpot/pull/11969))
- Add team, project and file management to the Plugin API for global plugins [#12170](https://github.com/penpot/penpot/issues/12170) (PR: [#12169](https://github.com/penpot/penpot/pull/12169))
- Run .penpot file import and export as durable background jobs [#12194](https://github.com/penpot/penpot/issues/12194) (PR: [#12056](https://github.com/penpot/penpot/pull/12056), [#12192](https://github.com/penpot/penpot/pull/12192))
- Open files and projects in a new tab with Ctrl/Cmd+click in the dashboard [#12228](https://github.com/penpot/penpot/issues/12228) (PR: [#12230](https://github.com/penpot/penpot/pull/12230))
- Add an RPC method to read file-level plugin data [#12258](https://github.com/penpot/penpot/issues/12258) (PR: [#10171](https://github.com/penpot/penpot/pull/10171))
- Add a get-file-tokens RPC method to read a file's design tokens [#12260](https://github.com/penpot/penpot/issues/12260) (PR: [#12172](https://github.com/penpot/penpot/pull/12172))
- Show the progress and the size of each file in the import and export dialogs [#12340](https://github.com/penpot/penpot/issues/12340) (PR: [#12192](https://github.com/penpot/penpot/pull/12192))
- Focus the search field when opening the color tokens tab of the color picker [#12344](https://github.com/penpot/penpot/issues/12344) (PR: [#12345](https://github.com/penpot/penpot/pull/12345))
- Harden the file data offload job with a row lock, per-file transactions, throttle and metrics [#12398](https://github.com/penpot/penpot/issues/12398) (PR: [#12403](https://github.com/penpot/penpot/pull/12403))
- Render email templates inside the mail job in the backend [#12445](https://github.com/penpot/penpot/issues/12445) (PR: [#12448](https://github.com/penpot/penpot/pull/12448))
- Serve Penpot under a subpath [#3194](https://github.com/penpot/penpot/issues/3194) (PR: [#12358](https://github.com/penpot/penpot/pull/12358))
- Run local WASM exports for shapes and boards without freezing the UI [#12512](https://github.com/penpot/penpot/issues/12512) (PR: [#12515](https://github.com/penpot/penpot/pull/12515))
- Use WASM to generate SVG markup in the code inspector when render-wasm is active [#12567](https://github.com/penpot/penpot/issues/12567) (PR: [#12608](https://github.com/penpot/penpot/pull/12608))
- Close the workspace search with the Escape key (by @filipsajdak) [#9540](https://github.com/penpot/penpot/issues/9540) (PR: [#12374](https://github.com/penpot/penpot/pull/12374))
- Allow MCP to access and manage Penpot Dashboard resources [#11761](https://github.com/penpot/penpot/issues/11761)
- Add OAuth support for MCP client connections [#12034](https://github.com/penpot/penpot/issues/12034) (PR: [#12480](https://github.com/penpot/penpot/pull/12480))
- Restrict management RPC methods to their declared shared keys [#12730](https://github.com/penpot/penpot/issues/12730) (PR: [#12736](https://github.com/penpot/penpot/pull/12736))
- Reduce memory used by duplicate shape ids in the workspace [#12791](https://github.com/penpot/penpot/issues/12791) (PR: [#12792](https://github.com/penpot/penpot/pull/12792))
- Reduce memory consumption on shape decoding [#12789](https://github.com/penpot/penpot/issues/12789) (PR: [#12790](https://github.com/penpot/penpot/pull/12790))
- Notify other users when a snapshot is restored [#12425](https://github.com/penpot/penpot/issues/12425) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Normalize the configured tenant to lowercase [#12842](https://github.com/penpot/penpot/issues/12842) (PR: [#12843](https://github.com/penpot/penpot/pull/12843))
- Speed up saves on large files by checking only the pages a save changes [#12602](https://github.com/penpot/penpot/issues/12602) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Retry saves after short server failures and keep the workspace open when a save fails [#12601](https://github.com/penpot/penpot/issues/12601) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
- Switch the workspace to read-only mode when the file can't be saved [#12507](https://github.com/penpot/penpot/issues/12507) (PR: [#12555](https://github.com/penpot/penpot/pull/12555))
## 2.19.1
### :lock: Security fixes
- Fix editor-role team members retrieving the signed invitation token of any pending invitation (https://github.com/penpot/penpot/security/advisories/GHSA-4mmc-j8rh-74pf) (PR: [#12892](https://github.com/penpot/penpot/pull/12892))
- Fix share-link page-scope escape in `get-profiles-for-file-comments` leaking the identity of comment authors outside the link's scope (https://github.com/penpot/penpot/security/advisories/GHSA-89cq-3wv7-987r) (PR: [#12888](https://github.com/penpot/penpot/pull/12888))
- Fix missing authorization and unbounded array in mark-all-threads-as-read that let any authenticated user write the status of inaccessible threads (https://github.com/penpot/penpot/security/advisories/GHSA-j4vc-cmf6-jhr7) (PR: [#12894](https://github.com/penpot/penpot/pull/12894))
- Fix incomplete SVG sanitizer escaping that let a "sanitized" SVG carry live script (CVE-2026-105694) (https://github.com/penpot/penpot/security/advisories/GHSA-qwhj-rchj-58mg) (PR: [#12887](https://github.com/penpot/penpot/pull/12887))
### :bug: Bugs fixed
- Fix component instance (variant) inside a layout resetting to its original size after a shared library update of an unrelated component [#12670](https://github.com/penpot/penpot/issues/12670) (PR: [#12851](https://github.com/penpot/penpot/pull/12851))
## 2.19.0
### :boom: Breaking changes & Deprecations
#### Migrate legacy file payloads to file_data and delete orphan revisions
File payloads now live in one place: every file state and snapshot is kept in `file_data`, and the orphan `file_change` rows that kept old payloads alive forever are removed. No read path reached those rows and nothing ever collected them, so the upgrade also reclaims the database and TOAST space they held. There is no API or UI change.
On small and medium instances this happens automatically on boot and there is nothing to do. On large instances (millions of snapshots, hundreds of GB in TOAST) the work does not fit in the startup transaction: run it by hand **before upgrading to 2.19.0** following the [large-instance runbook](https://github.com/penpot/penpot/blob/staging/operations/2.19.0-migrate-legacy-file-data.md). The runbook tells you whether your instance needs it, keeps the instance online while the data moves, and verifies the result before you deploy.
[#12784](https://github.com/penpot/penpot/issues/12784) (PR: [#12785](https://github.com/penpot/penpot/pull/12785))
#### Enable the Admin Console by default
The Admin Console is now enabled by default and the `enable-admin-console` flag is no longer needed. Self-hosted instances must deploy the `penpot-admin-console` service and set `PENPOT_ADMIN_CONSOLE_URI` in frontend and backend (check the [upgrade guide](https://help.penpot.app/technical-guide/getting-started/docker/#upgrade-to-2.19)).
[#12807](https://github.com/penpot/penpot/issues/12807) (PR: [#12475](https://github.com/penpot/penpot/pull/12475))
### :rocket: Epics and highlights
@ -74,6 +163,38 @@
- Fix thumbnails failing on older Chromium browsers and each thumbnail rendering several times [#11878](https://github.com/penpot/penpot/issues/11878) (PR: [#11879](https://github.com/penpot/penpot/pull/11879))
- Fix internal error when opening the libraries modal [#11943](https://github.com/penpot/penpot/issues/11943) (PR: [#11946](https://github.com/penpot/penpot/pull/11946))
- Fix Plugin API `connectLibrary` linking unpublished files as libraries [#12001](https://github.com/penpot/penpot/issues/12001) (PR: [#12002](https://github.com/penpot/penpot/pull/12002))
- Fix incorrect padding at the top of the tokens and themes section [#9585](https://github.com/penpot/penpot/issues/9585) (PR: [#12422](https://github.com/penpot/penpot/pull/12422))
- Fix token tree path handling for repeated and nested segments (by @makesomethingshit) [#11588](https://github.com/penpot/penpot/issues/11588) (PR: [#11591](https://github.com/penpot/penpot/pull/11591))
- Fix comment input duplicating text when confirming a Japanese IME composition with Enter [#11757](https://github.com/penpot/penpot/issues/11757) (PR: [#11768](https://github.com/penpot/penpot/pull/11768))
- Fix file not saving after deleting a variant [#11758](https://github.com/penpot/penpot/issues/11758) (PR: [#12216](https://github.com/penpot/penpot/pull/12216))
- Fix component copies not syncing when a shape is moved out of a main component and another change follows at once [#11978](https://github.com/penpot/penpot/issues/11978) (PR: [#11981](https://github.com/penpot/penpot/pull/11981))
- Fix MCP plugin showing a version mismatch warning on every patch release (by @robertkodra) [#12007](https://github.com/penpot/penpot/issues/12007) (PR: [#12009](https://github.com/penpot/penpot/pull/12009))
- Fix workspace crash when switching to the path tool while drawing a shape [#12100](https://github.com/penpot/penpot/issues/12100) (PR: [#12101](https://github.com/penpot/penpot/pull/12101))
- Fix referential integrity error when swapping a component [#12144](https://github.com/penpot/penpot/issues/12144) (PR: [#12333](https://github.com/penpot/penpot/pull/12333))
- Fix workspace saving text shapes on every page open when a custom font is missing [#12151](https://github.com/penpot/penpot/issues/12151) (PR: [#12164](https://github.com/penpot/penpot/pull/12164))
- Fix swapped nested components losing their absolute position after a library update [#12176](https://github.com/penpot/penpot/issues/12176) (PR: [#12556](https://github.com/penpot/penpot/pull/12556))
- Fix theme selector showing the editor message when the tokens source is a library [#12177](https://github.com/penpot/penpot/issues/12177) (PR: [#12203](https://github.com/penpot/penpot/pull/12203))
- Fix untranslated texts in the This file tab of the libraries modal [#12178](https://github.com/penpot/penpot/issues/12178) (PR: [#12204](https://github.com/penpot/penpot/pull/12204))
- Fix workspace staying in the saving state and losing unsaved edits when a save request gets no answer [#12222](https://github.com/penpot/penpot/issues/12222) (PR: [#12223](https://github.com/penpot/penpot/pull/12223))
- Fix scrub cursor showing on numeric inputs with mixed values in the sidebar [#12227](https://github.com/penpot/penpot/issues/12227) (PR: [#12229](https://github.com/penpot/penpot/pull/12229))
- Fix internal error when copying a swapped nested component [#12289](https://github.com/penpot/penpot/issues/12289) (PR: [#12434](https://github.com/penpot/penpot/pull/12434))
- Show an error when uploading a custom font fails [#12304](https://github.com/penpot/penpot/issues/12304) (PR: [#12315](https://github.com/penpot/penpot/pull/12315))
- Fix React warning about the unknown `tooltipClass` prop in layout-item size inputs [#12311](https://github.com/penpot/penpot/issues/12311) (PR: [#12329](https://github.com/penpot/penpot/pull/12329))
- Fix guides inside a board still being interactive [#12322](https://github.com/penpot/penpot/issues/12322) (PR: [#12460](https://github.com/penpot/penpot/pull/12460))
- Fix pasted text not being visible in an empty text box until it is resized [#12438](https://github.com/penpot/penpot/issues/12438) (PR: [#12859](https://github.com/penpot/penpot/pull/12859))
- Fix files failing referential integrity validation staying unsaveable in every session [#12464](https://github.com/penpot/penpot/issues/12464) (PR: [#12524](https://github.com/penpot/penpot/pull/12524), [#12662](https://github.com/penpot/penpot/pull/12662))
- Fix unicode characters not rendering in text layers with the new render engine [#12469](https://github.com/penpot/penpot/issues/12469) (PR: [#12471](https://github.com/penpot/penpot/pull/12471))
- Fix text image fills staying blank when media loads after text layout [#12563](https://github.com/penpot/penpot/issues/12563) (PR: [#12564](https://github.com/penpot/penpot/pull/12564))
- Fix swapped nested icons moving outside the layout after a library update [#12657](https://github.com/penpot/penpot/issues/12657) (PR: [#12716](https://github.com/penpot/penpot/pull/12716))
- Fix garbled bullets before the element counts in the libraries modal [#12677](https://github.com/penpot/penpot/issues/12677) (PR: [#12678](https://github.com/penpot/penpot/pull/12678))
- Fix token dropdown of numeric inputs showing the scrub cursor [#12679](https://github.com/penpot/penpot/issues/12679) (PR: [#12680](https://github.com/penpot/penpot/pull/12680))
- Fix variant property names and values accepting `,` and `=` [#12683](https://github.com/penpot/penpot/issues/12683) (PR: [#12688](https://github.com/penpot/penpot/pull/12688))
- Fix changing a variant property from another page leaving the main component label out of date [#12692](https://github.com/penpot/penpot/issues/12692) (PR: [#12693](https://github.com/penpot/penpot/pull/12693))
- Fix `:referential-integrity` errors when moving variant mains in or out of a set [#12696](https://github.com/penpot/penpot/issues/12696) (PR: [#12706](https://github.com/penpot/penpot/pull/12706))
- Fix `:referential-integrity` errors when creating, combining or renaming variant sets [#12734](https://github.com/penpot/penpot/issues/12734) (PR: [#12779](https://github.com/penpot/penpot/pull/12779))
- Fix save errors after masking a variant with Ctrl+M [#12748](https://github.com/penpot/penpot/issues/12748) (PR: [#12749](https://github.com/penpot/penpot/pull/12749))
- Fix icons in component instances not being shown on the canvas [#12752](https://github.com/penpot/penpot/issues/12752)
- Fix upload endpoints accepting non-multipart upload paths and validation error bodies being truncated [#12896](https://github.com/penpot/penpot/issues/12896) (PR: [#12067](https://github.com/penpot/penpot/pull/12067))
### :sparkles: New features & Enhancements
@ -89,7 +210,7 @@
- Apply tokens to stroke per side [#9842](https://github.com/penpot/penpot/issues/9842) (PR: [#10913](https://github.com/penpot/penpot/pull/10913))
- Add individual stroke widths per side (top, right, bottom, left) [#9854](https://github.com/penpot/penpot/issues/9854) (PR: [#10648](https://github.com/penpot/penpot/pull/10648))
- Expand the token tree right after importing it (by @AKnassa) [#9819](https://github.com/penpot/penpot/issues/9819) (PR: [#10664](https://github.com/penpot/penpot/pull/10664))
- Update the selection size badge live during resize, replacing the resize tooltip [#10259](https://github.com/penpot/penpot/issues/10259)
- Update the selection size badge live during resize, replacing the resize tooltip [#10259](https://github.com/penpot/penpot/issues/10259) (PR: [#11884](https://github.com/penpot/penpot/pull/11884))
- Add color variants, rotation correction, viewport flip and hide-when-too-small to the selection size badge [#10264](https://github.com/penpot/penpot/issues/10264) (PR: [#11884](https://github.com/penpot/penpot/pull/11884))
- Support single-file SVG export with the WebGL render [#10546](https://github.com/penpot/penpot/issues/10546)
- Persist the hide resolved comments preference and separate the filter options in the comments menu [#10686](https://github.com/penpot/penpot/issues/10686) (PR: [#10694](https://github.com/penpot/penpot/pull/10694))
@ -113,13 +234,36 @@
- Make "Fix when scrolling" work in the WebGL render [#11684](https://github.com/penpot/penpot/issues/11684) (PR: [#11752](https://github.com/penpot/penpot/pull/11752))
- Complete Catalan translations [#11739](https://github.com/penpot/penpot/issues/11739) (PR: [#11741](https://github.com/penpot/penpot/pull/11741))
- Remove the optional client-provided id from creation RPC endpoints [#11783](https://github.com/penpot/penpot/issues/11783) (PR: [#11784](https://github.com/penpot/penpot/pull/11784))
- Allow color tokens for the canvas background [#11791](https://github.com/penpot/penpot/issues/11791) (PR: [#11923](https://github.com/penpot/penpot/pull/11923))
- Allow color tokens for the canvas background [#11791](https://github.com/penpot/penpot/issues/11791) (PR: [#11923](https://github.com/penpot/penpot/pull/11923), [#11950](https://github.com/penpot/penpot/pull/11950))
- Show a "no assets yet" message for empty files in the libraries modal [#11811](https://github.com/penpot/penpot/issues/11811) (PR: [#11818](https://github.com/penpot/penpot/pull/11818))
- Upgrade the MCP SDK to v2 and adopt the new protocol version [#11827](https://github.com/penpot/penpot/issues/11827) (PR: [#11841](https://github.com/penpot/penpot/pull/11841))
- Remove legacy SSE support from the MCP server [#11846](https://github.com/penpot/penpot/issues/11846) (PR: [#11841](https://github.com/penpot/penpot/pull/11841))
- Delete selected path handles with Backspace or Delete [#11858](https://github.com/penpot/penpot/issues/11858) (PR: [#11896](https://github.com/penpot/penpot/pull/11896))
- Use the tooltip component in the token pill info [#11886](https://github.com/penpot/penpot/issues/11886) (PR: [#11900](https://github.com/penpot/penpot/pull/11900))
- Hide the duplicate cursor when alt-dragging a shape that cannot be duplicated (by @0xTHAC0) [#11905](https://github.com/penpot/penpot/issues/11905) (PR: [#11195](https://github.com/penpot/penpot/pull/11195))
- Document the team auto-add behavior with `disable-email-verification` [#11376](https://github.com/penpot/penpot/issues/11376) (PR: [#11425](https://github.com/penpot/penpot/pull/11425))
- Make the workspace recover from interrupted saves and show a proper error instead of the internal error page [#11799](https://github.com/penpot/penpot/issues/11799)
- Add component management operations to the Plugin API [#12301](https://github.com/penpot/penpot/issues/12301) (PR: [#12303](https://github.com/penpot/penpot/pull/12303))
- Record the imported file ids in the import-binfile audit event [#12485](https://github.com/penpot/penpot/issues/12485) (PR: [#12491](https://github.com/penpot/penpot/pull/12491))
## 2.18.7
### :bug: Bugs fixed
- Fix binfile import accepting library relations outside the package and self-relations [#12895](https://github.com/penpot/penpot/issues/12895) (PR: [#12862](https://github.com/penpot/penpot/pull/12862))
## 2.18.6
### :bug: Bugs fixed
- Fix upload endpoints storing server files when the path points outside the temp dir [#12771](https://github.com/penpot/penpot/issues/12771) (PR: [#12772](https://github.com/penpot/penpot/pull/12772))
## 2.18.5
### :bug: Bugs fixed
- Fix the Admin Console failing on every URL on Docker deployments by reverting the subpath support [#12358](https://github.com/penpot/penpot/pull/12358)
## 2.18.4
### :bug: Bugs fixed

View File

@ -54,6 +54,8 @@
org.jsoup/jsoup {:mvn/version "1.23.2"}
com.j256.simplemagic/simplemagic {:mvn/version "1.17"}
at.yawk.lz4/lz4-java
{:mvn/version "1.12.0"}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
"packageManager": "pnpm@12.18.0+sha512.9d7882d5dc82147ecfc4e062aa34834e0ad0296710a1837a1115ae012127d36b7109e321ac1598a1dd0a65597681b1e99917e12ed0d749eae91187943363beb3",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"

122
backend/pnpm-lock.yaml generated
View File

@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.15.0
version: 12.15.0
specifier: 12.18.0
version: 12.18.0
packages:
'@pnpm/exe.android-arm64@12.15.0':
resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==}
'@pnpm/exe.android-arm64@12.18.0':
resolution: {integrity: sha512-fOF6IwgvnUKEmVedGqiAsNPfN2Ry8GvaqzSQOiF3RJFbpBfPKO/6YT54s1GjToyYinRxw9preHHPe4y2N3SzhA==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.15.0':
resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==}
'@pnpm/exe.android-x64@12.18.0':
resolution: {integrity: sha512-6OiolnaCsB3/GcJ9XNrenbUH3zFHRJ+5QynrMUUEyThbgnnCzWxa199hpQlOmxN4rAXuYQ0IYgWFNUZxyzg31Q==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.15.0':
resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==}
'@pnpm/exe.darwin-arm64@12.18.0':
resolution: {integrity: sha512-zkq+4YzVIX39ETYLyC3W7LY7MpHEU/J3nRhHl4i2yPld2Cf2+rEKRh3ejJ4AmaQ77HHJLCktY4Z3jq0fpcxFXg==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.15.0':
resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==}
'@pnpm/exe.darwin-x64@12.18.0':
resolution: {integrity: sha512-qMsL9qx92unOTa6hgkfiTJv8ceZaTx0IQg7InhYDVX2ZrTECujaVEEVypnJK5m3eGUsP1KD7Y4qIgleCwfRsYw==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.15.0':
resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==}
'@pnpm/exe.freebsd-x64@12.18.0':
resolution: {integrity: sha512-/9vy/4ms52cU4MjZ5NhHd6N2d9L9W0kARf5/ff2tweAHu0VeUzs/0qp/7ZHFuddm2Wa/m0AKQoPum9AVUCqKsw==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.15.0':
resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==}
'@pnpm/exe.linux-arm64-musl@12.18.0':
resolution: {integrity: sha512-Vp+TIMPPyackVCXkOdEKH/RmAn/r07EcIyrgZYQK7VrkksqZOie0GnWIx+85mPqeFEYCRtXIZAFJ9L4ceTndDg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.15.0':
resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==}
'@pnpm/exe.linux-arm64@12.18.0':
resolution: {integrity: sha512-2dpyVBCHT+FfpuQh9i+7MXAqNWYLCvD5vYr5p6Eimxoga8USLnjbUgyj72AOrO0hjc5ri3TEFP3jiyBHjkSZpQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.15.0':
resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==}
'@pnpm/exe.linux-ppc64@12.18.0':
resolution: {integrity: sha512-BBOOOfNJ2QwIIya3eKqOjiN4WNTszfRz7HCvw9XggcT7+ftMjNP8XJ/GfW+ltCBeUFR9d8WBVsr2m/NLjX2CZQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.15.0':
resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==}
'@pnpm/exe.linux-riscv64@12.18.0':
resolution: {integrity: sha512-7kMN77xSOYJX8GPSH5RbNAc5oM61LMmcSUukDDUwllnZPXm8GRPskCNsTWy8SCw01Kr1FB+5SwGNMiTdiziLNQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.15.0':
resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==}
'@pnpm/exe.linux-s390x@12.18.0':
resolution: {integrity: sha512-htuSCpEcmDzhKoJqXbPkvfr176dBHU0QIF5mWcRiqxRXAB/1JoYiMYotfjWpk+ztgpB9qlx4XnWlLFAf7gzc6g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.15.0':
resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==}
'@pnpm/exe.linux-x64-musl@12.18.0':
resolution: {integrity: sha512-Uj+vzfIQ62kDP10x36yHZIXVs5ohdNtiPYwn5FhCLlNScHPo0OAI+ShGDtLofiBXKE7umu71Jmjm7N8a8zVcDw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.15.0':
resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==}
'@pnpm/exe.linux-x64@12.18.0':
resolution: {integrity: sha512-OJ+X2wcraldCwj75pCIerWARQSRpdfj0eBTs/2ODyhfeW46ylOnjTIE/GKra6Mn6E72/gBmXS8igrUioa+F9fQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.15.0':
resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==}
'@pnpm/exe.win32-arm64@12.18.0':
resolution: {integrity: sha512-wEY6jiACGcbhhi5yD59doHqNTndS7AaujaPmtWAu1DKkHru2hy5fJ9yUGecW72Ypaw2/I3BEprfMOopap2NQtw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.15.0':
resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==}
'@pnpm/exe.win32-x64@12.18.0':
resolution: {integrity: sha512-5FJkghO7RLRkQpWxeVEygW9XsmnNuxzPfXbgwFWCHsFyFhpDn/vOdL88ToQEM0Kt5igdW5s6U0/IDH8Dvkt3pg==}
cpu: [x64]
os: [win32]
pnpm@12.15.0:
resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==}
pnpm@12.18.0:
resolution: {integrity: sha512-nXiC1dyCFH7PxOBiqjSDTgrQKWcQoYN6ERWuASEn02txCeMhrBWYod0KZVl2gbHpmRfhLtDXSerpEYeUM2O+sw==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.15.0':
'@pnpm/exe.android-arm64@12.18.0':
optional: true
'@pnpm/exe.android-x64@12.15.0':
'@pnpm/exe.android-x64@12.18.0':
optional: true
'@pnpm/exe.darwin-arm64@12.15.0':
'@pnpm/exe.darwin-arm64@12.18.0':
optional: true
'@pnpm/exe.darwin-x64@12.15.0':
'@pnpm/exe.darwin-x64@12.18.0':
optional: true
'@pnpm/exe.freebsd-x64@12.15.0':
'@pnpm/exe.freebsd-x64@12.18.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.15.0':
'@pnpm/exe.linux-arm64-musl@12.18.0':
optional: true
'@pnpm/exe.linux-arm64@12.15.0':
'@pnpm/exe.linux-arm64@12.18.0':
optional: true
'@pnpm/exe.linux-ppc64@12.15.0':
'@pnpm/exe.linux-ppc64@12.18.0':
optional: true
'@pnpm/exe.linux-riscv64@12.15.0':
'@pnpm/exe.linux-riscv64@12.18.0':
optional: true
'@pnpm/exe.linux-s390x@12.15.0':
'@pnpm/exe.linux-s390x@12.18.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.15.0':
'@pnpm/exe.linux-x64-musl@12.18.0':
optional: true
'@pnpm/exe.linux-x64@12.15.0':
'@pnpm/exe.linux-x64@12.18.0':
optional: true
'@pnpm/exe.win32-arm64@12.15.0':
'@pnpm/exe.win32-arm64@12.18.0':
optional: true
'@pnpm/exe.win32-x64@12.15.0':
'@pnpm/exe.win32-x64@12.18.0':
optional: true
pnpm@12.15.0:
pnpm@12.18.0:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.15.0
'@pnpm/exe.android-x64': 12.15.0
'@pnpm/exe.darwin-arm64': 12.15.0
'@pnpm/exe.darwin-x64': 12.15.0
'@pnpm/exe.freebsd-x64': 12.15.0
'@pnpm/exe.linux-arm64': 12.15.0
'@pnpm/exe.linux-arm64-musl': 12.15.0
'@pnpm/exe.linux-ppc64': 12.15.0
'@pnpm/exe.linux-riscv64': 12.15.0
'@pnpm/exe.linux-s390x': 12.15.0
'@pnpm/exe.linux-x64': 12.15.0
'@pnpm/exe.linux-x64-musl': 12.15.0
'@pnpm/exe.win32-arm64': 12.15.0
'@pnpm/exe.win32-x64': 12.15.0
'@pnpm/exe.android-arm64': 12.18.0
'@pnpm/exe.android-x64': 12.18.0
'@pnpm/exe.darwin-arm64': 12.18.0
'@pnpm/exe.darwin-x64': 12.18.0
'@pnpm/exe.freebsd-x64': 12.18.0
'@pnpm/exe.linux-arm64': 12.18.0
'@pnpm/exe.linux-arm64-musl': 12.18.0
'@pnpm/exe.linux-ppc64': 12.18.0
'@pnpm/exe.linux-riscv64': 12.18.0
'@pnpm/exe.linux-s390x': 12.18.0
'@pnpm/exe.linux-x64': 12.18.0
'@pnpm/exe.linux-x64-musl': 12.18.0
'@pnpm/exe.win32-arm64': 12.18.0
'@pnpm/exe.win32-x64': 12.18.0
---
lockfileVersion: '9.0'

View File

@ -6,72 +6,74 @@
;; 60s for operations that can be slow (file updates, images, fonts, thumbnails,
;; snapshots, imports) and 30s for the rest.
;; Note: a queue bounds how many waiters pile up, a timeout bounds how long one
;; waiter waits. Every entry sets a timeout; the by-profile ones also set a
;; queue, which is where a pile-up hurts.
;; waiter waits. Every entry sets a timeout and a queue: a command takes the
;; limits it declares before the root ones, so the queue of a limit is where
;; its own pile-up waits. The root limits bound what runs at the same time,
;; not what waits for it.
;; The production file lives outside this repository: mirror the shape there,
;; not these numbers. It has to list every id a command declares: an id without
;; an entry runs with no limiter at all.
{:update-file/global
{:permits 20 :timeout 60000}
{:permits 20 :queue 50 :timeout 60000}
:update-file/by-profile
{:permits 1 :queue 5 :timeout 60000}
:process-font/global
{:permits 4 :timeout 60000}
{:permits 4 :queue 10 :timeout 60000}
:process-font/by-profile
{:permits 1 :timeout 60000}
{:permits 1 :queue 5 :timeout 60000}
:process-image/global
{:permits 8 :timeout 60000}
{:permits 8 :queue 20 :timeout 60000}
:process-image/by-profile
{:permits 1 :timeout 60000}
{:permits 1 :queue 5 :timeout 60000}
:auth/global
{:permits 8 :timeout 30000}
{:permits 8 :queue 20 :timeout 30000}
:root/global
{:permits 40 :timeout 30000}
{:permits 40 :queue 100 :timeout 30000}
:root/by-profile
{:permits 10 :queue 30 :timeout 30000}
:file-thumbnail-ops/global
{:permits 20 :timeout 60000}
{:permits 20 :queue 50 :timeout 60000}
:file-thumbnail-ops/by-profile
{:permits 2 :timeout 60000}
{:permits 2 :queue 10 :timeout 60000}
:submit-audit-events/by-profile
{:permits 1 :queue 3 :timeout 30000}
:restore-file-snapshot/global
{:permits 3 :timeout 60000}
{:permits 3 :queue 5 :timeout 60000}
:restore-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:create-file-snapshot/global
{:permits 3 :timeout 60000}
{:permits 3 :queue 5 :timeout 60000}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:send-user-feedback/global
{:permits 4 :timeout 30000}
{:permits 4 :queue 10 :timeout 30000}
:send-user-feedback/by-profile
{:permits 1 :queue 3 :timeout 30000}
:import-binfile/global
{:permits 4 :timeout 60000}
{:permits 4 :queue 10 :timeout 60000}
:import-binfile/by-profile
{:permits 1 :queue 2 :timeout 60000}
:create-import-binfile-job/global
{:permits 4 :timeout 60000}
{:permits 4 :queue 10 :timeout 60000}
:create-import-binfile-job/by-profile
{:permits 1 :queue 2 :timeout 60000}
:profile-plugin-ops/global
{:permits 4 :timeout 30000}
:profile-plugin-ops/by-profile
{:permits 1 :queue 2 :timeout 30000}}

View File

@ -62,6 +62,7 @@ export PENPOT_FLAGS="\
disable-tiered-file-data-storage \
enable-file-validation \
enable-file-schema-validation \
enable-debug-save-faults \
enable-redis-cache \
enable-link-preview \
enable-subscriptions";
@ -92,6 +93,7 @@ export JAVA_OPTS="\
-XX:+UseShenandoahGC \
-XX:+UseCompactObjectHeaders \
-XX:ShenandoahGCMode=generational \
-XX:NativeMemoryTracking=summary \
-XX:-OmitStackTraceInFastThrow \
--sun-misc-unsafe-memory-access=allow \
--enable-preview \

View File

@ -1115,11 +1115,15 @@
:else
(let [sxf (session/create-fn cfg profile info)
token (or (:invitation-token info)
(tokens/generate cfg
{:iss :auth
:exp (ct/in-future "15m")
:profile-id (:id profile)}))
;; The session token is always an :auth one; the
;; invitation token, when present, travels inside its
;; claims so the frontend goes through the regular
;; token verification path and then `logged-in`.
token (tokens/generate cfg
{:iss :auth
:exp (ct/in-future "15m")
:profile-id (:id profile)
:invitation-token (:invitation-token info)})
;; If proceed, update profile on the database
profile (update-profile-with-info cfg profile info)

View File

@ -9,7 +9,7 @@
for recently imported shapes."
(:require
[app.common.data :as d]
[app.common.svg :as csvg]
[app.common.geom.matrix :as gmt]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]))
@ -106,14 +106,11 @@
:reverse-column :column-reverse
dir))))
(defn- fix-svg-attrs
"The json reader of the binfile rewrites every key of every entry to
kebab-case, but `:svg-attrs` keys are react prop names and are stored
in camelCase. `attrs->props` is the transform the svg import path
already applies to them, so running it again restores the names; it
is idempotent, so shapes that come in correct are left untouched."
(defn- fix-svg-transform
"The json reader decodes `:svg-transform` as a plain map; the svg defs
renderer multiplies it as a matrix, so turn it back into one."
[shape]
(d/update-when shape :svg-attrs csvg/attrs->props))
(d/update-when shape :svg-transform gmt/coerce))
(defn clean-shape-post-decode
"A shape procesor that expected to be executed after schema decoding
@ -123,7 +120,7 @@
(fix-shape-shadow-color)
(fix-root-shape)
(fix-legacy-flex-dir)
(fix-svg-attrs)))
(fix-svg-transform)))
(defn- fix-container
[container]

View File

@ -239,24 +239,30 @@
(defn- migrate-file
[{:keys [::db/conn] :as cfg} {:keys [read-only?]} {:keys [id] :as file}]
(binding [pmap/*load-fn* (partial fdata/load-pointer cfg id)
pmap/*tracked* (pmap/create-tracked)]
(let [libs (delay (get-resolved-file-libraries cfg file))
;; For avoid unnecesary overhead of creating multiple
;; pointers and handly internally with objects map in their
;; worst case (when probably all shapes and all pointers
;; will be readed in any case), we just realize/resolve them
;; before applying the migration to the file.
file (-> (fdata/realize cfg file)
(fmg/migrate-file libs))]
(fdata/with-tracked-pointers cfg id
(let [libs (delay (get-resolved-file-libraries cfg file))
;; Capture the pointer layout before realizing: the migration
;; runs on plain data, and unchanged positions must keep their
;; original fragment ids instead of being re-wrapped with new
;; ones on persist.
original (fdata/original-pointers (:data file))
objects-map-before? (contains? (:features file) "fdata/objects-map")
realized (-> (fdata/realize cfg file)
(fmg/migrate-file libs))
objects-map-added? (and (not objects-map-before?)
(contains? (:features realized) "fdata/objects-map"))]
(if (or read-only? (db/read-only? conn))
file
(do ;; When file is migrated, we break the rule of no
;; perform mutations on get operations and update the
;; file with all migrations applied
realized
(let [file (if objects-map-added?
realized
(fdata/reattach-unchanged original realized))]
;; When the file is migrated, we break the rule of not
;; performing mutations on get operations and update the file
;; with all migrations applied. The caller still receives the
;; realized file: the read contract is plain data.
(update-file! cfg file)
(fmigr/resolve-applied-migrations cfg file))))))
(fmigr/resolve-applied-migrations cfg realized))))))
(defn- get-file*
[{:keys [::db/conn] :as cfg} id
@ -281,7 +287,7 @@
(assert (db/connection? conn) "expected cfg with valid connection")
(when (and (not load-data?)
(or lock-for-share? lock-for-share? skip-locked?))
(or lock-for-share? lock-for-update? skip-locked?))
(throw (IllegalArgumentException. "locking is incompatible when `load-data?` is false")))
(let [sql
@ -472,6 +478,18 @@
{:team-id team-id
:deleted-at nil}))
(def ^:private sql:get-files-rels
"SELECT flr.*,
fls.synced_at
FROM file_library_rel AS flr
JOIN file AS l
ON flr.library_file_id = l.id
LEFT JOIN file_library_sync AS fls
ON fls.file_id = flr.file_id
AND fls.library_file_id = flr.library_file_id
WHERE flr.file_id = ANY(?)
AND l.deleted_at IS NULL;")
(defn get-files-rels
"Given a set of file-id's, return all matching relations with the libraries"
[cfg ids]
@ -480,18 +498,8 @@
(assert (every? uuid? ids) "expected a set of uuids")
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids (db/create-array conn "uuid" ids)
sql (str "SELECT flr.*,"
" fls.synced_at"
" FROM file_library_rel AS flr"
" JOIN file AS l"
" ON flr.library_file_id = l.id"
" LEFT JOIN file_library_sync AS fls"
" ON fls.file_id = flr.file_id"
" AND fls.library_file_id = flr.library_file_id"
" WHERE flr.file_id = ANY(?)"
" AND l.deleted_at IS NULL;")]
(db/exec! conn [sql ids])))))
(let [ids (db/create-array conn "uuid" ids)]
(db/exec! conn [sql:get-files-rels ids])))))
(def ^:private sql:upsert-file-library-sync
"INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
@ -683,7 +691,8 @@
"Make the file its own tokens source when its tokens source is not one
of the given library-ids, as happens when the library was deleted. The
tokens lib of the source is copied while the library still exists, even
if deleted; otherwise the tokens source and status are removed."
if deleted; otherwise the tokens source is removed and the file gets back
the tokens status kept aside for its own tokens lib, if any."
[cfg data library-ids]
(let [source-id (cfo/get-tokens-source data)]
(if (and (some? source-id)
@ -692,7 +701,7 @@
:include-deleted? true
:throw-if-not-exists? false)]
(cfo/absorb-tokens-lib data (:data library))
(dissoc data :tokens-source :tokens-status))
(cfo/detach-tokens-source data))
data)))
(defn embed-assets
@ -943,7 +952,12 @@
(let [library-ids (->> (get-file-libraries conn (:id file))
(map :id)
(cons (:id file)))
load-fn #(get-file cfg % :migrate? false)]
;; Fragment rows are file-local and callers bind the loader of
;; the file they work on, so each library realizes its pointers
;; here, with its own id, before anything walks its data.
load-fn (fn [library-id]
(->> (get-file cfg library-id :migrate? false)
(fdata/realize-pointers cfg)))]
(weak/loadable-weak-value-map library-ids load-fn {id file})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -1079,12 +1093,13 @@
:else :detach-libraries))
(defn export-cfg
"The domain cfg of an export: the frozen set of files and how their
libraries are handled."
[cfg {:keys [ids export-type]}]
"The domain cfg of an export: the frozen set of files, how their
libraries are handled, and where the package is written."
[cfg {:keys [file-ids export-type output]}]
(-> cfg
(assoc ::ids ids)
(assoc ::export-type export-type)))
(assoc ::file-ids file-ids)
(assoc ::export-type export-type)
(assoc ::output output)))
(defn import-cfg
"The domain cfg of an import: the destination team/profile/project, the

View File

@ -5,7 +5,7 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.jobs
"The binfile work a job runs.
"The progress bridge of the binfile jobs.
A job has no SSE channel, so the `:progress` points that `bf.v1` and
`bf.v3` emit become `progress` events of the job through
@ -17,20 +17,15 @@
so the adapter holds no state between taps. The ids the taps carry are
not part of the job event contract.
The adapter checks that the job is still active before it starts, and
the beat checks it on every write: a job that was cancelled, or that
The handlers in `app.tasks.export-binfile` and
`app.tasks.import-binfile` run the core through `run` and
guard it with `check-active`: a job that was cancelled, or that
already reached a terminal state, raises an `:interrupt` right there on
the run thread, so the work stops at its next event point and the
transaction of its handler rolls back with it. A run whose last tap
happened before the interruption is discarded once it returns, which is
what makes that transaction roll back too."
(:require
[app.binfile.common :as bfc]
[app.binfile.v1 :as bf.v1]
[app.binfile.v3 :as bf.v3]
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.db :as db]
[app.jobs :as jobs]
[app.util.events :as events]))
@ -39,7 +34,7 @@
The substrate owns both the check and the error; this is the adapter's
entry point to it, used by the two handlers before their run and by the
tests."
tests. Answers with the cfg, so a handler can thread it."
[cfg context]
(jobs/check-active cfg (:id context)))
@ -98,10 +93,13 @@
(assoc :counters (cond-> (or outer-counters {})
(some? own) (assoc stage own))))))
(defn- with-progress
(defn run
"Run `f` with the progress taps of the core turned into milestones of the
job.
`f` is called with the cfg, so the adapter forwards what it receives
and a caller can pass the core directly.
Every tap is reported: the substrate throttles the durable events, and
a milestone that does not fit loses no information because the next one
carries the same state.
@ -117,55 +115,6 @@
:job-id job-id
:progress (milestone data))))]
(binding [events/*sink* sink]
(let [result (f)]
(let [result (f cfg)]
(check-active cfg context)
result))))
(defn export-files
"Export a set of files into `output` as part of a job, reporting
progress as job events."
[cfg context {:keys [ids export-type output]}]
(check-active cfg context)
(with-progress cfg context
#(bf.v3/export-files! (bfc/export-cfg cfg {:ids ids
:export-type export-type})
output)))
(defn- touch-project
"Mark the destination project as modified.
An import adds files, and the triggers of the schema only touch the
project on an update, so a project that just received files keeps its
old date and stays where it was in the list."
[cfg project-id]
(db/update! cfg :project
{:modified-at (ct/now)}
{:id project-id}
{::db/return-keys false}))
(defn import-files
"Import the package at `input` as part of a job, reporting progress as
job events. The package was read from the resource of the job, so the
caller resolves the destination team and the adapter only fixes what
the legacy RPC and a job must share.
Both formats answer with the same shape: the version 1 reader returns
the set of files it created, and the version 3 one returns a map that
already carries them together with the resolution of the libraries."
[cfg context {:keys [profile-id project-id team name input version]}]
(check-active cfg context)
(let [cfg (bfc/import-cfg cfg {:profile-id profile-id
:project-id project-id
:team team
:name name
:input input})]
(with-progress cfg context
#(let [result (case (int version)
1 (bf.v1/import-files! cfg)
3 (bf.v3/import-files! cfg)
(ex/raise :type :validation
:code :unsupported-version
:hint "unsupported binfile version"
:version version))]
(touch-project cfg project-id)
result))))

View File

@ -329,13 +329,13 @@
[:v1/metadata :v1/files :v1/rels :v1/sobjects]))))
(defmethod write-section :v1/metadata
[{:keys [::output ::bfc/ids ::bfc/include-libraries] :as cfg}]
(if-let [fids (get-files cfg ids)]
[{:keys [::output ::bfc/file-ids ::bfc/include-libraries] :as cfg}]
(if-let [fids (get-files cfg file-ids)]
(let [lids (when include-libraries
(bfc/get-libraries cfg ids))
ids (into fids lids)]
(write-obj! output {:version cf/version :files ids})
(vswap! bfc/*state* assoc :files ids))
(bfc/get-libraries cfg file-ids))
file-ids (into fids lids)]
(write-obj! output {:version cf/version :files file-ids})
(vswap! bfc/*state* assoc :files file-ids))
(ex/raise :type :not-found
:code :files-not-found
:hint "unable to retrieve files for export")))
@ -689,12 +689,12 @@
`::bfc/embed-assets`: instead of including the libraries, embed in the
same file library all assets used from external libraries."
[{:keys [::bfc/ids] :as cfg} output]
[{:keys [::bfc/file-ids] :as cfg} output]
(dm/assert!
"expected a set of uuid's for `::bfc/ids` parameter"
(and (set? ids)
(every? uuid? ids)))
"expected a set of uuid's for `::bfc/file-ids` parameter"
(and (set? file-ids)
(every? uuid? file-ids)))
(dm/assert!
"expected instance of jio/IOFactory for `input`"

View File

@ -454,14 +454,15 @@
(write-entry! output path encoded-status)))))
(defn- export-files
[{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}]
[{:keys [::bfc/file-ids ::bfc/export-type ::output] :as cfg}]
(let [original-ids ids
ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids)))
rels (if (= export-type :include-libraries)
(->> (bfc/get-files-rels cfg ids)
(mapv (juxt :file-id :library-file-id)))
[])
(let [original-ids file-ids
file-ids (into file-ids (when (= export-type :include-libraries)
(bfc/get-libraries cfg file-ids)))
rels (if (= export-type :include-libraries)
(->> (bfc/get-files-rels cfg file-ids)
(mapv (juxt :file-id :library-file-id)))
[])
;; Compute external libraries: referenced by original files but
;; not included in the export set. Only relevant for :link-later.
@ -486,11 +487,11 @@
(vswap! bfc/*state* assoc :files (d/ordered-map))
;; Write all the exporting files
(doseq [[index file-id] (d/enumerate ids)]
(doseq [[index file-id] (d/enumerate file-ids)]
(-> cfg
(assoc ::file-id file-id)
(with-outer-counter :files {:current (inc index)
:total (count ids)})
:total (count file-ids)})
(export-file)))
;; Write manifest file
@ -1038,15 +1039,17 @@
(defn- import-file-relations
[{:keys [::db/conn ::manifest ::bfc/timestamp] :as cfg}]
(let [relations (:relations manifest)]
(let [relations (:relations manifest)
file-ids (into #{} (map :id) (:files manifest))]
(doseq [[index [file-id libr-id]] (d/enumerate relations)]
(tap-progress cfg {:section :relations
:current (inc index) :total (count relations)})
(let [file-id (bfc/lookup-index file-id)
libr-id (bfc/lookup-index libr-id)]
(when (and file-id libr-id)
(if (and (not= file-id libr-id)
(contains? file-ids file-id)
(contains? file-ids libr-id))
(let [file-id (bfc/lookup-index file-id)
libr-id (bfc/lookup-index libr-id)]
(l/dbg :hint "create file library link"
:file-id (str file-id)
:lib-id (str libr-id)
@ -1054,7 +1057,10 @@
(let [rel-params {:file-id file-id
:library-file-id libr-id}]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))))))
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))
(l/warn :hint "ignoring invalid file library relation"
:file-id (str file-id)
:lib-id (str libr-id))))))
(defn- import-storage-objects
[{:keys [::bfc/input ::entries-index ::bfc/timestamp] :as cfg}]
@ -1384,24 +1390,25 @@
(defn export-files!
"Do the exportation of a specified file in custom penpot binary
format. There are some options available for customize the output:
format. The cfg carries what `bfc/export-cfg` froze: `::bfc/file-ids`
(the files to export), `::bfc/export-type` (how linked libraries are
handled) and `::bfc/output` (where the package is written).
`::bfc/export-type`: determines how linked libraries are handled.
Valid values:
`:include-libraries` (include linked libraries),
`::bfc/export-type` determines how linked libraries are handled.
Valid values: `:include-libraries` (include linked libraries),
`:merge-libraries` (embed library assets in the file),
`:detach-libraries` (treat assets as basic objects),
`:link-later` (preserve component metadata for relinking on import)."
[{:keys [::bfc/ids] :as cfg} output]
[{:keys [::bfc/file-ids ::bfc/output] :as cfg}]
(assert
(and (set? ids) (every? uuid? ids))
"expected a set of uuid's for `::bfc/ids` parameter")
(and (set? file-ids) (every? uuid? file-ids))
"expected a set of uuid's for `::bfc/file-ids` parameter")
(assert
(satisfies? jio/IOFactory output)
"expected instance of jio/IOFactory for `input`")
"expected instance of jio/IOFactory for `::bfc/output`")
(let [id (uuid/next)
tp (ct/tpoint)

View File

@ -111,8 +111,14 @@
:ssrf-extra-blocked-cidrs #{}})
(def schema:tenant
"Tenant identifier: hostname-label-style (letters, digits and
hyphens). It is interpolated into Redis keys separated by dots (msgbus
"Tenant identifier: hostname-label-style, lowercase (letters, digits
and hyphens). The value is normalized to lowercase when the
configuration is read, so an operator writing `PENPOT_TENANT=ACME` and
one writing `acme` name the same tenant: the value is compared as an
exact string in the keys and rows it lands in, and one uppercase
letter would otherwise split one tenant into two.
It is interpolated into Redis keys separated by dots (msgbus
topics, rate-limit buckets, exporter job keys, which are also matched
with a glob pattern), so `.` and whitespace are rejected at startup
because one tenant would then be able to read or overwrite another's
@ -125,7 +131,14 @@
tenant asking for it, and a tenant that has one would have to be
renamed. Tenants using those characters must be renamed before
upgrading."
[:re #"^[A-Za-z0-9-]+$"])
[:re #"^[a-z0-9-]+$"])
(defn- normalize-tenant
"Lowercase the configured tenant before it is validated, so
`PENPOT_TENANT=ACME` and `acme` mean the same tenant everywhere."
[config]
(cond-> config
(string? (:tenant config)) (update :tenant str/lower)))
(def schema:config
(do #_sm/optional-keys
@ -206,6 +219,7 @@
[:allow-demo-users {:optional true} ::sm/boolean]
[:error-report-webhook {:optional true} :string]
[:user-feedback-destination {:optional true} :string]
[:mcp-feedback-email {:optional true} ::sm/email]
[:default-rpc-rlimit {:optional true} [::sm/vec :string]]
[:rpc-rlimit-config {:optional true} ::fs/path]
@ -400,6 +414,7 @@
[& {:keys [prefix default] :or {prefix "penpot"}}]
(->> (read-env prefix)
(merge default)
(normalize-tenant)
(decode-config)))
(def version

View File

@ -305,7 +305,14 @@
second `params` provides the email data and optionally `::factory`
and `::reuse-conn`. When `::reuse-conn` is true the submission
reuses the caller's existing `::db/conn` (from cfg) instead of
acquiring a fresh one from the pool."
acquiring a fresh one from the pool.
NOTE: this renders the email before submitting, so the full subject
and body are stored as job params. Prefer `send-template` for
templated emails: it stores only the template id plus the raw data
and renders inside the worker. `send` is kept for raw (non
templated) emails and for draining jobs submitted in the old
format."
[cfg {:keys [::reuse-conn ::factory] :as params}]
(when (and reuse-conn (nil? (::db/conn cfg)))
(ex/raise :type :validation
@ -394,7 +401,7 @@
[:feedback-subject ::sm/text]
[:feedback-type ::sm/text]
[:feedback-content ::sm/text]
[:profile :map]])
[:profile [:map [:id ::sm/uuid]]]])
(def user-feedback
"A profile feedback email."
@ -468,7 +475,7 @@
(def ^:private schema:invite-to-organization
[:map
[:invited-by ::sm/text]
[:user-name [:maybe ::sm/text]]
[:user-name {:optional true} [:maybe ::sm/text]]
[:token ::sm/text]
[:organization schema:organization-data]])
@ -490,7 +497,7 @@
(def ^:private schema:renewal-notice
[:map
[:user-name [:maybe :string]]
[:user-name {:optional true} [:maybe :string]]
[:renewal-date ::sm/text]
[:estimated-amount ::sm/text]
[:organizations [:vector schema:organization-data]]])
@ -593,6 +600,125 @@
:id ::comment-notification
:schema schema:comment-notification))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TEMPLATED SEND (render inside the worker)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- template-entry
"Registry entry for a templated email: the factory that renders it and
the JSON encoder/decoder of its data schema. The job `params` column
is plain JSON, so values without a JSON form (e.g. the URIs of an
organization) are encoded to strings before submit and decoded back
to their types before the factory validates and renders them."
[factory schema]
{::factory factory
::encode (sm/encoder schema sm/json-transformer)
::decode (sm/decoder schema sm/json-transformer)})
;; Registry of template id to entry. This map is the single source of
;; truth for templated mail: `send-template` validates membership and
;; encodes the data here before submitting (so a job that could never
;; be rendered is rejected up front), and the worker decodes the data
;; and renders it with the entry factory. Callers pass the id string
;; itself (e.g. `"invite-to-team"`); keys stay `::eml/`-namespaced and
;; values stay plain data, following the params convention.
(def templates
{"feedback" (template-entry user-feedback schema:feedback)
"register" (template-entry register schema:register)
"password-recovery" (template-entry password-recovery schema:password-recovery)
"password-changed" (template-entry password-changed schema:password-changed)
"change-email" (template-entry change-email schema:change-email)
"invite-to-team" (template-entry invite-to-team schema:invite-to-team)
"invite-to-organization" (template-entry invite-to-organization schema:invite-to-organization)
"organization-setup-sso" (template-entry organization-setup-sso schema:organization-setup-sso)
"renewal-notice" (template-entry renewal-notice schema:renewal-notice)
"join-team" (template-entry join-team schema:join-team)
"request-file-access" (template-entry request-file-access schema:request-file-access)
"request-file-access-yourpenpot" (template-entry request-file-access-yourpenpot schema:request-file-access)
"request-file-access-yourpenpot-view" (template-entry request-file-access-yourpenpot-view schema:request-file-access)
"request-team-access" (template-entry request-team-access schema:request-team-access)
"comment-mention" (template-entry comment-mention schema:comment-mention)
"comment-thread" (template-entry comment-thread schema:comment-thread)
"comment-notification" (template-entry comment-notification schema:comment-notification)})
(defn render-template
"Render the templated email `template-id` from its JSON-decoded data,
as the worker does before sending it."
[template-id params]
(let [{:keys [::factory ::decode]} (get templates template-id)]
(factory (decode params))))
;; Envelope of the job params only: the template data travels encoded
;; by the template schema and is decoded by `render-template`.
(def ^:private schema:templated-params
[:map {:title "Templated Email Params"}
[:template (into [:enum] (sort (keys templates)))]
[:to [:or ::sm/email [::sm/vec ::sm/email]]]
[:reply-to {:optional true} ::sm/email]
[:from {:optional true} ::sm/email]
[:lang {:optional true} ::sm/text]
[:priority {:optional true} [:enum :high :low]]
[:extra-data {:optional true} ::sm/text]
[:attachments {:optional true}
[:map-of :string :string]]])
(defn send-template
"Schedule a templated email to be sent asynchronously using the
unified jobs machinery. Unlike `send`, this does NOT render the
email: it stores only the template id plus the raw template data as
`:send-templated-mail` job params, and the worker renders and sends
the email. The `::template` param is the template id string (one of
the keys of `templates`, e.g. `invite-to-team`);
`::reuse-conn` behaves like in `send`."
[cfg {:keys [::template ::reuse-conn] :as params}]
(when (and reuse-conn (nil? (::db/conn cfg)))
(ex/raise :type :validation
:code :missing-connection
:hint "reuse-conn requires the caller cfg to carry ::db/conn (call inside a transaction)"))
(when (nil? template)
(ex/raise :type :validation
:code :missing-template
:hint "template is mandatory for templated emails"))
(when-not (contains? templates template)
(ex/raise :type :validation
:code :unknown-email-template
:hint (str/ffmt "unknown email template: %", template)))
(let [encode (get-in templates [template ::encode])
recipe (-> params
(dissoc ::reuse-conn ::template)
;; Present-but-nil extras (e.g. `:file-id` when there is
;; no file) would fail the job schema, while the old
;; inline render simply ignored them.
(d/without-nils)
(encode))]
(jobs/submit (cond-> (dissoc cfg ::db/conn)
reuse-conn (assoc ::db/conn (::db/conn cfg)))
{::jobs/name :send-templated-mail
::jobs/delay 0
::jobs/max-retries 4
::jobs/priority 200
::jobs/params (cond-> (assoc recipe :template template)
;; `:public-uri` arrives as a URI record
;; from config, which plain JSON cannot
;; roundtrip; the templates only need its
;; string form.
(some? (:public-uri recipe))
(update :public-uri str))})))
(defmethod ig/init-key ::templated-job-def
[_ {sendmail ::sendmail}]
{::jobs/name :send-templated-mail
::jobs/schema schema:templated-params
::jobs/handler (fn [context params]
(let [template-id (:template params)]
(when-not (contains? templates template-id)
(ex/raise :type :internal
:code :unknown-email-template
:template-id template-id))
(sendmail context (render-template template-id (dissoc params :template)))))
::jobs/decoder (sm/decoder schema:templated-params sm/json-transformer)
::jobs/validator (sm/validator schema:templated-params)})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BOUNCE/COMPLAINS HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -51,10 +51,19 @@
(update file :data process-objects (partial into {})))
(defn enable-objects-map
"Ensure every page holds an ObjectsMap. Unloaded pointers are left
untouched: the fragment they point to was written by an `encode-file`
that already wrapped its objects, and loading it only to wrap it again
costs one page read per untouched page on every save. A page whose
fragment predates the feature is wrapped when it is first loaded or
edited; a data migration that enables the feature must rewrite the
data itself."
[file & _opts]
(let [update-page
(fn [page]
(update page :objects omap/wrap))
(if (and (pmap/pointer-map? page) (not (pmap/loaded? page)))
page
(update page :objects omap/wrap)))
update-data
(fn [fdata]
@ -264,9 +273,13 @@
(defn load-pointer
"A database loader pointer helper"
[cfg file-id id]
;; Keep the include-marked behavior explicit: `db/get*` only filters
;; rows by `deleted_at` when that column is selected, and a fragment a
;; previous pass marked must still load until `objects-gc` purges it.
(let [fragment (some-> (db/get* cfg :file-data
{:id id :file-id file-id :type "fragment"}
{::sql/columns [:data :backend :id :metadata]})
{::sql/columns [:data :backend :id :metadata]
::db/remove-deleted false})
(update :metadata decode-metadata))]
(l/trc :hint "load pointer"
@ -297,6 +310,40 @@
:type "fragment"
:data content})))))
(defmacro with-pointers
"Bind the fragment loader for `file-id` around `body`. All pointer
derefs must happen inside the body; a pointer derefed outside of it
raises because there is no loader bound."
[cfg file-id & body]
`(binding [pmap/*load-fn* (partial load-pointer ~cfg ~file-id)]
~@body))
(defmacro with-tracked
"Bind a fresh tracked set around `body`, without a fragment loader.
For write paths that create pointers before any file exists to load
from (e.g. file creation): wrapping tracks new pointers for
persistence, while any deref still fails loudly."
[& body]
`(binding [pmap/*tracked* (pmap/create-tracked)]
~@body))
(defmacro with-untracked
"Run `body` with pointer tracking disabled. For validation passes
that must not track anything for persistence: pointers created
inside are not collected, and derefs still go through the bound
loader when there is one."
[& body]
`(binding [pmap/*tracked* nil]
~@body))
(defmacro with-tracked-pointers
"Bind a fresh tracked set plus the fragment loader for `file-id`
around `body`. For write paths that create or modify pointers."
[cfg file-id & body]
`(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial load-pointer ~cfg ~file-id)]
~@body))
(defn process-pointers
"Apply a function to all pointers on the file. Usuly used for
dereference the pointer to a plain value before some processing."
@ -311,14 +358,20 @@
(defn realize-pointers
"Process a file and remove all instances of pointers realizing them to
a plain data. Used in operation where is more efficient have the
whole file loaded in memory."
[cfg {:keys [id] :as file}]
(binding [pmap/*load-fn* (partial load-pointer cfg id)]
(update file :data process-pointers deref)))
plain data. Accepts a file map (arity 1) or raw file data with its
file id (arity 2): a snapshot map carries the snapshot id, so always
pass the live file id explicitly."
([cfg {:keys [id] :as file}]
(update file :data #(realize-pointers cfg id %)))
([cfg file-id data]
(with-pointers cfg file-id
(process-pointers data deref))))
(defn get-used-pointer-ids
"Given a file, return all pointer ids used in the data."
"Given a file data map, return all pointer ids used in the data.
This is a plain readout with no liveness meaning of its own: file-gc
unions it over the main file and every unpurged snapshot (see
`app.tasks.file-gc/clean-fragments!` for the actual liveness rule)."
[fdata]
(->> (concat (vals fdata)
(vals (:pages-index fdata)))
@ -345,3 +398,50 @@
(->> file
(realize-pointers cfg)
(realize-objects cfg)))
(defn realize-entry
"Realize a file data entry (a page or the components map) to plain
data for value comparison: deref pointers and convert objects-maps
with the same `into {}` production uses. Requires *load-fn* bound."
[entry]
(let [entry (if (pmap/pointer-map? entry) (deref entry) entry)]
(if (and (map? entry) (contains? entry :objects))
(let [objects (:objects entry)]
(assoc entry :objects (if (omap/objects-map? objects)
(into {} objects)
objects)))
entry)))
(defn original-pointers
"Snapshot the current pointer layout of file data, mapping each
pointer position to its PointerMap. No loading involved."
[fdata]
{:root (into {} (filter (fn [[_ v]] (pmap/pointer-map? v))) fdata)
:pages (into {} (filter (fn [[_ v]] (pmap/pointer-map? v)))
(:pages-index fdata {}))})
(defn reattach-unchanged
"Put back the original fragment pointers wherever the current value
is value-equal to the stored fragment, so unchanged positions keep
their ids and no new fragment rows are persisted for them. `current`
may be a PointerMap (file-gc) or a plain page (the migration read
path); `orig` always comes from `original-pointers`. Anything added,
removed or actually changed keeps the current value. Requires
*load-fn* bound."
[{:keys [root pages]} file]
(letfn [(reattach [current orig]
(if (or (identical? current orig)
(= (realize-entry current) (realize-entry orig)))
orig
current))
(reattach-all [m origs]
(reduce-kv (fn [m k orig]
(if (contains? m k)
(update m k reattach orig)
m))
m origs))]
(update file :data (fn [data]
(cond-> (reattach-all data root)
(contains? data :pages-index)
(update :pages-index reattach-all pages))))))

View File

@ -48,11 +48,19 @@
AND fd.type = 'snapshot')
WHERE c.label IS NOT NULL")
;; `:migrations` is an ordered set everywhere (see `common.types.file`),
;; and `need-migration?`/`migrate` use set semantics on it: the pgarray
;; must never surface as a vector, where `contains?`/`set/difference`
;; would compare indexes instead of names.
(defn- decode-migrations
[value]
(into (d/ordered-set) (db/decode-pgarray value [])))
(defn- decode-snapshot
[snapshot]
(some-> snapshot
(-> (d/update-when :metadata fdata/decode-metadata)
(d/update-when :migrations db/decode-pgarray [])
(d/update-when :migrations decode-migrations)
(d/update-when :features db/decode-pgarray #{}))))
(def ^:private sql:get-minimal-file
@ -376,8 +384,13 @@
:snapshot-id (str (:id snapshot)))
;; In the same way, on reseting the file data, we need to restore
;; the applied migrations on the moment of taking the snapshot
(bfc/update-file! cfg file ::bfc/reset-migrations? true)
;; the applied migrations on the moment of taking the snapshot.
;; The snapshot data may hold shared pointer-mapped pages; the
;; loader stays bound while persisting so any page the encode
;; path does load keeps working (`enable-objects-map` skips the
;; unloaded ones).
(fdata/with-pointers cfg file-id
(bfc/update-file! cfg file ::bfc/reset-migrations? true))
;; FIXME: this should be separated functions, we should not have
;; inline sql here.
@ -441,7 +454,12 @@
(defn reduce-snapshots
"Process the file snapshots using efficient reduction; the file
reduction comes with all snapshots, including maked as deleted"
reduction comes with all snapshots, including maked as deleted.
Snapshot data may hold pointer-mapped pages, so the fragment loader
is bound while reducing: consumers that walk page content (media
refs, stats) transparently load fragments, consumers that only read
pointer ids never trigger a load."
[cfg file-id xform f init]
(let [conn (db/get-connection cfg)
xform (comp
@ -449,5 +467,6 @@
(map (partial fdata/decode-file-data cfg))
xform)]
(->> (db/plan conn [sql:get-snapshots file-id] {:fetch-size 1})
(transduce xform f init))))
(fdata/with-pointers cfg file-id
(->> (db/plan conn [sql:get-snapshots file-id] {:fetch-size 1})
(transduce xform f init)))))

View File

@ -19,6 +19,7 @@
[app.http.errors :as errors]
[app.http.link-preview :as-alias link-preview]
[app.http.management :as mgmt]
[app.http.mcp-oauth :as-alias mcp-oauth]
[app.http.middleware :as mw]
[app.http.security :as sec]
[app.http.session :as session]
@ -236,6 +237,7 @@
[::ws/routes schema:routes]
[::rpc/routes schema:routes]
[::oidc/routes schema:routes]
[::mcp-oauth/routes schema:routes]
[::assets/routes schema:routes]
[::link-preview/routes schema:routes]
[::debug/routes schema:routes]
@ -278,4 +280,7 @@
(::ws/routes cfg)
(::oidc/routes cfg)
(::rpc/routes cfg)]]))
(::rpc/routes cfg)]
;; OAuth owns form parsing, protocol JSON names and cross-origin handling;
;; it must not inherit the RPC transformations and session middleware above.
(::mcp-oauth/routes cfg)]))

View File

@ -14,14 +14,20 @@
[app.setup :as-alias setup]
[app.tokens :as tokens]))
(def mcp-token-issuer "urn:penpot:mcp-token")
(def ^:private valid-issuers
#{"access-token" mcp-token-issuer})
(defn decode-token
[cfg token]
(try
(tokens/verify cfg {:token token :iss "access-token"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(let [claims (tokens/verify cfg {:token token})]
(when (contains? valid-issuers (:iss claims))
claims))
;; The cause is not logged because its data carries the token
(catch Throwable _
(l/trc :hint "exception on decoding token"))))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at, type
@ -34,32 +40,48 @@
[pool claims]
(when-not (db/read-only? pool)
(when-let [token-id (get claims :tid)]
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{})))))
(let [token (db/exec-one! pool [sql:get-token-data token-id])
mcp-claims? (= mcp-token-issuer (:iss claims))
mcp-token? (= "mcp" (:type token))]
(when (and (= (:uid claims) (:profile-id token))
(= mcp-claims? mcp-token?))
(update token :perms db/decode-pgarray #{}))))))
(defn- wrap-authz
[handler {:keys [::db/pool]}]
(fn [request]
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (= :token type)
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
token-id (get claims :tid)]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at)
(some? token-id)
(assoc ::id token-id)
(some? type)
(assoc ::type type))))
"Authenticate requests carrying an access token. Personal tokens need the
`access-tokens` flag. MCP tokens are restricted to specific commands, so
only RPC routes opt in (the RPC wrapper enforces those restrictions); they
need either the `mcp` or the `access-tokens` flag."
[handler {:keys [::db/pool ::allow-mcp]}]
(let [personal-enabled? (contains? cf/flags :access-tokens)
mcp-enabled? (and allow-mcp
(or personal-enabled?
(contains? cf/flags :mcp)))]
(fn [request]
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (and (= :token type)
(if (= mcp-token-issuer (:iss claims))
mcp-enabled?
personal-enabled?))
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
token-id (get claims :tid)]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at)
(some? token-id)
(assoc ::id token-id)
(some? type)
(assoc ::type type))))
(handler request)))))
(handler request))))))
(def authz
{:name ::authz
:compile (fn [& _]
(when (contains? cf/flags :access-tokens)
(when (or (contains? cf/flags :access-tokens)
(contains? cf/flags :mcp))
wrap-authz))})

View File

@ -100,7 +100,7 @@
headers (cond-> {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
"cache-control" (str "max-age=" (ct/seconds cch-max-age))}
(not public?)
(assoc "content-disposition" "attachment"))]
{::yres/status 307
@ -115,7 +115,7 @@
bucket (:bucket mdata)
headers (cond-> {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
"cache-control" (str "max-age=" (ct/seconds cch-max-age))}
(not (public-bucket? bucket))
(assoc "content-disposition" "attachment"))]
{::yres/status 204

View File

@ -359,10 +359,11 @@
(let [path (tmp/tempfile :prefix "penpot.export." :min-age "30m")]
(with-open [output (io/output-stream path)]
(-> cfg
(assoc ::bfc/ids file-ids)
(assoc ::bfc/file-ids file-ids)
(assoc ::bfc/embed-assets embed?)
(assoc ::bfc/include-libraries libs?)
(bf.v3/export-files! output)))
(assoc ::bfc/output output)
(bf.v3/export-files!)))
(if clone?
(let [profile (profile/get-profile pool profile-id)
@ -784,8 +785,12 @@
(db/tx-run! (assoc cfg ::db/rollback true)
(fn [cfg]
(let [file (bfc/get-file cfg file-id)
libs (bfc/get-resolved-file-libraries cfg file-id)]
;; Validation walks every page and component, so the
;; file is read realized; the library map is built
;; from the file (not its id) and its own loader
;; resolves each library's pointers.
(let [file (bfc/get-file cfg file-id :realize? true)
libs (bfc/get-resolved-file-libraries cfg file)]
(if file
(let [errors (cfv/validate-file file libs)]
{::yres/status 200

View File

@ -17,6 +17,7 @@
[app.http.session :as-alias session]
[app.util.inet :as inet]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[yetti.request :as yreq]
[yetti.response :as yres]))
@ -223,6 +224,25 @@
"25P03" "The transaction was idle too long and was cancelled"
"A database error occurred"))
(defn- transient-sql-state?
"True for SQLSTATE codes of failures that pass on their own: lost or
refused connections, lack of resources, a server shutting down, and
serialization failures or deadlocks of concurrent transactions."
[state]
(and (string? state)
(or (str/starts-with? state "08")
(str/starts-with? state "53")
(contains? #{"57P01" "57P02" "57P03" "40001" "40P01"} state))))
(defn- unavailable
"Response for a failure that passes on its own: the request can be sent
again."
[code hint]
{::yres/status 503
::yres/body {:type :server-error
:code code
:hint hint}})
(defmethod handle-exception org.postgresql.util.PSQLException
[error request parent-cause]
(let [state (.getSQLState ^java.sql.SQLException error)
@ -231,6 +251,9 @@
(l/error :hint "postgresql error"
:cause cause)
(cond
(transient-sql-state? state)
(unavailable :database-unavailable "The database is not available right now")
(= state "57014")
{::yres/status 504
::yres/body {:type :server-error
@ -271,13 +294,37 @@
(assoc :type :server-error)
(update :code #(or % :unhandled)))}))))
(defmethod handle-exception java.sql.SQLTransientException
[cause request _]
(binding [l/*context* (request->context request)]
(l/wrn :hint "transient database error" :cause cause)
(unavailable :database-unavailable "The database is not available right now")))
(defn- transient-io-exception?
"True for I/O failures that pass on their own: a lost, refused or timed
out connection, or a host name that did not resolve. A parse error or
data that cannot be read fails again on every attempt."
[cause]
(or (instance? java.net.SocketException cause)
(instance? java.io.InterruptedIOException cause)
(instance? java.net.UnknownHostException cause)
(instance? java.net.http.HttpTimeoutException cause)))
(defmethod handle-exception java.io.IOException
[cause request _]
(binding [l/*context* (request->context request)]
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception}}))
(if (transient-io-exception? cause)
(unavailable :io-exception "An input/output operation failed")
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception}})))
(defmethod handle-exception InterruptedException
[cause request _]
(binding [l/*context* (request->context request)]
(l/wrn :hint "interrupted request" :cause cause)
(unavailable :interrupted "The request was interrupted")))
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]

View File

@ -0,0 +1,353 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.mcp-oauth
"MCP-specific OAuth authorization server.
The authorize endpoint stages a request; the existing Penpot session identifies
the user on the approval page. Approval creates a one-use code, and token
exchange returns the existing fixed MCP user token after verifying PKCE.
Client registration, login sessions, and authorization codes are distinct."
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.errors :as errors]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.mcp.oauth-clients :as clients]
[app.mcp.oauth-state :as state]
[app.rpc.commands.access-token :as access-token]
[app.util.json :as json]
[clojure.string :as str]
[integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as yres])
(:import
java.io.InputStream
java.net.URI
java.net.URLDecoder
java.net.URLEncoder
java.nio.charset.StandardCharsets
java.util.Base64))
(defn issuer [] (str (cf/get-public-uri "api/mcp/oauth")))
(defn resource [] (str (cf/get-public-uri "mcp/stream")))
(defn authorization-metadata
"OAuth capabilities and endpoints advertised to MCP clients."
[]
{:issuer (issuer)
:authorization_endpoint (str (issuer) "/authorize")
:token_endpoint (str (issuer) "/token")
:registration_endpoint (str (issuer) "/register")
:response_types_supported ["code"]
:grant_types_supported ["authorization_code"]
:code_challenge_methods_supported ["S256"]
:token_endpoint_auth_methods_supported ["none" "client_secret_basic" "client_secret_post"]
:scopes_supported ["mcp"]
:authorization_response_iss_parameter_supported true
:client_id_metadata_document_supported true})
(defn resource-metadata
"Protected-resource metadata referenced by the MCP server's 401 challenge."
[]
{:resource (resource)
:authorization_servers [(issuer)]
:scopes_supported ["mcp"]
:bearer_methods_supported ["header"]
:resource_name "Penpot MCP"})
(defn- response
([body] (response 200 body))
([status body]
{::yres/status status
::yres/headers {"content-type" "application/json"
"cache-control" "no-store"
"pragma" "no-cache"
"referrer-policy" "no-referrer"}
::yres/body (json/encode-str body)}))
(defn- redirect
[url]
{::yres/status 303
::yres/headers {"location" url "cache-control" "no-store"
"referrer-policy" "no-referrer"}
::yres/body ""})
(defn- encode [^String value] (URLEncoder/encode value StandardCharsets/UTF_8))
(defn- decode
[^String value]
(try (URLDecoder/decode value StandardCharsets/UTF_8)
(catch IllegalArgumentException _ (clients/invalid! "invalid_request"))))
(defn- parse-form
"Bounded OAuth form/query parser preserving protocol parameter names.
Rejects duplicate keys so validation never depends on which value wins."
[query]
(when (> (count query) 16384) (clients/invalid! "invalid_request"))
(reduce (fn [result part]
(let [[key value] (str/split part #"=" 2)
key (keyword (decode key))]
(when (contains? result key) (clients/invalid! "invalid_request"))
(assoc result key (decode (or value "")))))
{}
(remove str/blank? (str/split (or query "") #"&"))))
(defn- read-body
[request content-type]
(when-not (str/starts-with? (or (yreq/get-header request "content-type") "") content-type)
(clients/invalid! "invalid_request"))
(with-open [^InputStream body (yreq/body request)]
(when-not body (clients/invalid! "invalid_request"))
(let [bytes (.readNBytes body 16385)]
(when (> (alength bytes) 16384) (clients/invalid! "invalid_request"))
(String. bytes StandardCharsets/UTF_8))))
(defn- append-query
[url params]
(str url (if (str/includes? url "?") "&" "?")
(str/join "&" (for [[key value] params :when (some? value)]
(str (encode (name key)) "=" (encode value))))))
(defn- limit!
[cfg request kind limit]
(when-not (state/allow-request! cfg kind (str (yreq/remote-addr request)) limit)
(clients/invalid! "temporarily_unavailable" 429)))
(defn- authorize
"Validate and stage an authorization request before user login or consent.
The browser receives only an opaque handle to the ten-minute Redis record."
[cfg request]
(limit! cfg request :authorize-rate 60)
(let [params (parse-form (yreq/query request))
client (clients/resolve-client cfg (:client_id params))
callback (:redirect_uri params)]
;; Never redirect until the client and its callback have been checked.
(when-not (clients/redirect-matches? client callback)
(clients/invalid! "invalid_request"))
(when-not (and (= "code" (:response_type params))
(= "S256" (:code_challenge_method params))
(string? (:code_challenge params))
(re-matches #"[A-Za-z0-9_-]{43}" (:code_challenge params))
(= "mcp" (get params :scope "mcp"))
(= (resource) (get params :resource (resource)))
(<= (count (:state params)) 2048))
(clients/invalid! "invalid_request"))
(let [handle (state/create! cfg :request
{:client-id (:client_id client)
:client-name (:client_name client)
:logo-uri (:logo_uri client)
:redirect-uri callback
:challenge (:code_challenge params)
:state (:state params)
:resource (resource)
:scope "mcp"}
600)]
(redirect (append-query (str (cf/get-public-uri))
{:screen "mcp-authorize" :request handle})))))
(defn- session-binding
"Profile and login-session identity used to bind one approval request."
[request]
(let [session (session/get-session request)]
(when-not (:profile-id session) (clients/invalid! "login_required" 401))
{:profile-id (str (:profile-id session))
:session-id (state/digest (str (:id session)))}))
(defn- approval-request
"Bind a pending request to its first authenticated session and return UI data.
Compare-and-replace prevents another session from taking over that request."
[cfg request]
(let [binding (session-binding request)
handle (:request (parse-form (yreq/query request)))
record (state/read! cfg :request handle)]
(when-not record (clients/invalid! "invalid_request"))
(let [value (:value record)]
(if (:session-id value)
(when-not (= binding (select-keys value [:profile-id :session-id]))
(clients/invalid! "access_denied" 403))
(state/replace! cfg :request handle record
(merge value binding {:csrf (state/random-secret)}))))
;; Another GET may have won the binding race. Read the winning record so
;; same-session retries share its CSRF value; other sessions cannot proceed.
(let [value (:value (state/read! cfg :request handle))]
(when-not (= binding (select-keys value [:profile-id :session-id]))
(clients/invalid! "invalid_request"))
(response (select-keys value [:client-id :client-name :logo-uri :csrf :scope])))))
(defn- same-origin?
[request]
(let [public (URI. (str (cf/get-public-uri)))
origin (str (.getScheme public) "://" (.getRawAuthority public))]
(= origin (yreq/get-header request "origin"))))
(defn- decision
"Accept one session-bound approval/denial and return the client callback.
Origin and CSRF checks protect the cookie-authenticated decision endpoint."
[cfg request]
(when-not (same-origin? request) (clients/invalid! "access_denied" 403))
(let [binding (session-binding request)
params (parse-form (read-body request "application/x-www-form-urlencoded"))
handle (:request params)
record (state/read! cfg :request handle)
value (:value record)]
(when-not (and record
(= binding (select-keys value [:profile-id :session-id]))
(state/secret= (:csrf value) (:csrf params))
(contains? #{"approve" "deny"} (:decision params)))
(clients/invalid! "invalid_request"))
;; Consume before provisioning: concurrent clicks must not issue two codes.
;; A later failure requires a fresh authorization attempt.
(when-not (state/consume! cfg :request handle record)
(clients/invalid! "invalid_request"))
(let [result (if (= "deny" (:decision params))
{:error "access_denied"}
(let [token (db/tx-run! cfg access-token/ensure-mcp-access!
(uuid/parse (:profile-id value)))
;; Retain the token row ID, never the fixed token itself.
code (state/create! cfg :code
(-> value
(dissoc :csrf :session-id :client-name)
(assoc :token-id (str (:id token))))
120)]
{:code code}))]
(response {:redirect-uri (append-query (:redirect-uri value)
(merge result {:state (:state value)
:iss (issuer)}))}))))
(defn- client-authentication
"Extract one OAuth client-authentication method, rejecting mixed credentials.
Basic credentials are form-decoded after splitting the decoded header."
[request params]
(let [header (yreq/get-header request "authorization")]
(if header
(do
(when (or (:client_secret params) (not (str/starts-with? header "Basic ")))
(clients/invalid! "invalid_client" 401))
(let [decoded (try (String. (.decode (Base64/getDecoder) (subs header 6))
StandardCharsets/UTF_8)
(catch IllegalArgumentException _
(clients/invalid! "invalid_client" 401)))
[id secret] (str/split decoded #":" 2)
id (decode id)]
(when (or (nil? secret)
(and (:client_id params) (not= id (:client_id params))))
(clients/invalid! "invalid_client" 401))
{:id id :secret (decode secret) :method "client_secret_basic"}))
{:id (:client_id params) :secret (:client_secret params)
:method (if (:client_secret params) "client_secret_post" "none")})))
(defn- token
"Exchange a code for the fixed MCP token after client and PKCE verification."
[cfg request]
(limit! cfg request :token-rate 120)
(let [params (parse-form (read-body request "application/x-www-form-urlencoded"))
auth (client-authentication request params)
record (state/read! cfg :code (:code params))
value (:value record)]
(when-not (= "authorization_code" (:grant_type params))
(clients/invalid! "unsupported_grant_type"))
;; Compare the exact client ID, not decoded metadata: a self-contained ID
;; edited after approval identifies a different client for this exchange.
(when-not (and record
(= (:id auth) (:client-id value))
(= (:redirect_uri params) (:redirect-uri value))
(= (:resource value) (get params :resource (:resource value)))
(string? (:code_verifier params))
(re-matches #"[A-Za-z0-9._~-]{43,128}" (:code_verifier params))
(state/secret= (:challenge value) (state/digest (:code_verifier params))))
(clients/invalid! "invalid_grant"))
;; Resolve again because an HTTPS metadata document may have changed
;; since approval; its current callback and authentication method apply.
(let [client (clients/resolve-client cfg (:id auth))]
(when-not (and (= (:method auth) (:token_endpoint_auth_method client))
(clients/redirect-matches? client (:redirect-uri value))
(or (= "none" (:method auth))
(and (string? (:secret auth))
(state/secret= (:secret-hash client)
(state/digest (:secret auth))))))
(clients/invalid! "invalid_client" 401)))
;; Validate before consuming so an invalid verifier/secret cannot burn the
;; code. Atomic consumption allows only one valid exchange to succeed.
(when-not (state/consume! cfg :code (:code params) record)
(clients/invalid! "invalid_grant"))
;; Regeneration deletes the old token row. Never substitute the profile's
;; latest token for the one this approval authorized, nor return it expired.
(let [row (db/exec-one! (::db/pool cfg)
[(str "SELECT token FROM access_token"
" WHERE id = ? AND profile_id = ? AND type = 'mcp'"
" AND (expires_at IS NULL OR expires_at > now())")
(uuid/parse (:token-id value))
(uuid/parse (:profile-id value))])]
(when-not row (clients/invalid! "invalid_grant"))
(response {:access_token (:token row) :token_type "Bearer" :scope "mcp"}))))
(defn- register
"Issue a self-contained client registration; does not grant user access."
[cfg request]
(limit! cfg request :registration-rate 10)
(let [body (read-body request "application/json")
metadata (try (json/decode body)
(catch Exception _ (clients/invalid! "invalid_request")))]
(response 201 (clients/register! cfg metadata))))
(defn- wrap-errors
"Answer every failure with an OAuth error body. Unexpected failures also go
through the shared HTTP error handler, only for its logging; these routes
never set request params, so codes and credentials stay out of the log."
[handler]
(fn [request]
(try (handler request)
(catch Exception cause
(let [{:keys [oauth-error status]} (ex-data cause)
status (or status (if (and oauth-error (not= oauth-error "temporarily_unavailable")) 400 503))]
(when-not oauth-error
(errors/handle cause request))
(cond-> (response status {:error (or oauth-error "temporarily_unavailable")})
(and (= status 401) (= oauth-error "invalid_client"))
(assoc-in [::yres/headers "www-authenticate"] "Basic realm=\"Penpot MCP\"")))))))
(defn- wrap-public
"CORS for discovery, registration and exchange without browser credentials.
Cookie-authenticated approval endpoints must not use this wrapper."
[handler]
(fn [request]
(let [response (if (= :options (yreq/method request))
{::yres/status 204}
(handler request))]
(update response ::yres/headers merge
{"access-control-allow-origin" "*"
"access-control-allow-methods" "GET, POST, OPTIONS"
"access-control-allow-headers" "Content-Type, Authorization"
"access-control-expose-headers" "WWW-Authenticate"}))))
(defn- endpoint
[cfg handler method public?]
{:handler (partial handler cfg)
:middleware (into (if public? [{:compile (constantly wrap-public)}] [])
[{:compile (constantly wrap-errors)} [mw/restrict-methods]])
:allowed-methods (cond-> #{method} public? (conj :options))})
;; OAuth endpoints parse forms and emit snake_case JSON independently of RPC
;; middleware. Only the approval UI endpoints require a Penpot login session.
(defmethod ig/init-key ::routes
[_ cfg]
[""
["/.well-known/oauth-authorization-server/api/mcp/oauth"
(endpoint cfg (fn [_ _] (response (authorization-metadata))) :get true)]
["/api/mcp/oauth"
["/resource" (endpoint cfg (fn [_ _] (response (resource-metadata))) :get true)]
["/authorize" (endpoint cfg authorize :get false)]
["/register" (endpoint cfg register :post true)]
["/token" (endpoint cfg token :post true)]
;; Error handling wraps the session middleware too, so its failures also
;; get OAuth error bodies and logging.
["" {:middleware [{:compile (constantly wrap-errors)}
[mw/auth {:cookie (partial session/decode-token cfg)}]
[session/authz cfg]]}
["/request" (endpoint cfg approval-request :get false)]
["/decision" (endpoint cfg decision :post false)]]]])

View File

@ -847,7 +847,8 @@
The interrupt is an internal signal, not an error: the caller is a task
that must stop the work in course. `heartbeat` calls this when one of
its writes finds the job gone, and a caller can also ask explicitly,
which is what the binfile adapter does before and after its run."
which is what the binfile adapter does before and after its run.
Answers with the cfg, so a caller can thread the check into its run."
[cfg job-id]
(let [job (get-job cfg job-id)]
(when-not (and (some? job) (contains? active-statuses (:status job)))
@ -855,7 +856,8 @@
:code :job-interrupted
:hint "the job is no longer active"
:job-id job-id
:status (or (:status job) "gone")))))
:status (or (:status job) "gone")))
cfg))
(defn heartbeat
"Keep a running job alive and, optionally, report its progress.

View File

@ -48,6 +48,46 @@
"incomplete-user-info"
"unexpected-error"})
(def ^:private subscription-creation-events
#{"create-subscription" "create-trial-subscription"})
(def ^:private subscription-creation-telemetry-props
#{:type
:deployment
:quantity
:subscription-mode})
(def ^:private subscription-update-telemetry-props
#{:type
:deployment
:previous-status
:status
:previous-cancel-at-period-end
:cancel-at-period-end
:previous-cancel-at
:cancel-at
:changed-fields})
(def ^:private subscription-summary-telemetry-props
#{:type
:deployment
:instance-id
:snapshot-date
:trialing-count
:active-count
:active-cancel-scheduled-count
:past-due-count
:canceled-count
:unpaid-count
:incomplete-count
:incomplete-expired-count
:paused-count
:other-count
:total-count
:sync-succeeded
:sync-failed
:snapshot-complete})
(def ^:private safe-backend-context-keys
#{:version
:initiator
@ -348,6 +388,24 @@
(defn filter-telemetry-props
[{:keys [source name props type] :as params}]
(cond
(and (= type "action")
(contains? subscription-creation-events name)
(or (= source "backend")
(= source "frontend")))
(let [props' (into {} xf:filter-telemetry-props props)
props' (merge props' (select-keys props subscription-creation-telemetry-props))]
(assoc params :props props'))
(and (= source "backend")
(= type "action")
(= name "update-subscription"))
(assoc params :props (select-keys props subscription-update-telemetry-props))
(and (= source "backend")
(= type "action")
(= name "subscription-summary"))
(assoc params :props (select-keys props subscription-summary-telemetry-props))
(and (= source "backend")
(= name "organization-sso-auth-failed"))
(let [props' (into {} xf:filter-telemetry-props props)
@ -365,6 +423,8 @@
props' (-> props'
(assoc :lang (:lang props))
(assoc :auth-backend (:auth-backend props))
(assoc :source (:source props))
(assoc :activation (:activation props))
(assoc :email-domain (email/get-domain (:email props)))
(d/without-nils))]
(assoc params :props props'))

View File

@ -213,6 +213,7 @@
"accept-team-invitation-from"
"add-member-to-organization"
"add-team-to-organization"
"assign-organization-attribute"
"auditlog-exported"
"auditlog-guide-downloaded"
"cancel-organization-invitation"
@ -230,6 +231,7 @@
"delete-file"
"delete-font"
"delete-organization"
"delete-organization-attribute"
"delete-organization-member"
"delete-project"
"delete-team"
@ -253,6 +255,8 @@
"sso-config-tested"
"success-activate-organization-sso"
"success-apply-organization-sso-changes"
"unassign-organization-attribute"
"update-organization-attribute"
"update-organization-invitation"
"update-organization-permissions"
"update-team-invitation"

View File

@ -22,6 +22,7 @@
[app.http.debug :as-alias http.debug]
[app.http.link-preview :as-alias http.link-preview]
[app.http.management :as mgmt]
[app.http.mcp-oauth :as-alias http.mcp-oauth]
[app.http.session :as session]
[app.http.websocket :as http.ws]
[app.jobs :as-alias jobs]
@ -77,6 +78,12 @@
::mdef/buckets mtx/default-request-histogram-buckets
::mdef/type :histogram}
:update-file-repair
{::mdef/name "penpot_rpc_update_file_repair_total"
::mdef/help "update-file auto-repair attempts by outcome (repaired or unrepaired)."
::mdef/labels ["outcome"]
::mdef/type :counter}
:rpc-main-timing
{::mdef/name "penpot_rpc_main_timing"
::mdef/help "RPC command method call timing for main"
@ -534,9 +541,17 @@
::http.debug/routes (ig/ref ::http.debug/routes)
::http.assets/routes (ig/ref ::http.assets/routes)
::http.link-preview/routes (ig/ref ::http.link-preview/routes)
::http.mcp-oauth/routes (ig/ref ::http.mcp-oauth/routes)
::http.ws/routes (ig/ref ::http.ws/routes)
::http.awsns/routes (ig/ref ::http.awsns/routes)}
::http.mcp-oauth/routes
{::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
::setup/props (ig/ref ::setup/props)
::session/manager (ig/ref ::session/manager)
::http.client/client (ig/ref ::http.client/client)}
::http.link-preview/routes
{::db/pool (ig/ref ::db/pool)}
@ -641,24 +656,25 @@
::setup/shared-keys (ig/ref ::setup/shared-keys)}
::jobs/defs
{:sendmail (ig/ref ::email/job-def)
:delete-object (ig/ref :app.tasks.delete-object/job-def)
:demo-purge (ig/ref :app.tasks.demo-purge/job-def)
:run-webhook (ig/ref ::webhooks/run-webhook-job-def)
:process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def)
:file-gc (ig/ref :app.tasks.file-gc/job-def)
:offload-file-data (ig/ref :app.tasks.offload-file-data/job-def)
:objects-gc (ig/ref :app.tasks.objects-gc/job-def)
:export-binfile (ig/ref :app.tasks.export-binfile/job-def)
:import-binfile (ig/ref :app.tasks.import-binfile/job-def)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def)
:storage-gc-touched (ig/ref ::sto.gc-touched/job-def)
:storage-pending-gc (ig/ref ::sto.pending-gc/job-def)
:jobs-gc (ig/ref :app.jobs.gc/job-def)
:telemetry (ig/ref :app.tasks.telemetry/job-def)
:session-gc (ig/ref ::session/job-def)
:file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def)
:audit-log-consumer-nexus (ig/ref :app.loggers.audit.consumer-task/nexus-job-def)
{:sendmail (ig/ref ::email/job-def)
:send-templated-mail (ig/ref ::email/templated-job-def)
:delete-object (ig/ref :app.tasks.delete-object/job-def)
:demo-purge (ig/ref :app.tasks.demo-purge/job-def)
:run-webhook (ig/ref ::webhooks/run-webhook-job-def)
:process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def)
:file-gc (ig/ref :app.tasks.file-gc/job-def)
:offload-file-data (ig/ref :app.tasks.offload-file-data/job-def)
:objects-gc (ig/ref :app.tasks.objects-gc/job-def)
:export-binfile (ig/ref :app.tasks.export-binfile/job-def)
:import-binfile (ig/ref :app.tasks.import-binfile/job-def)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def)
:storage-gc-touched (ig/ref ::sto.gc-touched/job-def)
:storage-pending-gc (ig/ref ::sto.pending-gc/job-def)
:jobs-gc (ig/ref :app.jobs.gc/job-def)
:telemetry (ig/ref :app.tasks.telemetry/job-def)
:session-gc (ig/ref ::session/job-def)
:file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def)
:audit-log-consumer-nexus (ig/ref :app.loggers.audit.consumer-task/nexus-job-def)
:audit-log-consumer-admin-console (ig/ref :app.loggers.audit.consumer-task/admin-console-job-def)}
::jobs/request-pool
@ -669,6 +685,10 @@
{::email/sendmail (ig/ref ::email/sendmail)
::mtx/metrics (ig/ref ::mtx/metrics)}
:app.email/templated-job-def
{::email/sendmail (ig/ref ::email/sendmail)
::mtx/metrics (ig/ref ::mtx/metrics)}
:app.tasks.delete-object/job-def
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)

View File

@ -0,0 +1,220 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.mcp.oauth-clients
"Client metadata from self-contained registrations or HTTPS documents.
Registration describes an application; it does not authorize a Penpot user.
Both sources resolve to the same metadata shape without a client database."
(:require
[app.http.client :as http]
[app.mcp.oauth-state :as state]
[app.util.cache :as cache]
[app.util.json :as json]
[clojure.string :as str])
(:import
java.io.InputStream
java.net.URI
java.util.Base64))
(defn invalid!
([error] (invalid! error 400))
([error status]
(throw (ex-info "MCP OAuth request rejected" {:oauth-error error :status status}))))
(defn uri
[value]
(when (and (string? value) (<= 1 (count value) 2048))
(try (URI. value) (catch Exception _ nil))))
(defn- loopback?
[^URI uri]
(and (= "http" (.getScheme uri))
(contains? #{"127.0.0.1" "[::1]" "localhost"} (.getHost uri))))
(defn redirect-uri?
[value]
(when-let [uri (uri value)]
(and (nil? (.getFragment uri))
(nil? (.getUserInfo uri))
(or (and (= "https" (.getScheme uri)) (some? (.getHost uri)))
(loopback? uri)
;; Native applications use a reverse-domain scheme.
(and (some? (.getScheme uri))
(str/includes? (.getScheme uri) ".")
(not (.isOpaque uri)))))))
(defn redirect-matches?
"Exact callback match, except for the port of loopback HTTP URLs.
Native applications may allocate a different local port for each login."
[registered requested]
(and (redirect-uri? requested)
(some (fn [value]
(or (= value requested)
(let [a (uri value)
b (uri requested)]
(and (loopback? a) (loopback? b)
(= (.getHost a) (.getHost b))
(= (.getRawPath a) (.getRawPath b))
(= (.getRawQuery a) (.getRawQuery b))))))
(:redirect_uris registered))))
(defn- logo-uri?
[value]
(when-let [uri (uri value)]
(and (= "https" (.getScheme uri))
(some? (.getHost uri))
(nil? (.getUserInfo uri))
(nil? (.getFragment uri)))))
(defn validate-metadata
"Validate untrusted metadata and retain the fields this server supports.
CIMD selects public-client authentication from the advertised methods;
dynamic registration also supports Basic and form-post client secrets."
[metadata cimd?]
(let [redirects (:redirect_uris metadata)
grants (get metadata :grant_types ["authorization_code"])
methods (if cimd?
(or (:token_endpoint_auth_methods_supported metadata)
[(get metadata :token_endpoint_auth_method "none")])
[(get metadata :token_endpoint_auth_method "client_secret_basic")])
method (if cimd? "none" (first methods))]
(when-not (and (map? metadata)
(vector? redirects) (<= 1 (count redirects) 20)
(every? redirect-uri? redirects)
(or (nil? (:client_name metadata))
(and (string? (:client_name metadata))
(<= 1 (count (:client_name metadata)) 200)))
;; Published metadata describes client capabilities across
;; servers. Extra grants do not require us to issue them.
(if cimd?
(and (vector? grants) (every? string? grants)
(some #{"authorization_code"} grants))
(= ["authorization_code"] grants))
(= ["code"] (get metadata :response_types ["code"]))
(or (nil? (:scope metadata)) (= "mcp" (:scope metadata)))
(if cimd?
(and (vector? methods) (some #{"none"} methods))
(contains? #{"none" "client_secret_basic" "client_secret_post"} method)))
(invalid! "invalid_client_metadata"))
(cond-> {:redirect_uris redirects
:grant_types ["authorization_code"]
:response_types ["code"]
:scope "mcp"
:token_endpoint_auth_method method}
(:client_name metadata) (assoc :client_name (:client_name metadata))
;; Logos are optional browser-loaded images. Ignore unusable URLs
;; instead of preventing authorization because of cosmetic metadata.
(logo-uri? (:logo_uri metadata)) (assoc :logo_uri (:logo_uri metadata)))))
(defn register!
"Issue a reusable client ID and, when applicable, a separate client secret.
No client record is stored. The public ID carries only the secret hash;
the random nonce distinguishes registrations with otherwise equal metadata."
[_cfg metadata]
(let [metadata (validate-metadata metadata false)
secret (when-not (= "none" (:token_endpoint_auth_method metadata))
(state/random-secret))
;; Omit fixed protocol values to keep IDs short; validation restores
;; them on resolution. The version prefix defines the payload format.
payload (cond-> (-> metadata
(dissoc :grant_types :response_types :scope)
(assoc :nonce (state/random-secret)))
secret (assoc :secret-hash (state/digest secret)))
id (str "penpot-mcp-v1."
(.encodeToString (.withoutPadding (Base64/getUrlEncoder))
(json/encode payload)))]
;; Enforce the resolver's size cap here rather than issue an unusable ID.
(when (> (count id) 2048)
(invalid! "invalid_client_metadata"))
(cond-> (assoc metadata :client_id id
:client_id_issued_at (quot (System/currentTimeMillis) 1000))
secret (assoc :client_secret secret :client_secret_expires_at 0))))
(defn- decode-client-id
[client-id]
;; Open registration accepts client-supplied metadata, not a trusted identity.
;; Changing these details changes the ID; codes are bound to the exact ID
;; approved by the user, so an edited ID cannot redeem an existing code.
(try
(let [[_ encoded] (re-matches #"penpot-mcp-v1\.([A-Za-z0-9_-]+)" client-id)
_ (when-not encoded (invalid! "invalid_client"))
payload (json/decode (.decode (Base64/getUrlDecoder) ^String encoded))
metadata (validate-metadata payload false)
nonce (:nonce payload)
secret-hash (:secret-hash payload)]
(when-not (and (string? nonce) (re-matches #"[A-Za-z0-9_-]{43}" nonce)
(if (= "none" (:token_endpoint_auth_method metadata))
(nil? secret-hash)
(and (string? secret-hash)
(re-matches #"[A-Za-z0-9_-]{43}" secret-hash))))
(invalid! "invalid_client"))
(cond-> (assoc metadata :client_id client-id)
secret-hash (assoc :secret-hash secret-hash)))
(catch Exception _
(invalid! "invalid_client"))))
(defonce ^:private metadata-cache
(cache/create :expire "1m" :max-size 256))
(defn- fetch-metadata
"Fetch a client's own HTTPS metadata document with bounded size and freshness.
The shared HTTP client checks SSRF targets and does not follow redirects."
[cfg client-id]
(let [response (http/req cfg {:method :get :uri client-id :timeout 5000
:headers {"accept" "application/json"}}
{:response-type :input-stream})]
(with-open [^InputStream body (:body response)]
(when-not (and (= 200 (:status response))
(str/starts-with? (get-in response [:headers "content-type"] "")
"application/json"))
(invalid! "invalid_client"))
;; Bound both bytes read and time spent receiving the response body.
(let [reader (future (.readNBytes body 65537))
bytes (deref reader 5000 ::timeout)]
(when (= ::timeout bytes)
(future-cancel reader)
(invalid! "invalid_client"))
(when (> (alength ^bytes bytes) 65536)
(invalid! "invalid_client"))
(let [metadata (json/decode bytes)
control (get-in response [:headers "cache-control"] "")
;; The local cache ceiling never extends the publisher's TTL.
;; Missing cache lifetime is treated as immediately stale.
max-age (some-> (re-find #"(?i)max-age=(\d+)" control) second parse-long)
ttl (if (re-find #"(?i)no-store|no-cache" control)
0
(min 60 (or max-age 0)))]
(when-not (= client-id (:client_id metadata))
(invalid! "invalid_client"))
{:client (assoc (validate-metadata metadata true) :client_id client-id)
:expires (+ (System/currentTimeMillis) (* ttl 1000))})))))
(defn resolve-client
"Resolve a client ID to validated metadata for authorization or exchange.
HTTPS IDs use the document cache; versioned IDs decode locally without
PostgreSQL or Redis. Neither form establishes user approval."
[cfg client-id]
(when-not (and (string? client-id) (<= 1 (count client-id) 2048))
(invalid! "invalid_client"))
(if-let [url (when (str/starts-with? client-id "https://") (uri client-id))]
(do
(when-not (and (.getHost url)
(nil? (.getUserInfo url))
(nil? (.getFragment url))
(not (str/blank? (.getPath url))))
(invalid! "invalid_client"))
(try
(let [cached (cache/get metadata-cache client-id)
entry (if (and cached (> (:expires cached) (System/currentTimeMillis)))
cached
(fetch-metadata cfg client-id))]
(when-not (identical? cached entry)
(cache/invalidate metadata-cache client-id)
(cache/get metadata-cache client-id (constantly entry)))
(:client entry))
(catch Exception _
(invalid! "invalid_client"))))
(decode-client-id client-id)))

View File

@ -0,0 +1,5 @@
-- Copyright (c) KALEIDOS SUBSIDIARY SL
-- MPL-2.0
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return {count}

View File

@ -0,0 +1,105 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.mcp.oauth-state
"Temporary approval requests and authorization codes in Redis/Valkey.
Handles are opaque; tenant, record kind and handle hash form the key.
Mutations compare the exact record that the HTTP handler validated."
(:require
[app.config :as cf]
[app.redis :as rds]
[app.redis.script :as-alias script]
[app.util.json :as json])
(:import
java.nio.charset.StandardCharsets
java.security.MessageDigest
java.security.SecureRandom
java.util.Base64))
(defn random-secret
"32 random bytes encoded as a 43-character, unpadded base64url string."
[]
(let [bytes (byte-array 32)]
(.nextBytes (SecureRandom.) bytes)
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bytes)))
(defn digest
"Unpadded base64url SHA-256 digest, also used for PKCE S256 challenges."
[^String value]
(-> (.digest (MessageDigest/getInstance "SHA-256")
(.getBytes value StandardCharsets/UTF_8))
(#(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) %))))
(defn secret=
[a b]
(and (string? a) (string? b)
(MessageDigest/isEqual (.getBytes ^String a StandardCharsets/UTF_8)
(.getBytes ^String b StandardCharsets/UTF_8))))
(defn- state-key
[kind handle]
(str "penpot:" (cf/get :tenant) ":mcp-oauth:" (name kind) ":" (digest handle)))
(defn create!
"Store a new record for ttl seconds and return its random handle.
The handle is returned only after Redis confirms SET NX EX succeeded."
[cfg kind value ttl]
(let [handle (random-secret)
result (rds/run! cfg
(fn [{::rds/keys [conn]}]
(rds/set conn (state-key kind handle)
(json/encode-str value) {:ex ttl :nx true})))]
(when-not (= "OK" result)
(throw (ex-info "Unable to store OAuth state" {:oauth-error "temporarily_unavailable"})))
handle))
(defn read!
"Read an unexpired record, or nil for a missing/malformed handle.
Return both decoded :value for validation and original :raw for atomic
comparison; serializing the decoded map again is not an equivalent check."
[cfg kind handle]
(when (and (string? handle) (re-matches #"[A-Za-z0-9_-]{43}" handle))
(when-let [raw (rds/run! cfg (fn [{::rds/keys [conn]}]
(rds/get conn (state-key kind handle))))]
{:raw raw :value (json/decode raw)})))
(defn- compare-and-update!
"Atomically change only the exact record the caller read and validated.
Lua deletes for nil replacement, or replaces while preserving the TTL.
False means the record expired or another request changed/consumed it."
[cfg kind handle record replacement]
(let [result (rds/run! cfg
(fn [{::rds/keys [conn]}]
(rds/eval conn
{::script/name ::compare-and-update
::script/path "app/mcp/oauth_state.lua"
::script/keys [(state-key kind handle)]
::script/vals [(:raw record) (or replacement "")]})))]
(= [1] result)))
(defn consume!
"Delete a validated record once; true only for the request that consumes it."
[cfg kind handle record]
(compare-and-update! cfg kind handle record nil))
(defn replace!
"Replace a validated record without extending its original expiration."
[cfg kind handle record value]
(compare-and-update! cfg kind handle record (json/encode-str value)))
(defn allow-request!
"Limit requests per endpoint kind, tenant and remote address.
The one-minute window begins with the first request; subsequent requests
increment the count without extending that window."
[cfg kind remote-address limit]
(let [[count] (rds/run! cfg
(fn [{::rds/keys [conn]}]
(rds/eval conn
{::script/name ::rate
::script/path "app/mcp/oauth_rate.lua"
::script/keys [(state-key kind (or remote-address "unknown"))]
::script/vals [60]})))]
(and (number? count) (<= count limit))))

View File

@ -0,0 +1,11 @@
-- Copyright (c) KALEIDOS SUBSIDIARY SL
-- MPL-2.0
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
return {0}
end
if ARGV[2] == '' then
redis.call('DEL', KEYS[1])
else
redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
end
return {1}

View File

@ -19,6 +19,7 @@
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
@ -58,9 +59,22 @@
:code :unknown-size
:hint "seems like the url points to resource with unknown size"))
(-> {:size size :mtype mtype}
(validation/validate-media-type!)
(validation/validate-media-size!))))]
;; Fast fail on the declared header type and size; the
;; downloaded bytes are strictly verified after the write
;; (there is no file on disk yet to measure or sniff).
(when-not (contains? cm/image-types mtype)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like you are uploading an invalid media object"))
(when (> size (cf/get :media-max-file-size))
(ex/raise :type :restriction
:code :media-max-file-size-reached
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
size
(cf/get :media-max-file-size))))
{:size size :mtype mtype}))]
(let [{:keys [body] :as response}
(try
@ -102,7 +116,11 @@
:hint "unexpected state: unable to write to file"))
;; Sanitize: strip trailing data after image EOF markers
(let [new-size (sanitize/truncate-after-eof path mtype)]
(let [new-size (sanitize/truncate-after-eof path mtype)
upload {:path path :mtype mtype :size new-size}]
;; Strict: the downloaded bytes must be what the remote
;; header claimed, same rule as direct uploads.
(validation/check-media-type upload)
{:path path
:mtype mtype
:size new-size})))

View File

@ -12,8 +12,10 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.storage.tmp :as tmp]
[clojure.xml :as xml]
[cuerdas.core :as str])
[cuerdas.core :as str]
[datoteka.fs :as fs])
(:import
clojure.lang.XMLHandler
java.io.InputStream
@ -46,15 +48,85 @@
(xml/parse istream secure-parser-factory))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG SERIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private xml-declaration
"Declaration written at the top of every sanitized document."
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
(defn- escape-text
"Escape the characters that are special inside a XML text node."
[s]
(-> s
(str/replace "&" "&amp;")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")))
(defn- escape-attr
"Escape the characters that are special inside a XML attribute value.
The value is written between double quotes, so the quote itself is
escaped too.
Tabs and line breaks become spaces: that is what the XML parser does
with them on the next read, and it keeps the output stable."
[s]
(-> (str s)
(str/replace #"[\t\r\n]" " ")
(str/replace "&" "&amp;")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")
(str/replace "\"" "&quot;")
(str/replace "'" "&apos;")))
(defn- emit-element
"Serialize a parsed element, escaping every text and attribute value.
The tag and attribute names come from the parser, so they are valid
XML names; only their values can carry markup.
The attributes are sorted by name: the parser hands them over in the
reverse order of the document, so writing them as they come would
make the output change on every sanitization."
[{:keys [tag attrs content]}]
(let [attrs-str (apply str
(for [[k v] (sort-by (comp name key) attrs)]
(str " " (name k) "=\"" (escape-attr (str v)) "\"")))
content-str (apply str
(for [child content]
(if (map? child)
(emit-element child)
(escape-text child))))]
(if (seq content)
(str "<" (name tag) attrs-str ">" content-str "</" (name tag) ">")
(str "<" (name tag) attrs-str "/>"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG SANITIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
(def ^:private javascript-href-pattern #"(?i)^javascript:")
(def ^:private url-ignored-chars
"Characters that the URL parser discards before it interprets the
scheme of an URL."
#"[\u0000-\u0020\u007f]")
(defn- javascript-url?
"True when the value is a javascript: URL.
The comparison drops the characters the URL parser drops (tab, CR, LF
and spaces), so a scheme split with one of them (`java&#x09;script:`)
cannot slip through."
[v]
(let [v (str/replace (str v) url-ignored-chars "")]
(boolean (re-find javascript-href-pattern (str/trim v)))))
(def ^:private dangerous-tags
#{"script" "foreignObject" "set"
"animate" "animateTransform" "animateColor" "animateMotion"})
"animate" "animateTransform" "animateColor" "animateMotion"
"iframe" "meta" "embed" "link" "base"})
(defn- local-name
"Return the local name of a parsed tag or attribute keyword.
@ -70,24 +142,82 @@
(last (str/split n ":"))
n)))
(def ^:private svg-namespace "http://www.w3.org/2000/svg")
(defn- namespace-bindings
"Return the namespace bindings in scope for an element: `::default`
plus the keyword prefixes declared in `attrs`, merged over the
inherited ones. The default binding is stored under a namespaced
keyword because a prefix cannot contain `/`, so no prefixed binding
can produce the same key.
The XML parser does not process namespaces, so the declarations are
plain attributes (`:xmlns`, `:xmlns:svg`) and the prefix is part of
the tag name (`:svg:svg`)."
[attrs inherited]
(let [own (reduce-kv (fn [acc k v]
(let [n (name k)]
(cond
(= n "xmlns") (assoc acc ::default v)
(str/starts-with? n "xmlns:") (assoc acc (keyword (subs n 6)) v)
:else acc)))
{}
attrs)]
(merge inherited own)))
(defn- element-namespace
"Return the namespace URI of `tag` under `bindings`: the default
namespace when the tag has no prefix, the prefixed binding otherwise.
The prefix is part of the tag name (`:svg:svg`), so the binding key
is the prefix."
[tag bindings]
(let [n (name tag)
idx (str/last-index-of n ":")]
(if (nil? idx)
(::default bindings)
(get bindings (keyword (subs n 0 idx))))))
(defn- svg-root?
"True when the element is an `svg` element in the SVG namespace.
The namespace is resolved with the same model as the recursion
(`namespace-bindings` + `element-namespace`), so the check does not
introduce a second rule."
[{:keys [tag attrs]}]
(let [bindings (namespace-bindings attrs {})]
(and (= "svg" (local-name tag))
(= svg-namespace (element-namespace tag bindings)))))
(defn- sanitize-svg-element
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
[{:keys [tag attrs content] :as element}]
(when (and (map? element) tag)
(when-not (contains? dangerous-tags (local-name tag))
(let [clean-attrs (->> attrs
(remove (fn [[k v]]
(or (re-matches dangerous-attrs-pattern (local-name k))
(and (= "href" (local-name k))
(string? v)
(re-find javascript-href-pattern (str/trim v))))))
(into {}))
"Recursively sanitize an SVG element by removing dangerous tags and
attributes.
The namespace bindings are tracked through the recursion: an element
that is not in the SVG namespace is dropped with its whole subtree,
because the filter only knows about SVG elements and a child that
rebinds the namespace to XHTML can carry live HTML."
[element bindings]
(let [{:keys [tag attrs content]} element
bindings (namespace-bindings attrs bindings)]
(when (and (map? element) tag
(not (contains? dangerous-tags (local-name tag)))
(= svg-namespace (element-namespace tag bindings)))
(let [clean-attrs (->> attrs
(remove (fn [[k v]]
(let [n (name k)]
(or (and (not (str/starts-with? n "xmlns"))
(re-matches dangerous-attrs-pattern (local-name k)))
(and (= "href" (local-name k))
(string? v)
(javascript-url? v))))))
(into {}))
clean-content (when content
(->> content
(filter #(or (string? %) (map? %)))
(map (fn [child]
(if (map? child)
(sanitize-svg-element child)
(sanitize-svg-element child bindings)
child)))
(filter some?)
vec))]
@ -97,22 +227,65 @@
(defn sanitize-svg
"Sanitize SVG content by removing dangerous elements and attributes.
Removes <script> tags, <foreignObject> elements, event handlers (on*),
and javascript: URLs from href attributes."
and javascript: URLs from href attributes.
The document must be an SVG document: any element that is not in the
SVG namespace is dropped with its subtree, so a root that is not an
`svg` element in the SVG namespace is rejected, because the filter
only knows about SVG elements and attributes.
The filtered tree is written back with an escaping serializer: a
payload hidden in an attribute value or in a text node would become
live markup again if the values were written verbatim."
[svg-text]
(try
(let [parsed (parse-svg svg-text)
sanitized (sanitize-svg-element parsed)]
(if sanitized
(with-out-str (xml/emit sanitized))
(ex/raise :type :validation
:code :invalid-svg-file
:hint "SVG sanitization produced no output")))
(catch Exception e
(l/warn :hint "SVG sanitization failed, rejecting upload" :cause e)
(let [parsed (try
(parse-svg svg-text)
(catch Exception e
(l/warn :hint "SVG parsing failed, rejecting upload" :cause e)
(ex/raise :type :validation
:code :invalid-svg-file
:hint "SVG parsing failed during sanitization"
:cause e)))
_ (when-not (svg-root? parsed)
(l/warn :hint "SVG sanitization rejected a document that is not an svg document")
(ex/raise :type :validation
:code :invalid-svg-file
:hint "the uploaded document is not an svg document"))
sanitized (sanitize-svg-element parsed {})]
(when-not sanitized
(l/warn :hint "SVG sanitization produced no output, rejecting upload")
(ex/raise :type :validation
:code :invalid-svg-file
:hint "SVG parsing failed during sanitization"
:cause e))))
:hint "SVG sanitization produced no output"))
(str xml-declaration (emit-element sanitized))))
(defn sanitize-svg-upload
"Return the upload untouched when its mtype is not `image/svg+xml`.
When it is, write a sanitized copy to a temporary file and return the
upload pointing to it, with the size of the sanitized content.
The caller must check the size of the upload before calling this: the
whole file is read into memory to sanitize it. The size limit applies
to the upload, not to the sanitized copy, which can be up to six times
bigger (a single quote becomes `&apos;`).
Every path that stores an uploaded SVG as its final object must go
through this function, or the stored file can carry live markup. Two
paths store raw bytes on purpose and do not: `upload-tempfile` (a
channel between processes, not publicly accessible) and
`upload-chunk` (the chunks of an upload; the assembled object goes
through this function). The profile picture is not an SVG either:
`import-profile-picture` ignores an `image/svg+xml` download."
[{:keys [mtype path] :as upload}]
(if (= "image/svg+xml" mtype)
(let [content (slurp (str path) :encoding "UTF-8")
clean (sanitize-svg content)
tmp-path (tmp/tempfile :prefix "penpot-svg-" :suffix ".svg" :min-age "1h")]
(spit (str tmp-path) clean :encoding "UTF-8")
(assoc upload :path tmp-path :size (fs/size tmp-path)))
upload))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG INFO EXTRACTION
@ -120,7 +293,11 @@
(defn get-basic-info-from-svg
[{:keys [tag attrs] :as data}]
(when (not= tag :svg)
;; The local name is enough here: this function only reads the
;; dimensions. Whether the document is a valid SVG is decided by
;; `sanitize-svg`, which rejects anything that is not an `svg` element
;; in the SVG namespace.
(when (not= "svg" (local-name tag))
(ex/raise :type :validation
:code :unable-to-parse-svg
:hint "uploaded svg has invalid content"))

View File

@ -13,7 +13,11 @@
[app.common.schema :as sm]
[app.config :as cf]
[cuerdas.core :as str]
[datoteka.fs :as fs]))
[datoteka.fs :as fs])
(:import
com.j256.simplemagic.ContentInfo
com.j256.simplemagic.ContentInfoUtil
java.nio.file.Path))
(def schema:upload
[:map {:title "Upload"}
@ -32,37 +36,289 @@
(def check-input
(sm/check-fn schema:input))
(defn validate-media-type!
([upload] (validate-media-type! upload cm/image-types))
(def allowed-upload-prefixes
;; Upload temp files are created server-side by the multipart parser
;; (yetti `default-temp-dir`, `/tmp/undertow/`) plus the tmp dir used
;; by tests and storage tmp files (`/tmp/penpot/`). Anything else
;; (e.g. `/etc/passwd` smuggled via Transit `~#path`) is rejected.
["/tmp/undertow/" "/tmp/penpot/"])
(defn check-upload-path
[upload]
(let [real (try (str (fs/real (:path upload)))
(catch Exception _ nil))]
(when (or (nil? real)
(not (some #(str/starts-with? real %) allowed-upload-prefixes)))
(ex/raise :type :validation
:code :params-validation
:hint "invalid upload path"))
upload))
(def ^:private ^ContentInfoUtil magic-util
"Shared magic-byte detector (thread-safe for matching, read-only
after construction). Built once: parsing the embedded magic database
on every upload would be wasteful."
(ContentInfoUtil.))
(defn- normalize-mtype
"Lowercase a mime string and drop any `;` parameters. Returns nil
for missing or blank values."
[mtype]
(when (string? mtype)
(let [clean (-> (str/split mtype #";" 2)
(first)
(str/trim)
(str/lower))]
(when-not (str/empty? clean)
clean))))
(def ^:private simplemagic-aliases
"Spellings reported by `ContentInfoUtil` that differ from the Penpot
vocabulary in `app.common.media`. Single place to extend when the
magic database gains new spellings."
{"image/x-png" "image/png"
"image/x-jpeg" "image/jpeg"
"image/jpg" "image/jpeg"
"application/x-font-ttf" "font/ttf"
"application/x-font-otf" "font/otf"
"application/font-sfnt" "font/ttf"
"application/x-font-woff" "font/woff"
"application/font-woff" "font/woff"
"application/x-font-woff2" "font/woff2"
"application/font-woff2" "font/woff2"})
(def ^:private magic-name->mtype
"The magic database knows TrueType and OpenType by name but reports
an empty mime for them. Map those names to the Penpot vocabulary."
{"TrueType" "font/ttf"
"OpenType" "font/otf"})
(defn- read-four-cc
"Reads the FourCC signature of the file at `path` — its first four
bytes as an ASCII string. Used only for the font containers the
magic database does not know (WOFF/WOFF2). Returns nil on any read
failure."
[^Path path]
(try
(with-open [in (java.io.FileInputStream. (str path))]
(let [buf (byte-array 4)
n (.read in buf)]
(when (= n 4)
(String. ^bytes buf "US-ASCII"))))
(catch Exception _ nil)))
(def ^:private four-cc->mtype
{"wOFF" "font/woff"
"wOF2" "font/woff2"})
(def ^:private svg-probe-bytes
"How many leading bytes the SVG root probe reads. The root element
of a real document sits at the top; 64KB leaves room for a long
prolog or comments while keeping memory constant."
65536)
(defn- starts-at?
"True when `prefix` occurs in `s` exactly at `i`."
[^String s ^long i ^String prefix]
(let [plen (.length prefix)]
(and (<= (+ i plen) (.length s))
(.regionMatches s (int i) prefix (int 0) (int plen)))))
(defn- skip-ws
"Index of the first non-whitespace char in `s` at or after `i`."
[^String s ^long i]
(let [n (.length s)]
(loop [i i]
(if (and (< i n) (Character/isWhitespace (.charAt s i)))
(recur (inc i))
i))))
(defn- index-of-from
"Index of the first occurrence of `end` in `s` at or after `i`, or
nil when absent."
[^String s ^long i ^String end]
(loop [i i]
(cond
(> i (- (.length s) (.length end))) nil
(starts-at? s i end) i
:else (recur (inc i)))))
(defn- skip-markup-decl
"Index just past a `<!...>` declaration opening at `i` (which points
at `<`), honouring `[...]` nesting so a doctype internal subset
cannot fool it. Returns nil when unterminated."
[^String s ^long i]
(let [n (.length s)]
(loop [i (+ i 2) depth 0]
(when (< i n)
(let [c (.charAt s i)]
(cond
(= c \[) (recur (inc i) (inc depth))
(= c \]) (recur (inc i) (dec depth))
(and (= c \>) (zero? depth)) (inc i)
:else (recur (inc i) depth)))))))
(defn- svg-root-at?
"True when the first markup in `s` is a lowercase `<svg` root
element, skipping the XML prolog, comments and doctype. Anything
else — including a truncated head — is false; full validity stays
downstream with the SVG parser."
[^String s]
(loop [i (skip-ws s 0)]
(when (< i (.length s))
(cond
(starts-at? s i "<?")
(when-some [j (index-of-from s (+ i 2) "?>")]
(recur (skip-ws s (+ j 2))))
(starts-at? s i "<!--")
(when-some [j (index-of-from s (+ i 4) "-->")]
(recur (skip-ws s (+ j 3))))
(starts-at? s i "<!")
(when-some [j (skip-markup-decl s i)]
(recur (skip-ws s j)))
(starts-at? s i "<svg")
(let [j (+ i 4)]
(boolean
(when (< j (.length s))
(contains? #{\space \tab \newline \return \/ \>} (.charAt s j)))))
:else false))))
(defn- read-head
"Read at most `svg-probe-bytes` leading bytes of the file at `path`,
decoded as UTF-8 like the rest of the pipeline. Malformed sequences
become replacement chars and can never match a tag. A leading U+FEFF
(UTF-8 BOM) is stripped: it is an encoding artifact, not content.
Returns nil on any read failure."
[^Path path]
(try
(with-open [in (java.io.FileInputStream. (str path))]
(let [buf (byte-array svg-probe-bytes)
n (.read in buf)]
(when (pos? n)
(let [s (String. ^bytes buf (int 0) (int n) "UTF-8")]
(if (and (pos? (.length s)) (= \uFEFF (.charAt s 0)))
(subs s 1)
s)))))
(catch Exception _ nil)))
(defn- svg-file?
"True when the file at `path` opens with an `<svg` root element.
This is the arbiter for SVG when magic returns nothing or a generic
XML type. Reads at most `svg-probe-bytes` leading bytes, so memory
stays constant no matter the file size. Full validity stays
downstream (`parse-svg` + sanitize): this only tells SVG-looking
text apart from other magic-less files."
[^Path path]
(boolean
(when (and (some? path) (fs/exists? path))
(some-> (read-head path) (svg-root-at?)))))
(defn detect-mtype
"Sniff the real mime type of the file at `path` using magic bytes.
Returns a normalized Penpot mime string, or nil when the file cannot
be read or nothing matches."
[^Path path]
(try
(when (and (some? path) (fs/exists? path))
(let [^ContentInfo info (.findMatch magic-util (str path))]
(if (some? info)
(let [raw (normalize-mtype (.getMimeType info))]
(if (some? raw)
(get simplemagic-aliases raw raw)
(get magic-name->mtype (.getName info))))
(get four-cc->mtype (read-four-cc path)))))
(catch Exception _ nil)))
(defn- svg-declared?
[declared]
(= declared "image/svg+xml"))
(defn- sniff-match?
"Strict comparison of the declared client mime against the sniffed
file content. SVG is the only exception: magic may return
`image/svg+xml` (an SVG with an XML prolog) or a generic XML type;
when sniffed is nil or a generic XML type, a declared `image/svg+xml`
is accepted only when the root-tag probe confirms an `<svg` root.
Everything else must be exactly equal — including
`application/octet-stream`, which never matches."
[declared sniffed path]
(and (some? declared)
(or (= declared sniffed)
(and (svg-declared? declared)
(or (nil? sniffed)
(contains? #{"application/xml" "text/xml"} sniffed))
(svg-file? path)))))
(defn check-media-type
([upload] (check-media-type upload cm/image-types))
([upload allowed]
(when-not (contains? allowed (:mtype upload))
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "Seems like you are uploading an invalid media object"))
(let [declared (normalize-mtype (:mtype upload))
path (:path upload)
sniffed (detect-mtype path)]
;; Declared type outside the allowlist is rejected on its face,
;; with the established code.
(when-not (contains? allowed declared)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like you are uploading an invalid media object"))
;; Declared type allowed but file bytes differ: the client lied.
(when-not (sniff-match? declared sniffed path)
(ex/raise :type :validation
:code :media-type-mismatch
:hint (str/ffmt "the uploaded file content (%) does not match the declared type (%)"
(or sniffed "unknown")
(or (:mtype upload) "unknown")))))
upload))
(defn validate-media-size!
(defn- real-size
"Bytes on disk for the file at `path`. Returns nil when the file
cannot be read — callers run `check-upload-path` first, so a nil
here fails closed with the path code instead of a limit code."
[path]
(try
(when (some? path)
(fs/size path))
(catch Exception _ nil)))
(defn check-media-size
"Rejects when the file bytes on disk exceed the configured
`:media-max-file-size` limit. The declared `:size` is ignored —
only the file at `:path` counts."
[upload]
(let [max-size (cf/get :media-max-file-size)]
(when (> (:size upload) max-size)
(let [max-size (cf/get :media-max-file-size)
disk-size (real-size (:path upload))]
(when (nil? disk-size)
(ex/raise :type :validation
:code :params-validation
:hint "invalid upload path"))
(when (> disk-size max-size)
(ex/raise :type :restriction
:code :media-max-file-size-reached
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
(:size upload)
disk-size
max-size)))
upload))
(defn validate-font-size!
"Validates that the font file `upload` does not exceed the configured
`:font-max-file-size` limit. Accepts the same map shape as
`validate-media-size!` — requires a `:size` key in bytes."
(defn check-font-size
"Rejects when the font file bytes on disk exceed the configured
`:font-max-file-size` limit. The declared `:size` is ignored —
only the file at `:path` counts."
[upload]
(let [max-size (cf/get :font-max-file-size)]
(when (> (:size upload) max-size)
(let [max-size (cf/get :font-max-file-size)
disk-size (real-size (:path upload))]
(when (nil? disk-size)
(ex/raise :type :validation
:code :params-validation
:hint "invalid upload path"))
(when (> disk-size max-size)
(ex/raise :type :restriction
:code :font-max-file-size-reached
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
(:size upload)
disk-size
max-size)))
upload))

View File

@ -532,7 +532,13 @@
:fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")}
{:name "0157-add-audit-log-pending-table"
:fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")}])
:fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")}
{:name "0157-migrate-legacy-file-data"
:fn (mg/resource "app/migrations/sql/0157-migrate-legacy-file-data.sql")}
{:name "0158-del-forbidden-file-library-rels"
:fn (mg/resource "app/migrations/sql/0158-del-forbidden-file-library-rels.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,55 @@
-- Migrate legacy payloads to file_data: labelled file_change.data as
-- snapshots, file.data as mains (backend 'db', deleted_at copied so the
-- GC keeps collecting them), then delete orphan file_change rows with
-- data but no label (no read path reaches them).
--
-- Idempotent: INSERTs ignore conflicts, UPDATEs only null the legacy
-- column where the file_data row exists. Big instances pre-migrate by
-- hand (operations/2.19.0-migrate-legacy-file-data.md) and fake this
-- step instead:
--
-- INSERT INTO migrations (module, step)
-- VALUES ('main', '0157-migrate-legacy-file-data');
INSERT INTO file_data (file_id, id, created_at, modified_at, deleted_at,
type, backend, metadata, data)
SELECT file_id, id, created_at, updated_at, deleted_at,
'snapshot', 'db', NULL, data
FROM file_change
WHERE data IS NOT NULL
AND label IS NOT NULL
ON CONFLICT (file_id, id) DO NOTHING;
UPDATE file_change AS fc
SET data = NULL
WHERE fc.data IS NOT NULL
AND fc.label IS NOT NULL
AND EXISTS (SELECT 1
FROM file_data AS fd
WHERE fd.file_id = fc.file_id
AND fd.id = fc.id
AND fd.type = 'snapshot');
INSERT INTO file_data (file_id, id, created_at, modified_at, deleted_at,
type, backend, metadata, data)
SELECT id, id, created_at, modified_at, deleted_at,
'main', 'db', NULL, data
FROM file
WHERE data IS NOT NULL
ON CONFLICT (file_id, id) DO NOTHING;
UPDATE file AS f
SET data = NULL
WHERE f.data IS NOT NULL
AND EXISTS (SELECT 1
FROM file_data AS fd
WHERE fd.file_id = f.id
AND fd.id = f.id
AND fd.type = 'main');
-- Orphan revisions: legacy payloads without label. No file_data row
-- corresponds to them (snapshots are told apart by label), so there is
-- nothing to copy; the row itself is garbage. Delete it.
DELETE FROM file_change
WHERE data IS NOT NULL
AND label IS NULL;

View File

@ -0,0 +1,38 @@
-- Remove file-library relations that violate the same-team invariant:
-- relations across teams and self-relations. Leftover from before the
-- binfile export, the view-only bundle, link-file-to-library and the
-- manifest writers enforced same-team libraries. Relations with both
-- endpoints in the same team are kept.
--
-- The sync rows go first: the second statement reads file_library_rel
-- while it still contains the forbidden rows.
WITH forbidden AS (
SELECT flr.file_id, flr.library_file_id
FROM file_library_rel flr
JOIN file f ON f.id = flr.file_id
JOIN project fp ON fp.id = f.project_id
JOIN file l ON l.id = flr.library_file_id
JOIN project lp ON lp.id = l.project_id
WHERE fp.team_id <> lp.team_id
OR flr.file_id = flr.library_file_id
)
DELETE FROM file_library_sync fls
USING forbidden
WHERE fls.file_id = forbidden.file_id
AND fls.library_file_id = forbidden.library_file_id;
WITH forbidden AS (
SELECT flr.file_id, flr.library_file_id
FROM file_library_rel flr
JOIN file f ON f.id = flr.file_id
JOIN project fp ON fp.id = f.project_id
JOIN file l ON l.id = flr.library_file_id
JOIN project lp ON lp.id = l.project_id
WHERE fp.team_id <> lp.team_id
OR flr.file_id = flr.library_file_id
)
DELETE FROM file_library_rel flr
USING forbidden
WHERE flr.file_id = forbidden.file_id
AND flr.library_file_id = forbidden.library_file_id;

View File

@ -444,7 +444,8 @@
[:map
[:organization-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:permissions [:map-of :keyword :string]]]
[:permissions [:map-of :keyword :string]]
[:restrictions-met {:optional true} [:maybe [:map-of :keyword :boolean]]]]
params))
(defn- get-organization-sso-api
@ -652,6 +653,35 @@
:cause cause)
team)))))
(defn add-organization-info-to-team-strict
"Like `add-organization-info-to-team`, but fails closed for authorization
checks: the team is returned unchanged only when Nitrate answers 404 (the
team belongs to no organization). Any other failure, including a 4xx or an
invalid response body, raises instead of looking like a team without an
organization."
[cfg team params]
(let [params (assoc (or params {})
:team-id (:id team)
:throw-on-error? true)
team-with-organization
(try
(call cfg :get-team-organization params)
(catch Throwable cause
(let [{:keys [type status]} (ex-data cause)]
(if (and (= :nitrate-http-error type) (= 404 status))
::no-organization
(throw cause)))))]
(cond
(= ::no-organization team-with-organization)
team
(nil? team-with-organization)
(ex/raise :type :nitrate-unavailable
:hint "nitrate did not return a valid team organization response")
:else
(apply-organization-info-to-team team team-with-organization))))
(defn add-organization-info-to-teams
"Enriches teams with organization information using one batched Nitrate request.
Teams absent from the Nitrate response are returned unchanged.

View File

@ -124,7 +124,8 @@
(cond-> (and (not (uuid? session-pid))
(uuid? token-pid))
(-> (assoc ::auth-type :token)
(assoc ::token-perms (set (::actoken/perms request #{})))))
(assoc ::token-perms (set (::actoken/perms request #{})))
(assoc ::token-type (::actoken/type request))))
(cond-> key-id
(assoc ::auth-key-id key-id))
;; Server-side only: request bodies cannot forge
@ -171,10 +172,12 @@
[_ f mdata]
(let [required-auth? (::auth mdata true)
required-auth-type (::auth-type mdata)
required-token-type (::token-type mdata)
required-perms (into #{} (::perms mdata))]
(fn [cfg params]
(let [profile-id (::profile-id params)
auth-type (::auth-type params)
token-type (::token-type params)
token-perms (set (::token-perms params #{}))]
(cond
(and required-auth? (not (uuid? profile-id)))
@ -182,8 +185,13 @@
:code :authentication-required
:hint "authentication required for this endpoint")
(and (= required-auth-type :token)
(not= auth-type :token))
(or (and (= required-auth-type :token)
(not= auth-type :token))
(and required-token-type
(or (not= auth-type :token)
(not= required-token-type token-type)))
(and (= token-type "mcp")
(not= required-token-type "mcp")))
(ex/raise :type :authorization
:code :token-auth-required
:hint "access token authentication required for this endpoint")
@ -358,6 +366,28 @@
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(defn- wrap-shared-key-authorization
"Authorizes a shared-key caller against the method's `::allowed-shared-keys`.
A management method declares the shared-key ids allowed to call it with
`::allowed-shared-keys`. When the metadata is absent any configured shared key is
accepted, preserving the previous behavior. When it is present and empty no
key is accepted."
[_ f mdata]
(if (contains? mdata ::allowed-shared-keys)
(let [allowed-keys (::allowed-shared-keys mdata)]
(fn [cfg params]
(if (contains? allowed-keys (::auth-key-id params))
(f cfg params)
(do
(l/wrn :hint "shared key is not allowed for this method"
:handler-name (::handler-name params)
:key-id (::auth-key-id params))
(ex/raise :type :authorization
:code :shared-key-not-allowed
:hint "shared key is not allowed for this method")))))
f))
(defn- wrap-management
[cfg f mdata]
(as-> f $
@ -368,7 +398,10 @@
(wrap-audit cfg $ mdata)
(wrap-nitrate-sso cfg $ mdata)
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(wrap-authentication cfg $ mdata)
;; Outermost on purpose: a denied key never opens a transaction,
;; validates params or runs the method body.
(wrap-shared-key-authorization cfg $ mdata)))
(defn- process-method
[cfg wrap-fn [f mdata]]
@ -522,7 +555,7 @@
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
[actoken/authz (assoc cfg ::actoken/allow-mcp true)]]
:handler (make-rpc-handler methods)}]
(doc/routes :methods methods
@ -540,5 +573,5 @@
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
[actoken/authz (assoc cfg ::actoken/allow-mcp true)]]
:handler (make-rpc-handler methods)}]])

View File

@ -5,7 +5,16 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.rpc.climit
"Concurrencly limiter for RPC."
"Concurrencly limiter for RPC.
A command declares its limits in its `::climit/id` metadata and takes them
before the root ones, so a request waiting for its own limits does not hold
the root permits of its profile.
Every acquisition goes through this wrapper, so every path takes the
limiters in the same order. A command that calls another command from
inside its handler would take them twice: the second call waits for a
permit the first one holds, and answers 429 when the timeout runs out."
(:refer-clojure :exclude [run!])
(:require
[app.common.exceptions :as ex]
@ -221,16 +230,6 @@
(def ^:private idseq (AtomicLong. 0))
(def ^:private ^ThreadLocal held-limiters
;; Limiters a thread is inside of, identified by the limiter instance, which
;; already encodes the limit id and its key: the cache is per id+key pair.
;; `pbh/invoke!` runs the handler on the calling thread, so a handler that
;; enters climit again for a limit it holds finds it here. Without this a
;; thread parks forever behind a permit it holds itself.
(ThreadLocal/withInitial
(reify java.util.function.Supplier
(get [_] (java.util.HashSet.)))))
(defn- impl-invoke
[metrics counters cache config handler]
(let [limit-id (::id config)
@ -249,69 +248,57 @@
:permits (or (:permits config) (:concurrency config) 1)
:queue-max (or (:queue config) (:queue-size config))}
limiter (cache/get cache limit-id (partial create-limiter config))
held ^java.util.Set (.get ^ThreadLocal held-limiters)]
tpoint (ct/tpoint)
req-id (.incrementAndGet ^AtomicLong idseq)
waiting (volatile! true)
stop-waiting (fn []
;; Exactly one decrement per increment: the flag
;; makes a second call a no-op and the `finally`
;; guarantees that there is one.
(when @waiting
(vreset! waiting false)
(waiting-dec metrics counters limit-name)))]
(if (.contains held limiter)
;; The calling thread already holds this limiter for this key: run the
;; handler inline instead of queueing behind itself.
(do
(l/trc :hint "reentrant invocation" :id limit-id :label limit-label)
(handler))
(try
;; The increment lives inside the `try`, so a throwable from the
;; publishing path unwinds through the `finally`: the count comes back
;; down instead of staying up for good.
(waiting-inc metrics counters limit-name)
(let [tpoint (ct/tpoint)
req-id (.incrementAndGet ^AtomicLong idseq)
waiting (volatile! true)
stop-waiting (fn []
;; Exactly one decrement per increment: the flag
;; makes a second call a no-op and the `finally`
;; guarantees that there is one.
(when @waiting
(vreset! waiting false)
(waiting-dec metrics counters limit-name)))]
(let [stats (pbh/get-stats limiter)]
(log limit "enqueued" req-id stats nil nil nil))
(.add held limiter)
(pbh/invoke! limiter (fn []
(stop-waiting)
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(measure metrics mlabels elapsed)
(log limit "acquired" req-id stats elapsed nil nil)
(handler))))
(try
;; The increment lives inside the `try`, so a throwable from the
;; publishing path unwinds through the `finally`: the thread drops the
;; mark and the count comes back down instead of staying up for good.
(waiting-inc metrics counters limit-name)
(let [stats (pbh/get-stats limiter)]
(log limit "enqueued" req-id stats nil nil nil))
(pbh/invoke! limiter (fn []
(stop-waiting)
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(measure metrics mlabels elapsed)
(log limit "acquired" req-id stats elapsed nil nil)
(handler))))
(catch ExceptionInfo cause
(let [{:keys [type code] :as data} (ex-data cause)]
(if (= :bulkhead-error type)
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)
reason (if (some? (:timeout data)) "timeout" "queue-full")]
(mtx/run! metrics
:id :rpc-climit-rejected
:inc 1
:labels (into-array String [limit-name reason]))
(log limit "rejected" req-id stats elapsed code reason)
(ex/raise :type :concurrency-limit
:code code
:hint "concurrency limit reached"
:cause cause))
(throw cause))))
(finally
(.remove held limiter)
(stop-waiting)
(catch ExceptionInfo cause
(let [{:keys [type code] :as data} (ex-data cause)]
(if (= :bulkhead-error type)
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
stats (pbh/get-stats limiter)
reason (if (some? (:timeout data)) "timeout" "queue-full")]
(mtx/run! metrics
:id :rpc-climit-rejected
:inc 1
:labels (into-array String [limit-name reason]))
(log limit "rejected" req-id stats elapsed code reason)
(ex/raise :type :concurrency-limit
:code code
:hint "concurrency limit reached"
:cause cause))
(throw cause))))
(log limit "finished" req-id stats elapsed nil nil))))))))
(finally
(stop-waiting)
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(log limit "finished" req-id stats elapsed nil nil))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MIDDLEWARE
@ -340,6 +327,16 @@
:else
(throw (IllegalArgumentException. "unable to normalize limit")))))
(defn- resolve-limit-key
"The key of a limit, resolved the same way in every path: a keyword is
looked up in the params, a function is applied to them, any other value is
the key itself."
[key params]
(cond
(keyword? key) (get params key)
(fn? key) (key params)
:else key))
(defn wrap
[cfg handler {label ::sv/name :as mdata}]
(if-let [climit (::rpc/climit cfg)]
@ -363,7 +360,7 @@
(fn [cfg params]
(let [config (-> config
(assoc ::key (key-fn params))
(assoc ::key (resolve-limit-key key-fn params))
(assoc ::label label)
;; NOTE: only used for debugging output
(assoc ::params params))]
@ -373,40 +370,11 @@
(l/wrn :hint "no config found for specified queue" :id (id->str limit-id))
handler)))
handler
;; The root limiters go last so they end up outermost: every path
;; meets them before the command's own limits. The nested
;; `climit/invoke!` chains carry only the command's own limits and
;; run inside those of the enclosing call, so this puts both paths
;; in the same order. With the opposite order two calls of the same
;; profile hold the same limiters inverted and park for good.
(concat (get-limits mdata) global-limits))
;; The root limiters go first so they end up innermost: a command
;; takes the limits it declares before the root ones. Every path
;; meets the limiters in the same order, because every acquisition
;; goes through this wrapper.
(concat global-limits (get-limits mdata)))
handler))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- build-exec-chain
[{:keys [::label ::rpc/climit] :as cfg} f]
(reduce (fn [handler [limit-id limit-key]]
(if-let [config (get-config climit limit-id)]
(let [config (-> config
(assoc ::key limit-key)
(assoc ::label label))]
(fn [cfg params]
(let [config (assoc config ::params params)]
(invoke climit config (partial handler cfg params)))))
(do
(l/wrn :hint "config not found" :label label :id limit-id)
f)))
f
(get-limits cfg)))
(defn invoke!
"Run a function in context of climit."
[{:keys [::rpc/climit] :as cfg} f params]
(let [f (if climit
(build-exec-chain cfg f)
f)]
(f cfg params)))

View File

@ -6,12 +6,15 @@
(ns app.rpc.commands.access-token
(:require
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.http.access-token :as actoken]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.rpc.quotes :as quotes]
[app.setup :as-alias setup]
@ -28,16 +31,27 @@
AND id != ?
AND type = 'mcp'")
(def ^:private sql:get-valid-mcp-token
"SELECT * FROM access_token
WHERE profile_id = ?
AND type = 'mcp'
AND (expires_at IS NULL OR expires_at > now())
ORDER BY created_at DESC
LIMIT 1")
(defn create-access-token
"Create an access token with empty perms.
Elevated permissions (e.g. error-reports:read) are not assignable via
the public API; grant them with SQL or `repl:grant-access-token-perm`."
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
;; Serialize regeneration with OAuth provisioning for the same profile.
(when (= type "mcp")
(db/get-by-id conn :profile profile-id {::db/for-update true}))
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token")
token-iss (if (= type "mcp") actoken/mcp-token-issuer "access-token")
token (tokens/generate cfg {:iss token-iss
:uid profile-id
:iat created-at
@ -62,6 +76,27 @@
(decode-row token)))
(defn ensure-mcp-access!
"Enable MCP and return the profile's unexpired token, creating it if absent.
Must run in a transaction. Lock the profile even when no token exists, so
concurrent approvals and settings regeneration use the same lock. Enabling
MCP must not regenerate a token already held by a connected client; an
expired token is replaced because no client can use it, and so is a token
issued before MCP tokens had their own issuer, or one this instance cannot
decrypt, because the backend rejects both."
[{:keys [::db/conn] :as cfg} profile-id]
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
token (db/exec-one! conn [sql:get-valid-mcp-token profile-id])
;; A token this instance cannot decrypt is as unusable as an expired one
token (when (= actoken/mcp-token-issuer
(ex/ignoring (:iss (tokens/decode cfg (:token token)))))
token)]
(when-not (:mcp-enabled (:props profile))
(profile/update-profile-props cfg profile-id {:mcp-enabled true}))
(if token
(decode-row token)
(create-access-token cfg profile-id "MCP" nil "mcp"))))
(defn repl:create-access-token
[cfg profile-id name expiration]
(db/tx-run! cfg create-access-token profile-id name expiration))

View File

@ -202,11 +202,11 @@
(update-password conn))]
(when profile
(login-lockout/clear-attempts! cfg (:id profile))
(eml/send cfg {::eml/reuse-conn true
::eml/factory eml/password-changed
:public-uri (cf/get :public-uri)
:to (:email profile)
:name (:fullname profile)})))
(eml/send-template cfg {::eml/reuse-conn true
::eml/template "password-changed"
:public-uri (cf/get :public-uri)
:to (:email profile)
:name (:fullname profile)})))
nil))
@ -373,28 +373,40 @@
[cfg uri]
(try
(let [storage (sto/resolve cfg)
input (media/download-image cfg uri)
input (media/run cfg {:cmd :info :input input})
hash (sto/calculate-hash (:path input))
content (-> (sto/content (:path input) (:size input))
(sto/wrap-with-hash hash))
sobject (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "profile"
:content-type (:mtype input)})]
(:id sobject))
input (media/download-image cfg uri)]
(if (= "image/svg+xml" (:mtype input))
;; The avatar is always rendered as an image, so an SVG would only
;; add the risk of storing live markup: it is not allowed here.
(do
(l/wrn :hint "ignoring svg profile picture" :uri uri)
nil)
(let [input (media/run cfg {:cmd :info :input input})
hash (sto/calculate-hash (:path input))
content (-> (sto/content (:path input) (:size input))
(sto/wrap-with-hash hash))
sobject (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "profile"
:content-type (:mtype input)})]
(:id sobject))))
(catch Throwable cause
(l/wrn :hint "unable to import profile picture"
:uri uri
:cause cause)
nil)))
(def ^:private default-renderer-new-profiles
"Renderer stored on new profiles. Profiles without one fall back to SVG."
:wasm)
(defn create-profile
"Create the profile entry on the database with limited set of input
attrs (all the other attrs are filled with default values)."
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
(let [id (or (:id params) (uuid/next))
props (-> (:props params)
(update :renderer d/nilv default-renderer-new-profiles)
(merge {:viewed-tutorial? false
:viewed-walkthrough? false
:nudge {:big 10 :small 1}
@ -485,13 +497,13 @@
{:iss :profile-identity
:profile-id (:id profile)
:exp (ct/in-future {:days 30})})]
(eml/send cfg {::eml/reuse-conn true
::eml/factory eml/register
:public-uri (cf/get :public-uri)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken}))))
(eml/send-template cfg {::eml/reuse-conn true
::eml/template "register"
:public-uri (cf/get :public-uri)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken}))))
(defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token] :as params}]
@ -516,8 +528,9 @@
created? (-> profile meta :created true?)
props (-> (audit/profile->props profile)
(assoc :from-invitation (some? invitation)))]
props (cond-> (audit/profile->props profile)
:always (assoc :from-invitation (some? invitation))
created? (assoc :renderer-default (-> profile :props :renderer)))]
(cond
;; When profile is blocked, we just ignore it and return plain data
@ -543,8 +556,8 @@
;; return :invitation-token. The frontend redirects to
;; :auth-verify-token, which immediately accepts the
;; invitation.
;; - active + no/mismatched invitation → mint session
;; ("login" action). New profile, no further action.
;; - active + no/mismatched invitation → mint session.
;; New profile, no further action.
;; - not-active + matching invitation → send the
;; verify-email mail with the invitation token EMBEDDED
;; into the verify-email JWE. No session yet. When the
@ -557,7 +570,11 @@
created?
(let [accept-invitation? (and (some? invitation)
(= (:email profile)
(:member-email invitation)))]
(:member-email invitation)))
props (assoc props :activation
(if (:is-active profile)
"immediate"
"email-verification"))]
(cond
(and (:is-active profile) accept-invitation?)
(let [invitation (assoc invitation :member-id (:id profile))
@ -567,7 +584,6 @@
:invitation-token token}
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
::audit/profile-id (:id profile)})))
(:is-active profile)
@ -575,7 +591,6 @@
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-meta
{::audit/replace-props props
::audit/context {:action "login"}
::audit/profile-id (:id profile)}))
:else
@ -589,7 +604,6 @@
:email (:email profile)}
(rph/with-meta
{::audit/replace-props props
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)})))))
;; When email verification is disabled and an inactive profile already
@ -654,13 +668,13 @@
{:iss :profile-identity
:profile-id (:id profile)
:exp (ct/in-future {:days 30})})]
(eml/send cfg {::eml/reuse-conn true
::eml/factory eml/password-recovery
:public-uri (cf/get :public-uri)
:to (:email profile)
:token (:token profile)
:name (:fullname profile)
:extra-data ptoken})
(eml/send-template cfg {::eml/reuse-conn true
::eml/template "password-recovery"
:public-uri (cf/get :public-uri)
:to (:email profile)
:token (:token profile)
:name (:fullname profile)
:extra-data ptoken})
nil))]
(let [profile (->> (profile/clean-email email)

View File

@ -50,9 +50,10 @@
(let [output (tmp/tempfile*)]
(try
(-> (bfc/export-cfg cfg {:ids #{file-id}
:export-type (bfc/resolve-export-type params)})
(bf.v3/export-files! output))
(-> (bfc/export-cfg cfg {:file-ids #{file-id}
:export-type (bfc/resolve-export-type params)
:output output})
(bf.v3/export-files!))
(let [data (sto/content output)
object (sto/put-object! storage
@ -126,6 +127,35 @@
result))
(defn- prepare-import-event
"Build the audit event of an import. The RPC layer cannot emit it
because the import runs later, inside the SSE response, and the
event must carry the ids of the files it creates."
[params manifest]
(-> (audit/event-from-rpc-params params)
(assoc :type "command")
(assoc :name "import-binfile")
(assoc :props (-> params
(dissoc :file)
(assoc :generated-by (:generated-by manifest))
(assoc :referer (:referer manifest))
(audit/clean-props)))
(assoc ::webhooks/event? true)))
(defn- import-binfile-and-audit
[cfg params event]
(try
(let [result (import-binfile cfg params)
file-ids (:file-ids result)]
(audit/submit cfg (update event :props assoc
:file-ids (vec file-ids)
:file-count (count file-ids)
:status "completed"))
result)
(catch Throwable cause
(audit/submit cfg (assoc-in event [:props :status] "failed"))
(throw cause))))
(def ^:private schema:import-binfile
[:and
[:map {:title "import-binfile" :closed true}
@ -151,6 +181,7 @@
["2.15" "Add upload-id param for chunked upload support"]]
::webhooks/event? true
::audit/skip true
::sse/stream? true
::sm/params schema:import-binfile
::rpc/multipart-only-params [:file]
@ -163,15 +194,13 @@
(assoc params :file file))
params)
_ (media.v/check-upload-path (:file params))
version (or version
(case (bfc/parse-file-format (-> params :file :path))
:binfile-v1 1
:binfile-v3 3))
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
manifest
(case (int version)
1 nil
@ -179,10 +208,10 @@
(throw (ex-info (str "Unsupported binfile version: " version)
{:type :validation
:code :unsupported-version
:version version})))]
:version version})))
(with-meta
(sse/response (partial import-binfile cfg params))
{::audit/props {:file nil
:generated-by (:generated-by manifest)
:referer (:referer manifest)}})))
params (assoc params :version version)
event (prepare-import-event params manifest)
params (assoc params :profile-id profile-id)]
(sse/response (partial import-binfile-and-audit cfg params event))))

View File

@ -29,7 +29,6 @@
[app.rpc.doc :as-alias doc]
[app.rpc.quotes :as quotes]
[app.rpc.retry :as rtry]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[clojure.set :as set]
[cuerdas.core :as str]))
@ -41,6 +40,23 @@
(def comment-max-length 750)
(defn- share-link-pages
"The set of pages granted by a share-link, or nil when the access to the
file is not restricted to a page scope."
[perms]
(when (= :share-link (:type perms))
(or (:pages perms) #{})))
(defn- check-page-scope
"Raises a :not-found error when `page-id` is outside the page scope granted
by a share-link."
[perms page-id]
(when-let [pages (share-link-pages perms)]
(when-not (contains? pages page-id)
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))))
(defn- format-comment
[{:keys [content]}]
(->> (d/interleave-all
@ -113,10 +129,10 @@
(doseq [mention comment-mentions]
(let [{:keys [fullname email props]} (get team-users mention)]
(when (mention-email? props)
(eml/send
(eml/send-template
cfg
{::eml/reuse-conn true
::eml/factory eml/comment-mention
::eml/template "comment-mention"
:public-uri (cf/get :public-uri)
:to email
:name fullname
@ -129,10 +145,10 @@
(doseq [mention thread-mentions]
(let [{:keys [fullname email props]} (get team-users mention)]
(when (mention-email? props)
(eml/send
(eml/send-template
cfg
{::eml/reuse-conn true
::eml/factory eml/comment-thread
::eml/template "comment-thread"
:public-uri (cf/get :public-uri)
:to email
:name fullname
@ -145,10 +161,10 @@
(doseq [user-id notificate-users-ids]
(let [{:keys [id fullname email props]} (get team-users user-id)]
(when (notification-email? id (:owner-id thread) props)
(eml/send
(eml/send-template
cfg
{::eml/reuse-conn true
::eml/factory eml/comment-notification
::eml/template "comment-notification"
:public-uri (cf/get :public-uri)
:to email
:name fullname
@ -170,7 +186,7 @@
(defn- get-file
"A specialized version of get-file for comments module."
[cfg file-id page-id]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(feat.fdata/with-pointers cfg file-id
(let [file (bfc/get-file cfg file-id)
data (get file :data)]
(-> file
@ -236,8 +252,8 @@
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)
threads (get-comment-threads conn profile-id file-id)]
(if (= :share-link (:type perms))
(filterv #(contains? (:pages perms) (:page-id %)) threads)
(if-let [pages (share-link-pages perms)]
(filterv #(contains? pages (:page-id %)) threads)
threads)))))
(defn- get-comment-threads-sql
@ -338,11 +354,8 @@
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)
thread (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row))]
(when (and thread (= :share-link (:type perms)))
(when-not (contains? (:pages perms) (:page-id thread))
(ex/raise :type :not-found
:code :object-not-found
:hint "not found")))
(when thread
(check-page-scope perms (:page-id thread)))
thread))))
;; --- COMMAND: Retrieve Comments
@ -362,11 +375,7 @@
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [file-id page-id]} (get-comment-thread conn thread-id)
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(when (and (= :share-link (:type perms))
(not (contains? (:pages perms) page-id)))
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))
(check-page-scope perms page-id)
(get-comments conn thread-id)))))
(def sql:get-comments
@ -391,32 +400,49 @@
;; All the profiles that had comment the file, plus the current
;; profile.
(defn- file-comment-users-sql
[where]
(str/ffmt
"WITH available_profiles AS (
SELECT DISTINCT c.owner_id AS id
FROM comment c
JOIN comment_thread ct
ON ct.id = c.thread_id
WHERE ct.file_id = ?::uuid
%1
),
profile_ids AS (
SELECT id FROM available_profiles
UNION
SELECT ?::uuid
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname,
p.photo_id,
p.is_active
FROM profile p
JOIN profile_ids AS x
ON x.id = p.id;"
where))
(def ^:private sql:file-comment-users
"WITH available_profiles AS (
SELECT DISTINCT c.owner_id AS id
FROM comment c
JOIN comment_thread ct
ON ct.id = c.thread_id
WHERE ct.file_id = ?::uuid
),
profile_ids AS (
SELECT id FROM available_profiles
UNION
SELECT ?::uuid
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname,
p.photo_id,
p.is_active
FROM profile p
JOIN profile_ids AS x
ON x.id = p.id;")
(file-comment-users-sql ""))
(def ^:private sql:file-comment-users-by-page
(file-comment-users-sql "AND ct.page_id = ANY(?::uuid[])"))
(defn get-file-comments-users
[conn file-id profile-id]
(db/exec! conn [sql:file-comment-users file-id profile-id]))
"Returns the profiles of the users that commented the file. When `pages` is
not nil, only the commenters of the threads on those pages are returned."
[conn file-id profile-id pages]
(if (some? pages)
(db/exec! conn [sql:file-comment-users-by-page
file-id
(db/create-array conn "uuid" pages)
profile-id])
(db/exec! conn [sql:file-comment-users file-id profile-id])))
(def ^:private
schema:get-profiles-for-file-comments
@ -426,14 +452,17 @@
(sv/defmethod ::get-profiles-for-file-comments
"Retrieves a list of profiles with limited set of properties of all
participants on comment threads of the file."
participants on comment threads of the file. When the request is done with
a share link, only the participants on the pages granted by the link are
returned."
{::doc/added "1.15"
::doc/changes ["1.15" "Imported from queries and renamed."]
::doc/changes ["1.15" "Imported from queries and renamed."
"2.19" "With a share link, only the participants on the pages granted by the link are returned."]
::sm/params schema:get-profiles-for-file-comments}
[cfg {:keys [::rpc/profile-id file-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id))))
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(get-file-comments-users conn file-id profile-id (share-link-pages perms))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS
@ -475,21 +504,22 @@
::rtry/when rtry/conflict-exception?
::sm/params schema:create-comment-thread}
[cfg {:keys [::rpc/profile-id file-id page-id share-id] :as params}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(check-page-scope perms page-id)
(let [{:keys [team-id project-id] :as file} (get-file cfg file-id page-id)]
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
(assoc ::quotes/project-id project-id)
(assoc ::quotes/file-id file-id)
(quotes/check! {::quotes/id ::quotes/comment-threads-per-file}
{::quotes/id ::quotes/comments-per-file}))
(let [{:keys [team-id project-id] :as file} (get-file cfg file-id page-id)]
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
(assoc ::quotes/project-id project-id)
(assoc ::quotes/file-id file-id)
(quotes/check! {::quotes/id ::quotes/comment-threads-per-file}
{::quotes/id ::quotes/comments-per-file}))
(let [params (assoc params ::file file)
thread (db/tx-run! cfg create-comment-thread params)]
(let [params (assoc params ::file file)
thread (db/tx-run! cfg create-comment-thread params)]
(vary-meta thread assoc ::audit/props (assoc thread :team-id team-id)))))
(vary-meta thread assoc ::audit/props (assoc thread :team-id team-id))))))
(defn- create-comment-thread
[{:keys [::db/conn] :as cfg}
@ -560,8 +590,9 @@
::sm/params schema:update-comment-thread-status
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [thread (get-comment-thread conn id ::sql/for-update true)
perms (files/check-comment-permissions! cfg profile-id (:file-id thread) share-id)]
(check-page-scope perms (:page-id thread))
(upsert-comment-thread-status! conn profile-id id)))
;; --- COMMAND: Update Comment Thread
@ -578,8 +609,9 @@
::sm/params schema:update-comment-thread
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [thread (get-comment-thread conn id ::sql/for-update true)
perms (files/check-comment-permissions! cfg profile-id (:file-id thread) share-id)]
(check-page-scope perms (:page-id thread))
(db/update! conn :comment-thread
{:is-resolved is-resolved}
{:id id})
@ -604,56 +636,58 @@
(let [{:keys [file-id page-id] :as thread}
(get-comment-thread conn thread-id ::sql/for-update true)
{:keys [team-id project-id] :as file}
(get-file cfg file-id page-id)]
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(check-page-scope perms page-id)
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id})
(let [{:keys [team-id project-id] :as file}
(get-file cfg file-id page-id)]
(let [profile (profile/get-profile conn profile-id)
mentions (into #{} mentions)
params {:id (uuid/next)
:created-at request-at
:modified-at request-at
:thread-id thread-id
:owner-id profile-id
:content content
:mentions (db/encode-pgarray mentions conn "uuid")}
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id})
comment (-> (db/insert! conn :comment params)
(decode-row)
(assoc :file-id file-id)
(add-owner profile))]
(let [profile (profile/get-profile conn profile-id)
mentions (into #{} mentions)
params {:id (uuid/next)
:created-at request-at
:modified-at request-at
:thread-id thread-id
:owner-id profile-id
:content content
:mentions (db/encode-pgarray mentions conn "uuid")}
;; Update thread modified-at attribute and assoc the current
;; profile to the participant set.
(let [mentions (into (:mentions thread) mentions)
participants (-> (:participants thread #{})
(conj profile-id))
params {:modified-at request-at
:participants (db/tjson participants)
:mentions (db/encode-pgarray mentions conn "uuid")}
comment (-> (db/insert! conn :comment params)
(decode-row)
(assoc :file-id file-id)
(add-owner profile))]
;; Update the page-name cached attribute on comment thread table.
params (cond-> params
(not= (:page-name file) (:page-name thread))
(assoc :page-name (:page-name file)))]
;; Update thread modified-at attribute and assoc the current
;; profile to the participant set.
(let [mentions (into (:mentions thread) mentions)
participants (-> (:participants thread #{})
(conj profile-id))
params {:modified-at request-at
:participants (db/tjson participants)
:mentions (db/encode-pgarray mentions conn "uuid")}
(db/update! conn :comment-thread params
{:id thread-id}
{::db/return-keys false}))
;; Update the page-name cached attribute on comment thread table.
params (cond-> params
(not= (:page-name file) (:page-name thread))
(assoc :page-name (:page-name file)))]
;; Update the current profile status in relation to the current thread
(upsert-comment-thread-status! conn profile-id thread-id)
(db/update! conn :comment-thread params
{:id thread-id}
{::db/return-keys false}))
(send-comment-emails! cfg profile comment thread file)
;; Update the current profile status in relation to the current thread
(upsert-comment-thread-status! conn profile-id thread-id)
(vary-meta comment assoc ::audit/props (assoc comment :team-id team-id)))))
(send-comment-emails! cfg profile comment thread file)
(vary-meta comment assoc ::audit/props (assoc comment :team-id team-id))))))
;; --- COMMAND: Update Comment
@ -676,9 +710,11 @@
(get-comment conn id ::sql/for-update true)
{:keys [file-id page-id] :as thread}
(get-comment-thread conn thread-id ::sql/for-update true)]
(get-comment-thread conn thread-id ::sql/for-update true)
(files/check-comment-permissions! cfg profile-id file-id share-id)
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(check-page-scope perms page-id)
;; Don't allow edit comments to not owners
(when-not (= owner-id profile-id)
@ -716,8 +752,13 @@
::sm/params schema:delete-comment-thread
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [{:keys [owner-id file-id page-id] :as thread}
(get-comment-thread conn id ::sql/for-update true)
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(check-page-scope perms page-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
@ -742,10 +783,12 @@
(let [{:keys [owner-id thread-id] :as comment}
(get-comment conn id ::sql/for-update true)
{:keys [file-id]}
(get-comment-thread conn thread-id)]
{:keys [file-id page-id]}
(get-comment-thread conn thread-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(check-page-scope perms page-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
@ -769,8 +812,9 @@
::sm/params schema:update-comment-thread-position
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [thread (get-comment-thread conn id ::sql/for-update true)
perms (files/check-comment-permissions! cfg profile-id (:file-id thread) share-id)]
(check-page-scope perms (:page-id thread))
(db/update! conn :comment-thread
{:modified-at request-at
:position (db/pgpoint position)
@ -793,8 +837,9 @@
::sm/params schema:update-comment-thread-frame
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [thread (get-comment-thread conn id ::sql/for-update true)
perms (files/check-comment-permissions! cfg profile-id (:file-id thread) share-id)]
(check-page-scope perms (:page-id thread))
(db/update! conn :comment-thread
{:modified-at request-at
:frame-id frame-id}
@ -802,17 +847,69 @@
{::db/return-keys false})
nil))
;; --- COMMAND: Mark all comment threads as read
;; The dashboard sends the thread ids in batches of this same size
;; (frontend/src/app/main/data/comments.cljs), so the batch has to fit in
;; this limit.
(def ^:private max-mark-all-threads 500)
(def ^:private
schema:mark-all-threads-as-read
[:map {:title "mark-all-threads-as-read"}
[:threads [:vector ::sm/uuid]]])
[:threads [:vector {:max max-mark-all-threads} ::sm/uuid]]])
(def ^:private sql:comment-threads-files
"SELECT ct.id, ct.file_id
FROM comment_thread ct
WHERE ct.id = ANY(?::uuid[])")
(def ^:private sql:upsert-comment-thread-status-batch
"insert into comment_thread_status (thread_id, profile_id, modified_at)
select t.id, ?::uuid, ?::timestamptz
from unnest(?::uuid[]) as t(id)
on conflict (thread_id, profile_id)
do update set modified_at = excluded.modified_at;")
(defn- get-comment-threads-files
[conn thread-ids]
(db/exec! conn [sql:comment-threads-files
(db/create-array conn "uuid" thread-ids)]))
(defn- check-threads-access
"Raises a :not-found error when any of the threads does not exist or the
caller has no access to the file that owns it."
[cfg profile-id thread-ids]
(let [rows (get-comment-threads-files (::db/conn cfg) thread-ids)
found (into #{} (map :id) rows)]
(when-not (= (count thread-ids) (count found))
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))
(doseq [file-id (distinct (map :file-id rows))]
(files/check-comment-permissions! cfg profile-id file-id nil))))
(defn- upsert-comment-thread-status-batch
[conn profile-id thread-ids]
(db/exec! conn [sql:upsert-comment-thread-status-batch
profile-id
(ct/in-future "1s")
(db/create-array conn "uuid" thread-ids)]))
(sv/defmethod ::mark-all-threads-as-read
"Marks the given comment threads as read for the current profile. The
caller needs to have access to the file that owns every thread."
{::doc/added "1.15"
::doc/changes [["2.19" "The caller needs access to the file that owns every thread and the number of threads is limited to 500"]]
::sm/params schema:mark-all-threads-as-read}
[cfg {:keys [::rpc/profile-id threads] :as params}]
(db/tx-run!
cfg
(fn [{:keys [::db/conn]}]
(doseq [thread-id threads]
(upsert-comment-thread-status! conn profile-id thread-id)))))
[cfg {:keys [::rpc/profile-id threads]}]
(let [thread-ids (distinct threads)]
;; Nothing to check and nothing to write: an empty list must not open a
;; transaction nor run the queries with an empty array.
(when (seq thread-ids)
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as cfg}]
(check-threads-access cfg profile-id thread-ids)
(upsert-comment-thread-status-batch conn profile-id thread-ids)
nil)))))

View File

@ -66,7 +66,8 @@
::doc/changes [["1.15" "This method is migrated from mutations to commands."]
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]
["2.18" "Add optional `expires-in` param. When set, the demo purge is scheduled that long after creation instead of the global deletion delay. Only values between 5 minutes and the global delay are accepted."]
["2.18.1" "Add optional `renderer` param. When set to `wasm` or `svg`, the profile is created with that `renderer` in props. When omitted, no renderer is stored and the Penpot default applies."]]
["2.18.1" "Add optional `renderer` param. When set to `wasm` or `svg`, the profile is created with that `renderer` in props. When omitted, no renderer is stored and the Penpot default applies."]
["2.20" "When `renderer` is omitted, the profile is created with the default renderer for new accounts (`wasm`)."]]
::sm/params schema:create-demo-profile}
[cfg {:keys [skip-onboarding expires-in renderer]}]

View File

@ -13,11 +13,57 @@
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.loggers.audit :as-alias audit]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
[app.rpc.helpers :as rph]
[app.util.services :as sv]
[clojure.string :as str]))
(def schema:send-mcp-feedback
[:map {:title "send-mcp-feedback" :closed true}
[:subject [:and [:string {:min 1 :max 120}]
[:fn #(and (not (str/blank? %))
(not (re-find #"\p{Cc}" %)))]]]
;; Line breaks and tabs are the only control characters allowed
[:feedback [:and [:string {:min 1 :max 5000}]
[:fn #(and (not (str/blank? %))
(not (re-find #"[\p{Cc}&&[^\t\n\r]]" %)))]]]])
(sv/defmethod ::send-mcp-feedback
{::rpc/token-type "mcp"
::db/transaction true
::doc/added "2.20"
::sm/params schema:send-mcp-feedback}
[cfg {:keys [::rpc/profile-id subject feedback]}]
(let [profile (profile/get-profile cfg profile-id)
destination (cf/get :mcp-feedback-email)]
;; Blocking a profile invalidates its sessions but not its access tokens
(when (:is-blocked profile)
(ex/raise :type :authorization
:code :profile-blocked
:hint "profile is marked as blocked"))
(when-not (:is-active profile)
(ex/raise :type :authorization
:code :profile-not-active
:hint "profile is not active"))
(when (str/blank? destination)
(ex/raise :type :restriction
:code :feedback-disabled
:hint "MCP feedback is not configured"))
(eml/send cfg {::eml/reuse-conn true
:to destination
:reply-to (:email profile)
:subject (str "[MCP feedback] " subject)
:body {"text/plain" (str "Submitted by: " (:fullname profile)
" <" (:email profile) ">\n"
"Profile ID: " (:id profile) "\n\n"
"Feedback:\n\n" feedback)}})
;; The audit event keeps who sent feedback, not its text
(rph/with-meta {:status "queued"}
{::audit/replace-props {}})))
(declare ^:private send-user-feedback!)
@ -57,16 +103,16 @@
(d/without-nils
{"error-report.txt" (:error-report params)})]
(eml/send cfg {::eml/reuse-conn true
::eml/factory eml/user-feedback
:to destination
:reply-to (:email profile)
:email (:email profile)
:attachments attachments
(eml/send-template cfg {::eml/reuse-conn true
::eml/template "feedback"
:to destination
:reply-to (:email profile)
:email (:email profile)
:attachments attachments
:feedback-subject (:subject params)
:feedback-type (:type params "not-specified")
:feedback-content (:content params)
:feedback-error-href (:error-href params)
:profile profile})
:feedback-subject (:subject params)
:feedback-type (:type params "not-specified")
:feedback-content (:content params)
:feedback-error-href (:error-href params)
:profile (select-keys profile [:id :fullname :email])})
nil))

View File

@ -431,7 +431,7 @@
(cfeat/check-client-features! (:features params))
(cfeat/check-file-features! (:features file)))
page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
page (feat.fdata/with-pointers cfg file-id
(let [page (dm/get-in file [:data :pages-index resolved-page-id])]
(if (pmap/pointer-map? page)
(deref page)
@ -561,7 +561,7 @@
calculate-from-db
(fn []
(let [file (bfc/get-file cfg id)
result (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
result (feat.fdata/with-pointers cfg id
(calculate-library-summary file))]
(-> file
(assoc :library-summary result)
@ -685,7 +685,7 @@
(defn- get-file-stats
[{:keys [::db/conn] :as cfg} file-id]
(let [file (bfc/get-file cfg file-id)
base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
base (feat.fdata/with-pointers cfg file-id
(cfs/calc-file-stats (:data file)))
lib-cnt (get-file-stats-library-counts conn file-id)]
(-> base
@ -774,7 +774,7 @@
(bfc/check-file-exists cfg file-id)
(check-read-permissions! cfg profile-id file-id)
(let [{:keys [data]} (bfc/get-file cfg file-id)]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(feat.fdata/with-pointers cfg file-id
(when-let [tokens-lib (cfo/get-tokens-lib data)]
(ctob/export-dtcg-json tokens-lib (cfo/get-tokens-status data))))))
@ -993,8 +993,7 @@
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)
pmap/*tracked* (pmap/create-tracked)]
(feat.fdata/with-tracked-pointers cfg file-id
(let [file (-> (bfc/get-file cfg file-id
:include-deleted? true
:lock-for-update? true)
@ -1009,6 +1008,7 @@
:file-id (str file-id))
(bfc/update-file! cfg {:id file-id
:features (:features file)
:migrations (:migrations file)
:revn (inc (:revn file))
:data (:data file)
@ -1394,16 +1394,16 @@
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-file-changes
;; Only durable history comes back: a snapshot (carries `label`) or a legacy
;; payload row (carries `data`, the column the new model replaces with
;; `file_data`). Revisions have neither and are deliberately left alone: they
;; are born already scheduled for deletion one hour later, and the collector
;; only looks at rows that carry a date, so unmarking one here would keep it
;; in the table for ever. The `data` half is a guard for the rows still
;; pending migration, not a dependency: drop it with the last one.
;; Only durable history comes back: a snapshot, the rows that carry `label`.
;; Everything else is deliberately left alone. Revisions are born already
;; scheduled for deletion one hour later, and the collector only looks at
;; rows that carry a date, so unmarking one here would keep it in the table
;; for ever. Rows that still carry a legacy payload without a label are
;; garbage that no read path reaches; 0157-migrate-legacy-file-data deletes
;; them, so they must keep their date and go with the collector.
"UPDATE file_change SET deleted_at = null
WHERE file_id = ANY(?::uuid[])
AND (label IS NOT NULL OR data IS NOT NULL)")
AND label IS NOT NULL")
(def ^:private sql:restore-file-data
"UPDATE file_data SET deleted_at = null
@ -1418,38 +1418,46 @@
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-storage-objects
"WITH ids AS (SELECT unnest(?::uuid[]) AS file_id)
UPDATE storage_object
;; Collect referenced storage ids first (UUID set), then join by PK.
;; Casting `storage_object.id` to text (or OR-ing that with `id IN`) forces
;; a scan of deleted rows; cast the JSON side instead.
"WITH ids AS (SELECT unnest(?::uuid[]) AS file_id),
refs AS (
SELECT fmo.media_id AS id
FROM file_media_object AS fmo
JOIN ids ON (ids.file_id = fmo.file_id)
WHERE fmo.deleted_at IS NULL
AND fmo.media_id IS NOT NULL
UNION
SELECT fmo.thumbnail_id
FROM file_media_object AS fmo
JOIN ids ON (ids.file_id = fmo.file_id)
WHERE fmo.deleted_at IS NULL
AND fmo.thumbnail_id IS NOT NULL
UNION
SELECT ft.media_id
FROM file_thumbnail AS ft
JOIN ids ON (ids.file_id = ft.file_id)
WHERE ft.deleted_at IS NULL
AND ft.media_id IS NOT NULL
UNION
SELECT fot.media_id
FROM file_tagged_object_thumbnail AS fot
JOIN ids ON (ids.file_id = fot.file_id)
WHERE fot.deleted_at IS NULL
AND fot.media_id IS NOT NULL
UNION
SELECT (fd.metadata->>'storage-ref-id')::uuid
FROM file_data AS fd
JOIN ids ON (ids.file_id = fd.file_id)
WHERE fd.deleted_at IS NULL
AND (fd.metadata->>'storage-ref-id') IS NOT NULL
)
UPDATE storage_object AS so
SET deleted_at = null
WHERE deleted_at IS NOT NULL
AND (id IN (SELECT media_id
FROM file_media_object AS fmo
JOIN ids ON (ids.file_id = fmo.file_id)
WHERE fmo.deleted_at IS NULL
AND fmo.media_id IS NOT NULL
UNION
SELECT thumbnail_id
FROM file_media_object AS fmo
JOIN ids ON (ids.file_id = fmo.file_id)
WHERE fmo.deleted_at IS NULL
AND fmo.thumbnail_id IS NOT NULL
UNION
SELECT ft.media_id
FROM file_thumbnail AS ft
JOIN ids ON (ids.file_id = ft.file_id)
WHERE ft.deleted_at IS NULL
AND ft.media_id IS NOT NULL
UNION
SELECT fot.media_id
FROM file_tagged_object_thumbnail AS fot
JOIN ids ON (ids.file_id = fot.file_id)
WHERE fot.deleted_at IS NULL
AND fot.media_id IS NOT NULL)
OR id::text IN (SELECT fd.metadata->>'storage-ref-id'
FROM file_data AS fd
JOIN ids ON (ids.file_id = fd.file_id)
WHERE fd.deleted_at IS NULL))")
FROM refs
WHERE so.id = refs.id
AND so.deleted_at IS NOT NULL")
(defn- restore-files
[conn file-ids]

View File

@ -14,6 +14,7 @@
[app.common.types.file :as ctf]
[app.config :as cf]
[app.db :as db]
[app.features.fdata :as fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.rpc :as-alias rpc]
@ -22,7 +23,6 @@
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[clojure.set :as set]))
@ -44,32 +44,31 @@
(assert (db/connection? conn) "expected a valid connection")
(binding [pmap/*tracked* (pmap/create-tracked)
cfeat/*current* features]
(binding [cfeat/*current* features]
(fdata/with-tracked
(let [file (ctf/make-file {:id id
:project-id project-id
:name name
:revn revn
:is-shared is-shared
:features features
:migrations fmg/available-migrations
:ignore-sync-until ignore-sync-until
:created-at modified-at
:deleted-at deleted-at}
{:create-page create-page
:page-id page-id})]
(let [file (ctf/make-file {:id id
:project-id project-id
:name name
:revn revn
:is-shared is-shared
:features features
:migrations fmg/available-migrations
:ignore-sync-until ignore-sync-until
:created-at modified-at
:deleted-at deleted-at}
{:create-page create-page
:page-id page-id})]
(bfc/insert-file! cfg file)
(bfc/insert-file! cfg file)
(->> (assoc params :file-id (:id file) :role :owner)
(create-file-role! conn))
(->> (assoc params :file-id (:id file) :role :owner)
(create-file-role! conn))
(db/update! conn :project
{:modified-at (ct/now)}
{:id project-id})
(db/update! conn :project
{:modified-at (ct/now)}
{:id project-id})
(bfc/get-file cfg (:id file)))))
(bfc/get-file cfg (:id file))))))
(def ^:private schema:create-file
[:map {:title "create-file" :closed true}

View File

@ -9,10 +9,12 @@
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.features :as-alias cfeat]
[app.common.files.migrations :as fmg]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.features.fdata :as fdata]
[app.features.file-snapshots :as fsnap]
[app.features.logical-deletion :as ldel]
[app.main :as-alias main]
@ -53,7 +55,7 @@
::sm/params schema:get-file-snapshot
::sm/result files/schema:file-with-permissions
::db/transaction true}
[cfg {:keys [::rpc/profile-id file-id id] :as params}]
[cfg {:keys [::rpc/profile-id file-id id features] :as params}]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id)]
(files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot cfg file-id id)]
@ -66,12 +68,41 @@
;; Load current file metadata only (no data decoding) then overlay
;; the snapshot data so the client receives the same shape as a
;; normal get-file response but with historical page/object content.
(let [base-file (bfc/get-file cfg file-id :load-data? false)]
;; Snapshots may hold pointer-mapped pages shared with the live
;; file, so materialize them here: the preview UI applies the
;; response straight into state and cannot resolve pointers.
;; NOTE: the loader binds the live file-id, not the snapshot id.
;;
;; A snapshot taken while a migration was pending stores the old
;; format. Apply the pending migrations in memory, the same pure
;; step a normal read runs, and never persist them: the stored
;; version keeps its data and restoring it re-migrates on the
;; next read. `decode-snapshot` hands an ordered set, which is
;; what `need-migration?`/`migrate` require; a stored NULL is
;; regenerated from the snapshot's version.
(let [base-file (bfc/get-file cfg file-id :load-data? false)
snapshot (assoc snapshot :id file-id)
file (if (fmg/need-migration? snapshot)
(let [file (fdata/realize cfg snapshot)]
(fmg/migrate-file
file
(delay (bfc/get-resolved-file-libraries cfg file))))
(update snapshot :data
#(fdata/realize-pointers cfg file-id %)))
;; Works like in `get-file`: a client that does not declare
;; `fdata/objects-map` gets plain objects. The historical
;; feature set is deliberately not checked against the
;; enabled one: a version may predate a feature change.
file (if (and (contains? (:features file) "fdata/objects-map")
(not (contains? features "fdata/objects-map")))
(fdata/realize-objects cfg file)
file)]
(-> base-file
(assoc :data (:data snapshot))
(assoc :version (:version snapshot))
(assoc :features (:features snapshot))
(assoc :revn (:revn snapshot))
(assoc :data (:data file))
(assoc :version (:version file))
(assoc :features (:features file))
(assoc :migrations (:migrations file))
(assoc :revn (:revn file))
(assoc :vern (rand-int 100000))
(assoc :permissions perms))))))
@ -84,10 +115,22 @@
{::doc/added "1.20"
::sm/params schema:create-file-snapshot
::climit/id [[:create-file-snapshot/by-profile ::rpc/profile-id]
[:create-file-snapshot/global]]}
[:create-file-snapshot/global]]
::db/transaction true}
[cfg {:keys [::rpc/profile-id file-id label]}]
(files/check-edition-permissions! cfg profile-id file-id)
(let [file (bfc/get-file cfg file-id :realize? true)
;; NOTE: the file is intentionally NOT realized here: the snapshot
;; shares fragment rows with the live file (same as automatic
;; snapshots) instead of storing a materialized copy. file-gc treats
;; every unpurged snapshot as a liveness root, and readers
;; (get-file-snapshot, restore!, reduce-snapshots) resolve pointers
;; on demand.
;; NOTE: the file is read with FOR SHARE inside this transaction so
;; snapshot creation serializes with a concurrent :file-gc pass
;; (which takes FOR UPDATE + SKIP LOCKED on the same row). Without
;; the lock, a version pinned mid-pass could reference fragment rows
;; the pass is recreating.
(let [file (bfc/get-file cfg file-id :lock-for-share? true)
project (db/get-by-id cfg :project (:project-id file))]
(-> cfg
@ -115,38 +158,58 @@
[:restore-file-snapshot/global]]}
[{:keys [::db/pool ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}]
;; Check permissions and read current file state (short-lived, outside restore transaction)
;; Check permissions
(files/check-edition-permissions! pool profile-id file-id)
(let [file (bfc/get-file cfg file-id)
team (teams/get-team pool
(let [team (teams/get-team pool
:profile-id profile-id
:file-id file-id)
delay (ldel/get-deletion-delay team)
file-revn (:revn file)]
;; Create backup snapshot of the current state (committed immediately
;; independently of the restore outcome)
(fsnap/create! cfg file
{:profile-id profile-id
:deleted-at (ct/in-future delay)
:created-by "system"})
;; Create backup snapshot of the current state (committed immediately
;; independent of the restore outcome). The read takes FOR SHARE so a
;; concurrent :file-gc pass (which holds FOR UPDATE + SKIP LOCKED on the
;; file row) either finishes first -- and we snapshot the new fragments --
;; or skips the file and retries later. Without the lock, the backup
;; could reference fragments the pass is about to mark as deleted,
;; breaking the backup permanently.
;;
;; The backup read also provides the revn the guard below compares
;; against. An edit committed before this read is captured in the
;; backup and no longer aborts the restore; only edits committed
;; after the backup conflict.
backup-revn
(db/tx-run! cfg
(fn [cfg]
(let [file (bfc/get-file cfg file-id :lock-for-share? true)]
(fsnap/create! cfg file
{:profile-id profile-id
:deleted-at (ct/in-future delay)
:created-by "system"})
(:revn file))))]
;; Restore snapshot inside its own transaction; the revn check
;; ensures no data is lost if the file was edited concurrently
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [current (bfc/get-minimal-file conn file-id {::db/for-update true})]
(when (not= (:revn current) file-revn)
(when (not= (:revn current) backup-revn)
(ex/raise :type :conflict
:code :file-modified
:hint "the file was modified during the restore process, please retry")))
(let [vern (fsnap/restore! cfg file-id id)]
(let [snapshot (fsnap/get-minimal-snapshot cfg id)
vern (fsnap/restore! cfg file-id id)]
;; Peers name the restored version by its label when a
;; user named it, else by its date
(mbus/pub! msgbus
:topic (:id file)
:message {:type :file-restored
:session-id session-id
:file-id (:id file)
:vern vern})
:topic file-id
:message (cond-> {:type :file-restored
:session-id session-id
:profile-id profile-id
:file-id file-id
:vern vern
:created-at (:created-at snapshot)}
(= "user" (:created-by snapshot))
(assoc :label (:label snapshot))))
nil)))))
(def ^:private schema:update-file-snapshot

View File

@ -23,6 +23,7 @@
[app.features.fdata :as fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media.svg :as svg]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
@ -166,7 +167,7 @@
objects)))]
(binding [pmap/*load-fn* (partial fdata/load-pointer cfg id)]
(fdata/with-pointers cfg id
(let [frame (get-thumbnail-frame data)
frame-id (:id frame)
page-id (or (:page-id frame)
@ -247,8 +248,7 @@
(defn- persist-thumbnail!
[storage media created-at]
(let [path (:path media)
mtype (:mtype media)
(let [{:keys [path mtype]} (svg/sanitize-svg-upload media)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))]
@ -302,8 +302,9 @@
::rpc/multipart-only-params [:media]}
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
(media.v/validate-media-type! media)
(media.v/validate-media-size! media)
(media.v/check-upload-path media)
(media.v/check-media-type media)
(media.v/check-media-size media)
(db/run! cfg files/check-edition-permissions! profile-id file-id)
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
@ -404,8 +405,9 @@
::rpc/multipart-only-params [:media]}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(media.v/validate-media-type! (:media params))
(media.v/validate-media-size! (:media params))
(media.v/check-upload-path (:media params))
(media.v/check-media-type (:media params))
(media.v/check-media-size (:media params))
(db/run! cfg files/check-edition-permissions! profile-id file-id)
@ -413,7 +415,7 @@
(let [storage (::sto/storage cfg)
file (bfc/get-file cfg file-id :include-deleted? true :load-data? false)
props (db/tjson (or (:props params) {}))
{:keys [path mtype]} (:media params)
{:keys [path mtype]} (svg/sanitize-svg-upload (:media params))
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))

View File

@ -12,6 +12,7 @@
[app.common.features :as cfeat]
[app.common.files.changes :as cpc]
[app.common.files.migrations :as fmg]
[app.common.files.repair :as cfr]
[app.common.files.validate :as val]
[app.common.logging :as l]
[app.common.schema :as sm]
@ -37,10 +38,12 @@
[app.util.blob :as blob]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[clojure.set :as set]))
[clojure.set :as set]
[cuerdas.core :as str]))
(declare ^:private get-lagged-changes)
(declare ^:private send-notifications!)
(declare ^:private send-repair-notifications)
(declare ^:private update-file)
(declare ^:private update-file*)
(declare ^:private process-changes-and-validate)
@ -67,19 +70,38 @@
[:changes-with-metadata {:optional true}
[:vector [:map
[:changes [:vector cpc/schema:change]]
[:commit-id {:optional true} ::sm/uuid]
[:hint-origin {:optional true} :keyword]
[:hint-events {:optional true} [:vector [:string {:max 250}]]]]]]
[:skip-validate {:optional true} ::sm/boolean]])
(def ^:private
schema:update-file-result
[:vector {:title "update-file-result"}
[:map
[:changes [:vector cpc/schema:change]]
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:revn {:min 0} ::sm/int]
[:session-id ::sm/uuid]]])
[:map {:title "update-file-result"}
;; The revision the file had before this update
[:revn {:min 0} ::sm/int]
;; Every change stored after the incoming revision, this one included
[:lagged
[:vector
[:map
[:changes [:vector cpc/schema:change]]
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:revn {:min 0} ::sm/int]
[:session-id ::sm/uuid]]]]
;; Commits not saved: the first one that failed and every later one
[:rejected {:optional true}
[:map
[:commit-ids [:vector ::sm/uuid]]
[:failed-commit-id ::sm/uuid]
;; The data the failed commit touches was invalid before it
[:preexisting-damage {:optional true} :boolean]
[:error
[:map
[:type :keyword]
[:code {:optional true} [:maybe :keyword]]
[:hint {:optional true} [:maybe :string]]
[:details {:optional true} [:vector :any]]]]]]])
;; --- HELPERS
@ -124,6 +146,27 @@
(contains? token-change-types type)
(contains? file-change-types type)))
;; Not a real editor session: file-change messages with this id are
;; delivered to every connected session, including the saver.
(def repair-session-id
#uuid "a11c0de5-0000-4000-8000-000000000001")
(defn- referential-integrity-error?
[cause]
(= :referential-integrity (:code (ex-data cause))))
(defn- record-repair-outcome
[cfg outcome]
(when-let [metrics (::mtx/metrics cfg)]
(when (mtx/metrics? metrics)
(mtx/run! metrics {:id :update-file-repair
:labels [(name outcome)]
:inc 1}))))
(defn- error-codes
[errors]
(into [] (keep :code) errors))
;; If features are specified from params and the final feature
;; set is different than the persisted one, update it on the
;; database.
@ -161,12 +204,18 @@
(->> changes-with-metadata (mapcat :changes) vec)
(vec changes))
;; Saved commit by commit only when every group names its commit
commits (when (and (seq changes-with-metadata)
(every? :commit-id changes-with-metadata))
changes-with-metadata)
params (-> params
(assoc :profile-id profile-id)
(assoc :features (set/difference features cfeat/frontend-only-features))
(assoc :team team)
(assoc :file file)
(assoc :changes changes))
(assoc :changes changes)
(assoc :commits commits))
cfg (assoc cfg ::timestamp (ct/now)
::validation-timings (atom {}))
@ -225,7 +274,9 @@
:soft-validate-affected-ms (get timings :soft-validate-affected-ms)
:soft-validate-schema-ms (get timings :soft-validate-schema-ms)
:validate-affected-ms (get timings :validate-affected-ms)
:validate-schema-ms (get timings :validate-schema-ms)))))
:validate-schema-ms (get timings :validate-schema-ms)
:repair-ms (get timings :repair-ms)
:reprocess-changes-ms (get timings :reprocess-changes-ms)))))
(catch Throwable cause
;; NOTE: `with-defer` hooks only run on the success path
;; (`handle-response`), so timings collected before a hard
@ -241,7 +292,9 @@
:soft-validate-affected-ms (get timings :soft-validate-affected-ms)
:soft-validate-schema-ms (get timings :soft-validate-schema-ms)
:validate-affected-ms (get timings :validate-affected-ms)
:validate-schema-ms (get timings :validate-schema-ms)))
:validate-schema-ms (get timings :validate-schema-ms)
:repair-ms (get timings :repair-ms)
:reprocess-changes-ms (get timings :reprocess-changes-ms)))
(throw cause))))))
(defn- update-file*
@ -253,10 +306,9 @@
Only intended for internal use on this module."
[{:keys [::db/conn ::timestamp] :as cfg}
{:keys [profile-id file team features changes session-id skip-validate] :as params}]
{:keys [profile-id file team features changes commits session-id skip-validate] :as params}]
(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial fdata/load-pointer cfg (:id file))]
(fdata/with-tracked-pointers cfg (:id file)
(let [file (assoc file :features
(-> features
@ -272,47 +324,100 @@
cfeat/*previous* (:features file)]
(update-file-data! cfg file
process-changes-and-validate
changes skip-validate))
changes commits skip-validate))
;; Only the accepted changes are stored and broadcast
changes (::changes file)
rejected (::rejected file)
params (assoc params :changes changes)
file (dissoc file ::changes ::rejected)
;; A request whose first commit is rejected leaves the file as is
saved? (or (nil? rejected) (seq changes))
snapshot (when saved? (::snapshot file))
deleted-at
(ct/plus timestamp (ct/duration {:hours 1}))]
(ct/plus timestamp (ct/duration {:hours 1}))
(when-let [file (::snapshot file)]
(let [deleted-at (ct/plus timestamp (ldel/get-deletion-delay team))
label (str "internal/snapshot/" revn)]
repair
(::repair file)
(fsnap/create! cfg file
{:label label
snapshot-deleted-at
(ct/plus timestamp (ldel/get-deletion-delay team))
change-created-at
(cond-> timestamp
repair
(ct/plus (ct/duration {:millis 1})))]
(if repair
(let [snapshot-file (or snapshot
(fdata/realize cfg (:original repair)))]
(fsnap/create! cfg snapshot-file
{:label (str "internal/repair/" revn)
:created-by "system"
:deleted-at deleted-at
:deleted-at snapshot-deleted-at
:profile-id profile-id
:session-id repair-session-id}))
(when snapshot
(fsnap/create! cfg snapshot
{:label (str "internal/snapshot/" revn)
:created-by "system"
:deleted-at snapshot-deleted-at
:profile-id profile-id
:session-id session-id})))
;; Insert change (xlog) with deleted_at in a future data for
;; make them automatically eleggible for GC once they expires
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at timestamp
:updated-at timestamp
:deleted-at deleted-at
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:features (into-array (:features file))
:changes (blob/encode changes)}
{::db/return-keys false})
(when saved?
(when repair
(db/insert! conn :file-change
{:id (uuid/next)
:session-id repair-session-id
:profile-id profile-id
:created-at timestamp
:updated-at timestamp
:deleted-at deleted-at
:file-id (:id file)
:revn (:revn repair)
:version (:version file)
:features (into-array (:features file))
:changes (blob/encode (:changes repair))}
{::db/return-keys false})
(l/wrn :hint "file auto-repaired"
:file-id (str (:id file))
:error-codes (error-codes (:errors repair))
:error-count (count (:errors repair))
:change-count (count (:changes repair)))
(record-repair-outcome cfg :repaired))
(persist-file! cfg file)
;; Insert change (xlog) with deleted_at in a future data for
;; make them automatically eleggible for GC once they expires
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at change-created-at
:updated-at change-created-at
:deleted-at deleted-at
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:features (into-array (:features file))
:changes (blob/encode changes)}
{::db/return-keys false})
(when (contains? cf/flags :redis-cache)
(invalidate-caches! cfg file))
(persist-file! cfg (dissoc file ::repair))
;; Send asynchronous notifications
(send-notifications! cfg params file)
(when (contains? cf/flags :redis-cache)
(invalidate-caches! cfg file))
(with-meta {:revn revn :lagged (get-lagged-changes conn params)}
;; Send asynchronous notifications
(when repair
(send-repair-notifications cfg params file repair))
(send-notifications! cfg params file))
(with-meta (cond-> {:revn revn :lagged (get-lagged-changes conn params)}
(some? rejected)
(assoc :rejected rejected))
{::audit/replace-props
{:id (:id file)
:name (:name file)
@ -324,7 +429,7 @@
;; `modified-at` mirrors `persist-file!`: same `::timestamp`
;; fallback, so both agree to the millisecond when the
;; request timestamp exists.
:revn (:revn file)
:revn (if saved? (:revn file) revn)
:modified-at (or timestamp (ct/now))}}))))
(defn get-file
@ -363,11 +468,13 @@
(rds/del conn key)))))
(defn- attach-snapshot
"Attach snapshot data to the file. This should be called before the
upcoming file operations are applied to the file."
[cfg migrated? file]
(let [snapshot (if migrated? file (fdata/realize cfg file))]
(assoc file ::snapshot snapshot)))
"Attach snapshot data to the file. Snapshots share fragment rows with
the live file, so the snapshot is kept as-is (with pointers); it is
NOT realized/materialized here. The file-gc liveness set unions main
and snapshots, so shared fragments survive while any of them needs
them."
[file]
(assoc file ::snapshot file))
(defn- update-file-data!
"Perform a file data transformation in with all update context setup.
@ -375,8 +482,9 @@
This function expected not-decoded file and transformation function. Returns
an encoded file.
This function is not responsible of saving the file. It only saves
fdata/pointer-map modified fragments."
This function is not responsible of saving the file. Fragment rows are
persisted later by `persist-file!` (via `bfc/update-file!` inside the
same transaction); nothing is written to `file_data` here."
[cfg {:keys [id] :as file} update-fn & args]
(let [file (update file :data (fn [data]
@ -391,6 +499,11 @@
take-snapshot?
(take-snapshot? file)
;; Attach the snapshot before migrating: a migration can rebuild a
;; page and give its PointerMap a new id with `modified?` set, and a
;; snapshot taken after that would keep the very pointer the save
;; then rewrites, ending up with post-save content.
;;
;; For avoid unnecesary overhead of creating multiple
;; pointers and handly internally with objects map in their
;; worst case (when probably all shapes and all pointers
@ -401,18 +514,18 @@
;; need-migration?
;; (->> (fdata/realize cfg))
need-migration?
(fmg/migrate-file libs)
take-snapshot?
(->> (attach-snapshot cfg need-migration?)))]
(->> (attach-snapshot))
need-migration?
(fmg/migrate-file libs))]
(apply update-fn cfg file args)))
(defn- soft-validate-file-schema!
[file]
[file changes]
(try
(val/validate-file-schema! file)
(val/validate-file-schema-affected! file changes)
(catch Throwable cause
(l/error :hint "file schema validation error" :cause cause))))
@ -434,23 +547,340 @@
(l/error :hint "file validation error"
:cause cause))))
(defmacro ^:private with-validation-timing
"Runs `body` and records its time as the validation step `step-key`
(`step-label` in the metric), also when it throws."
[cfg step-key step-label & body]
`(let [t# (ct/tpoint)]
(try
~@body
(finally
(record-validation-timing ~cfg ~step-key ~step-label (inst-ms (t#)))))))
(defn- validate-file
"Validates the entities `changes` affect and the schema of the data they
modify."
[cfg file libs changes skip-validate]
(binding [pmap/*tracked* nil]
(when (contains? cf/flags :soft-file-validation)
(with-validation-timing cfg :soft-validate-affected-ms "soft-validate-affected"
(soft-validate-file! file libs changes)))
(when (contains? cf/flags :soft-file-schema-validation)
(with-validation-timing cfg :soft-validate-schema-ms "soft-validate-schema"
(soft-validate-file-schema! file changes)))
(when (and (contains? cf/flags :file-validation)
(not skip-validate))
(with-validation-timing cfg :validate-affected-ms "validate-affected"
(val/validate-file-affected! file libs changes)))
(when (and (contains? cf/flags :file-schema-validation)
(not skip-validate))
(with-validation-timing cfg :validate-schema-ms "validate-schema"
(val/validate-file-schema-affected! file changes)))))
(def ^:private debug-fault-prefix
"Start of the plugin data key that asks for a debug save fault."
"save-fault:")
(defn- get-debug-fault
"Name of the fault a debug marker in `changes` asks for, or nil. A marker
is a `:set-plugin-data` of the file in the `:penpot-debug` namespace with
no value, so applying it changes nothing."
[changes]
(some (fn [{:keys [type namespace key]}]
(when (and (= :set-plugin-data type)
(= :penpot-debug namespace)
(str/starts-with? key debug-fault-prefix))
(subs key (count debug-fault-prefix))))
changes))
(defn- raise-debug-fault
"Throws the error named `fault`, standing in for a real failure while the
changes are applied."
[fault]
(let [message "debug save fault"]
(case fault
"validation" (ex/raise :type :validation :code :debug-fault :hint message)
"runtime" (throw (RuntimeException. message))
"ex-info" (throw (ex-info message {}))
"assertion" (throw (AssertionError. message))
"sql" (throw (org.postgresql.util.PSQLException.
message org.postgresql.util.PSQLState/CONNECTION_FAILURE))
"io" (throw (java.net.SocketTimeoutException. message))
"eof" (throw (java.io.EOFException. message))
"interrupted" (throw (InterruptedException. message))
"not-found" (ex/raise :type :not-found :code :object-not-found :hint message)
"stack-overflow" (throw (StackOverflowError. message))
(ex/raise :type :validation :code :unknown-debug-fault :hint fault))))
(defn- apply-changes
"Applies and validates `changes`. With the `debug-save-faults` flag, a
debug marker in them makes this fail with the error it names."
[cfg file libs changes skip-validate]
(when (contains? cf/flags :debug-save-faults)
(some-> (get-debug-fault changes) (raise-debug-fault)))
(let [file (with-validation-timing cfg :process-changes-ms "process-changes"
(update file :data (fn [data]
(-> data
(cpc/process-changes changes)
(d/without-nils)))))]
(validate-file cfg file libs changes skip-validate)
file))
(defn- decode-objects
"Decodes the objects of the pages `changes` touch. Every file derived from
`file` shares them, so each attempt does not decode them again."
[file changes]
(doseq [page-id (into #{} (keep :page-id) changes)]
(let [objects (get-in file [:data :pages-index page-id :objects])]
(doseq [id (keys objects)]
(get objects id)))))
(defn- infrastructure-error?
[cause]
(some (fn [cause]
(or (instance? java.sql.SQLException cause)
(instance? java.io.IOException cause)
(instance? InterruptedException cause)
;; A file fragment that cannot be loaded
(= :not-found (:type (ex-data cause)))))
(take-while some? (iterate ex-cause cause))))
(defn- change-failure?
"True when `cause` comes from applying or validating changes. Only these
reject a commit; any other error fails the whole request."
[cause]
(and (or (instance? Exception cause)
(instance? AssertionError cause))
(not (infrastructure-error? cause))))
(defn- preexisting-damage?
"True when `file` already fails validation on the pages and components
`changes` touch, before they are applied."
[file libs changes skip-validate]
(try
(binding [pmap/*tracked* nil]
(when (and (contains? cf/flags :file-validation)
(not skip-validate))
(val/validate-file-affected! file libs changes))
(when (and (contains? cf/flags :file-schema-validation)
(not skip-validate))
(val/validate-file-schema-affected! file changes)))
false
(catch Throwable cause
(if (change-failure? cause)
true
(throw cause)))))
(defn- rejection
"Describes `commits` as rejected, the first one failing with `cause`.
With `damaged`, the data that commit touches was invalid before it."
[commits cause damaged]
(let [data (ex-data cause)]
(cond-> {:commit-ids (mapv :commit-id commits)
:failed-commit-id (:commit-id (first commits))
;; An ex-info without a type reports like any unexpected exception
:error (if (:type data)
(cond-> {:type (:type data)
:code (:code data)
:hint (or (:hint data) (ex-message cause))}
(:details data)
(assoc :details (mapv #(dissoc % :shape) (:details data))))
{:type :internal
:code :unexpected-error
:hint (or (ex-message cause) (.getName (class cause)))})}
damaged
(assoc :preexisting-damage true))))
(defn- apply-prefix
"Applies and validates the first `n` commits of `attempt`, starting from
its `:file` and its `:tracked` pointers. Returns the resulting `:file`,
with the pointers to persist in `:tracked` and the media refs in `:refs`,
or the cause of the failure."
[{:keys [cfg file libs commits skip-validate state tracked]} n]
(reset! pmap/*tracked* tracked)
(reset! state {})
(let [changes (into [] (comp (take n) (mapcat :changes)) commits)]
(try
(let [file (apply-changes cfg file libs changes skip-validate)]
{:file (assoc file ::changes changes)
:tracked @pmap/*tracked*
:refs @state})
(catch Throwable cause
(when-not (change-failure? cause)
(throw cause))
cause))))
(defn- apply-commits
"Keeps the longest prefix of the `:commits` of `attempt` that applies and
validates, found by bisection, and rejects the rest. `cause` is the
failure of all of them together. Takes about log2 of the commits
attempts; a commit that repairs an earlier one can keep both. Leaves the
pointers and media refs of the kept prefix in place and returns its file,
with the accepted changes in `::changes` and the rejection in
`::rejected`, which tells whether the data the first rejected commit
touches was already invalid. The whole search is timed as the `retry`
step of `cfg`."
[cfg {:keys [file libs commits skip-validate state tracked] :as attempt} cause]
(let [tpoint (ct/tpoint)]
(with-validation-timing cfg :retry-ms "retry"
(loop [lo 0
hi (count commits)
cause cause
attempts 0
accepted nil]
(if (> (- hi lo) 1)
(let [mid (quot (+ lo hi) 2)
result (apply-prefix attempt mid)]
(if (instance? Throwable result)
(recur lo mid result (inc attempts) accepted)
(recur mid hi cause (inc attempts) result)))
(let [rejected (subvec commits lo)
base (:file accepted file)
damaged (preexisting-damage? base libs (:changes (first rejected))
skip-validate)]
(reset! pmap/*tracked* (:tracked accepted tracked))
(reset! state (:refs accepted {}))
(l/error :hint "update-file commit rejected"
:file-id (str (:id file))
:commit-id (str (:commit-id (first rejected)))
:rejected (count rejected)
:commits (count commits)
:attempts attempts
:preexisting-damage damaged
:elapsed (ct/format-duration (tpoint))
:cause cause)
(assoc (:file accepted (assoc file ::changes []))
::rejected (rejection rejected cause damaged))))))))
(defn- fail-repair
[cfg file errors cause]
(l/wrn :hint "file auto-repair did not recover the file"
:file-id (str (:id file))
:error-codes (error-codes errors)
:error-count (count errors))
(record-repair-outcome cfg :unrepaired)
(throw cause))
(defn- repair-preexisting-damage
"Repair damage that was already in `original` on the pages/components
the client's `changes` touch, then re-apply those changes. Starts from
the `tracked` pointers and leaves the media refs of the changes in
`cpc/*state*`. Throws `cause` when the client's changes are at fault
or the repair cannot fix the file."
[cfg original libs changes tracked cause]
(let [libs (or libs {})]
(try
(let [pre-errors
(binding [pmap/*tracked* nil]
(val/validate-file-affected original libs changes))]
(when (empty? pre-errors)
(throw cause))
(reset! pmap/*tracked* tracked)
(let [result
(with-validation-timing cfg :repair-ms "repair"
(cfr/repair-file-until-valid
original libs
(fn [file applied]
(binding [pmap/*tracked* nil]
(val/validate-file-affected file libs (into applied changes))))
:initial-errors pre-errors))
repair-changes (:changes result)]
(when (seq (:errors result))
(fail-repair cfg original (:errors result) cause))
(reset! cpc/*state* {})
(let [repaired (with-validation-timing cfg :reprocess-changes-ms "reprocess-changes"
(update (:file result) :data
(fn [data]
(-> data
(cpc/process-changes changes)
(d/without-nils)))))
scope (into repair-changes changes)]
(binding [pmap/*tracked* nil]
(val/validate-file-affected! repaired libs scope)
(when (contains? cf/flags :file-schema-validation)
(with-validation-timing cfg :validate-schema-ms "validate-schema"
(val/validate-file-schema! repaired))))
(-> repaired
(assoc :revn (+ (:revn original) 2))
(assoc ::changes changes)
(assoc ::repair {:changes repair-changes
:errors pre-errors
:revn (inc (:revn original))
:original (dissoc original ::snapshot)})))))
(catch Throwable inner
(if (identical? inner cause)
(throw cause)
(do
(l/wrn :hint "file auto-repair failed"
:file-id (str (:id original))
:cause inner)
(record-repair-outcome cfg :unrepaired)
(throw cause)))))))
(defn- repair-or-reject
"Handles the failure `cause` of applying all `changes` at once. Repairs
damage `original` already had when `cause` is a referential integrity
error. If that does not save every change and there are `commits`,
keeps the longest prefix of them that validates."
[cfg original file libs changes commits skip-validate tracked cause]
(let [repaired
(when (and (referential-integrity-error? cause)
(contains? cf/flags :file-validation)
(not skip-validate))
(try
(repair-preexisting-damage cfg original libs changes tracked cause)
(catch Throwable inner
(when-not (identical? inner cause)
(throw inner)))))]
(cond
(some? repaired)
repaired
(and (some? commits)
(change-failure? cause))
(apply-commits cfg
{;; The retries record no step timings
:cfg (dissoc cfg ::mtx/metrics ::validation-timings)
:file file
:libs libs
:commits (vec commits)
:skip-validate skip-validate
:state cpc/*state*
:tracked tracked}
cause)
:else
(throw cause))))
(defn- process-changes-and-validate
[cfg file changes skip-validate]
(let [should-resolve-libs?
"Applies and validates all `changes` at once. If that fails, repairs
damage the file already had or keeps the longest prefix of the
`commits` that validates."
[cfg file changes commits skip-validate]
(let [original file
should-resolve-libs?
(and (or (contains? cf/flags :file-validation)
(contains? cf/flags :soft-file-validation))
(not skip-validate))
tlibs
(ct/tpoint)
libs
(try
(when should-resolve-libs?
(bfc/get-resolved-file-libraries cfg file))
(finally
(when should-resolve-libs?
(record-validation-timing cfg :resolve-libs-ms "resolve-libs" (inst-ms (tlibs))))))
(when should-resolve-libs?
(with-validation-timing cfg :resolve-libs-ms "resolve-libs"
(bfc/get-resolved-file-libraries cfg file)))
;; The main purpose of this atom is provide a contextual state
;; for the changes subsystem where optionally some hints can
@ -460,58 +890,35 @@
state
(atom {})
tproc
(ct/tpoint)
;; True when validation reads every object of the touched pages
schema-check?
(or (contains? cf/flags :soft-file-schema-validation)
(and (contains? cf/flags :file-schema-validation)
(not skip-validate)))
file
(try
(let [file
(binding [cpc/*state* state]
(-> (files/check-version! file)
(update :revn inc)
(update :data cpc/process-changes changes)
(update :data d/without-nils)))
(-> (files/check-version! file)
(update :revn inc))
file
(if-let [media-refs (-> @state :media-refs not-empty)]
(bfc/update-media-references! cfg file media-refs)
file)]
file)
(finally
(record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc)))))]
tracked
@pmap/*tracked*
(binding [pmap/*tracked* nil]
(when (contains? cf/flags :soft-file-validation)
(let [t (ct/tpoint)]
file
(binding [cpc/*state* state]
;; Decoded on `file`, the objects the schema check reads are
;; shared by every retry
(when (and (next commits) schema-check?)
(decode-objects file changes))
(try
(soft-validate-file! file libs changes)
(finally
(record-validation-timing cfg :soft-validate-affected-ms "soft-validate-affected" (inst-ms (t)))))))
(-> (apply-changes cfg file libs changes skip-validate)
(assoc ::changes changes))
(catch Throwable cause
(repair-or-reject cfg original file libs changes commits
skip-validate tracked cause))))]
(when (contains? cf/flags :soft-file-schema-validation)
(let [t (ct/tpoint)]
(try
(soft-validate-file-schema! file)
(finally
(record-validation-timing cfg :soft-validate-schema-ms "soft-validate-schema" (inst-ms (t)))))))
(when (and (contains? cf/flags :file-validation)
(not skip-validate))
(let [t (ct/tpoint)]
(try
(val/validate-file-affected! file libs changes)
(finally
(record-validation-timing cfg :validate-affected-ms "validate-affected" (inst-ms (t)))))))
(when (and (contains? cf/flags :file-schema-validation)
(not skip-validate))
(let [t (ct/tpoint)]
(try
(val/validate-file-schema! file)
(finally
(record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t))))))))
file))
(if-let [media-refs (-> @state :media-refs not-empty)]
(bfc/update-media-references! cfg file media-refs)
file)))
(defn- take-snapshot?
"Defines the rule when file `data` snapshot should be saved."
@ -541,6 +948,34 @@
(mapv (fn [row]
(update row :changes blob/decode)))))
(defn- send-repair-notifications
[cfg {:keys [team session-id] :as params} file repair]
(let [changes (:changes repair)
lchanges (filter library-change? changes)
msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
:topic (:id file)
:message {:type :file-change
:profile-id (:profile-id params)
:file-id (:id file)
:session-id repair-session-id
:revn (:revn repair)
:vern (:vern file)
:changes changes
:repaired true})
(when (and (:is-shared file) (seq lchanges))
(mbus/pub! msgbus
:topic (:id team)
:message {:type :library-change
:profile-id (:profile-id params)
:file-id (:id file)
:session-id session-id
:revn (:revn repair)
:modified-at (ct/now)
:changes lchanges}))))
(defn- send-notifications!
[cfg {:keys [team changes session-id] :as params} file]
(let [lchanges (filter library-change? changes)

View File

@ -122,9 +122,9 @@
(let [data (reduce-kv
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg profile-id session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media.v/validate-media-type! cm/font-types)
(media.v/validate-font-size!))
(-> {:mtype mtype :size (:size assembled) :path (:path assembled)}
(media.v/check-media-type cm/font-types)
(media.v/check-font-size))
(assoc acc mtype (:path assembled))))
{}
uploads)]

Some files were not shown because too many files have changed in this diff Show More