Merge remote-tracking branch 'origin/staging'

This commit is contained in:
Andrey Antukh 2026-11-06 09:50:49 +01:00
commit 0333410482
171 changed files with 5619 additions and 1218 deletions

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:` and `:rocket:` label 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,28 @@ 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,
pre-flight checks, anomaly types, and pitfalls.
2. `mem:scripts/changelog` — `boom`, `check-merged`, `cross-ref`, `report`.
3. `mem:scripts/gh` — `issues`, `issue`, `comments`, `prs`.
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/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 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: never hand-edit them; edit the label or the `## Breaking change note` comment and re-run the command.
- Verify PR merge status before writing; only merged PRs are referenced.

View File

@ -85,8 +85,8 @@
- 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.

View File

@ -15,11 +15,12 @@ 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 gotcha, 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`).
@ -59,7 +60,7 @@ 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`.

View File

@ -1,12 +1,13 @@
# 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.
- `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`.
- `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.
- `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 +16,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

@ -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:

View File

@ -35,11 +35,35 @@ 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.
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 +261,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
@ -303,6 +330,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

View File

@ -0,0 +1,187 @@
# 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. It is never written from scratch by hand; the only manual parts are the security advisory entries and the wording of a description when the title is not enough.
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 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.
- **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))
### :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:`, `: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.
- 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.
- **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 7). 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. 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 8).
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. **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. GHSA entries never appear; that absence is expected.
10. **Generate the anomaly report**: `python3 scripts/changelog.py report "<VERSION>"`. It overwrites `CHANGES-ISSUES.md` with the current state (section 9).
11. **Security advisories (GHSA)**: add them by hand under `:bug:` as `- Fix <user-facing description> (<advisory URL>)`, with no issue or PR link. Do not fetch or verify the URL: it may be draft and return 404. Write the description in imperative mood from the advisory title. These entries are invisible to the automation; apply only the duplicate/backport check to them.
## 8. 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.
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 9); 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.
## 9. 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.
## 10. 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:` and `:rocket:` are the only label-generated parts. Never patch them by hand; edit the label or the note comment and re-run.
## 11. Where the code is
- `scripts/gh.py`: `issues`, `issue`, `comments`, `prs`, `link-issue`, `advisories`. Details: `mem:scripts/gh`.
- `scripts/changelog.py`: `check-merged`, `boom`, `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

@ -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.

View File

@ -1,6 +1,22 @@
# CHANGELOG
## 2.19.0 (Unreleased)
## 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 +90,37 @@
- 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)
### :sparkles: New features & Enhancements
@ -89,7 +136,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 +160,30 @@
- 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.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

@ -472,6 +472,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 +492,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)

View File

@ -989,20 +989,24 @@
(defn- import-file-relations
[{:keys [::db/conn ::manifest ::bfc/timestamp] :as cfg}]
(events/tap :progress {:section :relations})
(doseq [[file-id libr-id] (:relations manifest)]
(let [file-id (bfc/lookup-index file-id)
libr-id (bfc/lookup-index libr-id)]
(when (and file-id libr-id)
(l/dbg :hint "create file library link"
:file-id (str file-id)
:lib-id (str libr-id)
::l/sync? true)
(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)))))))
(let [file-ids (into #{} (map :id) (:files manifest))]
(doseq [[file-id libr-id] (:relations manifest)]
(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)
::l/sync? true)
(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))))
(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}]

View File

@ -59,8 +59,8 @@
:hint "seems like the url points to resource with unknown size"))
(-> {:size size :mtype mtype}
(validation/validate-media-type!)
(validation/validate-media-size!))))]
(validation/check-media-type)
(validation/check-media-size))))]
(let [{:keys [body] :as response}
(try

View File

@ -32,8 +32,26 @@
(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))
(defn check-media-type
([upload] (check-media-type upload cm/image-types))
([upload allowed]
(when-not (contains? allowed (:mtype upload))
(ex/raise :type :validation
@ -42,7 +60,7 @@
upload))
(defn validate-media-size!
(defn check-media-size
[upload]
(let [max-size (cf/get :media-max-file-size)]
(when (> (:size upload) max-size)
@ -53,10 +71,10 @@
max-size)))
upload))
(defn validate-font-size!
(defn check-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."
`check-media-size` — requires a `:size` key in bytes."
[upload]
(let [max-size (cf/get :font-max-file-size)]
(when (> (:size upload) max-size)

View File

@ -526,7 +526,13 @@
:fn (mg/resource "app/migrations/sql/0155-add-idx-to-file-change.sql")}
{:name "0156-reset-pg-storage-defaults"
:fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")}])
:fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.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

@ -210,6 +210,8 @@
(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

View File

@ -1347,16 +1347,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

View File

@ -290,8 +290,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-media-type media)
(media.v/check-media-size media)
(media.v/check-upload-path 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})]
@ -392,8 +393,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-media-type (:media params))
(media.v/check-media-size (:media params))
(media.v/check-upload-path (:media params))
(db/run! cfg files/check-edition-permissions! profile-id file-id)

View File

@ -123,8 +123,8 @@
(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!))
(media.v/check-media-type cm/font-types)
(media.v/check-font-size))
(assoc acc mtype (:path assembled))))
{}
uploads)]

View File

@ -65,8 +65,9 @@
[:process-image/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
(files/check-edition-permissions! pool profile-id file-id)
(media.v/validate-media-type! content)
(media.v/validate-media-size! content)
(media.v/check-media-type content)
(media.v/check-media-size content)
(media.v/check-upload-path content)
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
;; TEMPORARILY DISABLED (2026-10-19): media-storage-bytes-per-team
@ -376,6 +377,7 @@
::sm/result schema:upload-chunk-result}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id index content] :as _params}]
(media.v/check-upload-path content)
(let [session (db/tx-run! cfg check-upload-chunk-slot session-id profile-id index content)]
(l/trc :hint "upload-chunk"
:session-id session-id
@ -576,8 +578,8 @@
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)
(media.v/validate-media-type!)
(media.v/validate-media-size!))
(media.v/check-media-type)
(media.v/check-media-size))
mobj (create-file-media-object cfg (assoc params
:from-chunks? true
:content content))]

View File

@ -313,8 +313,9 @@
::sm/result :nil}
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media.v/validate-media-size! file)
(media.v/check-media-type file #{"image/jpeg" "image/png" "image/webp"})
(media.v/check-media-size file)
(media.v/check-upload-path file)
(update-profile-photo cfg (assoc params :profile-id profile-id)))
(defn update-profile-photo

View File

@ -1092,8 +1092,9 @@
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media.v/validate-media-size! file)
(media.v/check-media-type file #{"image/jpeg" "image/png" "image/webp"})
(media.v/check-media-size file)
(media.v/check-upload-path file)
(update-team-photo cfg (assoc params :profile-id profile-id)))
(defn update-team-photo

View File

@ -34,7 +34,8 @@
::rpc/multipart-only-params [:content]
::sm/result schema:upload-tempfile-result}
[cfg {:keys [::rpc/profile-id content]}]
(media.v/validate-media-type! content cm/tempfile-types)
(media.v/check-media-type content cm/tempfile-types)
(media.v/check-upload-path content)
(let [storage (sto/resolve cfg)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))

View File

@ -139,7 +139,8 @@
::sm/result schema:upload-organization-logo-result
::nitrate/sso false}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(media.v/validate-media-type! content cm/image-types)
(media.v/check-media-type content cm/image-types)
(media.v/check-upload-path content)
(when previous-id
(sto/touch-object! storage previous-id))
(let [hash (sto/calculate-hash (:path content))

View File

@ -1,141 +0,0 @@
;; 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.srepl.procs.fdata-storage
(:require
[app.common.logging :as l]
[app.db :as db]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SNAPSHOTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def sql:get-unmigrated-snapshots
"SELECT fc.id, fc.file_id
FROM file_change AS fc
WHERE fc.data IS NOT NULL
AND fc.label IS NOT NULL
ORDER BY fc.id ASC")
(def sql:get-migrated-snapshots
"SELECT f.id, f.file_id
FROM file_data AS f
WHERE f.data IS NOT NULL
AND f.type = 'snapshot'
AND f.id != f.file_id
ORDER BY f.id ASC")
(defn migrate-snapshot-to-storage
"Migrate the current existing files to store data in new storage
tables."
{:query sql:get-unmigrated-snapshots}
[{:keys [::db/conn]} {:keys [id file-id]} & {:as options}]
(let [{:keys [id file-id data created-at updated-at]}
(db/get* conn :file-change {:id id :file-id file-id}
::db/for-update true
::db/remove-deleted false)]
(when data
(l/inf :hint "migrating snapshot" :file-id (str file-id) :id (str id))
(db/update! conn :file-change
{:data nil}
{:id id :file-id file-id}
{::db/return-keys false})
(db/insert! conn :file-data
{:backend "db"
:metadata nil
:type "snapshot"
:data data
:created-at created-at
:modified-at updated-at
:file-id file-id
:id id}
{::db/return-keys false}))))
(defn rollback-snapshot-from-storage
"Migrate back to the file table storage."
{:query sql:get-unmigrated-snapshots}
[{:keys [::db/conn]} {:keys [id file-id]} & {:as opts}]
(when-let [{:keys [id file-id data]}
(db/get* conn :file-data {:id id :file-id file-id :type "snapshot"}
::db/for-update true
::db/remove-deleted false)]
(l/inf :hint "rollback snapshot" :file-id (str file-id) :id (str id))
(db/update! conn :file-change
{:data data}
{:id id :file-id file-id}
{::db/return-keys false})
(db/delete! conn :file-data
{:id id :file-id file-id :type "snapshot"}
{::db/return-keys false})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FILES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def sql:get-unmigrated-files
"SELECT f.id
FROM file AS f
WHERE f.data IS NOT NULL
ORDER BY f.modified_at ASC")
(def sql:get-migrated-files
"SELECT f.id, f.file_id
FROM file_data AS f
WHERE f.data IS NOT NULL
AND f.id = f.file_id
ORDER BY f.id ASC")
(defn migrate-file-to-storage
"Migrate the current existing files to store data in new storage
tables."
{:query sql:get-unmigrated-files}
[{:keys [::db/conn] :as cfg} {:keys [id]} & {:as opts}]
(let [{:keys [id data created-at modified-at]}
(db/get* conn :file {:id id}
::db/for-update true
::db/remove-deleted false)]
(when data
(l/inf :hint "migrating file" :file-id (str id))
(db/update! conn :file {:data nil} {:id id} ::db/return-keys false)
(db/insert! conn :file-data
{:backend "db"
:metadata nil
:type "main"
:data data
:created-at created-at
:modified-at modified-at
:file-id id
:id id}
{::db/return-keys false}))
(let [snapshots-sql
(str "WITH snapshots AS (" sql:get-unmigrated-snapshots ") "
"SELECT s.* FROM snapshots AS s WHERE s.file_id = ?")]
(run! (fn [params]
(migrate-snapshot-to-storage cfg params opts))
(db/plan cfg [snapshots-sql id])))))
(defn rollback-file-from-storage
"Migrate back to the file table storage."
{:query sql:get-migrated-files}
[{:keys [::db/conn] :as cfg} {:keys [id]} & {:as opts}]
(when-let [{:keys [id data]}
(db/get* conn :file-data {:id id :file-id id :type "main"}
::db/for-update true
::db/remove-deleted false)]
(l/inf :hint "rollback file" :file-id (str id))
(db/update! conn :file {:data data} {:id id} ::db/return-keys false)
(db/delete! conn :file-data {:file-id id :id id :type "main"} ::db/return-keys false)
(let [snapshots-sql
(str "WITH snapshots AS (" sql:get-migrated-snapshots ") "
"SELECT s.* FROM snapshots AS s WHERE s.file_id = ?")]
(run! (fn [params]
(rollback-snapshot-from-storage cfg params opts))
(db/plan cfg [snapshots-sql id])))))

View File

@ -146,7 +146,7 @@
(defn- write-temp-svg
"Write `text` to a tempfile and return `[path size]`."
[text]
(let [path (fs/create-tempfile :prefix "penpot-svg-import-test-" :suffix ".svg")
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-svg-import-test-" :suffix ".svg")
data (utf8bytes text)]
(spit (str path) text :encoding "UTF-8")
[path (alength data)]))

View File

@ -30,6 +30,7 @@
[app.storage.tmp :as tmp]
[backend-tests.helpers :as th]
[backend-tests.storage-test :as stt]
[clojure.data.json :as json]
[clojure.java.io :as jio]
[clojure.test :as t]
[cuerdas.core :as str]
@ -37,6 +38,9 @@
[datoteka.io :as io])
(:import
java.io.ByteArrayInputStream
java.io.ByteArrayOutputStream
java.io.DataInputStream
java.io.DataOutputStream
java.io.OutputStreamWriter
java.io.Writer
java.util.zip.Deflater
@ -216,6 +220,63 @@
:token-radius token-radius
:token-opacity token-opacity}))
(defn- export-file
[file & {:keys [include-libraries]}]
(let [output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(cond-> (-> th/*system*
(assoc ::bfc/ids #{(:id file)}))
include-libraries
(assoc ::bfc/export-type :include-libraries))
(io/output-stream output))
output))
(defn- import-bundle
[profile path]
(-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/team-id (:default-team-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input path)
(v3/import-files!)
(:file-ids)))
(defn- read-manifest
[path]
(with-open [zip (ZipFile. (.toFile ^java.nio.file.Path path))]
(let [entry (.getEntry zip "manifest.json")]
(with-open [input (.getInputStream zip entry)]
(json/read-str (slurp input :encoding "UTF-8"))))))
(defn- tamper-manifest-relations
[src dst relations]
(with-open [zin (ZipFile. (.toFile ^java.nio.file.Path src))
zout (ZipOutputStream. (io/output-stream dst))]
(doseq [entry (enumeration-seq (.entries zin))]
(let [name (.getName ^ZipEntry entry)]
(if (= name "manifest.json")
(let [manifest (-> (slurp (.getInputStream zin entry) :encoding "UTF-8")
(json/read-str)
(assoc "relations" relations))]
(.putNextEntry zout (ZipEntry. name))
(.write zout (.getBytes (json/write-str manifest) "UTF-8"))
(.closeEntry zout))
(do
(.putNextEntry zout (ZipEntry. name))
(with-open [input (.getInputStream zin entry)]
(io/copy input zout))
(.closeEntry zout)))))))
(defn- prepare-victim-file
[n]
(let [profile (th/create-profile* n)
team (th/create-team* n {:profile-id (:id profile)})
project (th/create-project* n {:team-id (:id team)
:profile-id (:id profile)})
file (th/create-file* n {:profile-id (:id profile)
:project-id (:id project)})]
[profile file]))
(def ^:private svg-raw-page-id (uuid/custom 1 1))
(def ^:private svg-raw-root-id (uuid/custom 3 1))
(def ^:private svg-raw-child-id (uuid/custom 3 2))
@ -2898,3 +2959,181 @@
(t/is (= (str (first result) "/" thumb-page-id "/" thumb-frame-id "/" thumb-tag)
(:object-id thumb)))
(t/is (some? (:media-id thumb)))))))
;; --- Library relations security tests
(t/deftest import-v3-ignores-relations-outside-package
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
[_ victim] (prepare-victim-file 3)
bundle (export-file file)
evil (tmp/tempfile :suffix ".zip")
_ (tamper-manifest-relations bundle evil
[[(str (:id file)) (str (:id victim))]])
[file-id :as result] (import-bundle profile evil)]
(t/is (= 1 (count result)))
(t/is (empty? (th/db-query :file-library-rel {:file-id file-id})))
(t/is (empty? (th/db-query :file-library-sync {:file-id file-id})))))
(defn- prepare-other-team
[n]
(let [profile (th/create-profile* n)
team (th/create-team* n {:profile-id (:id profile)})
project (th/create-project* n {:team-id (:id team)
:profile-id (:id profile)})
file (th/create-file* n {:profile-id (:id profile)
:project-id (:id project)})
library (th/create-file* (inc n) {:profile-id (:id profile)
:project-id (:id project)})]
[profile file library]))
(t/deftest import-v3-ignores-foreign-file-id
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
[_ victim] (prepare-victim-file 3)
bundle (export-file file)
evil (tmp/tempfile :suffix ".zip")
_ (tamper-manifest-relations bundle evil
[[(str (:id victim)) (str (:id file))]])
result (import-bundle profile evil)]
(t/is (= 1 (count result)))
(t/is (empty? (th/db-query :file-library-rel {:file-id (:id victim)})))
(t/is (empty? (th/db-query :file-library-sync {:file-id (:id victim)})))))
(t/deftest import-v3-ignores-unknown-library
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
bundle (export-file file)
evil (tmp/tempfile :suffix ".zip")
_ (tamper-manifest-relations bundle evil
[[(str (:id file)) (str (uuid/random))]])
[file-id :as result] (import-bundle profile evil)]
(t/is (= 1 (count result)))
(t/is (empty? (th/db-query :file-library-rel {:file-id file-id})))))
(t/deftest import-v3-ignores-self-library-relation
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
bundle (export-file file)
evil (tmp/tempfile :suffix ".zip")
_ (tamper-manifest-relations bundle evil
[[(str (:id file)) (str (:id file))]])
[file-id :as result] (import-bundle profile evil)]
(t/is (= 1 (count result)))
(t/is (empty? (th/db-query :file-library-rel {:file-id file-id})))))
(t/deftest import-v3-keeps-valid-library-relation
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
library (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)})]
(th/link-file-to-library* {:file-id (:id file)
:library-id (:id library)})
(let [bundle (export-file file :include-libraries true)
result (import-bundle profile bundle)
new-file (->> result
(map #(bfc/get-file th/*system* %))
(some #(when (= (:name %) (:name file)) %)))]
(t/is (= 2 (count result)))
(t/is (some? new-file))
(t/is (= 1 (count (th/db-query :file-library-rel
{:file-id (:id new-file)})))))))
(t/deftest export-multi-file-includes-package-relations
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
library (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
[_ other-file other-library] (prepare-other-team 3)
_ (th/link-file-to-library* {:file-id (:id file)
:library-id (:id library)})
_ (th/link-file-to-library* {:file-id (:id other-file)
:library-id (:id other-library)})
_ (th/db-insert! :file-library-rel
{:file-id (:id file)
:library-file-id (:id other-library)})
output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file) (:id other-file)})
(assoc ::bfc/export-type :include-libraries))
(io/output-stream output))
(let [rels (set (map vec (get (read-manifest output) "relations")))]
(t/is (contains? rels [(str (:id file)) (str (:id library))]))
(t/is (contains? rels [(str (:id other-file)) (str (:id other-library))]))
(t/is (contains? rels [(str (:id file)) (str (:id other-library))])))))
;; --- Forbidden library relations cleanup (migration candidate)
;;
;; The two statements below are the exact cleanup proposed for a future
;; migration (see tmp/CHECK-LIBRARY-RELATIONS.sql, section 6). The sync
;; rows must go first: the second statement reads file_library_rel
;; while it still contains the forbidden rows.
(def ^:private sql:delete-forbidden-library-sync
"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")
(def ^:private sql:delete-forbidden-library-rel
"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")
(t/deftest cleanup-deletes-forbidden-library-relations
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
library (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
[_ victim] (prepare-victim-file 3)
_ (th/link-file-to-library* {:file-id (:id file)
:library-id (:id library)})
_ (th/db-insert! :file-library-rel
{:file-id (:id file)
:library-file-id (:id victim)})
_ (th/db-insert! :file-library-sync
{:file-id (:id file)
:library-file-id (:id victim)})
_ (th/db-insert! :file-library-rel
{:file-id (:id file)
:library-file-id (:id file)})
_ (th/db-insert! :file-library-sync
{:file-id (:id file)
:library-file-id (:id file)})]
(t/is (= 3 (count (th/db-query :file-library-rel {:file-id (:id file)}))))
(th/db-exec! [sql:delete-forbidden-library-sync])
(th/db-exec! [sql:delete-forbidden-library-rel])
(t/testing "same-team relation and its sync row survive"
(t/is (= [{:file-id (:id file) :library-file-id (:id library)}]
(map #(select-keys % [:file-id :library-file-id])
(th/db-query :file-library-rel {:file-id (:id file)}))))
(t/is (= [{:file-id (:id file) :library-file-id (:id library)}]
(map #(select-keys % [:file-id :library-file-id])
(th/db-query :file-library-sync {:file-id (:id file)})))))
(t/testing "cross-team and self relations and their sync rows are gone"
(t/is (empty? (th/db-query :file-library-rel {:library-file-id (:id victim)})))
(t/is (empty? (th/db-query :file-library-sync {:library-file-id (:id victim)})))
(t/is (empty? (th/db-query :file-library-rel {:library-file-id (:id file)})))
(t/is (empty? (th/db-query :file-library-sync {:library-file-id (:id file)}))))))

View File

@ -118,8 +118,8 @@
(t/testing "the snapshot comes back"
(t/is (nil? (:deleted-at (find-change snapshot-id)))))
(t/testing "the legacy payload row comes back"
(t/is (nil? (:deleted-at (find-change legacy-id))))))))
(t/testing "the legacy payload row keeps its date"
(t/is (= trash-at (:deleted-at (find-change legacy-id))))))))
(t/deftest deleting-a-file-does-not-push-the-revision-schedule-out
(binding [ct/*clock* (ct/fixed-clock test-now)]

View File

@ -86,7 +86,8 @@
:enable-quotes
:enable-rpc-climit
:enable-auto-file-snapshot
:disable-file-validation])
:disable-file-validation
:disable-admin-console])
(defn init-config
([next]

View File

@ -0,0 +1,68 @@
;; 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 INC Sucursal en España SL
(ns backend-tests.media-validation-test
(:require
[app.media.validation :as media.v]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
(t/deftest check-media-type
(t/testing "allowed mtype passes through"
(let [upload {:filename "a.png" :size 1 :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}]
(t/is (= upload (media.v/check-media-type upload)))))
(t/testing "disallowed mtype is rejected"
(let [upload {:filename "a.txt" :size 1 :path (fs/path "/tmp/penpot/a.txt") :mtype "text/plain"}
error (try (media.v/check-media-type upload) nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :media-type-not-allowed)))))
(t/deftest check-media-size
(t/testing "small file passes through"
(let [upload {:filename "a.png" :size 1 :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}]
(t/is (= upload (media.v/check-media-size upload)))))
(t/testing "oversized file is rejected"
(let [upload {:filename "a.png" :size (* 1024 1024 31) :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}
error (try (media.v/check-media-size upload) nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? error))
(t/is (th/ex-of-type? error :restriction))
(t/is (th/ex-of-code? error :media-max-file-size-reached)))))
(t/deftest check-font-size
(t/testing "small font passes through"
(let [upload {:filename "a.ttf" :size 1 :path (fs/path "/tmp/penpot/a.ttf") :mtype "font/ttf"}]
(t/is (= upload (media.v/check-font-size upload)))))
(t/testing "oversized font is rejected"
(let [upload {:filename "a.ttf" :size (* 1024 1024 31) :path (fs/path "/tmp/penpot/a.ttf") :mtype "font/ttf"}
error (try (media.v/check-font-size upload) nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? error))
(t/is (th/ex-of-type? error :restriction))
(t/is (th/ex-of-code? error :font-max-file-size-reached)))))
(t/deftest check-upload-path
(t/testing "tempfile under /tmp/penpot passes through"
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
upload {:filename "sample.jpg" :size 7923 :path path :mtype "image/jpeg"}]
(t/is (= upload (media.v/check-upload-path upload)))))
(t/testing "system file outside temp is rejected"
(let [upload {:filename "x.png" :size 1 :path (fs/path "/etc/passwd") :mtype "image/png"}
error (try (media.v/check-upload-path upload) nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation))))
(t/testing "missing file is rejected"
(let [upload {:filename "x.png" :size 1 :path (fs/path "/tmp/penpot/no-such-file-12345.png") :mtype "image/png"}
error (try (media.v/check-upload-path upload) nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation)))))

View File

@ -0,0 +1,180 @@
;; 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 backend-tests.migrations-legacy-file-data-test
"Covers the 0157 data migration: legacy file/file_change payloads move
to file_data, keeping timestamps and deleted_at, nulling the legacy
column; unlabelled file_change payloads (orphan revisions) are
deleted."
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.util.blob :as blob]
[backend-tests.helpers :as th]
[clojure.java.io :as io]
[clojure.test :as t]
[next.jdbc :as jdbc]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defn- run-migration
[]
(let [sql (slurp (io/resource "app/migrations/sql/0157-migrate-legacy-file-data.sql"))]
(jdbc/execute! th/*pool* [sql])))
(defn- get-file-data
[file-id id]
(th/db-get :file-data {:file-id file-id :id id} {::db/remove-deleted false}))
(t/deftest migrates-legacy-file-and-snapshot
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
fid (:id file)
;; Simulate an unmigrated main: move the payload back to
;; file.data and drop the file_data row.
main-data (:data (get-file-data fid fid))
_ (t/is (some? main-data) "expected file_data main row from create-file*")
file-row (th/db-get :file {:id fid} {::db/remove-deleted false})
_ (th/db-delete! :file-data {:file-id fid :id fid})
_ (th/db-update! :file {:data main-data} {:id fid})
;; Simulate an unmigrated snapshot (labelled) with its own
;; timestamps and a deletion date.
snap-id (uuid/next)
snap-data (blob/encode {:snapshot 1})
created-at (ct/in-past "2h")
updated-at (ct/in-past "1h")
deleted-at (ct/in-past "30m")
_ (th/db-insert! :file-change
{:id snap-id
:file-id fid
:revn 1
:data snap-data
:label "v1"
:created-at created-at
:updated-at updated-at
:deleted-at deleted-at})
;; Orphan revision (data, no label): deleted by the migration.
rev-id (uuid/next)
rev-data (blob/encode {:rev 1})
_ (th/db-insert! :file-change
{:id rev-id
:file-id fid
:revn 2
:data rev-data})
;; Live revision (changes, no data, no label): untouched.
live-id (uuid/next)
_ (th/db-insert! :file-change
{:id live-id
:file-id fid
:revn 3
:changes (blob/encode [{:type :dummy}])})]
(run-migration)
(t/testing "main payload moves to file_data as type main"
(let [fd (get-file-data fid fid)]
(t/is (some? fd))
(t/is (= "main" (:type fd)))
(t/is (= "db" (:backend fd)))
(t/is (nil? (:metadata fd)))
(t/is (= (seq ^bytes main-data) (seq ^bytes (:data fd))))
(t/is (= (:created-at file-row) (:created-at fd)))
(t/is (= (:modified-at file-row) (:modified-at fd)))
(t/is (= (:deleted-at file-row) (:deleted-at fd)))))
(t/testing "snapshot payload moves to file_data as type snapshot"
(let [fd (get-file-data fid snap-id)]
(t/is (some? fd))
(t/is (= "snapshot" (:type fd)))
(t/is (= "db" (:backend fd)))
(t/is (nil? (:metadata fd)))
(t/is (= (seq ^bytes snap-data) (seq ^bytes (:data fd))))
;; NOTE: timestamptz stores microsecond precision while the
;; test instants carry nanoseconds, so compare at millis.
(t/is (= (inst-ms created-at) (inst-ms (:created-at fd))))
(t/is (= (inst-ms updated-at) (inst-ms (:modified-at fd))))
(t/is (= (inst-ms deleted-at) (inst-ms (:deleted-at fd))))))
(t/testing "legacy columns are nulled"
(t/is (nil? (:data (th/db-get :file {:id fid} {::db/remove-deleted false}))))
(t/is (nil? (:data (th/db-get :file-change {:id snap-id} {::db/remove-deleted false})))))
(t/testing "unlabelled payload row is deleted, live revision stays"
(t/is (nil? (th/db-get :file-change {:id rev-id} {::db/remove-deleted false})))
(let [row (th/db-get :file-change {:id live-id} {::db/remove-deleted false})]
(t/is (some? row))
(t/is (some? (:changes row)))))
(t/testing "second run is a no-op"
(run-migration)
(t/is (some? (get-file-data fid fid)))
(t/is (some? (get-file-data fid snap-id)))
(t/is (nil? (:data (th/db-get :file {:id fid} {::db/remove-deleted false}))))
(t/is (nil? (:data (th/db-get :file-change {:id snap-id} {::db/remove-deleted false}))))
(t/is (nil? (th/db-get :file-change {:id rev-id} {::db/remove-deleted false}))))))
(t/deftest keeps-preexisting-file-data-row
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
fid (:id file)
;; file_data already holds the current payload; the legacy
;; column keeps a stale copy that must not overwrite it.
current (:data (get-file-data fid fid))
stale (blob/encode {:stale true})
_ (t/is (some? current))
_ (t/is (not= (seq ^bytes current) (seq ^bytes stale)))
_ (th/db-update! :file {:data stale} {:id fid})]
(run-migration)
(t/testing "file_data keeps its data and legacy is nulled"
(let [fd (get-file-data fid fid)]
(t/is (= (seq ^bytes current) (seq ^bytes (:data fd)))))
(t/is (nil? (:data (th/db-get :file {:id fid} {::db/remove-deleted false})))))))
(t/deftest keeps-preexisting-snapshot-row
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
fid (:id file)
;; file_data already holds the snapshot payload; the legacy column
;; keeps a stale copy that must not overwrite it.
snap-id (uuid/next)
current (blob/encode {:snapshot :current})
stale (blob/encode {:snapshot :stale})
_ (t/is (not= (seq ^bytes current) (seq ^bytes stale)))
_ (th/db-insert! :file-data
{:file-id fid
:id snap-id
:type "snapshot"
:backend "db"
:metadata nil
:data current})
_ (th/db-insert! :file-change
{:id snap-id
:file-id fid
:revn 1
:label "v1"
:data stale})]
(run-migration)
(t/testing "file_data keeps its data and legacy is nulled"
(let [fd (get-file-data fid snap-id)]
(t/is (= (seq ^bytes current) (seq ^bytes (:data fd)))))
(t/is (nil? (:data (th/db-get :file-change {:id snap-id}
{::db/remove-deleted false})))))))

View File

@ -122,7 +122,7 @@
(.close ^AutoCloseable conn)))))
(t/deftest login-with-password-locks-after-max-attempts
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])]
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :disable-admin-console])]
(let [profile (th/create-profile* 200 {:is-active true})]
(cleanup-redis (:id profile))
;; 4 failed attempts — not yet locked
@ -141,7 +141,7 @@
(t/is (th/ex-of-code? (:error out) :account-locked))))))
(t/deftest login-with-password-returns-rate-limit-when-locked
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])]
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :disable-admin-console])]
(let [profile (th/create-profile* 201 {:is-active true})]
(cleanup-redis (:id profile))
;; Trigger lockout
@ -161,7 +161,7 @@
(t/is (pos? ttl) "ttl should be a positive number"))))))
(t/deftest successful-login-clears-failed-attempts
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])]
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :disable-admin-console])]
(let [profile (th/create-profile* 202 {:is-active true})]
(cleanup-redis (:id profile))
;; 3 failed attempts
@ -184,7 +184,7 @@
(t/is (th/ex-of-code? (:error out) :wrong-credentials)))))))
(t/deftest lockout-does-not-apply-when-flag-disabled
(with-redefs [app.config/flags (flags/parse flags/default [])]
(with-redefs [app.config/flags (flags/parse flags/default [:disable-admin-console])]
(let [profile (th/create-profile* 203 {:is-active true})]
(cleanup-redis (:id profile))
;; 10 failed attempts — no lockout without the flag
@ -199,7 +199,7 @@
"Verify that login-with-ldap short-circuits when account is locked,
without attempting LDAP authentication. Calls the actual command
function with a mock provider to test the full flow."
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :login-with-ldap])
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :login-with-ldap :disable-admin-console])
ldap/authenticate (constantly nil)]
(let [profile (th/create-profile* 204 {:is-active true})
cfg (assoc th/*system* ::ldap/provider {})]
@ -217,7 +217,7 @@
(t/deftest recover-profile-clears-lockout
"Verify that clearing attempts (e.g. after password reset) unlocks the account."
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])]
(with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :disable-admin-console])]
(let [profile (th/create-profile* 205 {:is-active true})
cfg th/*system*]
(cleanup-redis (:id profile))

View File

@ -915,39 +915,44 @@
(t/is (= 1 (count rows2)))
(t/is (nil? (:deleted-at (first rows2)))))))
(t/deftest create-file-thumbnail-rejects-forged-media
;; A transit body like {"~:media": {"~:path": ["~#path", "/etc/passwd"]}}
;; decodes to this exact map. Without a multipart request it must fail at
;; params validation, before any file is read. NOTE: plain command! on
;; purpose, a multipart header would make it legitimate.
(t/deftest rejects-forged-path-on-create-file-thumbnail
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
out (th/command! {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:revn 0
:media {:filename "evil.jpg"
:size 123
:path (fs/path "/etc/passwd")
:mtype "image/jpeg"}})]
(t/is (th/ex-of-code? (:error out) :params-validation))))
data {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:revn 1
:media {:filename "x.png"
:size 1
:path (fs/path "/etc/passwd")
:mtype "image/png"}}
out (th/command! data)
error (:error out)]
(t/is (nil? (:result out)))
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation))
(t/is (= 0 (count (th/db-query :file-thumbnail {:file-id (:id file)}))))))
(t/deftest create-file-object-thumbnail-rejects-forged-media
;; Same transit-forgery vector through object thumbnails. NOTE: plain
;; command! on purpose, a multipart header would make it legitimate.
(t/deftest rejects-forged-path-on-create-file-object-thumbnail
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
out (th/command! {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id "obj"
:media {:filename "evil.jpg"
:size 123
:path (fs/path "/etc/passwd")
:mtype "image/jpeg"}})]
(t/is (th/ex-of-code? (:error out) :params-validation))))
data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id "test-key-forged"
:media {:filename "x.png"
:size 1
:path (fs/path "/etc/passwd")
:mtype "image/png"}}
out (th/command! data)
error (:error out)]
(t/is (nil? (:result out)))
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation))
(t/is (= 0 (count (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)}))))))

View File

@ -2296,6 +2296,45 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0034-fix-path-content-offset"
[data _]
;; A path stores its geometry twice: the box (:selrect and :points) and the
;; drawing (:content), and the box is always derived from the drawing. Some
;; files have component copies whose drawing and box were moved apart, so
;; the drawing renders away from where the sidebar, the layouts and the
;; syncs place the shape. We move the content back onto the box. A path whose drawing
;; has another size than its box is not a displaced drawing, and is left as
;; it is.
(let [tolerance 0.1]
(letfn [(fix-path [shape]
(when (and (cfh/path-shape? shape)
(some? (:selrect shape))
(some? (path.segment/content->selrect (:content shape))))
(let [selrect (:selrect shape)
derived (:selrect (path/update-geometry shape))
offset (gpt/subtract (grc/rect->center selrect)
(grc/rect->center derived))]
(when (and (mth/close? (:width selrect) (:width derived) tolerance)
(mth/close? (:height selrect) (:height derived) tolerance)
(or (> (abs (:x offset)) tolerance)
(> (abs (:y offset)) tolerance)))
(update shape :content path/move-content offset)))))
(fix-objects [objects]
(reduce-kv (fn [objects id shape]
(if-let [shape (fix-path shape)]
(assoc objects id shape)
objects))
objects
objects))
(update-container [container]
(d/update-when container :objects fix-objects))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container)))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@ -2386,4 +2425,5 @@
"0030-remove-stroke-per-side-attr"
"0031-migrate-stroke-width-token-attr"
"0032-fix-children-not-in-parent"
"0033-normalize-main-instance-names"]))
"0033-normalize-main-instance-names"
"0034-fix-path-content-offset"]))

View File

@ -174,7 +174,7 @@
;; redis for caching data
:redis-cache
;; Activates the admin-console module
;; enabled by default. Activates the admin-console module
:admin-console
;; disabled by default. When enabled, allows the admin-console
@ -235,7 +235,8 @@
:enable-stroke-per-side
:enable-token-combobox
:enable-custom-shortcuts
:enable-token-lib-sync])
:enable-token-lib-sync
:enable-admin-console])
(defn parse
[& flags]

View File

@ -3182,7 +3182,8 @@
(cond-> (ctsl/grid-layout? obj)
(ctsl/remap-grid-cells ids-map))
(cond-> (ctk/is-variant-container? parent)
(cond-> (and (ctk/is-variant-container? parent)
(ctk/main-instance? obj))
(assoc :variant-id parent-id))
(cond-> (and (ctk/is-variant? obj) (not (ctk/is-variant-container? parent)))

View File

@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.files.shapes-helpers :as cfsh]
[app.common.geom.shapes :as gsh]
[app.common.logic.variant-properties :as clvp]
[app.common.types.component :as ctk]
@ -450,6 +451,18 @@
[all-parents changes])))
(defn- find-empty-variant-containers
[objects excluded-parent-id ids parent-ids]
(let [ids (set ids)]
(reduce (fn [to-delete candidate-id]
(let [parent (get objects candidate-id)]
(if (and (ctk/is-variant-container? parent)
(empty? (remove ids (:shapes parent))))
(conj to-delete (:id parent))
to-delete)))
#{}
(remove #(= % excluded-parent-id) parent-ids))))
(defn generate-relocate
[changes parent-id to-index ids & {:keys [cell ignore-parents?]}]
(let [objects (pcb/get-objects changes)
@ -553,128 +566,146 @@
;; Parents that are a variant-container that becomes empty
empty-variant-cont (reduce
(fn [to-delete parent-id]
(let [parent (get objects parent-id)]
(if (and (ctk/is-variant-container? parent)
(empty? (remove (set ids) (:shapes parent))))
(conj to-delete (:id parent))
to-delete)))
#{}
(remove #(= % parent-id) all-parents))]
empty-variant-cont (find-empty-variant-containers objects parent-id ids all-parents)]
(-> changes
;; Remove layout-item properties and tokens when moving a shape outside a layout
(cond-> (not (ctl/any-layout? parent))
(-> (pcb/update-shapes ids ctl/remove-layout-item-data)
(pcb/update-shapes ids cto/unapply-layout-item-tokens)))
(if (and (ctk/is-variant-container? parent)
(not (every? ctk/main-instance? shapes)))
changes
(-> changes
;; Remove layout-item properties and tokens when moving a shape outside a layout
(cond-> (not (ctl/any-layout? parent))
(-> (pcb/update-shapes ids ctl/remove-layout-item-data)
(pcb/update-shapes ids cto/unapply-layout-item-tokens)))
;; Remove the hide in viewer flag
(cond-> (and (not= uuid/zero parent-id) (cfh/frame-shape? parent))
(pcb/update-shapes ids #(cond-> % (cfh/frame-shape? %) (assoc :hide-in-viewer true))))
;; Remove the hide in viewer flag
(cond-> (and (not= uuid/zero parent-id) (cfh/frame-shape? parent))
(pcb/update-shapes ids #(cond-> % (cfh/frame-shape? %) (assoc :hide-in-viewer true))))
;; Remove the swap slots if it is moving to a different component
(pcb/update-shapes
child-heads-ids
(fn [shape]
(cond-> shape
(not= component-main-parent (ctn/find-component-main objects shape false))
(ctk/remove-swap-slot))))
;; Remove the swap slots if it is moving to a different component
(pcb/update-shapes
child-heads-ids
(fn [shape]
(cond-> shape
(not= component-main-parent (ctn/find-component-main objects shape false))
(ctk/remove-swap-slot))))
;; Remove component-root property when moving a shape inside a component
(cond-> (ctn/get-instance-root objects parent)
(pcb/update-shapes children-ids #(dissoc % :component-root)))
;; Remove component-root property when moving a shape inside a component
(cond-> (ctn/get-instance-root objects parent)
(pcb/update-shapes children-ids #(dissoc % :component-root)))
;; Add component-root property when moving a component outside a component
(cond-> (not (ctn/get-instance-root objects parent))
(pcb/update-shapes child-heads-ids #(assoc % :component-root true)))
;; Add component-root property when moving a component outside a component
(cond-> (not (ctn/get-instance-root objects parent))
(pcb/update-shapes child-heads-ids #(assoc % :component-root true)))
;; Remove variant info and rename when moving outside a variant-container
(cond-> (not (ctk/is-variant-container? parent))
(clvp/generate-make-shapes-no-variant variant-shapes))
;; Remove variant info and rename when moving outside a variant-container
(cond-> (not (ctk/is-variant-container? parent))
(clvp/generate-make-shapes-no-variant variant-shapes))
;; Add variant info and rename when moving into a different variant-container
(cond-> (ctk/is-variant-container? parent)
(clvp/generate-make-shapes-variant child-heads parent))
;; Add variant info and rename when moving into a different variant-container
(cond-> (ctk/is-variant-container? parent)
(clvp/generate-make-shapes-variant child-heads parent))
;; Move the shapes
(pcb/change-parent parent-id
shapes
to-index)
;; Move the shapes
(pcb/change-parent parent-id
shapes
to-index)
;; Remove empty groups
(pcb/remove-objects groups-to-delete)
;; Remove empty groups
(pcb/remove-objects groups-to-delete)
;; Unmask groups whose mask have moved outside
(pcb/update-shapes groups-to-unmask
(fn [shape]
(assoc shape :masked-group false)))
;; Unmask groups whose mask have moved outside
(pcb/update-shapes groups-to-unmask
(fn [shape]
(assoc shape :masked-group false)))
;; Detach shapes moved out of their component
(pcb/update-shapes shapes-to-detach ctk/detach-shape)
;; Detach shapes moved out of their component
(pcb/update-shapes shapes-to-detach ctk/detach-shape)
;; Make non root a component moved inside another one
(pcb/update-shapes shapes-to-deroot
(fn [shape]
(assoc shape :component-root nil)))
;; Make non root a component moved inside another one
(pcb/update-shapes shapes-to-deroot
(fn [shape]
(assoc shape :component-root nil)))
;; Make root a subcomponent moved outside its parent component
(pcb/update-shapes shapes-to-reroot
(fn [shape]
(assoc shape :component-root true)))
;; Make root a subcomponent moved outside its parent component
(pcb/update-shapes shapes-to-reroot
(fn [shape]
(assoc shape :component-root true)))
;; Reset constraints depending on the new parent
(pcb/update-shapes ids
(fn [shape]
(let [frame-id (if (= (:type parent) :frame)
(:id parent)
(:frame-id parent))
moved-shape (assoc shape
:parent-id parent-id
:frame-id frame-id)]
(assoc shape
:constraints-h (gsh/default-constraints-h moved-shape)
:constraints-v (gsh/default-constraints-v moved-shape))))
{:ignore-touched true})
;; Reset constraints depending on the new parent
(pcb/update-shapes ids
(fn [shape]
(let [frame-id (if (= (:type parent) :frame)
(:id parent)
(:frame-id parent))
moved-shape (assoc shape
:parent-id parent-id
:frame-id frame-id)]
(assoc shape
:constraints-h (gsh/default-constraints-h moved-shape)
:constraints-v (gsh/default-constraints-v moved-shape))))
{:ignore-touched true})
;; Fix the sizing when moving a shape
(pcb/update-shapes parents
(fn [parent]
(if (ctl/flex-layout? parent)
(cond-> parent
(ctl/change-h-sizing? (:id parent) objects (:shapes parent))
(assoc :layout-item-h-sizing :fix)
;; Fix the sizing when moving a shape
(pcb/update-shapes parents
(fn [parent]
(if (ctl/flex-layout? parent)
(cond-> parent
(ctl/change-h-sizing? (:id parent) objects (:shapes parent))
(assoc :layout-item-h-sizing :fix)
(ctl/change-v-sizing? (:id parent) objects (:shapes parent))
(assoc :layout-item-v-sizing :fix))
parent)))
(ctl/change-v-sizing? (:id parent) objects (:shapes parent))
(assoc :layout-item-v-sizing :fix))
parent)))
;; Change the grid cell in a grid layout
(cond-> (ctl/grid-layout? objects parent-id)
(-> (pcb/update-shapes
[parent-id]
(fn [frame objects]
(let [[row column] cell]
(-> frame
;; Assign the cell when pushing into a specific grid cell
(cond-> (some? cell)
(-> (ctl/free-cell-shapes ids)
(ctl/push-into-cell ids row column)
(ctl/assign-cells objects)))
(ctl/assign-cell-positions objects))))
{:with-objects? true})
(pcb/reorder-grid-children [parent-id])))
;; Change the grid cell in a grid layout
(cond-> (ctl/grid-layout? objects parent-id)
(-> (pcb/update-shapes
[parent-id]
(fn [frame objects]
(let [[row column] cell]
(-> frame
;; Assign the cell when pushing into a specific grid cell
(cond-> (some? cell)
(-> (ctl/free-cell-shapes ids)
(ctl/push-into-cell ids row column)
(ctl/assign-cells objects)))
(ctl/assign-cell-positions objects))))
{:with-objects? true})
(pcb/reorder-grid-children [parent-id])))
;; If parent locked, lock the added shapes
(cond-> (:blocked parent)
(pcb/update-shapes ids #(assoc % :blocked true)))
;; If parent locked, lock the added shapes
(cond-> (:blocked parent)
(pcb/update-shapes ids #(assoc % :blocked true)))
;; Resize parent containers that need to
(pcb/resize-parents parents)
;; Resize parent containers that need to
(pcb/resize-parents parents)
;; Remove parents when are a variant-container that becomes empty
(cond-> (seq empty-variant-cont)
(#(second (generate-delete-shapes % empty-variant-cont {})))))))
;; Remove parents when are a variant-container that becomes empty
(cond-> (seq empty-variant-cont)
(#(second (generate-delete-shapes % empty-variant-cont {}))))))))
(defn generate-move-shapes-into-frame
"Reparent `shapes` into `frame-id`, stripping variant records when a
variant main leaves its container. The builder must already have
objects and library data."
[changes frame-id shapes objects remove-layout-data?]
(let [parent-id (dm/get-in objects [frame-id :parent-id])
ids (remove #(= % parent-id) shapes)
to-move (->> ids
(map (d/getf objects))
(filter some?))
new-parent (get objects frame-id)
variant-shapes (filter ctk/is-variant? to-move)
all-parents (into #{frame-id} (map #(cfh/get-parent-id objects %)) ids)
empty-variant-cont (find-empty-variant-containers objects frame-id ids all-parents)]
(if (empty? to-move)
changes
(-> changes
(cond-> (not (ctk/is-variant-container? new-parent))
(clvp/generate-make-shapes-no-variant variant-shapes))
(cfsh/prepare-move-shapes-into-frame frame-id shapes objects remove-layout-data?)
(cond-> (seq empty-variant-cont)
(#(second (generate-delete-shapes % empty-variant-cont {}))))))))
(defn change-show-in-viewer
[shape hide?]

View File

@ -61,15 +61,18 @@
[changes component-id pos value]
(if-let [value (ctv/prepare-property-value value)]
(let [data (pcb/get-library-data changes)
objects (pcb/get-objects changes)
component (ctcl/get-component data component-id true)
main-id (:main-instance-id component)
name (-> (:variant-properties component)
(update pos assoc :value value)
ctv/properties-to-name)]
(-> changes
(pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value)
{:apply-changes-local-library? true})
(pcb/update-shapes [main-id] #(assoc % :variant-name name))))
main-id (:main-instance-id component)]
(if (contains? objects main-id)
(let [name (-> (:variant-properties component)
(update pos assoc :value value)
ctv/properties-to-name)]
(-> changes
(pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value)
{:apply-changes-local-library? true})
(pcb/update-shapes [main-id] #(assoc % :variant-name name))))
changes))
changes))

View File

@ -9,12 +9,53 @@
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.logic.variant-properties :as clvp]
[app.common.path-names :as cpn]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]))
(defn generate-rename-variant
"Rename a variant set in one change vector.
The builder already has the mains' page objects and library data.
The stored name is `clean-path` of `name`, or \"(empty)\" when that
path is blank. The board and every direct child take that name.
Each component's path and name come from `split-group-name`."
[changes variant-id name]
(let [objects (pcb/get-objects changes)
data (pcb/get-library-data changes)
clean-name (cpn/clean-path name)
stored-name (if (empty? clean-name) "(empty)" clean-name)
shape-ids (cons variant-id (:shapes (get objects variant-id)))
components (cfv/find-variant-components data objects variant-id)]
(reduce (fn [changes component]
(let [[path item-name] (cpn/split-group-name stored-name)]
(pcb/update-component changes
(:id component)
#(assoc % :path path :name item-name))))
(pcb/update-shapes changes shape-ids #(assoc % :name stored-name))
components)))
(defn generate-define-variant-container
"One change vector. `name` is the string stored on the board.
The component path and name are `split-group-name` of that string.
Library data is refreshed before the first property is added."
[changes variant-id main-id component-id name]
(let [[path item-name] (cpn/split-group-name name)]
(-> changes
(pcb/update-shapes [variant-id]
#(assoc % :is-variant-container true :name name))
(pcb/update-shapes [main-id]
#(assoc % :name name :variant-id variant-id))
(pcb/update-component component-id
#(assoc % :variant-id variant-id
:path path
:name item-name)
{:apply-changes-local-library? true})
(clvp/generate-add-new-property variant-id {:fill-values? true}))))
(defn generate-add-new-variant
[changes shape variant-id new-component-id new-shape-id prop-num]
(let [data (pcb/get-library-data changes)

View File

@ -426,6 +426,52 @@
true
(has-any-copy-parent? objects (:parent-id shape))))))
(defn get-variant-boundary-id
"Id of the nearest variant main or variant container for this shape, or nil
when the shape is outside any variant set.
Nested children resolve to their enclosing main (not the container), so two
shapes under different variants are treated as different boundaries even
when they share a set."
[objects shape]
(some (fn [s]
(cond
(ctk/is-variant? s) (:id s)
(ctk/is-variant-container? s) (:id s)
:else nil))
(cfh/get-parents-with-self objects (:id shape))))
(defn crosses-variant-set-boundary?
"True when the selection mixes shapes that belong to different variant
boundaries — including outside vs in-set, two different variant mains
(even in the same set), or two different variant containers.
Structure commands (group, mask, board-from-selection, layout) must not
reparent across that boundary."
[objects shapes]
(let [ids (into #{} (map #(get-variant-boundary-id objects %)) shapes)]
(> (count ids) 1)))
(defn ids-for-structure-op
"Ids allowed for Group, Mask, and Create board from selection.
Drops copy-children and variant mains, refuses a cross-boundary or
container selection, then removes descendants whose ancestor is
still selected. Mains are dropped before loop-cleaning so a
selected main does not swallow nested children. Layout does not
use this helper."
[objects ids]
(let [shapes (->> ids
(remove #(has-any-copy-parent? objects (get objects %)))
(keep (d/getf objects)))]
(if (or (crosses-variant-set-boundary? objects shapes)
(some ctk/is-variant-container? shapes))
[]
(->> shapes
(remove ctk/is-variant?)
(map :id)
(cfh/clean-loops objects)))))
(defn has-any-main?
"Check if the shape is a main component or has any children or parent that is a main component."
[objects shape]

View File

@ -0,0 +1,116 @@
;; 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 common-tests.files-migrations-0034-test
(:require
[app.common.files.migrations :as cfm]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.common.types.modifiers :as ctm]
[app.common.types.path :as path]
[app.common.types.path.segment :as segment]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.test :as t]))
;; 0034-fix-path-content-offset
;; A path whose drawing (:content) was moved apart from its box
;; (:selrect and :points) gets its content moved back onto the box.
(def ^:private migration "0034-fix-path-content-offset")
(defn- triangle
[x0 y0]
[{:command :move-to :params {:x x0 :y y0}}
{:command :line-to :params {:x (+ x0 14) :y y0}}
{:command :line-to :params {:x (+ x0 7) :y (+ y0 14)}}
{:command :close-path}])
(defn- make-path
[shape-id x0 y0]
(cts/setup-shape {:id shape-id :type :path :content (triangle x0 y0)}))
(defn- rotate
[shape angle]
(gsh/transform-shape shape (ctm/rotation-modifiers shape (gsh/shape->center shape) angle)))
(defn- displace-content
[shape delta]
(update shape :content path/move-content delta))
(defn- migrate-shape
[shape]
(let [page-id (uuid/next)
data {:pages-index {page-id {:objects {(:id shape) shape}}}}
data' (cfm/migrate-data data migration)]
(get-in data' [:pages-index page-id :objects (:id shape)])))
(defn- content-matches-box?
[shape]
(let [derived (path/update-geometry shape)]
(and (every? true? (map gpt/close? (:points shape) (:points derived)))
(every? #(mth/close? (get (:selrect shape) %) (get (:selrect derived) %))
[:x :y :width :height]))))
(t/deftest migration-0034-moves-displaced-content-onto-its-box
(let [shape (make-path (uuid/next) 1 1)
broken (displace-content shape (gpt/point 0 -182))
shape' (migrate-shape broken)]
(t/is (not (content-matches-box? broken)) "the fixture is broken")
(t/is (content-matches-box? shape') "content back onto the box")
(t/is (= (:selrect broken) (:selrect shape')) "box unchanged")
(t/is (= (:points broken) (:points shape')) "points unchanged")
(t/is (= (segment/content->selrect (:content shape))
(segment/content->selrect (:content shape')))
"content where it was before being displaced")))
(t/deftest migration-0034-moves-displaced-content-of-a-rotated-path
(let [shape (rotate (make-path (uuid/next) 100 100) 30)
broken (displace-content shape (gpt/point 25 -40))
shape' (migrate-shape broken)]
(t/is (not (gmt/close? (gmt/matrix) (:transform shape))) "the fixture is rotated")
(t/is (not (content-matches-box? broken)) "the fixture is broken")
(t/is (content-matches-box? shape') "content back onto the box")
(t/is (= (:selrect broken) (:selrect shape')) "box unchanged")
(t/is (= (:transform broken) (:transform shape')) "rotation unchanged")))
(t/deftest migration-0034-keeps-paths-whose-content-matches-the-box
(let [shape (make-path (uuid/next) 10 20)
small (displace-content shape (gpt/point 0.05 0))]
(t/is (identical? shape (migrate-shape shape)) "consistent path untouched")
(t/is (identical? small (migrate-shape small)) "float noise untouched")))
(t/deftest migration-0034-keeps-paths-whose-content-has-another-size
(let [shape (make-path (uuid/next) 0 0)
bigger (assoc shape :content (path/content [{:command :move-to :params {:x 40 :y 0}}
{:command :line-to :params {:x 70 :y 0}}
{:command :line-to :params {:x 55 :y 30}}
{:command :close-path}]))]
(t/is (identical? bigger (migrate-shape bigger)) "size mismatch untouched")))
(t/deftest migration-0034-ignores-shapes-other-than-paths
(let [shape-id (uuid/next)
rect (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0 :width 10 :height 10})]
(t/is (identical? rect (migrate-shape rect)))))
(t/deftest migration-0034-fixes-paths-inside-components
(let [shape (make-path (uuid/next) 1 1)
broken (displace-content shape (gpt/point 0 109))
component-id (uuid/next)
data {:pages-index {}
:components {component-id {:id component-id
:deleted true
:objects {(:id broken) broken}}}}
data' (cfm/migrate-data data migration)
shape' (get-in data' [:components component-id :objects (:id broken)])]
(t/is (content-matches-box? shape') "deleted component content fixed")))

View File

@ -0,0 +1,316 @@
;; 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 common-tests.logic.variants-reparent-test
(:require
[app.common.files.changes-builder :as pcb]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.types.component :as ctk]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(defn- relocate
[file parent-id ids & {:keys [to-index] :or {to-index 0}}]
(let [page (thf/current-page file)]
(-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page))
(pcb/with-library-data (:data file))
(cls/generate-relocate parent-id to-index (set ids)))))
(defn- move-into-frame
[file frame-id ids]
(let [page (thf/current-page file)
objects (:objects page)]
(-> (pcb/empty-changes nil (:id page))
(pcb/with-objects objects)
(pcb/with-library-data (:data file))
(cls/generate-move-shapes-into-frame frame-id ids objects true))))
(defn- property-names
[component]
(mapv :name (:variant-properties component)))
(defn- strip-component-times
"Undo restamps :modified-at on components when mains are updated.
Compare file data without that clock."
[data]
(update data :components update-vals #(dissoc % :modified-at)))
(t/deftest test-relocate-plain-shape-onto-container-is-rejected
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-rect :rect1))
container (ths/get-shape file :v01)
rect (ths/get-shape file :rect1)
children (:shapes container)
changes (relocate file (:id container) [(:id rect)])
file' (thf/apply-changes file changes)
rect' (ths/get-shape file' :rect1)
container' (ths/get-shape file' :v01)]
(t/is (empty? (:redo-changes changes)))
(t/is (= (:parent-id rect) (:parent-id rect')))
(t/is (= (:constraints-h rect) (:constraints-h rect')))
(t/is (= (:constraints-v rect) (:constraints-v rect')))
(t/is (= children (:shapes container')))
(thf/validate-file! file')))
(t/deftest test-relocate-component-copy-onto-container-is-rejected
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-simple-component-with-copy :c03 :m03 :r03 :copy03))
container (ths/get-shape file :v01)
copy (ths/get-shape file :copy03)
comp (thc/get-component file :c03)
children (:shapes container)
changes (relocate file (:id container) [(:id copy)])
file' (thf/apply-changes file changes)
copy' (ths/get-shape file' :copy03)
comp' (thc/get-component file' :c03)
container' (ths/get-shape file' :v01)]
(t/is (empty? (:redo-changes changes)))
(t/is (= (:parent-id copy) (:parent-id copy')))
(t/is (nil? (:variant-id copy')))
(t/is (= (:variant-id comp) (:variant-id comp')))
(t/is (= (:variant-properties comp) (:variant-properties comp')))
(t/is (= children (:shapes container')))
(thf/validate-file! file')))
(t/deftest test-relocate-mixed-selection-onto-container-is-rejected
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-rect :rect1)
(tho/add-simple-component :c03 :m03 :r03))
container (ths/get-shape file :v01)
rect (ths/get-shape file :rect1)
main (ths/get-shape file :m03)
changes (relocate file (:id container) [(:id rect) (:id main)])
file' (thf/apply-changes file changes)
rect' (ths/get-shape file' :rect1)
main' (ths/get-shape file' :m03)]
(t/is (empty? (:redo-changes changes)))
(t/is (= (:parent-id rect) (:parent-id rect')))
(t/is (= (:parent-id main) (:parent-id main')))
(thf/validate-file! file')))
(t/deftest test-relocate-main-into-container-becomes-variant
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-simple-component :c03 :m03 :r03))
container (ths/get-shape file :v01)
main (ths/get-shape file :m03)
sibling (thc/get-component file :c01)
changes (relocate file (:id container) [(:id main)])
file' (thf/apply-changes file changes)
file'' (thf/apply-undo-changes file' changes)
main' (ths/get-shape file' :m03)
comp' (thc/get-component file' :c03)
sibling' (thc/get-component file' :c01)
container' (ths/get-shape file' :v01)]
(t/is (= (:id container') (:parent-id main')))
(t/is (= (:id container') (:variant-id main')))
(t/is (= (:id container') (:variant-id comp')))
(t/is (some? (:variant-name main')))
(t/is (= (property-names sibling') (property-names comp')))
(t/is (= (property-names sibling) (property-names sibling')))
(t/is (= (strip-component-times (:data file))
(strip-component-times (:data file''))))
(thf/validate-file! file')))
(t/deftest test-relocate-reorder-mains-does-not-rebuild-properties
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
container (ths/get-shape file :v01)
m01 (ths/get-shape file :m01)
m02 (ths/get-shape file :m02)
c01 (thc/get-component file :c01)
c02 (thc/get-component file :c02)
changes (relocate file (:id container) [(:id m01)] :to-index 0)
file' (thf/apply-changes file changes)
file'' (thf/apply-undo-changes file' changes)
m01' (ths/get-shape file' :m01)
m02' (ths/get-shape file' :m02)
c01' (thc/get-component file' :c01)
c02' (thc/get-component file' :c02)]
(t/is (= (:variant-name m01) (:variant-name m01')))
(t/is (= (:variant-name m02) (:variant-name m02')))
(t/is (= (:variant-properties c01) (:variant-properties c01')))
(t/is (= (:variant-properties c02) (:variant-properties c02')))
(t/is (= (strip-component-times (:data file))
(strip-component-times (:data file''))))
(thf/validate-file! file')))
(t/deftest test-relocate-all-mains-within-container-keeps-container
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
container (ths/get-shape file :v01)
m01 (ths/get-shape file :m01)
m02 (ths/get-shape file :m02)
changes (relocate file (:id container) [(:id m01) (:id m02)])
file' (thf/apply-changes file changes)
container' (ths/get-shape file' :v01)]
(t/is (= (:id container) (:id container')))
(t/is (= (set (:shapes container)) (set (:shapes container'))))
(thf/validate-file! file')))
(t/deftest test-relocate-main-out-of-container-clears-variant-data
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-frame :board1))
board (ths/get-shape file :board1)
m01 (ths/get-shape file :m01)
changes (relocate file (:id board) [(:id m01)])
file' (thf/apply-changes file changes)
file'' (thf/apply-undo-changes file' changes)
m01' (ths/get-shape file' :m01)
c01' (thc/get-component file' :c01)
container' (ths/get-shape file' :v01)]
(t/is (= (:id board) (:parent-id m01')))
(t/is (nil? (:variant-id m01')))
(t/is (nil? (:variant-name m01')))
(t/is (nil? (:variant-id c01')))
(t/is (nil? (:variant-properties c01')))
(t/is (some? container'))
(t/is (= (strip-component-times (:data file))
(strip-component-times (:data file''))))
(thf/validate-file! file')))
(t/deftest test-relocate-last-mains-out-deletes-empty-container
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-frame :board1))
board (ths/get-shape file :board1)
m01 (ths/get-shape file :m01)
m02 (ths/get-shape file :m02)
changes (relocate file (:id board) [(:id m01) (:id m02)])
file' (thf/apply-changes file changes)
file'' (thf/apply-undo-changes file' changes)
container' (ths/get-shape file' :v01)
m01' (ths/get-shape file' :m01)
m02' (ths/get-shape file' :m02)
c01' (thc/get-component file' :c01)
c02' (thc/get-component file' :c02)]
(t/is (nil? container'))
(t/is (some? m01'))
(t/is (some? m02'))
(t/is (= (:id board) (:parent-id m01')))
(t/is (= (:id board) (:parent-id m02')))
(t/is (nil? (:variant-id m01')))
(t/is (nil? (:variant-id m02')))
(t/is (nil? (:variant-name m01')))
(t/is (nil? (:variant-name m02')))
(t/is (some? c01'))
(t/is (some? c02'))
(t/is (nil? (:variant-id c01')))
(t/is (nil? (:variant-id c02')))
(t/is (nil? (:variant-properties c01')))
(t/is (nil? (:variant-properties c02')))
(t/is (= (strip-component-times (:data file))
(strip-component-times (:data file''))))
(thf/validate-file! file')))
(t/deftest test-board-draw-strips-variant-data-when-main-leaves
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-frame :board1))
board (ths/get-shape file :board1)
m01 (ths/get-shape file :m01)
changes (move-into-frame file (:id board) [(:id m01)])
file' (thf/apply-changes file changes)
file'' (thf/apply-undo-changes file' changes)
m01' (ths/get-shape file' :m01)
c01' (thc/get-component file' :c01)
container' (ths/get-shape file' :v01)]
(t/is (= (:id board) (:parent-id m01')))
(t/is (nil? (:variant-id m01')))
(t/is (nil? (:variant-name m01')))
(t/is (nil? (:variant-id c01')))
(t/is (nil? (:variant-properties c01')))
(t/is (some? container'))
(t/is (= (strip-component-times (:data file))
(strip-component-times (:data file''))))
(thf/validate-file! file')))
(t/deftest test-board-draw-last-main-deletes-empty-container
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-frame :board1))
board (ths/get-shape file :board1)
m01 (ths/get-shape file :m01)
m02 (ths/get-shape file :m02)
file* (thf/apply-changes file (move-into-frame file (:id board) [(:id m01)]))
changes (move-into-frame file* (:id board) [(:id m02)])
file' (thf/apply-changes file* changes)
container' (ths/get-shape file' :v01)
m01' (ths/get-shape file' :m01)
m02' (ths/get-shape file' :m02)
c01' (thc/get-component file' :c01)
c02' (thc/get-component file' :c02)]
(t/is (nil? container'))
(t/is (some? m01'))
(t/is (some? m02'))
(t/is (= (:id board) (:parent-id m01')))
(t/is (= (:id board) (:parent-id m02')))
(t/is (nil? (:variant-id m01')))
(t/is (nil? (:variant-id m02')))
(t/is (nil? (:variant-name m01')))
(t/is (nil? (:variant-name m02')))
(t/is (some? c01'))
(t/is (some? c02'))
(t/is (nil? (:variant-id c01')))
(t/is (nil? (:variant-id c02')))
(t/is (nil? (:variant-properties c01')))
(t/is (nil? (:variant-properties c02')))
(thf/validate-file! file')))
(t/deftest test-board-draw-undo-restores-emptied-container
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
(tho/add-frame :board1))
board (ths/get-shape file :board1)
m01 (ths/get-shape file :m01)
m02 (ths/get-shape file :m02)
changes* (move-into-frame file (:id board) [(:id m01)])
file* (thf/apply-changes file changes*)
changes (move-into-frame file* (:id board) [(:id m02)])
file' (thf/apply-changes file* changes)
file'' (thf/apply-undo-changes file' changes)
file''' (thf/apply-undo-changes file'' changes*)]
(t/is (= (strip-component-times (:data file*))
(strip-component-times (:data file''))))
(t/is (= (strip-component-times (:data file))
(strip-component-times (:data file'''))))))
(t/deftest test-duplicate-variant-main-keeps-variant-id
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
container (ths/get-shape file :v01)
file' (tho/duplicate-shape file :m01)
objects' (:objects (thf/current-page file'))
variant-mains (->> (vals objects')
(filter ctk/main-instance?)
(filter #(= (:id container) (:variant-id %))))]
(t/is (= 3 (count variant-mains)))
(thf/validate-file! file')))

View File

@ -11,11 +11,15 @@
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.logic.variant-properties :as clvp]
[app.common.logic.variants :as clv]
[app.common.path-names :as cpn]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.types.component :as ctk]
[app.common.types.variant :as ctv]
[clojure.test :as t]
[cuerdas.core :as str]))
@ -511,3 +515,174 @@
(thf/validate-file! file')
(t/is (some? new-shape'))
(t/is (not= (:parent-id new-shape') (:id container)))))
(defn- two-property-file-with-other-page
[]
(-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02)
(thf/add-sample-page :page2)))
(t/deftest test-update-property-value-skips-when-main-not-in-objects
(let [file (two-property-file-with-other-page)
other (thf/current-page file)
comp01 (thc/get-component file :c01)
main (ths/get-shape file :m01 :page-label :page1)
before (:variant-properties comp01)
label (:variant-name main)
changes (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id other))
(pcb/with-library-data (:data file))
(pcb/with-objects (:objects other))
(clvp/generate-update-property-value (:id comp01) 1 "crítica"))
file' (thf/apply-changes file changes)
comp01' (thc/get-component file' :c01)
main' (ths/get-shape file' :m01 :page-label :page1)]
(thf/validate-file! file')
(t/is (empty? (:redo-changes changes)))
(t/is (= before (:variant-properties comp01')))
(t/is (= label (:variant-name main')))))
(t/deftest test-update-property-value-writes-main-page-and-chains
(let [file (two-property-file-with-other-page)
mains (thf/get-page file :page1)
comp01 (thc/get-component file :c01)
changes (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id mains))
(pcb/with-library-data (:data file))
(pcb/with-objects (:objects mains))
(clvp/generate-update-property-value (:id comp01) 0 "active")
(clvp/generate-update-property-value (:id comp01) 1 "crítica"))
file' (thf/apply-changes file changes)
comp01' (thc/get-component file' :c01)
main' (ths/get-shape file' :m01 :page-label :page1)
props' (:variant-properties comp01')]
(thf/validate-file! file')
(t/is (= "active" (-> props' first :value)))
(t/is (= "crítica" (-> props' second :value)))
(t/is (= (ctv/properties-to-name props') (:variant-name main')))
(t/is (= "active, crítica" (:variant-name main')))))
(defn- post-artboard-alpha-file
"A normal frame holding one main named Alpha — before the container flag."
[]
(-> (thf/sample-file :file1)
(tho/add-frame :v01 {:name "Board"})
(tho/add-simple-component :c01 :m01 :r01
:root-params {:parent-label :v01 :name "Alpha"})))
(t/deftest test-define-variant-container-renames-component-in-same-vector
(let [file (post-artboard-alpha-file)
page (thf/current-page file)
variant-id (thi/id :v01)
main-id (thi/id :m01)
component-id (:id (thc/get-component file :c01))
changes (-> (pcb/empty-changes nil (:id page))
(pcb/with-objects (:objects page))
(pcb/with-library-data (:data file))
(clv/generate-define-variant-container
variant-id main-id component-id "Component"))
file' (thf/apply-changes file changes)
container' (ths/get-shape file' :v01)
main' (ths/get-shape file' :m01)
comp' (thc/get-component file' :c01)
restored (thf/apply-undo-changes file' changes)
container-rest (ths/get-shape restored :v01)
main-rest (ths/get-shape restored :m01)
comp-rest (thc/get-component restored :c01)]
(t/is (ctk/is-variant-container? container'))
(t/is (= "Component"
(:name container')
(:name main')
(cpn/merge-path-item (:path comp') (:name comp'))))
(t/is (= variant-id (:variant-id main') (:variant-id comp')))
(t/is (= (ctv/properties-to-name (:variant-properties comp'))
(:variant-name main')))
(t/is (not (ctk/is-variant-container? container-rest)))
(t/is (= "Alpha" (:name main-rest)))
(t/is (= "Alpha" (cpn/merge-path-item (:path comp-rest) (:name comp-rest))))
(t/is (nil? (:variant-id main-rest)))
(t/is (nil? (:variant-id comp-rest)))))
(defn- rename-variant-changes
[file name]
(let [page (thf/current-page file)]
(-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-library-data (:data file))
(pcb/with-objects (:objects page))
(clv/generate-rename-variant (thi/id :v01) name))))
(defn- redo-ids
[changes change-type]
(into #{}
(comp (filter #(= change-type (:type %)))
(map :id))
(:redo-changes changes)))
(t/deftest test-rename-variant-one-vector
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
board (ths/get-shape file :v01)
main1 (ths/get-shape file :m01)
main2 (ths/get-shape file :m02)
comp1 (thc/get-component file :c01)
comp2 (thc/get-component file :c02)
changes (rename-variant-changes file "Group / Button")
file' (thf/apply-changes file changes)
board' (ths/get-shape file' :v01)
main1' (ths/get-shape file' :m01)
main2' (ths/get-shape file' :m02)
comp1' (thc/get-component file' :c01)
comp2' (thc/get-component file' :c02)
restored (thf/apply-undo-changes file' changes)]
(t/is (= #{(thi/id :v01) (thi/id :m01) (thi/id :m02)}
(redo-ids changes :mod-obj)))
(t/is (= #{(thi/id :c01) (thi/id :c02)}
(redo-ids changes :mod-component)))
(t/is (= "Group / Button" (:name board') (:name main1') (:name main2')))
(t/is (= "Group" (:path comp1') (:path comp2')))
(t/is (= "Button" (:name comp1') (:name comp2')))
(t/is (= "Group / Button"
(cpn/merge-path-item (:path comp1') (:name comp1'))
(cpn/merge-path-item (:path comp2') (:name comp2'))))
(t/is (= (:id board) (:variant-id comp1') (:variant-id comp2')))
(t/is (= (:variant-name main1) (:variant-name main1')))
(t/is (= (:variant-name main2) (:variant-name main2')))
(t/is (= (:variant-properties comp1) (:variant-properties comp1')))
(t/is (= (:variant-properties comp2) (:variant-properties comp2')))
(t/is (= (:name board) (:name (ths/get-shape restored :v01))))
(t/is (= (:name main1) (:name (ths/get-shape restored :m01))))
(t/is (= (:name main2) (:name (ths/get-shape restored :m02))))
(t/is (= (:path comp1) (:path (thc/get-component restored :c01))))
(t/is (= (:name comp1) (:name (thc/get-component restored :c01))))
(t/is (= (:path comp2) (:path (thc/get-component restored :c02))))
(t/is (= (:name comp2) (:name (thc/get-component restored :c02))))))
(t/deftest test-rename-variant-slash-only-name
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
changes (rename-variant-changes file "///")
file' (thf/apply-changes file changes)
comp1' (thc/get-component file' :c01)
comp2' (thc/get-component file' :c02)]
(t/is (= "(empty)"
(:name (ths/get-shape file' :v01))
(:name (ths/get-shape file' :m01))
(:name (ths/get-shape file' :m02))
(:name comp1')
(:name comp2')))
(t/is (= "" (:path comp1') (:path comp2')))
(t/is (= "(empty)"
(cpn/merge-path-item (:path comp1') (:name comp1'))
(cpn/merge-path-item (:path comp2') (:name comp2'))))
(t/is (some? (:variant-id comp1')))
(t/is (some? (:variant-id comp2')))))

View File

@ -22,6 +22,7 @@
[common-tests.files-changes-test]
[common-tests.files-migrations-0025-test]
[common-tests.files-migrations-0026-test]
[common-tests.files-migrations-0034-test]
[common-tests.files-migrations-test]
[common-tests.files.comp-processors-test]
[common-tests.files.helpers-test]
@ -77,6 +78,7 @@
[common-tests.logic.text-touched-test]
[common-tests.logic.token-apply-test]
[common-tests.logic.token-test]
[common-tests.logic.variants-reparent-test]
[common-tests.logic.variants-switch-test]
[common-tests.logic.variants-test]
[common-tests.math-test]
@ -129,6 +131,7 @@
'common-tests.files-changes-test
'common-tests.files-migrations-0025-test
'common-tests.files-migrations-0026-test
'common-tests.files-migrations-0034-test
'common-tests.files-migrations-test
'common-tests.files.comp-processors-test
'common-tests.files.helpers-test
@ -184,6 +187,7 @@
'common-tests.logic.text-touched-test
'common-tests.logic.token-apply-test
'common-tests.logic.token-test
'common-tests.logic.variants-reparent-test
'common-tests.logic.variants-switch-test
'common-tests.logic.variants-test
'common-tests.math-test

View File

@ -154,3 +154,140 @@
shape-b (make-shape id-b :parent-id id-a)
objects (objects-map shape-a shape-b)]
(t/is (nil? (ctc/find-component-main objects shape-a false))))))
(t/deftest get-variant-boundary-id-recognizes-container-main-and-nested
(let [page-id uuid/zero
container-id (uuid/next)
main-id (uuid/next)
nested-id (uuid/next)
outside-id (uuid/next)
container (make-shape container-id
:parent-id page-id
:type :frame
:is-variant-container true)
main (make-shape main-id
:parent-id container-id
:type :frame
:variant-id container-id)
nested (make-shape nested-id :parent-id main-id)
outside (make-shape outside-id :parent-id page-id)
objects (objects-map container main nested outside)]
(t/is (= main-id (ctc/get-variant-boundary-id objects nested)))
(t/is (= main-id (ctc/get-variant-boundary-id objects main)))
(t/is (= container-id (ctc/get-variant-boundary-id objects container)))
(t/is (nil? (ctc/get-variant-boundary-id objects outside)))))
(t/deftest crosses-variant-set-boundary?
(let [page-id uuid/zero
container-id (uuid/next)
main-id (uuid/next)
main-2-id (uuid/next)
nested-id (uuid/next)
nested-2-id (uuid/next)
outside-id (uuid/next)
other-set-id (uuid/next)
other-main-id (uuid/next)
other-nest-id (uuid/next)
container (make-shape container-id
:parent-id page-id
:type :frame
:is-variant-container true)
main (make-shape main-id
:parent-id container-id
:type :frame
:variant-id container-id)
main-2 (make-shape main-2-id
:parent-id container-id
:type :frame
:variant-id container-id)
nested (make-shape nested-id :parent-id main-id)
nested-2 (make-shape nested-2-id :parent-id main-2-id)
outside (make-shape outside-id :parent-id page-id)
other-set (make-shape other-set-id
:parent-id page-id
:type :frame
:is-variant-container true)
other-main (make-shape other-main-id
:parent-id other-set-id
:type :frame
:variant-id other-set-id)
other-nested (make-shape other-nest-id :parent-id other-main-id)
objects (objects-map container main main-2 nested nested-2
outside other-set other-main other-nested)]
(t/testing "nested child + outside crosses"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [nested outside]))))
(t/testing "main + outside crosses"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [main outside]))))
(t/testing "container + outside crosses"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [container outside]))))
(t/testing "nested children of two variants in the same set cross"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [nested nested-2]))))
(t/testing "nested children from two different sets cross"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [nested other-nested]))))
(t/testing "two outside shapes do not cross"
(let [outside-2 (make-shape (uuid/next) :parent-id page-id)]
(t/is (false? (ctc/crosses-variant-set-boundary?
(assoc objects (:id outside-2) outside-2)
[outside outside-2])))))
(t/testing "nested only does not cross"
(t/is (false? (ctc/crosses-variant-set-boundary? objects [nested]))))
(t/testing "main + nested under that main do not cross"
(t/is (false? (ctc/crosses-variant-set-boundary?
objects [main nested]))))
(t/testing "two nested under the same main do not cross"
(let [nested-b (make-shape (uuid/next) :parent-id main-id)]
(t/is (false? (ctc/crosses-variant-set-boundary?
(assoc objects (:id nested-b) nested-b)
[nested nested-b])))))))
(t/deftest ids-for-structure-op
(let [page-id uuid/zero
container-id (uuid/next)
main-id (uuid/next)
main-2-id (uuid/next)
nested-id (uuid/next)
nested-2-id (uuid/next)
outside-id (uuid/next)
outside-2-id (uuid/next)
container (make-shape container-id
:parent-id page-id
:type :frame
:is-variant-container true)
main (make-shape main-id
:parent-id container-id
:type :frame
:variant-id container-id)
main-2 (make-shape main-2-id
:parent-id container-id
:type :frame
:variant-id container-id)
nested (make-shape nested-id :parent-id main-id)
nested-2 (make-shape nested-2-id :parent-id main-id)
outside (make-shape outside-id :parent-id page-id)
outside-2 (make-shape outside-2-id :parent-id page-id)
objects (objects-map container main main-2 nested nested-2
outside outside-2)]
(t/testing "container-only is refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [container-id])))))
(t/testing "nested + outside is refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [nested-id outside-id])))))
(t/testing "main + outside is refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id outside-id])))))
(t/testing "two mains cross and are refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id main-2-id])))))
(t/testing "single main is dropped to empty"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id])))))
(t/testing "main + nested under that main keeps only the nested"
(t/is (= [nested-id]
(vec (ctc/ids-for-structure-op objects [main-id nested-id])))))
(t/testing "two nested under one main are both kept"
(t/is (= [nested-id nested-2-id]
(vec (ctc/ids-for-structure-op objects [nested-id nested-2-id])))))
(t/testing "two ordinary outside shapes are both kept"
(t/is (= [outside-id outside-2-id]
(vec (ctc/ids-for-structure-op objects [outside-id outside-2-id])))))))

View File

@ -1,4 +1,4 @@
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS build
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:af61a3296b0c92f16969f11ce8c2a93722e84c9e5c1bb42ed6afec796be71b04 AS build
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \
@ -53,7 +53,7 @@ RUN set -eux; \
--output /opt/jre;
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS image
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:af61a3296b0c92f16969f11ce8c2a93722e84c9e5c1bb42ed6afec796be71b04 AS image
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \

View File

@ -1,4 +1,4 @@
FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c
FROM dhi.io/node:24.22.2-debian13-dev@sha256:df819cfcb0de8a9e461bb8437d96d6fcfac341e565129e50597338756a88db5b
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \

View File

@ -1,4 +1,4 @@
FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:fbff190f9162256975ed225adcc9a294e333897de543fd18be630de1d6c8de17
FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:f69a0ea1762c51080fe96ce5f12103fb2977231308ae1784acd915bf63a0a1a2
LABEL maintainer="Penpot <docker@penpot.app>"
ENV DEBIAN_FRONTEND=noninteractive

View File

@ -1,4 +1,4 @@
FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c AS build
FROM dhi.io/node:24.22.2-debian13-dev@sha256:df819cfcb0de8a9e461bb8437d96d6fcfac341e565129e50597338756a88db5b AS build
LABEL maintainer="Penpot <docker@penpot.app>"
ENV DEBIAN_FRONTEND=noninteractive
@ -42,7 +42,7 @@ RUN set -eux; \
RUN ./setup
FROM dhi.io/node:24.22.2-debian13@sha256:24fa6258b891ef66e6c3b7ed82daad550b81cdfcd76f6cc64eee4d2478366747 AS image
FROM dhi.io/node:24.22.2-debian13@sha256:ce582f85254ceba6c572a854b8fb4008ced2f5d365c929f4f580360829037184 AS image
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c
FROM dhi.io/node:24.22.2-debian13-dev@sha256:df819cfcb0de8a9e461bb8437d96d6fcfac341e565129e50597338756a88db5b
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \

View File

@ -1,4 +1,4 @@
FROM dhi.io/nginx:1.31.8-alpine3.24@sha256:83489ce440fa56cb5149c088dbf75422362e45c532c814100253e5ab71871366
FROM dhi.io/nginx:1.31.8-alpine3.24@sha256:7a4e9021737f414ad0f851efb939600690d9d092f51712ce6512bfd0a767ceae
LABEL maintainer="Penpot <docker@penpot.app>"
ARG BUNDLE_PATH="./bundle-storybook/"

View File

@ -24,7 +24,7 @@
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
x-flags: &penpot-flags
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001

View File

@ -61,7 +61,7 @@ fi
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then
if [[ " $PENPOT_FLAGS " != *" disable-admin-console "* ]]; then
export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000}
envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \
< /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

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