Compare commits

...
68 Commits
Author SHA1 Message Date
andrés gonzález 88d715938f 📚 Add migration guide page (#11454)
Point First Steps at the enterprise migration PDF with a short
summary, without duplicating the Community post.
2026-09-02 14:06:06 +02:00
andrés gonzález b6b1a47a7b 📚 Update MCP Quick demo video (#11453)
Replace the outdated Quick demo embed on the MCP docs with the
new recording.
2026-09-02 13:54:01 +02:00
David Barragán Merino 6f35348c7c 🐳 Pin docker images to 2.17
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-08-27 10:51:08 +02:00
Andrey Antukh 1d2c37e52c 📎 Update changelog 2026-08-27 10:00:13 +02:00
Andrey Antukh f7fd3e1cd5 📚 Update the update-changelog skill 2026-08-27 10:00:00 +02:00
Alejandro Alonso f7bdc9786c 🐛 Compare text numeric attrs with float tolerance (#11366)
Editor/WASM round-trips can truncate line-height strings
(e.g. 1.3333333333333333 → 1.33333). Exact string compare
treated that as a style change and detached typography tokens.
2026-08-27 09:41:50 +02:00
Andrey Antukh 88a52d1098 📎 Update changelog 2026-08-27 09:24:53 +02:00
Alejandro AlonsoandAndrés Moya 2318866f8d 🎉 Add repair functions for variant validation errors (#10768) (#11309)
* 🎉 Add repair functions for variant validation errors

* 📚 Fix copyright notice

Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
2026-08-21 11:17:25 +02:00
Andrey AntukhandSumit Ridhal 4da6499197 🐛 Fix linear gradients in SVG text exports (#11272)
* 🐛 Use gradient type instead of export type in SVG renderer

data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.

Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])

Closes #5972

* 🐛 Add SVG gradient export regression test

Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.

AI-assisted-by: gpt-5.6-luna

*  Standardize exporter testing workflow

Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.

AI-assisted-by: gpt-5.6-luna

*  Add focused exporter test execution

Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.

AI-assisted-by: gpt-5.6-luna

* 🐛 Replace shell exec with execFile in exporter

Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.

This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.

Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission

All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)

AI-assisted-by: qwen3.7-plus

* 🐛 Use existing hex-color-string? and fix test path mismatch

Address code review feedback:

- Replace duplicated hex-color-rx and valid-hex-color? with existing
  hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned

AI-assisted-by: qwen3.7-plus

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
2026-08-19 13:57:14 +02:00
Sebastien MALOT 7ac61e0597 🐛 Fix typo in auto-file-snapshot timeout setting (#10909)
Corrected a typo in the configuration documentation regarding the auto-file-snapshot timeout setting.

Signed-off-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
2026-08-17 22:14:01 +02:00
Andrey Antukh 509f5395cb 📎 Update changelog 2026-08-17 12:08:38 +02:00
Andrey Antukh d835baefec Merge remote-tracking branch 'origin/staging' 2026-08-03 09:15:03 +02:00
Andrey Antukh d04cbf175e 🐛 Fix nil dereference crash during flex layout drag operations (#10845)
Production crash where @(get bounds id) threw
"No protocol method IDeref.-deref defined for type null"
when a shape ID had no corresponding entry in the bounds map
during layout calculations.

Added defensive nil guards (when-let / when) to all unprotected
bounds dereference sites:

- flex_layout/bounds.cljc: layout-content-points (parent + child)
  and layout-content-bounds
- grid_layout/bounds.cljc: layout-content-points and
  layout-content-bounds
- min_size_layout.cljc: child-min-width grid branch (3 sites) and
  child-min-height grid branch

Added 7 new tests in geom_bounds_layout_nil_test.cljc covering all
nil-bounds edge cases for flex, grid, and min-size layout paths.
Registered in runner.cljc.

Closes #10843

AI-assisted-by: qwen3.7-plus
2026-07-31 12:51:35 +02:00
Andrey Antukh 764b62906b 🐛 Handle unrecognized JSON escape sequences as malformed-json (#10808)
* 📎 Update serena documentation about creating-prs workflow

* 🐛 Handle unrecognized JSON escape sequences as malformed-json

When clojure.data.json's read-escaped-char encounters an unrecognized
escape sequence (e.g. a backslash followed by '}', or other case
fall-throughs in the parser) in a JSON request body, it throws a bare
IllegalArgumentException. Previously this fell through to the generic
RuntimeException branch in wrap-parse-request's handle-error, which
unwrapped and recurred without matching, eventually reaching the
internal-error handler and producing HTTP 500 + an error report — even
though the root cause was malformed client input, not a server bug.

The fix converts any IllegalArgumentException raised in the JSON parse
path into a `:validation`/`:malformed-json` error by raising a new
ex-info (which is caught by the top-level error handler in
`app.http/router-handler`). The result is an HTTP 400 response with a
descriptive hint, and no error report is generated. This addresses
~10% of all error reports received.

The new IAE branch is placed before the RuntimeException branch in
the cond (since IllegalArgumentException IS-A RuntimeException) and
uses the throw-style (ex/raise) to match the existing
RequestTooBigException / EOFException branches. A comment above the
handle-error cond documents why raising is intentional and is caught
by the top-level app.http error handler, not by the per-route
wrap-errors middleware.

Test suite changes:

- Extend the existing `DummyRequest` defrecord in
  `http_middleware_test.clj` from 2 fields to 12 fields, implementing
  every IRequest method, and add a private `make-dummy-request`
  constructor that accepts an options map with every key optional and
  sensible `:or` defaults. Future fields added to DummyRequest won't
  break existing call sites as long as the `:or` defaults are kept in
  sync.

- Remove the now-redundant `JsonRequest` defrecord and migrate all 11
  `->DummyRequest` call sites to `make-dummy-request`.

- Add 6 new deftest cases:
  - parse-request-illegal-argument-exception: malformed JSON body
    (containing `\}`) is converted to `:malformed-json`.
  - parse-request-request-too-big-exception: RequestTooBigException
    is converted to `:request-body-too-large`.
  - parse-request-eof-exception: java.io.EOFException is converted
    to `:malformed-json`.
  - parse-request-runtime-exception-with-cause: a wrapped
    RuntimeException recurses on ex-cause and dispatches to the
    matching specific branch.
  - parse-request-runtime-exception-without-cause: a bare
    RuntimeException falls through to errors/handle, returning 500
    with :type :server-error :code :unexpected.
  - parse-request-non-runtime-throwable: java.io.IOException (a
    non-RuntimeException Throwable) is handled by the dedicated
    handle-exception method, returning 500 with :code :io-exception.

Together, the new tests cover all 6 branches of wrap-parse-request's
handle-error cond.

Refs #10804.

AI-assisted-by: minimax-m3
2026-07-31 12:06:19 +02:00
Dominik Jain 30943f1074 Make MCP tool call timeout configurable, raising default
The timeout for tool calls (which is trictly relevant for plugin tasks only)
is now configurable via env. var PENPOT_MCP_TOOL_TIMEOUT_S.

The default was raised from 30 to 120, because 30 seconds was not enough for
some calls, especially in larger Penpot files. #10953
2026-07-30 14:21:42 +02:00
Dominik Jain b4659df5b2 🐛 Preserve established plugin connection when rejecting a duplicate #10961
In multi-user mode, rejecting a second plugin WebSocket connection for an
already-registered user token performed the full removeConnection cleanup
for the newcomer. Since the token-keyed cleanup is keyed by token rather
than by socket, this deleted the clientsByToken entry and the Redis
request-channel subscription of the established, healthy connection. That
connection then remained open and heartbeating but was unroutable, so every
subsequent MCP tool call for the user failed although a valid plugin
connection existed.

removeConnection now performs the token-keyed cleanup only if the removed
connection actually owns the token registration, so rejecting a duplicate
releases only the resources the newcomer itself registered.

AI-assisted-by: claude-fable-5
2026-07-30 14:21:42 +02:00
Dominik Jain 1ae9334064 🐛 Fail fast on Redis task dispatch when no instance is connected #10958
In multi-user mode, plugin task requests are published to a Redis channel
keyed by user token. When no MCP server instance held a plugin connection
for that token (e.g. after the user navigated away from the workspace),
the publish reached zero subscribers and the request was silently dropped,
so every tool call stalled until the 30-second task timeout instead of
failing with a meaningful error.

RedisBridge.sendTaskRequest now returns the PUBLISH receiver count and
releases its response-channel subscription when the request reached no
receiver (or publishing failed), since no response can arrive. PluginBridge
uses the count to reject the pending task immediately with the multi-user
connection error message; publish failures likewise reject the task instead
of surfacing as an unhandled rejection followed by a timeout. The pending-
task settlement logic shared with the timeout handler is extracted into a
rejectPendingTask helper.

AI-assisted-by: claude-fable-5
2026-07-30 14:21:42 +02:00
Andrey Antukh ef593514f2 🐛 Add nil guards on viewport-node in pixel overlay component (#10812)
* 🐛 Add nil guards on viewport-node in pixel overlay component

Add nil checks for viewport-node in process-pointer-move, viewport->canvas-coords, process-pointer-move-wasm, pick-color-at-wasm, and handle-draw-picker-canvas.

Fixes a crash ("can't access property 'getBoundingClientRect', ... is null")
when the viewport DOM node is unmounted while the color picker eyedropper
is active and pointer move events are still firing.

Fixes #10811

AI-assisted-by: mimo-v2.5

* 🐛 Remove unused app.common.pprint require from errors.cljs

Fixes clj-kondo warning: namespace app.common.pprint is required but never used.

AI-assisted-by: mimo-v2.5
2026-07-30 12:50:46 +02:00
Andrey Antukh 040080749b 🐛 Fix shape export failures when export name is nil or empty (#10852)
Use cuerdas blank-name handling directly when normalizing frontend export
payloads. Replace nil or blank export names with the object-id string in
request-simple-export, request-multiple-export, clipboard export, and plugin
direct export payloads so that the backend always receives a valid name. Add
focused frontend tests for nil/blank name normalization and normalized request
params.

AI-assisted-by: nex-n2-pro
2026-07-30 12:49:19 +02:00
Andrey Antukh fadb3124a0 🐛 Clamp gradient stop offsets to valid range (#10881)
* 🐛 Clamp gradient stop offsets to valid [0, 1] range

Fixed a bug where gradient stop offsets outside the valid [0, 1] range were being sent to the server, causing schema validation errors ('invalid shape found').

Changes:
- Viewport gradient handler: clamp offset in points-on-pointer-down before creating new stops
- Colorpicker gradient preview: clamp offset in handle-preview-down before adding stops
- Data layer: clamp offset parameter in update-colorpicker-add-stop and all stop offsets in update-colorpicker-stops; added app.common.math require

All clamping follows the existing pattern used in handle-marker-pointer-move.

AI-assisted-by: deepseek-v4-flash

* 💄 Fix formatting in gradient handlers

Fix cljfmt formatting issues in gradient handler functions.

AI-assisted-by: qwen3.7-plus
2026-07-30 12:49:00 +02:00
Andrey Antukh 25618febcd Merge remote-tracking branch 'origin/main' into staging 2026-07-30 08:46:52 +02:00
Andrey Antukh 5e5465a0fe 🐛 Fix audit event validation for error reports with string profile-id (#10898)
The audit event validation was failing when processing error reports that
contain string profile-id values. The error report storage converts
profile-id to string format, but the audit schema expects a UUID.

Changes:
- Modified prepare-rpc-event to convert string profile-id to UUID using
  uuid/parse* (exception-safe parsing)
- Updated access token middleware to set ::id and ::type on request so
  audit context includes token identification
- Added tests for profile-id conversion and token context population

Closes #10897

AI-assisted-by: qwen3.7-plus
2026-07-30 07:32:02 +02:00
Andrey Antukh adda7e6645 📚 Update testing serena memories 2026-07-29 19:55:12 +02:00
Alejandro AlonsoandCursor c8d1f5b397 🐛 Guard finalize-view-interaction! against spurious pointerup events (#10917)
Every pointerup unconditionally fires finish-panning and finish-zooming,
which call finalize-view-interaction!. This triggered internal-render
(and reset_canvas) on plain clicks — causing a visible white flash on
large viewports or weak GPUs.

Add a guard so finalize-view-interaction! only runs when a view
interaction (pan/zoom) is actually active.

Fixes #10915

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:43:29 +02:00
Andrey Antukh bbc7e9bee9 Add a script for run ci-like tasks 2026-07-28 12:47:33 +02:00
Andrey Antukh 4b994d20aa 🐛 Fix leaked focus timers in dashboard sidebar navigation (#10715)
* 🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone

The React reconciliation "removeChild" error surfaced during rapid
dashboard navigation because several effects scheduled deferred DOM
operations (focus, CSS positioning) without returning a cleanup that
cancelled them. When the component unmounted before the callback
fired, it ran against stale DOM and desynchronized React fiber tree
from the actual DOM.

- context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle
  window) with tm/schedule (setTimeout 0) and return a rx/dispose!
  cleanup.
- dropdown.cljs: capture the tm/schedule handle and dispose it in
  the effect cleanup.
- tooltip.cljs: capture the ts/raf handle and cancel it on cleanup.

AI-assisted-by: opencode-go/mimo-v2.5-pro

* 🐛 Fix leaked focus timers in dashboard sidebar navigation

Six sidebar navigation handlers scheduled setTimeout callbacks to mutate
tabindex/focus on React-managed title elements without cancelling prior
pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts)
the stale callbacks fired against unmounted DOM, desyncing React fiber tree
and triggering "removeChild" NotFoundError.

- sidebar-project*: cancel prior timer in on-key-down
- sidebar-search*: cancel prior timer in on-key-press
- sidebar-content*: cancel prior timer in go-projects-with-key,
  go-fonts-with-key, go-drafts-with-key, go-libs-with-key

Each handler now stores the timer handle in a component-level ref and
disposes any pending handle before scheduling a new one.

AI-assisted-by: opencode-go/mimo-v2.5-pro

* ♻️ Refactor sidebar focus timer handling into helpers

Extract the repeated dispose-before-schedule focus idiom into
schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable!
(app.util.dom). Replaces the six duplicated blocks and adds
mf/use-effect unmount cleanup to dispose any pending timer in the
three sidebar components, closing the remaining leak noted in the
original fix.

AI-assisted-by: opencode/hy3-free

* ♻️ Extract use-focus-timer-ref hook for sidebar components

Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in
sidebar-project*, sidebar-search*, and sidebar-content* with a shared
use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref
and disposes any pending timer on unmount via mf/with-effect, reading the
ref with mf/ref-val instead of deref. mf/use-effect is now a body-level
hook call rather than a let binding.

AI-assisted-by: opencode/hy3-free

* 📎 Add pr feedback fix
2026-07-28 11:06:00 +02:00
Andrey Antukh 6c2b61e1ad 🐛 Fix several issues in RPC command handlers (#10670)
* 🐛 Fix several issues in RPC command handlers

- Reject circular library references in link-file-to-library
- Add explicit team permission check in search-files
- Constrain search-term max length to 250 chars
- Include :deleted-at in file ETag for COND caching
- Move storage I/O outside DB transaction in create-file-thumbnail

AI-assisted-by: deepseek-v4-pro

* 📎 Check perms before circular link checks

* 🐛 Handle circular library reference error

Catch :circular-library-reference error from backend when linking
files to libraries. Show user-friendly toast notification instead of
propagating unhandled error. Add English and Spanish translations.

AI-assisted-by: qwen3.7-plus
2026-07-28 10:59:16 +02:00
Andrey Antukh 458fa41036 Merge remote-tracking branch 'origin/main' into staging 2026-07-28 10:42:03 +02:00
Alejandro Alonso 6063a45c3c 🐛 Fix area selection aborted by select-shapes interrupt (#10870)
* 🐛 Fix area selection aborted by select-shapes interrupt

Only emit :interrupt from select-shapes when edition mode is active.
Unconditional :interrupt (from #10798) made drag-stopper cancel the
marquee mid-drag.

* 🔧 Fix text editor v2 fill e2e test on develop
2026-07-28 10:26:31 +02:00
Andrey Antukh 63e0c536f0 Align event names column in format-last-events output
The third column (event name) in error report "last events" now starts at
a consistent position regardless of the delta value, by right-padding the
delta string to 10 characters. The first event always shows (+0ms).

Adds tests for empty, single, multi-event, and column alignment cases.

AI-assisted-by: deepseek-v4-flash
2026-07-27 13:24:11 +00:00
Andrey Antukh af120feb1f 🐛 Fix workspace crash and cleanup viewport_ref event/resize handling (#10721)
Replace `globals/document` and `globals/window` with
`js/document` and `js/window` in workspace.cljs, removing
the unused `app.util.globals` import. This avoids "can't
access dead object" errors in Firefox when navigating between
pages/files, matching the existing pattern used in
viewport/hooks.cljs.

Fix a leaked MOUSELEAVE listener in viewport_ref.cljs — the
ref callback added a new listener on every mount but never
unregistered the previous one. Now uses standard
.addEventListener/.removeEventListener with a React ref to
track the handler for proper cleanup.

Fix ResizeObserver cleanup in viewport_ref.cljs —
`init-observer` is now a private function that only creates
an observer when a node is provided, and cleanup is handled
via the ref callback on unmount.

AI-assisted-by: mimo-v2.5-pro
2026-07-27 12:11:12 +02:00
Andrey Antukh 5a0cee44b1 🐛 Harden frontend .getData call sites against undefined receivers (#10718)
* 🐛 Fix nil getData crash dropping ZIP without manifest.json

Add nil-guard in read-as-text to raise typed :invalid-entry error instead of calling (.getData nil writer) which produced a raw TypeError.

Made read-zip-manifest public (was defn-) with explicit detection of missing manifest.json, raising typed :invalid-penpot-file validation error. The existing catch path surfaces this hint as a friendly user error instead of the raw TypeError text.

Add regression tests for both paths. 374 users were affected, 704 occurrences across 2.17.0-RC2/RC3/RC4.

Fixes #10709.

AI-assisted-by: minimax-m3

* 🐛 Harden dnd/get-data against missing dataTransfer

When the sortable hook or any caller passes a synthetic event
without a dataTransfer property (e.g. a dragend fired after a drop
that has already cleared the transfer), the previous implementation
called .getData directly on the nil/undefined result and threw
"Cannot read properties of undefined (reading 'getData')".

Wrap the body in when-let so get-data returns nil cleanly when
dataTransfer is missing. All three current callers
(hooks.cljs:164, viewport/actions.cljs:531 and :562) already treat
the return value as optional via when-let / when, so no caller
breaks.

Add a regression test covering both the missing-dataTransfer case
and a real dataTransfer roundtrip.

AI-assisted-by: minimax-m3

* 🐛 Harden paste handler against missing clipboardData in forms

When a paste event arrives without a clipboardData property (e.g.
a programmatically dispatched ClipboardEvent in some browsers, or
edge cases like dragging a file with no text content), the previous
implementation called .getData directly on the nil/undefined
clipboardData and threw "Cannot read properties of undefined
(reading 'getData')".

Wrap the body in when-let so the paste logic is skipped entirely
when clipboardData is missing. The existing (string? paste-data)
guard in the inner when already tolerates nil; no other caller
behavior changes.

AI-assisted-by: minimax-m3

* 🐛 Harden paste handler against missing clipboardData in components/forms

Same defensive pattern as the main/ui/forms.cljs paste handler: wrap
the body in when-let so the .getData call is skipped when the
clipboardData property is missing on the paste event. Prevents the
raw "Cannot read properties of undefined (reading 'getData')"
TypeError for programmatic / edge-case paste events.

AI-assisted-by: minimax-m3

* 🐛 Harden v3 text editor paste and styles-fn against undefined receivers

Two related fixes for the "Cannot read properties of undefined
(reading 'getData')" family of bugs in the workspace text editor:

- v3_editor.cljs: wrap the paste body in when-let on clipboardData
  so .getData("text/plain") is never called on a nil receiver. The
  existing (when (and text (seq text))) guard already tolerates nil
  text; only the outer .getData call was unprotected.

- editor.cljs: add (and content ...) to the if branch in styles-fn
  so .getText and .getData are never called on a nil content. The
  else branch (legacy.txt/styles-to-attrs) is already the correct
  fallback for missing content.

Add a regression test that mirrors the fixed patterns and verifies
they no longer throw on synthetic events with no clipboardData or
nil content.

AI-assisted-by: minimax-m3

* 🐛 Harden get-editor-block-data and get-editor-block-type against nil block

getCurrentBlock from Draft.js can return undefined for an empty
selection (e.g. before any block is created). The previous
implementations called .getData / .getType directly on the result
and threw "Cannot read properties of undefined (reading
'getData')" / "...reading 'getType')".

Wrap both functions in (when (some? block) ...) so they return nil
cleanly. Callers in editor.cljs and text_editor.cljs already handle
nil results (render-block short-circuits via the case on type; the
text-data caller in text_editor.cljs lets nil flow up), so no
upstream change is required.

Add a regression test covering both functions with nil and
js/undefined input.

AI-assisted-by: minimax-m3

* 🐛 Harden draft-js block-data helpers against nil block

Three related fixes in the vendored draft-js package:

- mergeBlockData: early-return undefined when block is falsy.
  Without this, the first line (block.getData()) throws for callers
  that pass a nil block.

- splitBlockPreservingData: guard the blockMap.get(...) lookup. If
  the start key is stale (e.g. after a Modifier.splitBlock that
  doesn't actually produce the expected key), .get() returns
  undefined and the subsequent .getData() throws. Fall back to an
  empty Immutable Map for the block data.

- updateBlockData: short-circuit (return state unchanged) when
  mergeBlockData returns undefined. Without this, the chain
  newBlock.getData() would throw on the same nil-block case that
  mergeBlockData now guards.

These match the defensive nil-handling pattern used elsewhere in
the frontend (.getData callers) and protect against stale
selection keys in the Draft.js content state.

AI-assisted-by: minimax-m3
2026-07-27 10:56:27 +02:00
Andrey Antukh aad9bc8f65 📎 Fix playwright version 2026-07-27 10:45:59 +02:00
Andrey Antukh 0b072b22b0 📚 Update changelog 2026-07-27 09:01:43 +02:00
Andrey Antukh 170d129a5a Merge remote-tracking branch 'origin/staging' 2026-07-27 08:52:50 +02:00
Andrey Antukh eda5aa76f0 Merge remote-tracking branch 'origin/main' into staging 2026-07-27 08:52:31 +02:00
Andrey Antukh b54c1f316a Add minor improvements for error report script 2026-07-25 09:56:38 +02:00
Eva Marco d94b139071 🐛 Fix text edition state when relesecting (#10798)
* 🐛 Fix text edition state when relesecting

* 🐛 Fix CI
2026-07-24 12:15:16 +02:00
Andrey Antukh d5d6c61ba4 💄 Remove unused app.common.pprint import in errors.cljs
AI-assisted-by: deepseek-v4-flash
2026-07-23 15:05:18 +00:00
Andrey Antukh f7c312021b Improve error-reports CLI with streaming, time-range, and stats
Server changes:
- Switch list ordering from DESC to ASC (oldest first)
- Flip cursor direction to > for forward pagination
- Add 'until' param for server-side upper-bound filtering

CLI changes:
- Add --from/--to flags mapping to server's since/until
- Streaming output for --all and --format ndjson
- Add --format ndjson option (one JSON object per line)
- Add --normalize-hints flag to strip dynamic values
- Add --output flag to write list results to file
- Add 'stats' subcommand with aggregations (signature, host,
  tenant, version, source, kind, hour) reading from API, file, stdin
- stats input supports JSON, JSON array, and NDJSON formats

Test changes:
- Fix pagination assertions for ASC ordering

AI-assisted-by: mimo-v2.5-pro
2026-07-23 13:35:09 +00:00
Andrey Antukh 45405a018b 📎 Add minor changes on error reports 2026-07-23 13:06:46 +02:00
Alejandro Alonso 8bf411c347 🐛 Fix viewer wasm position data init (#10805) 2026-07-23 12:58:20 +02:00
Andrey Antukh 9b88d35664 Merge remote-tracking branch 'origin/main' into staging 2026-07-23 11:02:56 +02:00
Andrey Antukh e4d88b3ab4 🐛 Show proper version on error report api 2026-07-23 11:02:27 +02:00
Andrey Antukh 66b978c01e Add wall-clock timestamps to last-events buffer
Each event entry in `last-events` is now wrapped as
`{:name <event-type> :t (app.common.time/now)}` so every event carries a
wall-clock timestamp. A new helper `format-last-events` renders the
buffer as a multi-line string with ISO time and delta-since-previous-
event in ms, replacing the previous pprint dump in error reports.

This lets support/devs tell whether the events leading up to a crash
were spaced out (user action) or jammed together (runaway loop).

AI-assisted-by: minimax-m3
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-07-23 10:50:06 +02:00
Andrey Antukh 52e5e0bec6 🐛 Add more fields on table format on errors report cli client 2026-07-23 10:46:17 +02:00
Andrey Antukh 7430ffe718 ⬆️ Update opencode on devenv 2026-07-23 10:46:05 +02:00
Andrey Antukh b3091399cd Add timestamp to the frontend error report (#10772) 2026-07-23 10:26:41 +02:00
Andrey Antukh b6629c0034 🐛 Mark non-Penpot zip files as unknown in import worker (#10782)
Non-export zip files were tagged as :legacy-zip with the body attached,
causing downstream parsing to crash on unrecognized zip content. Now
they are marked :unknown, matching how other unrecognized formats are
handled, so the import fails gracefully.

AI-assisted-by: deepseek-v4-flash
2026-07-23 10:26:22 +02:00
Andrey Antukh 707cfac375 🐛 Throttle nudge stream to cap re-renders under fast key-repeat (#10736)
Holding an arrow key on a selection with a fast OS key-repeat rate
crashed the workspace with React error #185 (Maximum update depth
exceeded): each OS key-repeat event was converted 1:1 into a
`set-modifiers`/`set-wasm-modifiers` store write inside
`nudge-selected-shapes` with no throttle, starving the renderer.

The sibling mouse-driven resize/rotate/move paths got an `rx/sample`
throttle in PR #10560; the keyboard-nudge path was the only transform
stream left un-throttled. This change applies the same `rx/sample`
throttle to the nudge stream, mirroring the drag-path structure, and
adds a regression test guarding the final committed position
invariant under a burst of 20 `move-selected` events for both the WASM
and legacy (non-WASM) branches.

Closes #10726

AI-assisted-by: glm-5.2
2026-07-23 09:57:28 +02:00
Andrey Antukh 702a435569 📎 Add token id to the dom for more easy identify the token id 2026-07-23 09:29:45 +02:00
Andrey Antukh bac739717c 📎 Do not print exeption when cant setup reloading
happens only when production jar is executed
2026-07-23 09:29:09 +02:00
Andrey Antukh 4c222c469a 🐛 Move to runtime the repl reploading config 2026-07-23 09:04:57 +02:00
Andrey Antukh b1ccb252fd 📎 Update review command and code-quality skill 2026-07-23 08:31:36 +02:00
Andrey Antukh eb1e0ad186 🐛 Guard team-container* when team-id is not a uuid (#10645)
The dashboard route can be reached without a `:team-id` query parameter
(e.g. `/#/dashboard/recent`). When that happened, `team-container*` was
emitting `dtm/initialize-team` with a `nil` team-id, which set
`:current-team-id` to `nil` in the application state. The dashboard
and workspace initialize events then built `df/fetch-fonts` with a
`nil` team-id, producing a `:get-font-variants` RPC with empty params
`{}` that the backend rejected with HTTP 400.

Guard `team-container*` so it does not emit `initialize-team` /
`finalize-team` and does not render the children when `team-id` is
not a uuid. The `with-effect` body and the render are guarded
independently; the cleanup closure captures the same `team-id` as the
setup, so the finalize still fires correctly when transitioning between
valid teams.

AI-assisted-by: minimax-m3
2026-07-22 16:57:46 +02:00
Andrey Antukh 2523a72c32 :boolk: Update changelog 2026-07-22 16:26:14 +02:00
Andrey Antukh e076443eca Merge remote-tracking branch 'origin/main' into staging 2026-07-22 16:23:48 +02:00
Alejandro Alonso f21bd45893 🐛 Fix paths and layout performance and rendering on boolean exclusions (#10778)
* 🐛 Skip identity transforms in layout reflow propagation

Layout reflow emitted identity transforms for unchanged children, which
fanned out through the whole subtree on every drag frame and froze large
files in the WASM renderer.

* 🐛 Fix exclude boolean rendering in render WASM
2026-07-22 15:47:43 +02:00
Andrey Antukh 40ab48ea01 🐛 Fix inconsistencies on serenea memories 2026-07-22 15:23:25 +02:00
Andrey Antukh 2344ba22a6 🎉 Add error reports API and CLI tool
Implement RPC methods for querying server error reports with pagination
and filtering. Add CLI tool (tools/error-reports.mjs) for convenient
access with table and JSON output formats. Extract profile-id from audit
events and logging context for better error categorization. Build
improved HREF using request path when available.

AI-assisted-by: qwen3.7-plus
2026-07-22 14:18:51 +02:00
Andrey Antukh 018d840bab ♻️ Refactor internal organization of system initialization
Add the ability to suspend and add nrepl to the whole system

AI-assisted-by: qwen3.7-plus
2026-07-22 14:18:51 +02:00
Andrey Antukh e1f976aa2f Merge remote-tracking branch 'origin/staging' 2026-07-22 14:01:42 +02:00
Andrey Antukh 80c84a3331 🐛 Strip Authorization header when proxying asset redirects to S3 (#10777)
When nginx follows a backend 307 redirect to a presigned S3 URL, it was
forwarding the client's Authorization header to S3. Production S3 rejects
this because it sees two auth mechanisms (presigned URL signature +
Authorization header). MinIO in devenv is more lenient and ignores the
extra header.

Fix: add proxy_set_header Authorization "" in the @handle_redirect block.

Fixes #10776

AI-assisted-by: mimo-v2.5-pro
2026-07-22 14:00:35 +02:00
Andrey Antukh e4cddd8536 📚 Update serena memories 2026-07-22 10:45:18 +02:00
Andrey Antukh 72f3165341 📎 Update changelog 2026-07-22 10:45:00 +02:00
Andrey Antukh f98b1ddb8e Merge remote-tracking branch 'origin/main' into staging 2026-07-22 10:36:47 +02:00
Andrey Antukh f3bf24b4f6 ♻️ Consolidate dev tooling into scripts/ and reorganize docs
Move all development tools from tools/ to scripts/ for consistency.
Rename lint/fmt/check-fmt to lint-clj/fmt-clj/check-fmt-clj to clarify
they target Clojure specifically. Remove unused scripts (attach-opencode,
start-opencode, start-opencode-server) and the backport-commit skill.

Update all internal references across .serena/, AGENTS.md, and
CONTRIBUTING.md to point to the new script locations. Simplify
CONTRIBUTING.md by delegating module-specific fmt/lint instructions
to the respective serena memories.

AI-assisted-by: deepseek-v4-flash
2026-07-22 09:18:06 +02:00
Andrey Antukh 73bfc0dc15 🐛 Guard workspace-page* when file-id is not a uuid (#10655)
The workspace route can be reached without a `:file-id` query parameter
(e.g. `/#/workspace?team-id=...`). When that happened, `workspace*` was
emitting `dw/initialize-workspace` with a nil file-id, which stored nil
in `:current-file-id`. The `fetch-profiles` event then read nil from
state and called `:get-profiles-for-file-comments` with `{:file-id
nil}`, producing a 400 response.

Move the `use-equal-memo` calls for `file-id` and `page-id` from
`workspace*` up to `workspace-page*`, and guard the render with
`(when (uuid? file-id) ...)` so `workspace*` only mounts when `file-id`
is a valid uuid. Since `workspace*` never mounts with a nil `file-id`,
`initialize-workspace` is never emitted with nil, and the 400 is
prevented at the source.

AI-assisted-by: minimax-m3
2026-07-22 09:00:43 +02:00
175 changed files with 6228 additions and 1787 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Penpot API configuration for error-reports CLI tool
PENPOT_API_URI=http://localhost:3450
PENPOT_ACCESS_TOKEN=your-access-token-here
+7 -6
View File
@@ -62,14 +62,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Fmt
working-directory: ./backend
run: |
cljfmt check --parallel=true src/ test/
- name: Lint
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
clj-kondo --parallel --lint ../common/src/ src/
- name: Tests
working-directory: ./backend
@@ -81,4 +82,4 @@ jobs:
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation
clojure -M:dev:test
+58
View File
@@ -0,0 +1,58 @@
name: "CI: Exporter"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'exporter/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'exporter/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
name: "Exporter Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run lint:clj
- name: Tests
working-directory: ./exporter
run: |
./scripts/test
+2 -2
View File
@@ -9,6 +9,7 @@
.clj-kondo
.cpcache
.lsp
.env
.nrepl-port
.nyc_output
.rebel_readline_history
@@ -73,8 +74,6 @@ opencode.json
/frontend/target/
/frontend/test-results/
/frontend/.shadow-cljs
/other/
/scripts/
/nexus/
/tmp/
/vendor/**/target
@@ -102,5 +101,6 @@ opencode.json
/.opencode/plans
/.opencode/reports
/.opencode/prompts
/.ci-logs
/.codex/
/tools/__pycache__
+13 -17
View File
@@ -1,21 +1,17 @@
---
description: Review a commit (defaults to the last commit) with the code-review-and-quality skill across all five axes
agent: plan
subtask: true
---
Act as a senior software engineer and perform a thorough code review.
You are performing a code review of a git commit. You MUST conduct it using the **`code-review-and-quality`** skill (the five-axis review: correctness, readability, architecture, security, performance).
## Instructions
The user may specify a commit or revision range as an argument ($ARGUMENTS). If no argument is given, default to reviewing the **last commit** (`HEAD`, i.e. the changes introduced by `HEAD` vs its parent).
Workflow:
1. Determine the target to review:
- If the user provided a revision/range in $ARGUMENTS, use it.
- Otherwise, default to the last commit: review `HEAD` (the diff of `HEAD` against `HEAD~1`).
2. Inspect the change with `git show <target>` / `git diff <target>~1 <target>` and `git log -1 --stat <target>` to understand the intent and the files touched.
3. Invoke the **`code-review-and-quality`** skill and review the commit across all five axes. Categorize every finding as Critical / Required / Optional / Nit / FYI, and lead with correctness and security.
4. For each finding, state the axis it belongs to, the severity, and a concrete suggested fix (propose the structural remedy, not just the problem).
5. Conclude with a clear verdict: **Approve** (ready to merge) or **Request changes** (issues that must be addressed), and summarize the highest-leverage items.
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
2. Determine the diff or code to review from the provided context.
3. Read the diff and the surrounding context for each changed file.
4. Review across all five axes: correctness, readability, architecture, security, performance.
5. Produce the review using the **Review Output** format from the skill (Summary → Critical/High → Other Findings → Refactoring → Testing Recommendations → Positive Observations → Final Verdict).
6. For each finding: state the severity (Critical / High / Medium / Low / Suggestion), identify the file and line, describe failure circumstances, and propose a concrete fix.
7. Do not invent problems. Every finding must be real and actionable.
Do not modify any code and do not create a commit — this command only reviews.
## Context
$ARGUMENTS
+299
View File
@@ -0,0 +1,299 @@
import { tool } from "@opencode-ai/plugin"
import path from "path"
import { spawn } from "child_process"
const penpotPsqlTool = tool({
description:
"Execute a SQL command against the Penpot database. Uses the defaults from scripts/psql.",
args: {
sql: tool.schema
.string()
.describe("SQL command to execute"),
test: tool.schema
.boolean()
.describe("Use the penpot_test database")
.optional(),
},
async execute(args, context) {
const host = process.env.PENPOT_DB_HOST || "postgres"
const user = process.env.PENPOT_DB_USER || "penpot"
const db = args.test
? "penpot_test"
: process.env.PENPOT_DB_NAME || "penpot"
const password = process.env.PENPOT_DB_PASSWORD || "penpot"
const psqlArgs = ["-h", host, "-U", user, "-d", db, "-c", args.sql]
return new Promise((resolve) => {
let stdout = ""
let stderr = ""
const proc = spawn("psql", psqlArgs, {
cwd: context.worktree,
env: { ...process.env, PGPASSWORD: password },
})
proc.stdout.on("data", (data) => {
stdout += data.toString()
})
proc.stderr.on("data", (data) => {
stderr += data.toString()
})
proc.on("error", (error) => {
resolve(`Error: ${error.message}`)
})
proc.on("close", (exitCode) => {
const output =
exitCode === 0
? stdout.trim() || "Query executed successfully"
: `Error (exit ${exitCode}): ${
(stderr || stdout).trim() || "No error output"
}`
resolve(output)
})
})
},
})
const parenRepairTool = tool({
description:
"Fix mismatched parentheses/braces in Clojure files (.clj, .cljs, .cljc) then reformat with cljfmt.",
args: {
// A string is used instead of an array so OpenCode displays it
// in the generic tool invocation.
files: tool.schema
.string()
.describe(
"Comma-separated file paths to fix, for example: frontend/src/app/config.cljs, backend/src/core.clj",
)
.optional(),
code: tool.schema
.string()
.describe("Code string to fix via stdin")
.optional(),
},
async execute(args, context) {
const script = path.join(context.worktree, "scripts/paren-repair")
const files = args.files
? args.files
.split(",")
.map((file) => file.trim())
.filter(Boolean)
: []
const paramInfo =
files.length > 0
? `files=[${files.join(", ")}]`
: args.code !== undefined
? `code=(${args.code.length} chars)`
: "none"
return new Promise((resolve) => {
const childArgs =
files.length > 0
? [script, ...files]
: [script]
const proc = spawn("bb", childArgs, {
cwd: context.worktree,
})
let stdout = ""
let stderr = ""
proc.stdout.on("data", (data) => {
stdout += data.toString()
})
proc.stderr.on("data", (data) => {
stderr += data.toString()
})
proc.on("error", (error) => {
resolve(`Error: ${error.message}`)
})
proc.on("close", (exitCode) => {
const output =
exitCode === 0
? stdout.trim() || "No changes needed"
: `Error (exit ${exitCode}): ${
(stderr || stdout).trim() || "No error output"
}`
resolve(output)
})
// Close stdin in all cases so the process cannot wait indefinitely.
if (args.code !== undefined) {
proc.stdin.end(args.code)
} else {
proc.stdin.end()
}
})
},
})
export default async function plugin() {
return {
tool: {
"paren-repair": parenRepairTool,
"penpot-psql": penpotPsqlTool,
},
}
}
// import { tool } from "@opencode-ai/plugin"
// import path from "path"
// import { spawn } from "child_process"
// function formatFiles(files) {
// if (files.length === 0) return "stdin"
// // Keep the visible tool title reasonably short.
// if (files.length <= 3) return files.join(", ")
// return `${files.slice(0, 3).join(", ")} (+${files.length - 3} more)`
// }
// const parenRepairTool = tool({
// description:
// "Fix mismatched parentheses/braces in Clojure files, then reformat with cljfmt.",
// args: {
// files: tool.schema
// .array(tool.schema.string())
// .describe("Array of file paths to fix")
// .optional(),
// code: tool.schema
// .string()
// .describe("Code string to fix via stdin")
// .optional(),
// },
// async execute(args, context) {
// const script = path.join(context.worktree, "scripts/paren-repair")
// const files = (args.files ?? []).map((file) => {
// const absolute = path.isAbsolute(file)
// ? file
// : path.resolve(context.worktree, file)
// return path.relative(context.worktree, absolute)
// })
// const targetSummary =
// files.length > 0
// ? formatFiles(files)
// : args.code !== undefined
// ? `stdin (${args.code.length} chars)`
// : "no input"
// // This updates the tool-call title immediately, while it is running.
// await context.metadata({
// title: `Paren repair: ${targetSummary}`,
// metadata: {
// files,
// codeChars: args.code?.length,
// },
// })
// const childArgs =
// args.files && args.files.length > 0
// ? [script, ...args.files]
// : [script]
// return new Promise((resolve) => {
// const proc = spawn("bb", childArgs, {
// cwd: context.worktree,
// })
// let stdout = ""
// let stderr = ""
// if (args.code !== undefined) {
// proc.stdin.end(args.code)
// }
// proc.stdout.on("data", (data) => {
// stdout += data.toString()
// })
// proc.stderr.on("data", (data) => {
// stderr += data.toString()
// })
// proc.on("close", (exitCode) => {
// const successful = exitCode === 0
// const commandOutput = successful
// ? stdout.trim() || "No changes needed"
// : `Error (exit ${exitCode}): ${(stderr || stdout).trim()}`
// const parameterOutput =
// files.length > 0
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
// : args.code !== undefined
// ? `Input passed through stdin: ${args.code.length} characters`
// : "No files or stdin input were passed"
// resolve({
// title: `Paren repair: ${targetSummary}`,
// output: `${parameterOutput}\n\n${commandOutput}`,
// metadata: {
// files,
// codeChars: args.code?.length,
// exitCode,
// successful,
// },
// })
// })
// proc.on("error", (error) => {
// resolve({
// title: `Paren repair failed: ${targetSummary}`,
// output: [
// files.length > 0
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
// : `Input: ${targetSummary}`,
// `Failed to start bb: ${error.message}`,
// ].join("\n\n"),
// metadata: {
// files,
// codeChars: args.code?.length,
// successful: false,
// },
// })
// })
// })
// },
// })
// export default async function plugin() {
// return {
// tool: {
// "paren-repair": parenRepairTool,
// },
// }
// }
-85
View File
@@ -1,85 +0,0 @@
---
name: backport-commit
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
---
# Backport Commit
Port changes from a specific Git commit to the current branch by manually
applying the diff, avoiding `git cherry-pick` when it would introduce
complex conflicts.
## When to Use
Use this skill whenever the user asks to backport a commit, especially when:
- The commit touches multiple modules or files with significant divergence
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
- The target commit is old enough that conflicts are likely
- The commit introduces both source changes AND new files (tests, etc.)
- You need full control over how each hunk is applied
## Workflow
### 1. Identify the target commit
```bash
# Verify the commit exists and understand what it does
git log --oneline -1 <commit-sha>
# Get the full diff (including new/deleted files)
git show <commit-sha>
# Capture the original commit message for later reuse
git log --format='%B' -1 <commit-sha>
```
### 2. Identify affected modules
From the file paths in the diff, determine which Penpot modules are affected
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
files **before** making any changes. If a module has no `AGENTS.md`, skip
that step — verify with `ls <module>/AGENTS.md` first.
### 3. Read the current state of each affected file
For every file the diff touches, read the current version on disk to understand
context and ensure correct placement before editing.
### 4. Apply changes manually (the core of this approach)
Process every hunk in the diff using the appropriate tool:
| Diff action | Tool to use |
|-------------|-------------|
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
| Add new file | `write` — include proper license header and namespace conventions matching project style |
| Delete file | `bash rm <path>` |
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
> Use separate calls when hunks are far apart to keep `oldString` short and
> unambiguous.
Repeat until **all** hunks in the diff are ported.
### 5. Validate
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
indentation, verify the logic is still semantically correct. All checks must
pass before moving on.
### 6. Commit
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
(exclude unrelated untracked files) and provide the original commit message as
a reference, adapting it as needed for the target branch context.
## Key Principles
- **Context matters** — always read files before editing; never guess
indentation or surrounding code
- **Lint + format + test** — never skip validation before committing
- **Preserve intent** — keep the original commit message meaning; the
`commiter` agent handles formatting
+115 -257
View File
@@ -19,9 +19,18 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
- When refactoring existing code
- After any bug fix (review both the fix and the regression test)
## Core Principles
These principles underpin every axis. When in doubt, default to them.
- **DRY (Don't Repeat Yourself):** Every piece of knowledge has one authoritative representation. If the same logic appears in two places, extract it into a shared helper, model, or type. Reviewers: flag duplicated logic as a required change — it's not "just similar," it's drift that will diverge.
- **KISS (Keep It Simple, Stupid):** The simplest solution that works is the best solution. Complexity must earn its place. Reviewers: if you need more than one sentence to explain what a piece of code does, it's too complex — push for simplification before merge.
- **YAGNI (You Aren't Gonna Need It):** Don't add abstractions, hooks, or generalizations for hypothetical future use cases. Generalize on the third occurrence, not the first. Reviewers: delete speculative generality.
- **Don't invent problems:** Do not manufacture issues to produce more feedback. Every finding must be a real risk, a real readability barrier, or a real architectural concern — not a hypothetical or a stylistic preference disguised as a problem.
## The Five-Axis Review
Every review evaluates code across these dimensions:
Every review evaluates code across these dimensions.
### 1. Correctness
@@ -39,14 +48,13 @@ Can another engineer (or agent) understand this code without the author explaini
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
- Is the code organized logically (related code grouped, clear module boundaries)?
- Are there any "clever" tricks that should be simplified?
- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)
- **Are abstractions earning their complexity?** (Don't generalize until the third use case)
- Would comments help clarify non-obvious intent? (But don't comment obvious code.)
- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?
- **Is a new conditional bolted onto an unrelated flow?** That's a design smell, not a nit — push the logic into its own helper, state, or policy instead of tangling an existing path.
- **Do repeated conditionals on the same shape appear?** They signal a missing model or dispatcher. A "temporary" branch is usually permanent debt.
- **KISS check:** Is this the simplest approach that solves the problem? A 20-line straightforward function beats a 5-line clever one that requires a comment to explain.
- Could this be done in fewer lines? (1000 lines where 100 suffice is a failure)
- Are abstractions earning their complexity? (Don't generalize until the third use case)
- Is a new conditional bolted onto an unrelated flow? Push the logic into its own helper, state, or policy.
- Do repeated conditionals on the same shape appear? They signal a missing model or dispatcher.
- Are there dead code artifacts: no-op variables, backwards-compat shims, or `// removed` comments?
### 3. Architecture
@@ -54,16 +62,17 @@ Does the change fit the system's design?
- Does it follow existing patterns or introduce a new one? If new, is it justified?
- Does it maintain clean module boundaries?
- Is there code duplication that should be shared?
- **DRY check:** Is there existing code that does the same thing? Reuse the canonical helper instead of writing a near-duplicate. If two branches do nearly the same thing, collapse them.
- Are dependencies flowing in the right direction (no circular dependencies)?
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
- **Does this refactor reduce complexity or just relocate it?** Count the concepts a reader must hold to follow the change. If a "cleaner" version leaves that count unchanged, it isn't cleaner — prefer the restructuring that makes whole branches, modes, or layers disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
- **Is feature-specific logic leaking into a shared or general-purpose module?** Keep logic in its owning layer, reuse the existing canonical helper instead of a near-duplicate, and don't normalize architectural drift.
- **Are type boundaries explicit?** Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks that paper over an unclear invariant — making the boundary explicit often makes the surrounding control flow simpler.
- Does this refactor reduce complexity or just relocate it? Count the concepts a reader must hold. Prefer the restructuring that makes whole branches disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
- Is feature-specific logic leaking into a shared or general-purpose module?
- Are type boundaries explicit? Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks.
- **Structural remedies:** When you flag a problem, propose the move — not just the problem. Replace conditionals with dispatchers, collapse duplicate branches, separate orchestration from business logic, extract helpers, split large files. Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
### 4. Security
For detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?
For detailed security guidance, see `security-and-hardening`.
- Is user input validated and sanitized?
- Are secrets kept out of code, logs, and version control?
@@ -72,12 +81,9 @@ For detailed security guidance, see `security-and-hardening`. Does the change in
- Are outputs encoded to prevent XSS?
- Are dependencies from trusted sources with no known vulnerabilities?
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
- Are external data flows validated at system boundaries before use in logic or rendering?
### 5. Performance
Does the change introduce performance problems?
- Any N+1 query patterns?
- Any unbounded loops or unconstrained data fetching?
- Any synchronous operations that should be async?
@@ -85,24 +91,66 @@ Does the change introduce performance problems?
- Any missing pagination on list endpoints?
- Any large objects created in hot paths?
## Structural Remedies
## Review Process
When you flag a structural problem, propose the move — not just the problem. A review that only says "this is complex" leaves the author guessing. Reach for a named restructuring:
1. **Understand the intent** — What is this change trying to accomplish? What spec or task does it implement?
2. **Review tests first** — Tests reveal intent and coverage. Do they test behavior, not implementation details? Are edge cases covered?
3. **Review the implementation** — Walk through each file with the five axes in mind.
4. **Categorize findings** — Label every comment with its severity:
- **Replace a chain of conditionals** with a typed model or an explicit dispatcher.
- **Collapse duplicate branches** into a single clearer flow.
- **Separate orchestration from business logic** so each reads on its own.
- **Move feature-specific logic** out of a shared module into the package that owns the concept.
- **Reuse the canonical helper** instead of a bespoke near-duplicate.
- **Make a type boundary explicit** so downstream branching disappears.
- **Delete a pass-through wrapper** that adds indirection without clarifying the API.
- **Extract a helper, or split a large file** into focused modules.
| Prefix | Meaning | Author Action |
|--------|---------|---------------|
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
| **High:** | Required change | Must address before merge |
| **Medium:** | Should fix | Strongly recommended, not a blocker |
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Suggestion:** | Worth considering | Not required, but improves the code |
Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
5. **Verify the verification** — What tests were run? Did the build pass? Was the change tested manually? Screenshots for UI changes?
## Review Output
Structure every review using this format:
### Summary
Briefly explain what the code does and give an overall assessment.
### Critical and High-Priority Issues
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
### Other Findings
List medium- and low-priority issues, including maintainability and design concerns.
### Suggested Refactoring
Provide focused code changes or revised snippets. Preserve existing behavior unless a behavior change is explicitly justified.
### Testing Recommendations
Identify missing tests and describe specific test cases, including edge cases and failure scenarios.
### Positive Observations
Mention implementation choices that are clear, safe, efficient, or well designed. This is not fluff — it reinforces good patterns and tells the author what to keep doing.
### Final Verdict
Choose one:
- **Approve** — Ready to merge
- **Approve with minor changes** — Good to merge after addressing low/medium issues
- **Request changes** — Critical or high issues must be resolved before merge
## Change Sizing
Small, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:
Small, focused changes are easier to review, faster to merge, and safer to deploy.
```
~100 lines changed → Good. Reviewable in one sitting.
@@ -110,11 +158,9 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
~1000 lines changed → Too large. Split it.
```
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add.
**Watch file size, not just diff size.** Around 1000 *total* lines in a single file is a common inspection signal. When a change materially grows an already-large file, decompose first.
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
**Splitting strategies when a change is too large:**
**Splitting strategies:**
| Strategy | How | When |
|----------|-----|------|
@@ -123,164 +169,17 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.
**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.
**Separate refactoring from feature work.** A change that refactors and adds new behavior is two changes — submit them separately.
## Change Descriptions
Every change needs a description that stands alone in version control history.
- **First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC."
- **Body:** What is changing and why. Include context and reasoning not visible in the code itself.
- **Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Phase 1."
**First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC." Must be informative enough that someone searching history can understand the change without reading the diff.
## Dependencies
**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.
**Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Moving code from A to B," "Phase 1," "Add convenience functions."
## Review Process
### Step 1: Understand the Context
Before looking at code, understand the intent:
```
- What is this change trying to accomplish?
- What spec or task does it implement?
- What is the expected behavior change?
```
### Step 2: Review the Tests First
Tests reveal intent and coverage:
```
- Do tests exist for the change?
- Do they test behavior (not implementation details)?
- Are edge cases covered?
- Do tests have descriptive names?
- Would the tests catch a regression if the code changed?
```
### Step 3: Review the Implementation
Walk through the code with the five axes in mind:
```
For each file changed:
1. Correctness: Does this code do what the test says it should?
2. Readability: Can I understand this without help?
3. Architecture: Does this fit the system?
4. Security: Any vulnerabilities?
5. Performance: Any bottlenecks?
```
### Step 4: Categorize Findings
Label every comment with its severity so the author knows what's required vs optional:
| Prefix | Meaning | Author Action |
|--------|---------|---------------|
| *(no prefix)* | Required change | Must address before merge |
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
| **FYI** | Informational only | No action needed — context for future reference |
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review.
### Step 5: Verify the Verification
Check the author's verification story:
```
- What tests were run?
- Did the build pass?
- Was the change tested manually?
- Are there screenshots for UI changes?
- Is there a before/after comparison?
```
## Multi-Model Review Pattern
Use different models for different review perspectives:
```
Model A writes the code
Model B reviews for correctness and architecture
Model A addresses the feedback
Human makes the final call
```
This catches issues that a single model might miss — different models have different blind spots.
**Example prompt for a review agent:**
```
Review this code change for correctness, security, and adherence to
our project conventions. The spec says [X]. The change should [Y].
Flag any issues as Critical, Required, Optional, or Nit.
```
## Dead Code Hygiene
After any refactoring or implementation change, check for orphaned code:
1. Identify code that is now unreachable or unused
2. List it explicitly
3. **Ask before deleting:** "Should I remove these now-unused elements: [list]?"
Don't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.
```
DEAD CODE IDENTIFIED:
- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()
- OldTaskCard component in src/components/ — replaced by TaskCard
- LEGACY_API_URL constant in src/config.ts — no remaining references
→ Safe to remove these?
```
## Review Speed
Slow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.
- **Respond within one business day** — this is the maximum, not the target
- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day
- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed
- **Large changes:** Ask the author to split them rather than reviewing one massive changeset
## Handling Disagreements
When resolving review disputes, apply this hierarchy:
1. **Technical facts and data** override opinions and preferences
2. **Style guides** are the absolute authority on style matters
3. **Software design** must be evaluated on engineering principles, not personal preference
4. **Codebase consistency** is acceptable if it doesn't degrade overall health
**Don't accept "I'll clean it up later."** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.
## Honesty in Review
When reviewing code — whether written by you, another agent, or a human:
- **Don't rubber-stamp.** "LGTM" without evidence of review helps no one.
- **Don't soften real issues.** "This might be a minor concern" when it's a bug that will hit production is dishonest.
- **Quantify problems when possible.** "This N+1 query will add ~50ms per item in the list" is better than "this could be slow."
- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.
- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.
## Dependency Discipline
Part of code review is dependency review:
**Before adding any dependency:**
Before adding any dependency:
1. Does the existing stack solve this? (Often it does.)
2. How large is the dependency? (Check bundle impact.)
@@ -290,67 +189,14 @@ Part of code review is dependency review:
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
**Upgrading an existing dependency** is a code change like any other, and the riskiest upgrades are the ones merged in bulk with a message like "bump deps." Review them with the same discipline:
**Upgrading dependencies:**
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
- Read the changelog, not just the version number. Semver is a promise the maintainer may not have kept.
- One dependency per change. When a bulk bump breaks the build, you've lost which package did it.
- Let the tests decide a green suite before *and* after, not just "it installed."
- Review the lockfile diff, not just `package.json`. Commit it and never hand-edit it.
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict.
## The Review Checklist
```markdown
## Review: [PR/Change title]
### Context
- [ ] I understand what this change does and why
### Correctness
- [ ] Change matches spec/task requirements
- [ ] Edge cases handled
- [ ] Error paths handled
- [ ] Tests cover the change adequately
### Readability
- [ ] Names are clear and consistent
- [ ] Logic is straightforward
- [ ] No unnecessary complexity
### Architecture
- [ ] Follows existing patterns
- [ ] No unnecessary coupling or dependencies
- [ ] Appropriate abstraction level
- [ ] Refactors reduce complexity rather than relocate it
- [ ] No feature logic in shared modules; file stays within a healthy size
### Security
- [ ] No secrets in code
- [ ] Input validated at boundaries
- [ ] No injection vulnerabilities
- [ ] Auth checks in place
- [ ] External data sources treated as untrusted
### Performance
- [ ] No N+1 patterns
- [ ] No unbounded operations
- [ ] Pagination on list endpoints
### Verification
- [ ] Tests pass
- [ ] Build succeeds
- [ ] Manual verification done (if applicable)
### Verdict
- [ ] **Approve** — Ready to merge
- [ ] **Request changes** — Issues must be addressed
```
## See Also
- For detailed security review guidance, see `security-and-hardening`
For supply-chain risk triage, follow the `security-and-hardening` skill.
## Common Rationalizations
@@ -358,13 +204,16 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
|---|---|
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. |
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. |
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve — look for the version where branches disappear. |
| "It's only a small addition to this file" | Small diffs still push files past a healthy size and bolt branches onto unrelated flows. Judge the resulting structure, not the diff size. |
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog; semver doesn't guarantee no breakage. |
| "I'll upgrade everything in one PR to save time" | A bulk bump that breaks the build hides which package did it. One dependency per change keeps the cause and the revert clean. |
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture, security, or readability problems. |
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve. |
| "It's only a small addition to this file" | Small diffs still push files past healthy size and bolt branches onto unrelated flows. |
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog. |
| "I'll upgrade everything in one PR" | A bulk bump hides which package broke the build. One per change. |
| "It's duplicated but it's only two places" | Two becomes three becomes five. Extract now, before the copies diverge. |
| "The abstraction is future-proof" | YAGNI. Delete speculative generality — generalize on the third occurrence, not the first. |
| "It's clever but efficient" | Cleverness is a readability tax. If it needs a comment to understand, simplify it. |
## Red Flags
@@ -374,14 +223,11 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
- Security-sensitive changes without security-focused review
- Large PRs that are "too big to review properly" (split them)
- No regression tests with bug fix PRs
- Review comments without severity labels — makes it unclear what's required vs optional
- Accepting "I'll fix it later" — it never happens
- A refactor that moves code around without reducing the number of concepts a reader must hold
- A change that grows an already-large file instead of decomposing it
- New conditionals scattered into unrelated code paths (a missing abstraction)
- A bespoke helper that duplicates an existing canonical one, or feature logic placed in a shared module
- A bulk "bump dependencies" PR with no changelog review and no per-package isolation
- A lockfile change that's hand-edited, uncommitted, or merged without reviewing its diff
- A bespoke helper that duplicates an existing canonical one
- A bulk "bump dependencies" PR with no changelog review
## Verification
@@ -392,6 +238,18 @@ After review is complete:
- [ ] Tests pass
- [ ] Build succeeds
- [ ] The verification story is documented (what changed, how it was verified)
- [ ] Dependency upgrades were reviewed against their changelog, isolated per package, and verified by a green suite with the lockfile diff reviewed
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
**Presumptive blockers:** surface and propose the simpler design for each of these; escalate to Required only when the change actively makes structure worse: a refactor that relocates complexity instead of reducing it; a change that pushes a file past the size boundary with no decomposition; feature logic added to a shared module; a near-duplicate of an existing canonical helper; a silent fallback that hides an unclear invariant.
## Multi-Model Review Pattern
Use different models for different review perspectives:
```
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
```
Different models have different blind spots.
## See Also
- For detailed security review guidance, see `security-and-hardening`
+8 -8
View File
@@ -1,19 +1,19 @@
---
name: nrepl-eval
description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool.
description: Evaluate Clojure code via nREPL using the standalone scripts/nrepl-eval.mjs CLI tool.
---
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs`.
`scripts/nrepl-eval.mjs`.
Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`)
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
## Quick Reference
```bash
./tools/nrepl-eval.mjs [options] [<code>]
./scripts/nrepl-eval.mjs [options] [<code>]
```
| Flag | Description | Default |
@@ -30,8 +30,8 @@ Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-
## Examples
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
./tools/nrepl-eval.mjs -e
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
./scripts/nrepl-eval.mjs -e
```
+15 -15
View File
@@ -13,7 +13,7 @@ Fetch information from Taiga public API for the **Penpot** project
## Prerequisites
- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only)
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
## Quick Start
@@ -21,17 +21,17 @@ The easiest way is to use the bundled Python script:
```bash
# Pass a Taiga URL directly
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Or use "<type> <ref>" syntax
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
python3 scripts/taiga.py us 14128
python3 scripts/taiga.py task 13648
# Add --json for raw output
python3 tools/taiga.py --json issue 13714
python3 scripts/taiga.py --json issue 13714
# See full usage
python3 tools/taiga.py --help
python3 scripts/taiga.py --help
```
## URL Pattern Reference
@@ -51,30 +51,30 @@ To extract the **type** and **ref** from a URL:
## Python Script Reference
The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI
The `scripts/taiga.py` script wraps the Taiga API into a single convenient CLI
with sensible defaults.
### Usage
```
python3 tools/taiga.py <taiga-url>
python3 tools/taiga.py <type> <ref>
python3 tools/taiga.py [--json] <taiga-url>
python3 tools/taiga.py [--json] <type> <ref>
python3 scripts/taiga.py <taiga-url>
python3 scripts/taiga.py <type> <ref>
python3 scripts/taiga.py [--json] <taiga-url>
python3 scripts/taiga.py [--json] <type> <ref>
```
### Examples
```bash
# By URL (recommended — no need to think about type/ref)
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# By type and ref
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
python3 scripts/taiga.py us 14128
python3 scripts/taiga.py task 13648
# Raw JSON output
python3 tools/taiga.py --json issue 13714
python3 scripts/taiga.py --json issue 13714
```
### Output
+73 -22
View File
@@ -20,7 +20,7 @@ primary link, with the fix PR inline on the same line.
- `gh` CLI authenticated (`gh auth status`)
- Python 3.8+
- `tools/gh.py` helper script available
- `scripts/gh.py` helper script available
## Workflow
@@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching
```bash
# All closed issues (default)
python3 tools/gh.py issues "2.16.0"
python3 scripts/gh.py issues "2.16.0"
# Include open issues too
python3 tools/gh.py issues "2.16.0" --state all
python3 scripts/gh.py issues "2.16.0" --state all
# Exclude entries that should not go in the changelog
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
```
**Exclusion rules (issue-level):**
@@ -68,7 +68,7 @@ If updating from an existing `CHANGES.md`, find issues in the milestone that
are NOT yet referenced in the changelog:
```bash
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
```
This returns a filtered JSON array with only the missing issues.
@@ -85,23 +85,23 @@ community contribution attribution, or to read the PR body for
```bash
# One or more PR numbers
python3 tools/gh.py prs 9179 9204 9311
python3 scripts/gh.py prs 9179 9204 9311
# From a file
python3 tools/gh.py prs --file prs.txt
python3 scripts/gh.py prs --file prs.txt
# From stdin
cat prs.txt | python3 tools/gh.py prs --stdin
cat prs.txt | python3 scripts/gh.py prs --stdin
```
The `prs` command also supports listing all PRs in a milestone in one call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
python3 scripts/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py prs --milestone "2.16.0" --state all
```
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
@@ -113,13 +113,13 @@ You can also list all PRs in a milestone in a single call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
python3 scripts/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py prs --milestone "2.16.0" --state all
# Open PRs only
python3 tools/gh.py prs --milestone "2.16.0" --state open
python3 scripts/gh.py prs --milestone "2.16.0" --state open
```
The milestone path uses paginated GraphQL on the milestone's `pullRequests`
@@ -147,6 +147,12 @@ belongs to.
The `gh.py` issues command already includes `issue_type` in every entry's
output. **No separate GraphQL query is needed.**
**Preserve highlighted entries:** If an entry is already featured in
`### :rocket: Epics and highlights`, keep it in that section when refreshing a
changelog version. Do not remove a highlighted entry just because issue type
categorization would otherwise place it under `### :sparkles: New features &
Enhancements`.
**Community contribution attribution:** If the issue or its fix PR has the
`community contribution` label, add an attribution `(by @<github_username>)`
on the changelog entry line, **before** the GitHub issue/PR references.
@@ -155,7 +161,7 @@ The attribution should reference the **PR author**, not the issue author.
The `prs` subcommand includes the `author` field — use that:
```bash
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
python3 scripts/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
```
Placement in the entry line:
@@ -190,7 +196,7 @@ only reference **merged** PRs. Verify before writing:
```bash
# Collect all PR numbers from the candidate entries and check them
python3 tools/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
python3 scripts/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
import json, sys
for pr in json.load(sys.stdin):
if pr['state'] != 'MERGED':
@@ -206,6 +212,37 @@ superseded it:
Replace the reference in the changelog entry with the correct merged PR number.
### 5b. Security advisory (GHSA) entries
Security advisories fixed in a release are documented in the changelog even
though they are **neither milestone issues nor PRs**. The GHSA ID and its
description are supplied by the user or the release notes — they never come
from the milestone fetch in step 2.
**Format** (matches the existing precedent in `CHANGES.md`, e.g. the
`create-font-variant` arbitrary file read advisory):
```markdown
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
```
Rules:
- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link**
only the advisory URL.
- The advisory may be **draft/unpublished** at changelog time (the URL 404s
publicly). Do **not** web-fetch or verify the URL, and do **not** drop the
entry because of that. Rely on the GHSA ID provided by the user.
- Derive the description from the supplied advisory title, imperative mood and
user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`).
- These entries are **invisible to the automation**: they are not returned by
`gh.py issues`, not matched by `--compare` (step 3), not part of the PR
cross-reference (step 10), and not scanned by the anomaly-report regexes
(step 11, which only match `issues/` and `pull/` links). Add them manually.
- During pre-flight checks (step 6a) apply only the **backport/duplicate**
check: if the same GHSA already appears in an earlier version section, remove
it from the current section. Their absence from milestone cross-references
is expected, not an anomaly.
### 6. Read the current CHANGES.md
Read the top of `CHANGES.md` to understand the existing format and find the
@@ -265,7 +302,7 @@ section) or in the candidate set for the current milestone, check:
current milestone since the changelog was last updated (e.g., a fix
arrived late and the issue was reassigned to a future milestone)?
- Verify the issue is still in the current milestone via
`python3 tools/gh.py issues <MILESTONE> --state all`. If it's no
`python3 scripts/gh.py issues <MILESTONE> --state all`. If it's no
longer there, remove the entry from the current section. (If the
target section doesn't exist yet, the entry is simply dropped.)
@@ -337,7 +374,7 @@ cross-reference to catch gaps:
```bash
# List all merged PRs in the milestone
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
# Extract PR numbers from the changelog section
python3 -c "
@@ -378,7 +415,7 @@ changelog or is legitimately excluded (check its labels).
Also verify that no closed-unmerged PRs remain in the changelog:
```bash
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
import json, sys
data = json.load(sys.stdin)
closed = [p for p in data if p['state'] == 'CLOSED']
@@ -394,6 +431,8 @@ if closed:
- ✅ Every merged milestone PR is either in the changelog or excluded by label
- ✅ PR and issue counts are internally consistent
- ✅ No false-positive PR-to-issue associations
- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the
cross-reference is intentional (see step 5b)
## Version section template
@@ -404,8 +443,12 @@ if closed:
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
```
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See
step 5b.
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
After all edits and cross-referencing are complete, generate a structured
@@ -483,13 +526,13 @@ def fmt_issue_list(nums):
# --- Fetch milestone data ---
result = subprocess.run(
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
["python3", "scripts/gh.py", "issues", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_issues = json.loads(result.stdout)
issue_by_num = {i['number']: i for i in all_issues}
result = subprocess.run(
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
["python3", "scripts/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_prs = json.loads(result.stdout)
pr_by_num = {p['number']: p for p in all_prs}
@@ -726,9 +769,17 @@ self-contained and clickable in any Markdown viewer.
Taiga description text or by searching GitHub PRs that reference the Taiga
URL. Replace the Taiga reference with the GitHub issue link and add the PR
reference if applicable.
- **Security advisory (GHSA) entries.** Advisories fixed in the release are
listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or
PR link**, even though they are not in the milestone. The GHSA ID and
description come from the user — do **not** fetch or verify the URL, and do
not drop a draft (unpublished) advisory. Precedent:
`- Fix arbitrary file read security issue on create-font-variant rpc method
(https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`.
See step 5b.
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
before making edits, don't rely on cached data.
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
milestone issue listing and PR detail fetching. It handles GraphQL
pagination, batching, and label filtering automatically.
- **Verify PR merge status.** Not all closing PRs are merged — community PRs
@@ -739,7 +790,7 @@ self-contained and clickable in any Markdown viewer.
labels. Check both.
- **Cross-reference milestone PRs, not just issues.** The `--compare` flag on
the `issues` command only compares issue numbers. Merged PRs not linked to
any milestone issue can be missed. Use `python3 tools/gh.py prs --milestone`
any milestone issue can be missed. Use `python3 scripts/gh.py prs --milestone`
for a full PR cross-reference.
- **False-positive PR-to-issue associations.** A PR may claim to close an
issue from a different project or context. If the PR title and issue title
+12 -12
View File
@@ -39,8 +39,8 @@ Backend RPC command areas without focused memories include access tokens, binfil
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump
the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`).
For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump
the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`).
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
@@ -56,14 +56,14 @@ In devenv, backend nREPL is exposed on port 6064.
### Non-interactive eval (preferred for agents)
`./tools/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
`./scripts/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
```bash
./tools/nrepl-eval.mjs '(+ 1 2)' # single expression
./tools/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
./tools/nrepl-eval.mjs -e # inspect last exception (*e)
./tools/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
./tools/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
./scripts/nrepl-eval.mjs '(+ 1 2)' # single expression
./scripts/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
./scripts/nrepl-eval.mjs -e # inspect last exception (*e)
./scripts/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
./scripts/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
(def x 10)
(+ x 20)
EOF
@@ -92,12 +92,12 @@ Fixtures can populate local data for manual testing/perf work. From the backend
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Linting:** `pnpm run lint` from the repository root.
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
**Before linting:** if delimiter errors are suspected (after LLM edits), run
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:tools/paren-repair`.
`scripts/paren-repair` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:scripts/paren-repair`.
## Testing
+25 -22
View File
@@ -22,23 +22,23 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- Align `let` binding values: when a `let` form has multiple bindings spanning
several lines, align the value forms to the same column with spaces.
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
See `mem:tools/paren-repair` for usage.
fix them with `scripts/paren-repair` BEFORE running lint/format checks.
See `mem:scripts/paren-repair` for usage.
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
# Project modules
This is a monorepo. Principles that apply to one module do *not* generally apply to others. Do not make assumptions.
- `frontend/`: ClojureScript + SCSS SPA/design editor.
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers.Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities.
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend.
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export.
- `mcp/`: TypeScript Model Context Protocol integration.
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types.
- `library/`: design library workflows.
- `docs/`: documentation site.
- `frontend/`: ClojureScript + SCSS SPA/design editor; core conventions: `mem:frontend/core`.
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers; core conventions: `mem:backend/core`. Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities; core conventions: `mem:common/core`.
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend; core conventions: `mem:render-wasm/core`.
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export; core conventions: `mem:exporter/core`.
- `mcp/`: TypeScript Model Context Protocol integration; core conventions: `mem:mcp/core`.
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
- `library/`: design library workflows; core conventions: `mem:library/core`.
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
The memory is structured in a way that you can get the critical information about the
module. You can read it from `mem:<MODULE>/core`
@@ -52,20 +52,23 @@ module. You can read it from `mem:<MODULE>/core`
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
# Dev tools
# Dev Scripts (scripts/)
- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
See `mem:tools/nrepl-eval`.
- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files
See `mem:scripts/nrepl-eval`.
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files
and reformat with cljfmt. Run before lint checks when LLM edits break parens.
See `mem:tools/paren-repair`.
- `tools/psql` — PostgreSQL client wrapper with devenv defaults.
Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`.
- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the
Penpot Taiga project without authentication. See `mem:tools/taiga`.
- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`.
See `mem:scripts/paren-repair`.
- `scripts/psql` — PostgreSQL client wrapper with devenv defaults.
Companion: `scripts/db-schema` for DDL dumps. See `mem:scripts/psql`.
- `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/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`.
# Dependency graph
@@ -1,4 +1,4 @@
# Docs Workflow
# Docs
`docs/`: Penpot documentation site; Eleventy.
@@ -16,4 +16,4 @@ From `docs/`:
- Build: `pnpm run build`.
- Watch: `pnpm run watch`.
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
+3 -2
View File
@@ -5,9 +5,10 @@
## Layout and commands
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Exporter test conventions and CI: `mem:exporter/testing`.
## HTTP and browser pool
@@ -31,4 +32,4 @@
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
+16
View File
@@ -0,0 +1,16 @@
# Exporter Testing
- READ `mem:testing` first.
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
- Register every test namespace in `exporter-tests.runner`.
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
- For iterative focused runs, build once and reuse the compiled bundle.
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.
+2 -2
View File
@@ -27,9 +27,9 @@ From `frontend/`:
- Translation formatting after i18n edits: `pnpm run translations`.
**Before linting:** if delimiter errors are suspected (after LLM edits, or
lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the
lint/compiler reports syntax errors), run `scripts/paren-repair` on the
affected files first. Delimiter errors produce misleading linter output.
See `mem:tools/paren-repair`.
See `mem:scripts/paren-repair`.
## Focused memory routing
@@ -11,10 +11,10 @@ The latter is needed because syntax errors in parentheses give an uninformative
tool can often find the exact location of such errors.
When delimiter errors are detected (typically from lint or compiler output),
fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses`
fix the affected files with `scripts/paren-repair`. The `clj_check_parentheses`
MCP tool can also pinpoint the error location when available, but it is not
required — standard build errors are usually enough.
See `mem:tools/paren-repair`.
See `mem:scripts/paren-repair`.
## Runtime patching with `set!`
+289
View File
@@ -0,0 +1,289 @@
# Error Reports CLI Tool
`scripts/error-reports.mjs` is a Node.js CLI tool for querying Penpot error reports via the RPC API. Provides access to error logs with filtering, pagination, and multiple output formats.
## When to use
- Querying error reports from the database for debugging or analysis
- Filtering errors by source, kind, tenant, or backend version
- Exporting error data in JSON, NDJSON, or table format
- Computing error statistics (top signatures, version, source, audit-log kind, hourly distribution, bursts, heatmap)
- Investigating specific error reports by ID
## Prerequisites
- Node.js with `commander` and `dotenv` packages installed (in root `package.json`)
- Running Penpot backend with error-reports RPC endpoints
- Access token with `error-reports:read` permission
## Configuration
Create a `.env` file in the project root:
```bash
PENPOT_API_URI=http://localhost:3450
PENPOT_ACCESS_TOKEN=<your-token>
```
Grant the required permission to your access token:
```sql
UPDATE access_token
SET perms = ARRAY['error-reports:read']::text[],
updated_at = now()
WHERE id = '<token-uuid>';
```
## Usage
```bash
./scripts/error-reports.mjs <command> [options]
```
### Commands
#### `list` - List error reports with pagination and filters
```bash
./scripts/error-reports.mjs list [options]
```
**Options:**
| Flag | Description | Default |
|------|-------------|---------|
| `-l, --limit <n>` | Max items per page (max: 200) | `50` |
| `--from <date>` | ISO timestamp — oldest boundary (items after this) | — |
| `--to <date>` | ISO timestamp — newest boundary (items before this) | — |
| `--since <date>` | ISO timestamp — explicit cursor for manual pagination | — |
| `--since-id <uuid>` | Fetch errors after this ID (cursor pagination) | — |
| `-s, --source <name>` | Filter by source (see source names below) | — |
| `-p, --profile-id <uuid>` | Filter by profile ID | — |
| `-k, --kind <kind>` | Filter by kind (string) | — |
| `-t, --tenant <tenant>` | Filter by tenant (string) | — |
| `--version <version>` | Filter by version | — |
| `--hint <text>` | Filter by hint (ILIKE match) | — |
| `-a, --all` | Fetch all pages automatically (streams output) | `false` |
| `-f, --format <type>` | Output format: `json`, `table`, or `ndjson` | `table` |
| `--normalize-hints` | Normalize hints by stripping dynamic values | `false` |
| `-o, --output <file>` | Write output to file instead of stdout | — |
| `--env <path>` | Custom .env file path | `.env` |
| `-h, --help` | Show help message | — |
**Streaming behavior:** With `--all`, output must be `ndjson` or `table`; `--all --format json` is rejected because `--all` streams output. `--all --format table` prints rows immediately. `--format ndjson` always streams one JSON object per line.
#### `get` - Get a single error report by ID
```bash
./scripts/error-reports.mjs get [options]
```
**Options:**
| Flag | Description | Required |
|------|-------------|----------|
| `--id <uuid>` | Error report ID | Yes (or --error-id) |
| `--error-id <id>` | Error report error-id | Yes (or --id) |
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `table`) |
| `--env <path>` | Custom .env file path | No (default: `.env`) |
| `-h, --help` | Show help message | No |
#### `stats` - Compute error report statistics
```bash
./scripts/error-reports.mjs stats [options]
```
Reads from `--input <file>`, stdin (piped), or fetches from API. Computes aggregations by signature, version, source, audit-log kind, hour, optional 5-minute bursts, and optional day-of-week × hour heatmap.
**Options:**
| Flag | Description | Default |
|------|-------------|---------|
| `--from <date>` | Start of interval (ISO timestamp) | — |
| `--to <date>` | End of interval (ISO timestamp) | — |
| `--limit <n>` | Items per page when fetching from API | `200` |
| `--input <file>` | Read from local JSON/NDJSON file instead of API | — |
| `--burst` | Detect 5-minute windows above 3× the average rate | `false` |
| `--heatmap` | Show day-of-week × hour-of-day heatmap | `false` |
| `-f, --format <type>` | Output format: `json` or `table` | `table` |
| `--env <path>` | Custom .env file path | `.env` |
## Source Names
The `--source` filter accepts these values:
- `logging`
- `audit-log`
- `rlimit`
## Hint Normalization
With `--normalize-hints` (or always in `stats`), hints are normalized by stripping dynamic values:
1. File IDs in file-id context → `<file-id>`
2. UUIDs (8-4-4-4-12 hex) → `<uuid>`
3. Numeric IDs in parentheses `(12345)``(<id>)`
4. Elapsed times (`7.5s`, `2m3.027s`) → `<elapsed>`
5. URIs (`https://...`) → `<uri>`
6. Unicode quotes and whitespace normalized
## Examples
### List recent errors
```bash
./scripts/error-reports.mjs list --limit 10
```
### Time-range query (today)
```bash
./scripts/error-reports.mjs list --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --all
```
### Stream all errors as NDJSON
```bash
./scripts/error-reports.mjs list --all --format ndjson > errors.ndjson
```
### Save to file with --output
```bash
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
./scripts/error-reports.mjs list --format json -o errors.json
```
### Filter by source
```bash
./scripts/error-reports.mjs list --source audit-log --limit 20
```
### Filter by kind
```bash
./scripts/error-reports.mjs list --kind exception-page
```
### Filter by tenant
```bash
./scripts/error-reports.mjs list --tenant production
```
### Filter by version
```bash
./scripts/error-reports.mjs list --version 2.1.0
```
### Search by hint (partial match)
```bash
./scripts/error-reports.mjs list --hint "NullPointerException"
```
### Fetch all errors with pagination
```bash
./scripts/error-reports.mjs list --all
```
### Get specific error by ID
```bash
./scripts/error-reports.mjs get --id 550e8400-e29b-41d4-a716-446655440000
```
### Output as JSON
```bash
./scripts/error-reports.mjs list --limit 5 --format json
```
### Combine filters
```bash
./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50
```
### Stats with burst and heatmap analysis
```bash
./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --burst --heatmap
```
### Stats from file
```bash
./scripts/error-reports.mjs stats --input errors.json
```
### Stats from pipe
```bash
./scripts/error-reports.mjs list --all --format json | ./scripts/error-reports.mjs stats
```
## Output Formats
### Table (default)
Human-readable table format for terminal display. With `--all`, rows stream as they arrive.
### JSON
Single page: `{items: [...], nextSince, nextId}`. `--all` cannot be combined with `--format json`; use `--format ndjson` for streaming.
### NDJSON
One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`.
## Pagination
The server returns items in **ascending** order (oldest first). Cursor pagination uses `--since` / `--since-id` to fetch the next page of newer items.
### Manual pagination
Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the response:
```bash
./scripts/error-reports.mjs list --limit 50
# Use nextSince and nextId from response
./scripts/error-reports.mjs list --limit 50 --since "2026-01-20T10:29:00Z" --since-id "next-uuid"
```
### Automatic pagination
Use `--all` to fetch all pages automatically (streams output):
```bash
./scripts/error-reports.mjs list --all
```
### Time-range queries
Use `--from` and `--to` to bound the query. These map to the server's `--since` and `--until` parameters:
```bash
./scripts/error-reports.mjs list --from 2026-07-20T00:00:00Z --to 2026-07-23T23:59:59Z --all
```
## Key principles
- **Authentication required** - Uses access token with `error-reports:read` permission
- **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file
- **Table is default format** - Use `--format json` for structured JSON, `--format ndjson` for streaming
- **Streaming with --all** - Items print as they arrive, no buffering. Use `--format ndjson` or `--format table`; `--all --format json` is rejected.
- **Filters are combinable** - All filter options can be used together
- **Both flag formats supported** - `--option=value` and `--option value` both work
- **Ascending order** - Server returns oldest items first (changed from DESC)
## Error handling
The tool provides helpful error messages for common issues:
- **Missing configuration**: Shows setup instructions for `.env` file
- **Authentication errors (401)**: Indicates invalid or expired token
- **Authorization errors (403)**: Indicates missing `error-reports:read` permission
- **RPC errors**: Displays error code and message from the API
## Integration with other scripts
- **jq**: Pipe NDJSON output to `jq` for further processing
```bash
./scripts/error-reports.mjs list --all --format ndjson | jq -c '{id, hint}'
```
- **stats from pipe**: Fetch data once, compute stats
```bash
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
```
- **stats from NDJSON pipe**: Works with NDJSON format too
```bash
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
```
- **grep/search**: Filter output by specific patterns
- **--output**: Save to file without shell redirection
```bash
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
```
@@ -1,6 +1,6 @@
# GitHub operations helper
`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
`scripts/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
repository via GraphQL and REST APIs through the authenticated `gh` CLI.
## When to use
@@ -23,24 +23,24 @@ List issues in a milestone, with filtering by state, labels, and project status.
```bash
# Closed issues in a milestone (default)
python3 tools/gh.py issues "2.16.0"
python3 scripts/gh.py issues "2.16.0"
# All issues in a milestone
python3 tools/gh.py issues "2.16.0" --state all
python3 scripts/gh.py issues "2.16.0" --state all
# Issues with no milestone
python3 tools/gh.py issues none
python3 tools/gh.py issues none --state open
python3 scripts/gh.py issues none
python3 scripts/gh.py issues none --state open
# Filter by label (include only)
python3 tools/gh.py issues "2.16.0" --label "bug"
python3 tools/gh.py issues "2.16.0" --label "bug,regression"
python3 scripts/gh.py issues "2.16.0" --label "bug"
python3 scripts/gh.py issues "2.16.0" --label "bug,regression"
# Exclude by label
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
# Show only issues NOT yet in CHANGES.md
python3 tools/gh.py issues "2.16.0" --compare CHANGES.md
python3 scripts/gh.py issues "2.16.0" --compare CHANGES.md
```
**Default filters** (override with flags):
@@ -55,19 +55,19 @@ Fetch PR details by number or by milestone.
```bash
# Fetch specific PRs
python3 tools/gh.py prs 9179 9204 9311
python3 scripts/gh.py prs 9179 9204 9311
# Read PR numbers from file
python3 tools/gh.py prs --file prs.txt
python3 scripts/gh.py prs --file prs.txt
# Read PR numbers from stdin
cat prs.txt | python3 tools/gh.py prs --stdin
cat prs.txt | python3 scripts/gh.py prs --stdin
# All PRs in a milestone (default: merged only)
python3 tools/gh.py prs --milestone "2.16.0"
python3 scripts/gh.py prs --milestone "2.16.0"
# All PRs in a milestone (all states)
python3 tools/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py prs --milestone "2.16.0" --state all
```
**Output**: JSON array to stdout; progress to stderr.
@@ -1,7 +1,7 @@
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs` — a standalone CLI application.
`scripts/nrepl-eval.mjs` — a standalone CLI application.
Session state (defs, in-ns, etc.) persists across invocations via a stored
session ID, so you can build up state incrementally.
@@ -9,9 +9,9 @@ session ID, so you can build up state incrementally.
## Usage
```bash
node tools/nrepl-eval.mjs [options] [<code>]
node scripts/nrepl-eval.mjs [options] [<code>]
# or
./tools/nrepl-eval.mjs [options] [<code>]
./scripts/nrepl-eval.mjs [options] [<code>]
```
## Options
@@ -47,37 +47,37 @@ Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
carries across calls automatically:
```bash
./tools/nrepl-eval.mjs '(def x 42)'
./tools/nrepl-eval.mjs 'x'
./scripts/nrepl-eval.mjs '(def x 42)'
./scripts/nrepl-eval.mjs 'x'
# => 42
```
Reset the session to start fresh:
```bash
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
./scripts/nrepl-eval.mjs --reset-session '(def x 0)'
```
### Evaluate code
**Single expression (inline) — uses default port 6064:**
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
```
**Backend nREPL (explicit):**
```bash
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
```
**Frontend nREPL:**
```bash
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
```
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
```bash
./tools/nrepl-eval.mjs <<'EOF'
./scripts/nrepl-eval.mjs <<'EOF'
(def x 10)
(+ x 20)
EOF
@@ -85,7 +85,7 @@ EOF
**Override with a different port:**
```bash
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
./scripts/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
```
### Inspect last exception
@@ -93,26 +93,48 @@ EOF
After code throws an error, retrieve the full exception details:
```bash
./tools/nrepl-eval.mjs -e
./scripts/nrepl-eval.mjs -e
```
## Common Patterns
**Require a namespace with reload:**
```bash
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
./scripts/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
```
**Test a function:**
```bash
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
./scripts/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
```
**Long-running operation with custom timeout:**
```bash
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
./scripts/nrepl-eval.mjs -t 300000 "(long-running-fn)"
```
### Accessing Private Functions
Private functions (declared with `^:private` or `defn-`) cannot be called
directly from outside their namespace. Use the var quote syntax `#'` to
access the underlying var:
**This fails:**
```bash
./scripts/nrepl-eval.mjs "(app.rpc.commands.error-reports/build-list-query {})"
# => Syntax error: app.rpc.commands.error-reports/build-list-query is not public
```
**This works:**
```bash
./scripts/nrepl-eval.mjs "(#'app.rpc.commands.error-reports/build-list-query {})"
# => Returns the result
```
The `#'` reader macro resolves to `(var ...)`, giving you direct access to
the var regardless of its visibility modifier. The syntax is `#'` followed
by the fully qualified symbol.
## Key Principles
- **Default port is 6064** — just pass code directly, no `-p` needed when
+43
View File
@@ -0,0 +1,43 @@
# Paren-Repair
`scripts/paren-repair` fixes mismatched parentheses, brackets, and braces in
Clojure/ClojureScript files, then reformats them with cljfmt.
## When to use
- After LLM edits introduce broken delimiters — proactively run it on files
you just touched.
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
errors mentioning mismatched/unclosed delimiters, reader errors, or
unexpected EOF.
- Before running lint/format checks — delimiter errors make linter output
misleading. Fix them first, then lint.
## How to use (CLI)
```bash
# File mode (in-place fix + format)
bb scripts/paren-repair path/to/file.clj
# Pipe mode (stdin → fixed code to stdout)
echo '(def x 1' | bb scripts/paren-repair
# Help
bb scripts/paren-repair --help
```
`bb` must be invoked from the repo root so the path `scripts/paren-repair` resolves.
## Native Tool Available (opencode)
A native opencode tool `paren-repair` is available at `.opencode/scripts/paren-repair.ts`.
The LLM can call it directly with:
- `files`: Array of file paths to fix
- `code`: Code string to fix via stdin
Example usage by the LLM:
```
paren-repair(files="src/foo.clj, src/bar.cljs")
paren-repair(code="(defn foo [x")
```
+39
View File
@@ -0,0 +1,39 @@
# Psql
`scripts/psql` is a wrapper around `psql` that connects to the Penpot PostgreSQL
database using environment variables (`PENPOT_DB_HOST`, `PENPOT_DB_USER`,
`PENPOT_DB_PASSWORD`, `PENPOT_DB_NAME`) with sensible defaults for local
development.
## When to use
- Running ad-hoc SQL queries against the Penpot database.
- Inspecting schema, migrations, or data during development or debugging.
## How to use (CLI)
```bash
# Default connection (penpot db, localhost)
scripts/psql -c "SELECT version();"
# Test database
scripts/psql --test -c "SELECT * FROM migrations;"
# Custom host/user/database
scripts/psql --host myhost --user myuser --db mydb
```
`scripts/psql` must be invoked from the repo root so the path resolves.
## Native Tool Available (opencode)
A native opencode tool `penpot-psql` is available. The LLM can call it directly
with:
- `sql`: SQL command string to execute
- `test`: Boolean flag to use the `penpot_test` database
Example usage by the LLM:
```
penpot-psql(sql="SELECT version();")
penpot-psql(sql="SELECT * FROM migrations;", test=true)
```
@@ -1,6 +1,6 @@
# Taiga API client
`tools/taiga.py` fetches public issues, user stories, and tasks from the
`scripts/taiga.py` fetches public issues, user stories, and tasks from the
Penpot Taiga project (id 345963) without authentication.
## When to use
@@ -13,16 +13,16 @@ Penpot Taiga project (id 345963) without authentication.
```bash
# Fetch by full Taiga URL
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Fetch by type and ref number
python3 tools/taiga.py issue 13714
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
python3 scripts/taiga.py issue 13714
python3 scripts/taiga.py us 14128
python3 scripts/taiga.py task 13648
# Output raw JSON instead of formatted summary
python3 tools/taiga.py --json issue 13714
python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
python3 scripts/taiga.py --json issue 13714
python3 scripts/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
```
## Supported types
+19 -4
View File
@@ -137,17 +137,32 @@ E2E tests should not be added unless explicitly requested.
## Execution discipline
When running CLJS/JS tests (frontend, common):
**CRITICAL: Test output handling rules**
When running ANY test command (CLJS/JS or JVM):
1. **NEVER pipe test output directly to `| head`, `| tail`, `| grep`, or similar filters** — this can hide failures and cause you to miss critical errors.
2. **ALWAYS pipe to a file first, then read the file:**
```bash
# CORRECT:
pnpm run test 2>&1 > /tmp/test-output.txt
grep -A 5 "failures" /tmp/test-output.txt
# WRONG:
pnpm run test 2>&1 | tail -20
pnpm run test 2>&1 | grep "failures"
```
3. **Use `--focus` to narrow test scope** instead of filtering output.
4. **Read the full output file** to understand test results completely.
When running CLJS/JS tests (frontend, common):
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead.
- **If you need to filter output, tee to a temp file first:** `pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running.
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
When running JVM tests (backend, common):
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
- The same no-piping rule applies: use `--focus` to narrow scope.
- Same file-piping rule applies.
## Verification Checklist
-29
View File
@@ -1,29 +0,0 @@
# Paren-Repair
`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in
Clojure/ClojureScript files, then reformats them with cljfmt.
## When to use
- After LLM edits introduce broken delimiters — proactively run it on files
you just touched.
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
errors mentioning mismatched/unclosed delimiters, reader errors, or
unexpected EOF.
- Before running lint/format checks — delimiter errors make linter output
misleading. Fix them first, then lint.
## How to use
```bash
# File mode (in-place fix + format)
bb tools/paren-repair.bb path/to/file.clj
# Pipe mode (stdin → fixed code to stdout)
echo '(def x 1' | bb tools/paren-repair.bb
# Help
bb tools/paren-repair.bb --help
```
`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves.
-51
View File
@@ -1,51 +0,0 @@
# PostgreSQL client wrapper
`tools/psql` is a wrapper around the `psql` command with defaults preconfigured
for the Penpot development environment.
## When to use
- Running SQL queries against the dev database (`penpot`) or test database
(`penpot_test`).
- Inspecting table structures, running migrations manually, or debugging
database state.
- Any time you need PostgreSQL access and want the correct host/user/password
without typing them each time.
## How to use
```bash
# Interactive session (penpot database)
tools/psql
# Interactive session (penpot_test database)
tools/psql --test
# Inline query (penpot)
tools/psql -c "SELECT 1"
# Inline query (penpot_test)
tools/psql --test -c "SELECT 1"
# Override defaults
tools/psql -h other-host -U other-user -d other-db
# Pipe SQL from a file
tools/psql -f some-query.sql
```
All standard `psql` flags are passed through after the wrapper's own flags.
## Defaults
| Setting | Default | Env override |
|----------|-----------|---------------------|
| Host | `postgres` | `PENPOT_DB_HOST` |
| User | `penpot` | `PENPOT_DB_USER` |
| Password | `penpot` | `PENPOT_DB_PASSWORD` |
| Database | `penpot` | `PENPOT_DB_NAME` |
## See also
`tools/db-schema` — a companion script that dumps the current DDL schema
using `pg_dump --schema-only`, with the same defaults and `--test` flag.
+4 -4
View File
@@ -4,10 +4,10 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<s
## Target Branch
Auto-detect the base branch with `tools/detect-target-branch`:
Auto-detect the base branch with `scripts/detect-target-branch`:
```bash
TARGET=$(tools/detect-target-branch)
TARGET=$(scripts/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
@@ -30,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
@@ -83,7 +83,7 @@ cat > /tmp/pr-body.md << 'PR_BODY'
<body content here>
PR_BODY
TARGET=$(tools/detect-target-branch)
TARGET=$(scripts/detect-target-branch)
gh pr create \
--repo penpot/penpot \
+20 -1
View File
@@ -1,6 +1,6 @@
# AI AGENT GUIDE
## Hard rules (always apply — no exceptions)
## HARD RULES (always apply — no exceptions)
- **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 to surface the
@@ -92,3 +92,22 @@ precision while maintaining a strong focus on maintainability and performance.
down into atomic steps.
2. Be concise and autonomous.
3. Do **not** touch unrelated modules unless the task explicitly requires it.
---
# Available Scripts & Tools
## Native opencode Tools (callable directly by the LLM)
- `paren-repair` — Fix mismatched delimiters + reformat Clojure files. Example: `paren-repair(files="src/foo.clj, src/bar.cljs")`
- `penpot-psql` — Execute SQL against the Penpot database. Example: `penpot-psql(sql="SELECT version();")`
## Scripts (from repo root via `scripts/<name>`)
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files + reformat with cljfmt. See `mem:scripts/paren-repair`.
- `scripts/psql` — Connect to the Penpot PostgreSQL database (wraps `psql` with env-var defaults). See `mem:scripts/psql`.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
+44 -28
View File
@@ -1,5 +1,39 @@
# CHANGELOG
## 2.17.2
### :bug: Bugs fixed
- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272))
- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366))
- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86)
## 2.17.1
### :bug: Bugs fixed
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715))
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
## 2.17.0
### :rocket: Epics and highlights
@@ -36,49 +70,28 @@
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Add color variants and positioning to selection size badge [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
### :bug: Bugs fixed
- Fix LDAP provider params schema typo (`bind-passwor``bind-password`) introduced during the `clojure.spec``malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide``:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
- Fix Plugin API variant creation failing due to undocumented multi-step workflow [#10075](https://github.com/penpot/penpot/issues/10075) (PR: [#10149](https://github.com/penpot/penpot/pull/10149))
- Fix workspace crash when editing text shapes with degenerate selrect [#10617](https://github.com/penpot/penpot/issues/10617) (PR: [#10618](https://github.com/penpot/penpot/pull/10618))
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @peter-rango) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix lost-update race on team features during concurrent file creation (by @Lobster-0429) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @davidv399) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
@@ -165,6 +178,9 @@
- Fix Plugin API validation error when listing shared plugin data keys [#10628](https://github.com/penpot/penpot/issues/10628) (PR: [#10632](https://github.com/penpot/penpot/pull/10632))
- Fix Plugin API silently dropping plugin data written to shared library [#10629](https://github.com/penpot/penpot/issues/10629) (PR: [#10632](https://github.com/penpot/penpot/pull/10632))
- Fix workspace crash when event target is a DOM text node [#10640](https://github.com/penpot/penpot/issues/10640) (PR: [#10641](https://github.com/penpot/penpot/pull/10641))
- Fix text shape position-data to include required fills in WASM and DOM calculation paths [#10646](https://github.com/penpot/penpot/issues/10646) (PR: [#10650](https://github.com/penpot/penpot/pull/10650))
- Log expired OIDC tokens as auth failures instead of server errors [#10635](https://github.com/penpot/penpot/issues/10635) (PR: [#10636](https://github.com/penpot/penpot/pull/10636))
- Return 400 instead of 500 when ImageMagick rejects invalid uploaded images [#10642](https://github.com/penpot/penpot/issues/10642) (PR: [#10643](https://github.com/penpot/penpot/pull/10643))
## 2.16.2
+21 -77
View File
@@ -14,9 +14,9 @@ Center](https://help.penpot.app/).
- [Reporting Bugs](#reporting-bugs)
- [Pull Requests](#pull-requests)
- [Workflow](#workflow)
- [Title format](#title-format)
- [Description](#description)
- [Branch naming](#branch-naming)
- [Format](#format)
- [Title format](#title-format)
- [Description](#description)
- [Review process](#review-process)
- [What we won't accept](#what-we-wont-accept)
- [Good first issues](#good-first-issues)
@@ -73,35 +73,23 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
4. **Format and lint** — run the checks described in
[Formatting and Linting](#formatting-and-linting) before submitting.
### Title format
### Format
#### Title
> **IMPORTANT:** When a PR is squash-merged, the PR title becomes the
> commit message on the main branch. Getting the title right matters.
Pull request titles **must** follow the same convention as commit subjects:
```
:emoji: <subject>
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
```
- Use the **imperative mood** (e.g. "Fix", not "Fixed").
- Capitalize the first letter of the subject.
- Do not end the subject with a period.
- Keep the subject to **70 characters** or fewer.
- Use one of the [commit type emojis](#commit-types) listed below.
Read [Creating Commits](./.serena/memories/workflow/creating-commits.md)
for more concrete information.
When a PR contains multiple unrelated commits, choose the emoji that
best represents the dominant change.
**Examples:**
```
:bug: Fix unexpected error on launching modal
:sparkles: Enable new modal for profile
:zap: Improve performance of dashboard navigation
```
> **Note:** When a PR is squash-merged, the PR title becomes the
> commit message on the main branch. Getting the title right matters.
### Description
#### Description
Every pull request should include a description that helps reviewers
understand the change quickly:
@@ -114,24 +102,8 @@ understand the change quickly:
5. **Breaking changes** — call out anything that affects existing users
or requires migration steps.
### Branch naming
Use a descriptive branch name that reflects the type and scope of the
change:
```
<type>/<short-description>
```
Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`.
Optionally include the issue number:
```
fix/9122-email-blacklisting
feat/export-webp
refactor/layout-sizing
```
Read [Creating PRs](./.serena/memories/workflow/creating-prs.md)
for more concrete information.
### Review process
@@ -151,10 +123,10 @@ refactor/layout-sizing
To save time on both sides, please avoid submitting PRs that:
- Introduce new dependencies without prior discussion.
- Change the build system or CI configuration without maintainer
approval.
- Mix unrelated changes in a single PR — keep PRs focused on one
concern.
- Change the build system or CI configuration without maintainer approval.
- Mix unrelated changes in a single PR — keep PRs focused on one concern.
- Submit AI-generated code without human review.
- Skip local syntax and formatting checks before submitting.
- Skip the [discussion step](#workflow) for non-bug-fix changes.
### Good first issues
@@ -217,36 +189,8 @@ Commit messages must follow this format:
## Formatting and Linting
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
```bash
# Check formatting (does not modify files)
./scripts/check-fmt
# Fix formatting (modifies files in place)
./scripts/fmt
# Lint
./scripts/lint
```
For frontend SCSS, we use `stylelint` for linting and
`Prettier` for formatting:
```bash
cd frontend
# Lint SCSS
pnpm run lint:scss (does not modify files)
# Fix SCSS formatting (modifies files in place)
pnpm run fmt:scss
```
Ideally, run these as git pre-commit hooks.
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
setting this up.
Each module has its own linting and formatting commands — see the relevant one on the
[Serena Memories](./.serena/memories/)
## Changelog
+3 -7
View File
@@ -104,24 +104,20 @@
[]
(try
(main/start)
:started
(catch Throwable cause
(ex/print-throwable cause))))
(defn- stop
[]
(main/stop)
:stopped)
(main/stop))
(defn restart
[]
(stop)
(repl/refresh :after 'user/start))
(main/restart))
(defn restart-all
[]
(stop)
(repl/refresh-all :after 'user/start))
(main/restart-all))
;; (defn compression-bench
;; [data]
@@ -10,9 +10,10 @@ penpot - error list
<a href="/dbg"> [BACK]</a>
<h1>Error reports (last 300)</h1>
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
<a class="{% if source = 0 %}strong{% endif %}" href="?source=0">[ALL ERRORS]</a>
<a class="{% if source = 3 %}strong{% endif %}" href="?source=3">[BACKEND ERRORS]</a>
<a class="{% if source = 4 %}strong{% endif %}" href="?source=4">[FRONTEND ERRORS]</a>
<a class="{% if source = 5 %}strong{% endif %}" href="?source=5">[RLIMIT REPORTS]</a>
</div>
</nav>
<main class="horizontal-list">
@@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div>
@@ -6,11 +6,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
{% if trace %}
<div>[<a href="#trace">trace</a>]</div>
{% endif %}
</nav>
<main>
@@ -20,7 +20,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
<h2><span class="not-important">Kind:</span> <br/> {{kind}}</h2>
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
</div>
</div>
@@ -33,11 +33,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
</div>
</div>
{% if report %}
{% if trace %}
<div class="table-row multiline">
<div id="report" class="table-key">REPORT:</div>
<div id="trace" class="table-key">TRACE:</div>
<div class="table-val">
<pre>{{report}}</pre>
<pre>{{trace}}</pre>
</div>
</div>
{% endif %}
@@ -6,10 +6,10 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
<div>[<a href="#result">result</a>]</div>
<div>[<a href="#value">value</a>]</div>
</nav>
<main>
<div class="table">
@@ -30,9 +30,9 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
</div>
<div class="table-row multiline">
<div id="result" class="table-key">RESULT: </div>
<div id="value" class="table-key">VALUE: </div>
<div class="table-val">
<pre>{{result}}</pre>
<pre>{{value}}</pre>
</div>
</div>
</div>
+1
View File
@@ -42,6 +42,7 @@ export PENPOT_FLAGS="\
enable-smtp \
enable-prepl-server \
enable-urepl-server \
enable-nrepl-server \
enable-rpc-climit \
enable-rpc-rlimit \
enable-quotes \
+2
View File
@@ -253,6 +253,8 @@
[:urepl-port {:optional true} ::sm/int]
[:prepl-host {:optional true} :string]
[:prepl-port {:optional true} ::sm/int]
[:nrepl-host {:optional true} :string]
[:nrepl-port {:optional true} ::sm/int]
[:file-data-backend {:optional true} [:enum "db" "legacy-db" "storage"]]
+8 -4
View File
@@ -24,7 +24,7 @@
:cause cause))))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at
"SELECT perms, profile_id, expires_at, type
FROM access_token
WHERE id = ?
AND (expires_at IS NULL
@@ -42,15 +42,19 @@
(fn [request]
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (= :token type)
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
;; FIXME: revisit this, this data looks unused
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
token-id (get claims :tid)]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))
(assoc ::expires-at expires-at)
(some? token-id)
(assoc ::id token-id)
(some? type)
(assoc ::type type))))
(handler request)))))
+15 -11
View File
@@ -230,25 +230,28 @@
(-> (io/resource "app/templates/error-report.v3.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 3)
(assoc :source 3)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v4 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v4.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 4)
(assoc :source 4)
(assoc :kind (or (:kind content) (:origin content)))
(assoc :trace (or (:trace content) (:report content)))
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v5 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v5.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 5)
(assoc :source 5)
(assoc :value (or (:value content) (:result content)))
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
(if-let [report (get-report request)]
(let [result (case (:version report)
(let [result (case (:source report)
1 (render-template-v1 report)
2 (render-template-v2 report)
3 (render-template-v3 report)
@@ -265,18 +268,19 @@
"SELECT id, created_at,
content->>'~:hint' AS hint
FROM server_error_report
WHERE version = ?
ORDER BY created_at DESC
LIMIT 300")
WHERE (version = ? OR source = ? OR ? = 0)
ORDER BY created_at DESC
LIMIT 300")
(defn- error-list-handler
[{:keys [::db/pool]} {:keys [params]}]
(let [version (or (some-> (get params :version) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports version])
(map #(update % :created-at ct/format-inst :rfc1123)))]
(let [source (or (some-> (get params :source) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports source source source])
(map #(update % :created-at ct/format-inst :rfc1123)))]
{::yres/status 200
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items :version version}))
(tmpl/render {:items items :source source}))
::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}}))
+1 -1
View File
@@ -31,7 +31,7 @@
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :request/auth-data (dissoc auth :token))
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
+22 -4
View File
@@ -65,12 +65,25 @@
:else
request)))
;; The specific-exception branches below (IAE,
;; RequestTooBigException, EOFException) raise with
;; `ex/raise` rather than calling `errors/handle` directly.
;; This is intentional: the throw is caught by the
;; top-level error handler in `app.http/router-handler`
;; (`backend/src/app/http.clj`), which routes every
;; uncaught exception through `errors/handle`. The
;; per-route `wrap-errors` middleware in the route list
;; is a defensive layer; correctness does not depend on
;; it. Raising here keeps the cond uniform with the
;; existing RequestTooBigException / EOFException
;; branches.
(handle-error [cause request]
(cond
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
(instance? IllegalArgumentException cause)
(ex/raise :type :validation
:code :malformed-json
:hint (ex-message cause)
:cause cause)
(instance? RequestTooBigException cause)
(ex/raise :type :validation
@@ -83,6 +96,11 @@
:hint (ex-message cause)
:cause cause)
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
:else
(errors/handle cause request)))]
+3 -1
View File
@@ -336,7 +336,9 @@
(let [resultm (meta result)
request (-> params meta ::http/request)
profile-id (or (::profile-id resultm)
(:profile-id result)
(some-> (:profile-id result)
(cond-> (string? (:profile-id result))
uuid/parse*))
(::rpc/profile-id params)
uuid/zero)
+40 -24
View File
@@ -12,6 +12,7 @@
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.schema :as sm]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
@@ -30,11 +31,12 @@
(defonce enabled (atom true))
(defn- persist-on-database!
[pool id version report]
[pool id source report]
(when-not (db/read-only? pool)
(db/insert! pool :server-error-report
{:id id
:version version
:source source
:version source ;; backward compatibility with old code that reads version column
:content (db/tjson report)})))
(defn- concurrent-exception?
@@ -56,19 +58,27 @@
(assoc :backend/version (:full cf/version))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(dissoc :request/params :value :params :data))
href (if-let [path (:request/path context)]
(str (u/join (cf/get :public-uri) path))
(str (cf/get :public-uri)))]
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))
:tenant (cf/get :tenant)
:version (:full cf/version)
:profile-id (some-> (:request/profile-id context) str)
:href href}
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
@@ -97,7 +107,7 @@
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(defn- audit-event->report
[{:keys [context props ip-addr] :as record}]
[{:keys [context props ip-addr profile-id] :as record}]
(let [context
(reduce-kv (fn [context k v]
(let [k' (keyword "frontend" (name k))]
@@ -115,12 +125,15 @@
(assoc :backend/version (:full cf/version))
(assoc :frontend/ip-addr ip-addr))]
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:origin (:name record)
:href (get props :href)
:hint (get props :hint)
:report (get props :report)}))
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:kind (:name record)
:profile-id (some-> profile-id str)
:href (get props :href)
:hint (get props :hint)
:trace (get props :report)
:tenant (cf/get :tenant)
:version (:full cf/version)}))
(defn- handle-audit-event
"Convert the log record into a report object and persist it on the database"
@@ -153,10 +166,13 @@
(-> (into (sorted-map) result)
(dissoc ::rlimit/method)))))]
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:result (pp/pprint-str result :length 50)}))
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:value (pp/pprint-str result :length 50)
:tenant (cf/get :tenant)
:version (:full cf/version)
:href (str (cf/get :public-uri))}))
(defn- handle-rlimit-event
"Convert the log record into a report object and persist it on the database"
+64 -32
View File
@@ -38,6 +38,7 @@
[app.storage.gc-deleted :as-alias sto.gc-deleted]
[app.storage.gc-touched :as-alias sto.gc-touched]
[app.storage.s3 :as-alias sto.s3]
[app.system :as sys]
[app.util.cron]
[app.worker :as-alias wrk]
[app.worker.executor]
@@ -45,7 +46,6 @@
[clojure.tools.namespace.repl :as repl]
[cuerdas.core :as str]
[integrant.core :as ig]
[nrepl.server :as nrepl]
[promesa.exec :as px])
(:gen-class))
@@ -444,13 +444,17 @@
::http.client/client (ig/ref ::http.client/client)
::setup/props (ig/ref ::setup/props)}
[::srepl/urepl ::srepl/server]
{::srepl/port (cf/get :urepl-port 6062)
::srepl/host (cf/get :urepl-host "localhost")}
::srepl/urepl
{:port (cf/get :urepl-port 6062)
:host (cf/get :urepl-host "localhost")}
[::srepl/prepl ::srepl/server]
{::srepl/port (cf/get :prepl-port 6063)
::srepl/host (cf/get :prepl-host "localhost")}
::srepl/prepl
{:port (cf/get :prepl-port 6063)
:host (cf/get :prepl-host "localhost")}
::srepl/nrepl
{:port (cf/get :nrepl-port 6064)
:host (cf/get :nrepl-host "localhost")}
::setup/templates {}
@@ -584,42 +588,70 @@
::db/pool (ig/ref ::db/pool)}})
(def system nil)
(defn start
[]
(cf/validate!)
(ig/load-namespaces (merge system-config worker-config))
(alter-var-root #'system (fn [sys]
(when sys (ig/halt! sys))
(-> system-config
(cond-> (contains? cf/flags :backend-worker)
(merge worker-config))
(ig/expand)
(ig/init))))
(alter-var-root #'app.system/system
(fn [sys]
(some-> sys not-empty ig/halt!)
(-> system-config
(cond-> (contains? cf/flags :backend-worker)
(merge worker-config))
(ig/expand)
(ig/init))))
(l/inf :hint "welcome to penpot"
:flags (str/join "," (map name cf/flags))
:worker? (contains? cf/flags :backend-worker)
:version (:full cf/version)))
:version (:full cf/version))
:start)
(defn resume
[]
(cf/validate!)
(ig/load-namespaces (merge system-config worker-config))
(alter-var-root #'app.system/system
(fn [sys]
(let [config (-> system-config
(cond-> (contains? cf/flags :backend-worker)
(merge worker-config))
(ig/expand))]
(if-let [sys (not-empty sys)]
(ig/resume config sys)
(ig/init config)))))
:resume)
(defn start-custom
[config]
(ig/load-namespaces config)
(alter-var-root #'system (fn [sys]
(when sys (ig/halt! sys))
(-> config
(ig/expand)
(ig/init)))))
(alter-var-root #'app.system/system
(fn [sys]
(some-> sys not-empty ig/halt!)
(-> config
(ig/expand)
(ig/init)))))
(defn stop
[]
(alter-var-root #'system (fn [sys]
(when sys (ig/halt! sys))
nil)))
(alter-var-root #'app.system/system
(fn [sys]
(some-> sys not-empty ig/halt!)
{}))
:stop)
(defn suspend
[]
(alter-var-root #'app.system/system
(fn [sys]
(some-> sys not-empty ig/suspend!)
sys))
:suspend)
(defn restart
[]
(stop)
(repl/refresh :after 'app.main/start))
(suspend)
(repl/refresh :after 'app.main/resume))
(defn restart-all
[]
@@ -646,15 +678,15 @@
(test/test-vars [(resolve o)]))
(test/test-ns o)))))
(repl/disable-reload! (find-ns 'integrant.core))
(defn -main
[& _args]
(try
(let [p (promise)]
(l/inf :hint "start nrepl server" :port 6064)
(nrepl/start-server :bind "0.0.0.0" :port 6064)
(ex/ignoring
(repl/disable-reload! (find-ns 'integrant.core))
(repl/disable-reload! (find-ns 'app.system))
(repl/disable-reload! (find-ns 'app.common.debug)))
(let [p (promise)]
(start)
(deref p))
(catch Throwable cause
+4 -1
View File
@@ -493,7 +493,10 @@
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
{:name "0151-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}])
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
(defn apply-migrations!
[pool name migrations]
@@ -0,0 +1,44 @@
-- Add source column (keep version column as-is for backward compatibility)
ALTER TABLE server_error_report
ADD COLUMN source integer;
-- Trigger function to sync version -> source (backward compatibility with old code)
CREATE OR REPLACE FUNCTION server_error_report__sync_version_to_source()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.version IS NOT NULL AND NEW.source IS NULL THEN
NEW.source := NEW.version;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger fires on INSERT or UPDATE OF version column
CREATE TRIGGER server_error_report__sync_version_to_source__tgr
BEFORE INSERT OR UPDATE OF version ON server_error_report
FOR EACH ROW
EXECUTE FUNCTION server_error_report__sync_version_to_source();
-- Backfill existing rows
UPDATE server_error_report SET source = version WHERE source IS NULL;
-- Drop old version index
DROP INDEX IF EXISTS server_error_report__version__idx;
-- Create new source index
CREATE INDEX server_error_report__source__idx
ON server_error_report (source);
-- Content-based indexes
CREATE INDEX server_error_report__content_kind__idx
ON server_error_report (COALESCE(content->>'~:kind', content->>'~:origin'));
CREATE INDEX server_error_report__content_tenant__idx
ON server_error_report ((content->>'~:tenant'));
CREATE INDEX server_error_report__content_version__idx
ON server_error_report ((content->>'~:version'));
-- Index for pagination
CREATE INDEX server_error_report__created_at_id__idx
ON server_error_report (created_at DESC, id DESC);
+49 -10
View File
@@ -41,6 +41,7 @@
[app.util.cache :as cache]
[app.util.inet :as inet]
[app.util.services :as sv]
[clojure.set :as set]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
@@ -102,8 +103,10 @@
session-id (yreq/get-header request "x-session-id")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request)
(::actoken/profile-id request)
session-pid (::session/profile-id request)
token-pid (::actoken/profile-id request)
profile-id (or session-pid
token-pid
(if key-id uuid/zero nil))
ip-addr (inet/parse-request request)
@@ -116,7 +119,15 @@
(assoc ::session-id (some-> session-id uuid/parse*))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
(assoc ::profile-id profile-id))
(cond-> (uuid? session-pid)
(assoc ::auth-type :session))
(cond-> (and (not (uuid? session-pid))
(uuid? token-pid))
(-> (assoc ::auth-type :token)
(assoc ::token-perms (set (::actoken/perms request #{})))))
(cond-> key-id
(assoc ::auth-key-id key-id)))
data (with-meta data
{::http/request request})
@@ -151,13 +162,40 @@
(defn- wrap-authentication
[_ f mdata]
(fn [cfg params]
(let [profile-id (::profile-id params)]
(if (and (::auth mdata true) (not (uuid? profile-id)))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint")
(f cfg params)))))
(let [required-auth? (::auth mdata true)
required-auth-type (::auth-type mdata)
required-perms (into #{} (::perms mdata))]
(fn [cfg params]
(let [profile-id (::profile-id params)
auth-type (::auth-type params)
token-perms (set (::token-perms params #{}))]
(cond
(and required-auth? (not (uuid? profile-id)))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint")
(and (= required-auth-type :token)
(not= auth-type :token))
(ex/raise :type :authorization
:code :token-auth-required
:hint "access token authentication required for this endpoint")
(and (seq required-perms)
(not= auth-type :token))
(ex/raise :type :authorization
:code :token-auth-required
:hint "access token authentication required for this endpoint")
(and (seq required-perms)
(not (set/subset? required-perms token-perms)))
(ex/raise :type :authorization
:code :missing-perms
:hint "missing required permissions"
:required required-perms)
:else
(f cfg params))))))
(defn- wrap-db-transaction
[_ f mdata]
@@ -332,6 +370,7 @@
'app.rpc.commands.binfile
'app.rpc.commands.comments
'app.rpc.commands.demo
'app.rpc.commands.error-reports
'app.rpc.commands.files
'app.rpc.commands.files-create
'app.rpc.commands.files-share
@@ -29,6 +29,10 @@
AND type = 'mcp'")
(defn create-access-token
"Create an access token with empty perms.
Elevated permissions (e.g. error-reports:read) are not assignable via
the public API; grant them with SQL or `repl:grant-access-token-perm`."
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
@@ -61,6 +65,27 @@
[cfg profile-id name expiration]
(db/tx-run! cfg create-access-token profile-id name expiration))
(def ^:private sql:grant-access-token-perm
"UPDATE access_token
SET perms = (
SELECT ARRAY(
SELECT DISTINCT unnest(perms || ARRAY[?]::text[])
)
),
updated_at = now()
WHERE id = ?
RETURNING id, perms")
(defn repl:grant-access-token-perm
"Append a permission string to an access token (operator/SQL path).
Example: (repl:grant-access-token-perm cfg token-id \"error-reports:read\")"
[cfg token-id perm]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [row (db/exec-one! conn [sql:grant-access-token-perm perm token-id])]
(some-> row (update :perms db/decode-pgarray #{}))))))
(def ^:private schema:create-access-token
[:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]]
@@ -0,0 +1,185 @@
;; 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 app.rpc.commands.error-reports
"RPC methods for listing and fetching server error reports.
Access is restricted to access-token authentication with the
`error-reports:read` permission. Grant via SQL (or REPL helper):
UPDATE access_token
SET perms = ARRAY['error-reports:read']::text[],
updated_at = now()
WHERE id = '<token-uuid>';
Call with: Authorization: Token <jwt>"
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.util.services :as sv]
[cuerdas.core :as str]))
(def ^:private max-limit 200)
(def ^:private default-limit 50)
(def ^:private source-names
{1 "legacy-v1"
2 "legacy-v2"
3 "logging"
4 "audit-log"
5 "rlimit"})
(defn- source->name
[source]
(get source-names source (str "unknown-" source)))
(defn- name->source
[name]
(some (fn [[k v]] (when (= v name) k)) source-names))
(def ^:private schema:error-report-summary
[:map
[:id ::sm/uuid]
[:created-at ct/schema:inst]
[:source ::sm/text]
[:profile-id {:optional true} ::sm/text]
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]])
(def ^:private schema:get-error-reports-params
[:map {:title "get-error-reports-params"}
[:since {:optional true} ct/schema:inst]
[:since-id {:optional true} ::sm/uuid]
[:limit {:optional true}
[:and ::sm/int [:fn #(<= 1 % max-limit)]]]
[:source {:optional true} ::sm/text]
[:profile-id {:optional true} ::sm/text]
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]
[:until {:optional true} ct/schema:inst]])
(def ^:private schema:get-error-reports-result
[:map
[:items [:vector schema:error-report-summary]]
[:next-since {:optional true} ct/schema:inst]
[:next-id {:optional true} ::sm/uuid]])
(def ^:private schema:error-report
[:map
[:id ::sm/uuid]
[:created-at ct/schema:inst]
[:source ::sm/text]
[:profile-id {:optional true} ::sm/text]
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]
[:report {:optional true} ::sm/text]
[:href {:optional true} ::sm/text]
[:context {:optional true} ::sm/text]])
(def ^:private schema:get-error-report-params
[:map
[:id ::sm/uuid]])
(def ^:private base-list-sql
(str "SELECT id, created_at, source, "
"COALESCE(content->>'~:kind', content->>'~:origin') AS kind, "
"content->>'~:tenant' AS tenant, "
"content->>'~:version' AS version, "
"content->>'~:hint' AS hint, "
"content->>'~:profile-id' AS profile_id "
"FROM server_error_report"))
(defn- build-list-query
[{:keys [since since-id source profile-id kind tenant version hint until limit]
:or {limit default-limit}}]
(let [source-id (when source (name->source source))
clauses (keep identity
[(when source-id
{:where "source = ?" :params [source-id]})
(when profile-id
{:where "content->>'~:profile-id' = ?"
:params [profile-id]})
(when kind
{:where "COALESCE(content->>'~:kind', content->>'~:origin') = ?"
:params [kind]})
(when tenant
{:where "content->>'~:tenant' = ?"
:params [tenant]})
(when version
{:where "content->>'~:version' = ?"
:params [version]})
(when hint
{:where "content->>'~:hint' ILIKE ?"
:params [(str "%" hint "%")]})
(when since
{:where "(created_at, id) > (?::timestamptz, ?::uuid)"
:params [since (or since-id uuid/zero)]})
(when until
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
:params [until uuid/zero]})])
sql-parts (map :where clauses)
sql-params (mapcat :params clauses)
sql (str base-list-sql
(when (seq sql-parts)
(str " WHERE " (str/join " AND " sql-parts)))
" ORDER BY created_at ASC, id ASC"
" LIMIT ?")]
(into [sql] (concat sql-params [limit]))))
(sv/defmethod ::get-error-reports
{::doc/added "2.20"
::rpc/auth-type :token
::rpc/perms #{"error-reports:read"}
::sm/params schema:get-error-reports-params
::sm/result schema:get-error-reports-result}
[cfg params]
(let [limit (min (or (:limit params) default-limit) max-limit)
params (assoc params :limit (inc limit))
[sql & sql-args] (build-list-query params)
rows (db/exec! cfg (into [sql] sql-args))]
(if (seq rows)
(let [items (->> (take limit rows)
(mapv #(-> %
(update :source source->name)
d/without-nils)))
last-item (peek items)
has-more? (> (count rows) limit)]
{:items items
:next-since (when has-more? (:created-at last-item))
:next-id (when has-more? (:id last-item))})
{:items []})))
(sv/defmethod ::get-error-report
{::doc/added "2.20"
::rpc/auth-type :token
::rpc/perms #{"error-reports:read"}
::sm/params schema:get-error-report-params
::sm/result schema:error-report}
[cfg {:keys [id]}]
(if-let [report (db/get-by-id cfg :server-error-report id {::db/check-deleted false})]
(let [content (db/decode-transit-pgobject (:content report))]
(-> report
(dissoc :content)
(merge content)
(update :source source->name)
(assoc :kind (or (:kind content) (:origin content)))
(assoc :version (:version content))
(d/without-nils)))
(ex/raise :type :not-found
:code :report-not-found
:hint (str "error report " id " not found"))))
+11 -2
View File
@@ -156,11 +156,13 @@
(assoc mfile :permissions perms)))
(defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
(ct/format-inst modified-at :iso)
"/"
(uri/map->query-string permissions)))
(uri/map->query-string permissions)
"/"
(some-> deleted-at (ct/format-inst :iso))))
(sv/defmethod ::get-file
"Retrieve a file by its ID. Only authenticated users."
@@ -1102,6 +1104,13 @@
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
(when (contains? transitive-deps file-id)
(ex/raise :type :validation
:code :circular-library-reference
:hint "linking this library would create a circular dependency")))
(link-file-to-library conn params)
(bfc/get-libraries cfg [library-id]))
@@ -374,61 +374,6 @@
;; --- MUTATION COMMAND: create-file-thumbnail
(defn- create-file-thumbnail
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
(media/validate-media-type! media)
(media/validate-media-size! media)
(let [file (bfc/get-file cfg file-id
:include-deleted? true
:load-data? false)
props (db/tjson (or props {}))
path (:path media)
mtype (:mtype media)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))
tnow (ct/now)
media (sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at tnow
:content-type mtype
:bucket "file-thumbnail"})
thumb (db/get* conn :file-thumbnail
{:file-id file-id
:revn revn}
{::db/remove-deleted false
::sql/for-update true})]
(if (some? thumb)
(do
;; We mark the old media id as touched if it does not match
(when (not= (:id media) (:media-id thumb))
(sto/touch-object! storage (:media-id thumb)))
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at (:deleted-at file)
:updated-at tnow
:props props}
{:file-id file-id
:revn revn}))
(db/insert! conn :file-thumbnail
{:file-id file-id
:revn revn
:created-at tnow
:updated-at tnow
:deleted-at (:deleted-at file)
:props props
:media-id (:id media)}))
media))
(def ^:private
schema:create-file-thumbnail
[:map {:title "create-file-thumbnail"}
@@ -448,12 +393,57 @@
::rtry/when rtry/conflict-exception?
::sm/params schema:create-file-thumbnail}
;; FIXME: do not run the thumbnail upload inside a transaction
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media))
:id (:id media)})))))
(media/validate-media-type! (:media params))
(media/validate-media-size! (:media params))
(db/run! cfg files/check-edition-permissions! profile-id file-id)
(when-not (db/read-only? (::db/pool cfg))
(let [storage (::sto/storage cfg)
file (bfc/get-file cfg file-id :include-deleted? true :load-data? false)
props (db/tjson (or (:props params) {}))
{:keys [path mtype]} (:media params)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))
tnow (ct/now)
media (sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at tnow
:content-type mtype
:bucket "file-thumbnail"})
revn (:revn params)
result (db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [thumb (db/get* conn :file-thumbnail
{:file-id file-id :revn revn}
{::db/remove-deleted false
::sql/for-update true})]
(if (some? thumb)
(do
(when (not= (:id media) (:media-id thumb))
(sto/touch-object! storage (:media-id thumb)))
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at (:deleted-at file)
:updated-at tnow
:props props}
{:file-id file-id :revn revn}))
(db/insert! conn :file-thumbnail
{:file-id file-id
:revn revn
:created-at tnow
:updated-at tnow
:deleted-at (:deleted-at file)
:props props
:media-id (:id media)}))
media)))]
(when result
{:uri (files/resolve-public-uri (:id result))
:id (:id result)}))))
+6 -2
View File
@@ -6,9 +6,11 @@
(ns app.rpc.commands.search
(:require
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@@ -66,11 +68,13 @@
(def ^:private schema:search-files
[:map {:title "search-files"}
[:team-id ::sm/uuid]
[:search-term {:optional true} :string]])
[:search-term {:optional true} [:string {:max 250}]]])
(sv/defmethod ::search-files
{::doc/added "1.17"
::doc/module :files
::sm/params schema:search-files}
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
(some->> search-term (search-files pool profile-id team-id)))
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! conn profile-id team-id)
(some->> search-term (search-files conn profile-id team-id))))
+94 -24
View File
@@ -19,7 +19,8 @@
[clojure.core :as c]
[clojure.core.server :as ccs]
[clojure.main :as cm]
[integrant.core :as ig]))
[integrant.core :as ig]
[nrepl.server :as nrepl]))
(defn- repl-init
[]
@@ -107,37 +108,106 @@
(finally
(remove-tap tapfn))))))
;; --- State initialization
;; --- UREPL
(defmethod ig/assert-key ::server
(defmethod ig/assert-key ::urepl
[_ params]
(assert (int? (::port params)) "expected valid port")
(assert (string? (::host params)) "expected valid host"))
(assert (int? (:port params)) "expected valid port")
(assert (string? (:host params)) "expected valid host"))
(defmethod ig/expand-key ::server
[[type :as k] v]
{k (assoc v ::flag (keyword (str (name type) "-server")))})
(defmethod ig/init-key ::urepl
[_ {:keys [:port :host] :as cfg}]
(when (contains? cf/flags :urepl-server)
(defmethod ig/init-key ::server
[[type _] {:keys [::flag ::port ::host] :as cfg}]
(when (contains? cf/flags flag)
(l/inf :hint "initializing repl server"
:name (name type)
:port port
:host host)
(let [accept (case type
::prepl 'app.srepl/json-repl
::urepl 'app.srepl/user-repl)
(l/inf :hint "init urepl server" :host host :port port)
(let [accept 'app.srepl/user-repl
params {:address host
:port port
:name (name type)
:name "urepl"
:accept accept}]
(ccs/start-server params)
(assoc params :type type))))
"urepl")))
(defmethod ig/halt-key! ::server
(defmethod ig/halt-key! ::urepl
[_ name]
(some-> name ccs/stop-server))
(defmethod ig/resume-key ::urepl
[key opts _ old-name]
(if old-name
(do
(l/inf :hint "keep urepl server")
old-name)
(ig/init-key key opts)))
(defmethod ig/suspend-key! ::urepl
[_ _]
(l/inf :hint "keep urepl server"))
;; --- PREPL
(defmethod ig/assert-key ::prepl
[_ params]
(some-> params :name ccs/stop-server))
(assert (int? (:port params)) "expected valid port")
(assert (string? (:host params)) "expected valid host"))
(defmethod ig/init-key ::prepl
[_ {:keys [:port :host] :as cfg}]
(when (contains? cf/flags :prepl-server)
(l/inf :hint "init prepl server" :host host :port port)
(let [accept 'app.srepl/json-repl
params {:address host
:port port
:name "prepl"
:accept accept}]
(ccs/start-server params)
"prepl")))
(defmethod ig/halt-key! ::prepl
[_ name]
(some-> name ccs/stop-server))
(defmethod ig/resume-key ::prepl
[key opts _ old-name]
(if old-name
(do
(l/inf :hint "keep prepl server")
old-name)
(ig/init-key key opts)))
(defmethod ig/suspend-key! ::prepl
[_ _]
(l/inf :hint "keep prepl server"))
;; --- NREPL
(defmethod ig/assert-key ::nrepl
[_ params]
(assert (int? (:port params)) "expected valid port")
(assert (string? (:host params)) "expected valid host"))
(defmethod ig/init-key ::nrepl
[_ {:keys [:port :host] :as cfg}]
(when (contains? cf/flags :nrepl-server)
(l/inf :hint "init nrepl server" :host host :port port)
(nrepl/start-server :bind host :port port)))
(defmethod ig/halt-key! ::nrepl
[_ server]
(some-> server nrepl/stop-server))
(defmethod ig/resume-key ::nrepl
[key opts _ old-server]
(if old-server
(do
(l/inf :hint "keep nrepl server")
old-server)
(ig/init-key key opts)))
(defmethod ig/suspend-key! ::nrepl
[_ _]
(l/inf :hint "keep nrepl server"))
+3 -3
View File
@@ -8,18 +8,18 @@
(:require
[app.binfile.v2 :as binfile.v2]
[app.db :as db]
[app.main :as main]
[app.srepl.helpers :as h]
[app.system :as sys]
[cuerdas.core :as str]))
(defn export-team!
[team-id]
(let [team-id (h/parse-uuid team-id)]
(binfile.v2/export-team! main/system team-id)))
(binfile.v2/export-team! sys/system team-id)))
(defn import-team!
[path & {:keys [owner rollback?] :or {rollback? true}}]
(db/tx-run! (assoc main/system ::db/rollback rollback?)
(db/tx-run! (assoc sys/system ::db/rollback rollback?)
(fn [cfg]
(let [team (binfile.v2/import-team! cfg path)
owner (cond
+1 -1
View File
@@ -29,7 +29,7 @@
(defn- get-current-system
[]
(or (deref (requiring-resolve 'app.main/system))
(or (deref (requiring-resolve 'app.system/system))
(deref (requiring-resolve 'user/system))))
(defmulti ^:private exec-command ::cmd)
+3 -3
View File
@@ -15,7 +15,7 @@
[app.common.time :as ct]
[app.db :as db]
[app.features.file-snapshots :as fsnap]
[app.main :as main]))
[app.system :as sys]))
(def ^:dynamic *system* nil)
@@ -37,13 +37,13 @@
(defn get-file
"Get the migrated data of one file."
([id]
(get-file (or *system* main/system) id))
(get-file (or *system* sys/system) id))
([system id]
(db/run! system bfc/get-file id)))
(defn get-raw-file
"Get the migrated data of one file."
([id] (get-raw-file (or *system* main/system) id))
([id] (get-raw-file (or *system* sys/system) id))
([system id]
(db/run! system
(fn [system]
+53 -53
View File
@@ -27,7 +27,6 @@
[app.features.file-snapshots :as fsnap]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as main]
[app.msgbus :as mbus]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files :as files]
@@ -37,6 +36,7 @@
[app.rpc.commands.teams :as teams]
[app.srepl.helpers :as h]
[app.srepl.procs.file-repair :as procs.file-repair]
[app.system :as sys]
[app.util.blob :as blob]
[app.util.pointer-map :as pmap]
[app.worker :as wrk]
@@ -58,14 +58,14 @@
(defn print-tasks
[]
(let [tasks (:app.worker/registry main/system)]
(let [tasks (:app.worker/registry sys/system)]
(pp/pprint (keys tasks) :level 200)))
(defn run-task!
([tname]
(run-task! tname {}))
([tname params]
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task tname)
(assoc ::wrk/params params)))))
@@ -73,14 +73,14 @@
([name]
(schedule-task! name {}))
([name params]
(wrk/submit! (-> main/system
(wrk/submit! (-> sys/system
(assoc ::wrk/task name)
(assoc ::wrk/params params)))))
(defn send-test-email!
[destination]
(assert (string? destination) "destination should be provided")
(-> main/system
(-> sys/system
(assoc ::wrk/task :sendmail)
(assoc ::wrk/params {:body "test email"
:subject "test email"
@@ -89,7 +89,7 @@
(defn resend-email-verification-email!
[email]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [{:keys [::db/conn] :as cfg}]
(let [email (profile/clean-email email)
profile (profile/get-profile-by-email conn email)]
@@ -103,7 +103,7 @@
"Mark the profile blocked and removes all the http sessiones
associated with the profile-id."
[email]
(some-> main/system
(some-> sys/system
(db/tx-run!
(fn [{:keys [::db/conn] :as system}]
(when-let [profile (db/get* conn :profile
@@ -117,7 +117,7 @@
"Mark the profile blocked and removes all the http sessiones
associated with the profile-id."
[email]
(some-> main/system
(some-> sys/system
(db/tx-run!
(fn [{:keys [::db/conn] :as system}]
(when-let [profile (db/get* conn :profile
@@ -135,7 +135,7 @@
(assert (string? email) "expected email")
(assert (string? password) "expected password")
(some-> main/system
(some-> sys/system
(db/tx-run!
(fn [{:keys [::db/conn] :as system}]
(let [password (derive-password password)
@@ -156,7 +156,7 @@
:hint (str "feature '" feature "' not supported")))
(let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [{:keys [::db/conn]}]
(let [team (-> (db/get conn :team {:id team-id})
(update :features db/decode-pgarray #{}))
@@ -175,7 +175,7 @@
:hint (str "feature '" feature "' not supported")))
(let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [{:keys [::db/conn]}]
(let [team (-> (db/get conn :team {:id team-id})
(update :features db/decode-pgarray #{}))
@@ -216,7 +216,7 @@
:code :incorrect-level
:hint (str "level '" level "' not supported")))
(let [{:keys [::mbus/msgbus ::db/pool]} main/system
(let [{:keys [::mbus/msgbus ::db/pool]} sys/system
send
(fn [dest]
@@ -321,7 +321,7 @@
collectable file-changes entry."
[& {:keys [file-id label]}]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [cfg]
(let [file (bfc/get-file cfg file-id :realize? true)]
(fsnap/create! cfg file {:label label :created-by "admin"}))))))
@@ -330,7 +330,7 @@
[file-id & {:keys [label id]}]
(let [file-id (h/parse-uuid file-id)
snapshot-id (some-> id h/parse-uuid)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [{:keys [::db/conn] :as system}]
(cond
(uuid? snapshot-id)
@@ -348,7 +348,7 @@
(defn list-file-snapshots!
[file-id & {:as _}]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [cfg]
(->> (fsnap/get-visible-snapshots cfg file-id)
(print-table [:label :id :revn :created-at :created-by]))))))
@@ -356,7 +356,7 @@
(defn take-team-snapshot!
[team-id & {:keys [label rollback?] :or {rollback? true}}]
(let [team-id (h/parse-uuid team-id)]
(-> (assoc main/system ::db/rollback rollback?)
(-> (assoc sys/system ::db/rollback rollback?)
(db/tx-run! h/take-team-snapshot! team-id label))))
(defn restore-team-snapshot!
@@ -364,7 +364,7 @@
exists for all files; if is not the case, an exception is raised."
[team-id label & {:keys [rollback?] :or {rollback? true}}]
(let [team-id (h/parse-uuid team-id)]
(-> (assoc main/system ::db/rollback rollback?)
(-> (assoc sys/system ::db/rollback rollback?)
(db/tx-run! h/restore-team-snapshot! team-id label))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -376,7 +376,7 @@
all contents of a file. Returns a list of errors."
[file-id]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! (assoc main/system ::db/rollback true)
(db/tx-run! (assoc sys/system ::db/rollback true)
(fn [system]
(let [file (bfc/get-file system file-id)
libs (bfc/get-resolved-file-libraries system file)]
@@ -387,7 +387,7 @@
all contents of a file. Returns a list of errors."
[file-id]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! (assoc main/system ::db/rollback true)
(db/tx-run! (assoc sys/system ::db/rollback true)
(fn [system]
(try
(let [file (bfc/get-file system file-id)]
@@ -405,7 +405,7 @@
(defn repair-file!
"Repair the list of errors detected by validation."
[file-id & {:keys [rollback?] :or {rollback? true} :as options}]
(let [system (assoc main/system ::db/rollback rollback?)
(let [system (assoc sys/system ::db/rollback rollback?)
file-id (h/parse-uuid file-id)
options (assoc options ::h/with-libraries? true)]
(db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options)))
@@ -415,7 +415,7 @@
The function receives the decoded and migrated file data."
[file-id update-fn & {:keys [rollback?] :or {rollback? true} :as opts}]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! (assoc main/system ::db/rollback rollback?)
(db/tx-run! (assoc sys/system ::db/rollback rollback?)
(fn [system]
(binding [h/*system* system
db/*conn* (db/get-connection system)]
@@ -461,7 +461,7 @@
(when-let [[index item] (sp/<! in-ch)]
(l/dbg :hint "process item" :worker-id worker-id :index index :item item)
(try
(-> main/system
(-> sys/system
(assoc ::db/rollback rollback?)
(db/tx-run! (fn [system]
(binding [h/*system* system
@@ -506,7 +506,7 @@
(doall))]
(try
(db/tx-run! main/system process-items)
(db/tx-run! sys/system process-items)
;; Await threads termination
(doseq [thread threads]
@@ -535,13 +535,13 @@
(defn mark-file-as-trimmed
[id]
(let [id (h/parse-uuid id)]
(db/tx-run! main/system (fn [cfg]
(-> (db/update! cfg :file
{:has-media-trimmed true}
{:id id}
{::db/return-keys false})
(db/get-update-count)
(pos?))))))
(db/tx-run! sys/system (fn [cfg]
(-> (db/update! cfg :file
{:has-media-trimmed true}
{:id id}
{::db/return-keys false})
(db/get-update-count)
(pos?))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
@@ -553,14 +553,14 @@
(let [file-id (h/parse-uuid file-id)
tnow (ct/now)]
(audit/insert main/system
(audit/insert sys/system
{:name "delete-file"
:type "action"
:props {:id file-id}
:context {:triggered-by "srepl"
:cause "explicit call to delete-file!"}
:tracked-at tnow})
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :file
:deleted-at tnow
@@ -571,7 +571,7 @@
"Mark a file and all related objects as not deleted"
[file-id]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [{:keys [::db/conn] :as system}]
(when-let [file (db/get* system :file
{:id file-id}
@@ -593,7 +593,7 @@
(let [project-id (h/parse-uuid project-id)
tnow (ct/now)]
(audit/insert main/system
(audit/insert sys/system
{:name "delete-project"
:type "action"
:props {:id project-id}
@@ -601,7 +601,7 @@
:cause "explicit call to delete-project!"}
:tracked-at tnow})
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :project
:deleted-at tnow
@@ -625,7 +625,7 @@
"Mark a project and all related objects as not deleted"
[project-id]
(let [project-id (h/parse-uuid project-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [system]
(when-let [project (db/get* system :project
{:id project-id}
@@ -645,7 +645,7 @@
(let [team-id (h/parse-uuid team-id)
tnow (ct/now)]
(audit/insert main/system
(audit/insert sys/system
{:name "delete-team"
:type "action"
:props {:id team-id}
@@ -653,7 +653,7 @@
:cause "explicit call to delete-profile!"}
:tracked-at tnow})
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :team
:deleted-at tnow
@@ -681,7 +681,7 @@
"Mark a team and all related objects as not deleted"
[team-id]
(let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [system]
(when-let [team (some-> (db/get* system :team
{:id team-id}
@@ -702,14 +702,14 @@
(let [profile-id (h/parse-uuid profile-id)
tnow (ct/now)]
(audit/insert main/system
(audit/insert sys/system
{:name "delete-profile"
:type "action"
:context {:triggered-by "srepl"
:cause "explicit call to delete-profile!"}
:tracked-at tnow})
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :profile
:deleted-at tnow
@@ -720,7 +720,7 @@
"Mark a team and all related objects as not deleted"
[profile-id]
(let [profile-id (h/parse-uuid profile-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [system]
(when-let [profile (some-> (db/get* system :profile
{:id profile-id}
@@ -793,9 +793,9 @@
(defn process-deleted-profiles-cascade
[]
(->> (db/exec! main/system ["select id, deleted_at from profile where deleted_at is not null"])
(->> (db/exec! sys/system ["select id, deleted_at from profile where deleted_at is not null"])
(run! (fn [{:keys [id deleted-at]}]
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :profile
:deleted-at deleted-at
@@ -803,9 +803,9 @@
(defn process-deleted-teams-cascade
[]
(->> (db/exec! main/system ["select id, deleted_at from team where deleted_at is not null"])
(->> (db/exec! sys/system ["select id, deleted_at from team where deleted_at is not null"])
(run! (fn [{:keys [id deleted-at]}]
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :team
:deleted-at deleted-at
@@ -813,9 +813,9 @@
(defn process-deleted-projects-cascade
[]
(->> (db/exec! main/system ["select id, deleted_at from project where deleted_at is not null"])
(->> (db/exec! sys/system ["select id, deleted_at from project where deleted_at is not null"])
(run! (fn [{:keys [id deleted-at]}]
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :project
:deleted-at deleted-at
@@ -823,9 +823,9 @@
(defn process-deleted-files-cascade
[]
(->> (db/exec! main/system ["select id, deleted_at from file where deleted_at is not null"])
(->> (db/exec! sys/system ["select id, deleted_at from file where deleted_at is not null"])
(run! (fn [{:keys [id deleted-at]}]
(wrk/invoke! (-> main/system
(wrk/invoke! (-> sys/system
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :file
:deleted-at deleted-at
@@ -842,7 +842,7 @@
(assert (string? client-id) "expected a valid client-id")
(assert (string? client-secret) "expected a valid client-secret")
(assert (string? domain) "expected a valid domain")
(db/insert! main/system :sso-provider
(db/insert! sys/system :sso-provider
{:id (uuid/next)
:type "oidc"
:client-id client-id
@@ -856,7 +856,7 @@
(defn decode-session-token
[token]
(session/decode-token main/system token))
(session/decode-token sys/system token))
(defn instrument-var
[var]
@@ -881,7 +881,7 @@
(defn duplicate-team
[team-id & {:keys [name]}]
(let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system
(db/tx-run! sys/system
(fn [{:keys [::db/conn] :as cfg}]
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
(let [team (-> (assoc cfg ::bfc/timestamp (ct/now))
+9
View File
@@ -0,0 +1,9 @@
;; 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 app.system)
(defonce system nil)
@@ -21,19 +21,73 @@
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.request :as yreq]
[yetti.response :as yres]))
[yetti.response :as yres])
(:import
io.undertow.server.RequestTooBigException))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies]
(defrecord DummyRequest [headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name)))
(get headers name))
(method [_] method)
(body [_] body-stream)
(path [_] path)
(query [_] query)
(server-port [_] server-port)
(server-name [_] server-name)
(remote-addr [_] remote-addr)
(ssl-client-cert [_] ssl-client-cert)
(scheme [_] scheme)
(protocol [_] protocol))
(defn- make-dummy-request
"Constructs a DummyRequest from an options map. Every key is
optional; missing values fall back to sensible defaults. New
fields added to DummyRequest won't break existing call sites
as long as this constructor keeps its `:or` defaults in sync.
Recognized keys:
:headers — map of header name → value
:cookies — map of cookie name → value
:method — HTTP method keyword (default :get)
:body-stream — InputStream for the body (used directly)
:body-bytes — bytes or string for the body; wrapped in a
ByteArrayInputStream if :body-stream is not
given
:remote-addr — string (default \"127.0.0.1\")
:server-name — string (default \"test\")
:server-port — long (default 0)
:scheme — keyword (default :http)
:protocol — string (default \"HTTP/1.1\")
:path — string (default \"/test\")
:query — string or nil (default nil)
:ssl-client-cert — X509Certificate or nil (default nil)"
[{:keys [headers cookies method body-stream body-bytes
remote-addr server-name server-port scheme protocol
path query ssl-client-cert]
:or {headers {} cookies {} method :get
body-stream nil
remote-addr "127.0.0.1" server-name "test" server-port 0
scheme :http protocol "HTTP/1.1" path "/test" query nil
ssl-client-cert nil}}]
(let [body-stream (or body-stream
(when body-bytes
(java.io.ByteArrayInputStream.
(if (string? body-bytes)
(.getBytes ^String body-bytes "UTF-8")
body-bytes))))]
(->DummyRequest headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert)))
(t/deftest auth-middleware-1
(let [request (volatile! nil)
@@ -41,11 +95,11 @@
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
(handler (make-dummy-request {:headers {"authorization" "Token aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :token token-type))
@@ -58,10 +112,10 @@
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
(handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :bearer token-type))
@@ -74,10 +128,10 @@
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(handler (make-dummy-request {:cookies {"auth-token" "foobar"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :cookie token-type))
@@ -89,16 +143,16 @@
(fn [req] {::yres/status 200})
{:test1 "secret-key"})]
(let [response (handler (->DummyRequest {} {}))]
(let [response (handler (make-dummy-request {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
(t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz
@@ -113,6 +167,21 @@
(t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
(t/deftest access-token-authz-sets-token-id-and-type
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token
(:id profile) "test" nil "mcp")
handler (#'app.http.access-token/wrap-authz identity th/*system*)
request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}}
response (handler request)]
;; Must set ::actoken/id from claims :tid
(t/is (= (:id token) (:app.http.access-token/id response)))
;; Must set ::actoken/type from database
(t/is (= "mcp" (:app.http.access-token/type response)))
;; Existing assertions still pass
(t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response)))))
(defrecord MethodAwareDummyRequest [req-method headers]
yreq/IRequest
(method [_] req-method)
@@ -194,7 +263,7 @@
:user-agent "user agent"})
(#'session/assign-token cfg))
response (handler (->DummyRequest {} {"auth-token" (:token session)}))
response (handler (make-dummy-request {:cookies {"auth-token" (:token session)}}))
{:keys [token claims] token-type :type}
(get response ::http/auth-data)]
@@ -205,3 +274,127 @@
(t/is (= "penpot" (:aud claims)))
(t/is (= (:id session) (:sid claims)))
(t/is (= (:id profile) (:uid claims)))))
(t/deftest parse-request-illegal-argument-exception
;; clojure.data.json raises IllegalArgumentException (case
;; fall-through) on several kinds of malformed input. The
;; parse-request middleware should convert any such IAE into a
;; 400 :malformed-json validation error rather than letting it
;; surface as a 500 internal error. Because the conversion is
;; done by raising an ex-info (caught by the top-level error
;; handler in app.http/router-handler), this test asserts on
;; the ex-info thrown by wrap-parse-request directly.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] {::yres/status 200 ::yres/body :ok}))
;; Body contains the bytes for: {"x": "\}"} -- a string
;; value with a backslash followed by '}', which
;; clojure.data.json v0.5.x cannot handle.
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes body})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-request-too-big-exception
;; When RequestTooBigException is raised (e.g. the request body
;; exceeded the configured size limit), the middleware should
;; convert it to a 413 :request-body-too-large validation
;; error.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RequestTooBigException. "too large"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :request-body-too-large (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-eof-exception
;; When java.io.EOFException is raised (e.g. the body stream
;; was closed before the parser could read it), the middleware
;; should convert it to a 400 :malformed-json validation error.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.EOFException. "stream closed"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-runtime-exception-with-cause
;; When a RuntimeException with a non-nil ex-cause is raised,
;; the middleware should recurse on the cause and dispatch
;; through the specific-exception branches. Here we wrap an
;; IllegalArgumentException in a RuntimeException and verify
;; it surfaces as :malformed-json.
(let [iae (IllegalArgumentException. "No matching clause: 99")
wrapped (doto (RuntimeException. "wrapped")
(.initCause iae))
handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw wrapped)))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))))
(t/deftest parse-request-runtime-exception-without-cause
;; When a bare RuntimeException (no ex-cause) is raised, the
;; middleware should fall through to errors/handle's :default
;; path and return a 500 with :type :server-error :code
;; :unexpected. This is the "true internal error" path.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RuntimeException. "boom"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
response (handler request)
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :unexpected (:code body)))
(t/is (= "boom" (:hint body)))))
(t/deftest parse-request-non-runtime-throwable
;; When a non-RuntimeException Throwable is raised (e.g. an
;; Error subclass or a non-RuntimeException checked-style
;; exception), the middleware should fall through to the
;; :else branch and call errors/handle. java.io.IOException
;; has a dedicated handle-exception method that returns 500
;; with :code :io-exception.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.IOException. "network gone"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
response (handler request)
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :io-exception (:code body)))
(t/is (= "network gone" (:hint body)))))
@@ -29,8 +29,7 @@
(t/testing "create access token without expiration date"
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token 1"
:perms ["get-profile"]}
:name "token 1"}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
@@ -40,13 +39,25 @@
(t/is (contains? result :id))
(t/is (contains? result :created-at))
(t/is (contains? result :updated-at))
(t/is (contains? result :token)))))
(t/is (contains? result :token))
(t/is (not (contains? result :perms))))))
(t/testing "create access token ignores client-supplied perms"
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token ignored-perms"
:perms ["error-reports:read"]}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [result (:result out)
row (th/db-get :access-token {:id (:id result)})]
(t/is (not (contains? result :perms)))
(t/is (= [] (db/decode-pgarray (:perms row) []))))))
(t/testing "create access token with expiration date in the future"
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token 1"
:perms ["get-profile"]
:expiration "130h"}
out (th/command! params)]
;; (th/print-result! out)
@@ -64,7 +75,6 @@
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token 1"
:perms ["get-profile"]
:expiration "-130h"}
out (th/command! params)]
;; (th/print-result! out)
@@ -85,11 +95,12 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[result :as results] (:result out)]
(t/is (= 3 (count results)))
(t/is (= 4 (count results)))
(t/is (contains? result :id))
(t/is (contains? result :created-at))
(t/is (contains? result :updated-at))
(t/is (not (contains? result :token))))))
(t/is (not (contains? result :token)))
(t/is (not (contains? result :perms))))))
(t/testing "delete access token"
(let [params {::th/type :delete-access-token
@@ -107,14 +118,13 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [results (:result out)]
(t/is (= 2 (count results))))))
(t/is (= 3 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
:name "token 1"})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
@@ -126,16 +136,14 @@
(let [;; Create a regular token
regular-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "regular token"
:perms ["get-profile"]})
:name "regular token"})
regular-token (:result regular-out)
;; Create an MCP token
mcp-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "mcp token"
:perms []})
:name "mcp token"})
mcp-token (:result mcp-out)
;; Fetch all tokens
@@ -163,24 +171,21 @@
first-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "first mcp"
:perms []})
:name "first mcp"})
first-mcp (:result first-out)
;; Create second MCP token
second-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "second mcp"
:perms []})
:name "second mcp"})
second-mcp (:result second-out)
;; Create third MCP token
third-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "third mcp"
:perms []})
:name "third mcp"})
third-mcp (:result third-out)
;; Fetch all tokens
@@ -13,6 +13,7 @@
[app.db :as db]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.util.services :as sv]
[backend-tests.helpers :as th]
[clojure.test :as t]
[yetti.request]))
@@ -498,3 +499,47 @@
(t/is (some? (:tracked-at row)))
(t/is (= {} (:props row)))
(t/is (= {} (:context row))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID CONVERSION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest prepare-rpc-event-converts-string-profile-id-to-uuid
;; When result contains a string :profile-id (e.g. from error reports),
;; prepare-rpc-event must convert it to a UUID for audit schema compliance.
(let [prof (th/create-profile* 1 {:is-active true})
string-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
expected #uuid "33601240-a00b-11ea-ba1b-c554cc60e361"
mdata {::sv/name "test-cmd"}
params {::rpc/profile-id (:id prof)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
result {:profile-id string-pid :some-data "value"}
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must be a UUID, not a string
(t/is (uuid? (:profile-id event)))
(t/is (= expected (:profile-id event)))))
(t/deftest prepare-rpc-event-handles-invalid-string-profile-id
;; When result contains an invalid string :profile-id, it should fall back
;; to the RPC params profile-id (which is always a valid UUID).
(let [prof (th/create-profile* 1 {:is-active true})
mdata {::sv/name "test-cmd"}
params {::rpc/profile-id (:id prof)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
result {:profile-id "not-a-valid-uuid"}
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must fall back to the RPC params profile-id
(t/is (uuid? (:profile-id event)))
(t/is (= (:id prof) (:profile-id event)))))
@@ -0,0 +1,288 @@
;; 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 Espana SL
(ns backend-tests.rpc-commands-error-reports-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.util.services :as sv]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[yetti.request]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(def ^:private error-reports-read #{"error-reports:read"})
(defn- insert-report!
"Insert a raw error report row. content is a plain map (will be tjson-encoded)."
[sys {:keys [id source content created-at]
:or {created-at (ct/now)}}]
(th/db-insert! :server-error-report
{:id (or id (uuid/next))
:source source
:created-at created-at
:content (db/tjson content)}))
(defn- token-cmd
"Invoke an RPC method as an access token with the given perms."
[profile params & {:keys [perms] :or {perms error-reports-read}}]
(th/command! (assoc params
::rpc/profile-id (:id profile)
::rpc/auth-type :token
::rpc/token-perms perms)))
(defn- session-cmd
"Invoke an RPC method as a session-authenticated profile."
[profile params]
(th/command! (assoc params
::rpc/profile-id (:id profile)
::rpc/auth-type :session)))
;; --- Auth tests
(t/deftest get-error-reports-requires-authentication
(let [out (th/command! {::th/type :get-error-reports})]
(t/is (not (th/success? out)))
(t/is (= :authentication (th/ex-type (:error out))))
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest get-error-reports-requires-token-auth
(let [profile (th/create-profile* 1 {:is-active true})
out (session-cmd profile {::th/type :get-error-reports})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :token-auth-required (th/ex-code (:error out))))))
(t/deftest get-error-reports-requires-perm
(let [profile (th/create-profile* 1 {:is-active true})
out (token-cmd profile {::th/type :get-error-reports} :perms #{})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :missing-perms (th/ex-code (:error out))))))
(t/deftest get-error-reports-allows-token-with-perm
(let [profile (th/create-profile* 1 {:is-active true})
out (token-cmd profile {::th/type :get-error-reports})]
(t/is (th/success? out))
(t/is (vector? (:items (:result out))))
(t/is (zero? (count (:items (:result out)))))))
;; --- List shape
(t/deftest get-error-reports-returns-summary-shape
(let [id (uuid/next)
profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system*
{:id id
:source 3
:content {:hint "test error"
:tenant "devenv"
:version "2.20.0-devenv"}})
(let [out (token-cmd profile {::th/type :get-error-reports})
result (:result out)]
(t/is (th/success? out))
(t/is (vector? (:items result)))
(t/is (= 1 (count (:items result))))
(let [item (first (:items result))]
(t/is (= id (:id item)))
(t/is (= "logging" (:source item)))
(t/is (= "test error" (:hint item)))
(t/is (= "devenv" (:tenant item)))
(t/is (= "2.20.0-devenv" (:version item)))
(t/is (nil? (:content item)))
(t/is (nil? (:kind item)))))))
;; --- Filters
(t/deftest get-error-reports-filter-by-source
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "backend error"}})
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "frontend error"}})
(insert-report! th/*system* {:id (uuid/next) :source 5 :content {:hint "rlimit error"}})
(let [out (token-cmd profile {::th/type :get-error-reports :source "audit-log"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "audit-log" (:source (first (:items (:result out)))))))))
(t/deftest get-error-reports-filter-by-kind
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "page err" :kind "exception-page"}})
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "unhandled" :kind "unhandled-exception"}})
(let [out (token-cmd profile {::th/type :get-error-reports :kind "exception-page"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "exception-page" (:kind (first (:items (:result out)))))))))
(t/deftest get-error-reports-filter-by-tenant
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "a" :tenant "tenant-a"}})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "b" :tenant "tenant-b"}})
(let [out (token-cmd profile {::th/type :get-error-reports :tenant "tenant-a"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "tenant-a" (:tenant (first (:items (:result out)))))))))
(t/deftest get-error-reports-filter-by-hint-ilike
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "NullPointerException in render"}})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "Timeout connecting to DB"}})
(let [out (token-cmd profile {::th/type :get-error-reports :hint "null"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (str/includes? (:hint (first (:items (:result out)))) "Null")))))
;; --- Kind normalization (old ~:origin vs new ~:kind)
(t/deftest get-error-reports-kind-normalization
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "old shape" :origin "old-origin"}})
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "new shape" :kind "new-kind"}})
;; Both should appear in unfiltered list
(let [out (token-cmd profile {::th/type :get-error-reports})]
(t/is (th/success? out))
(t/is (= 2 (count (:items (:result out))))))
;; Filtering by kind should match old ~:origin
(let [out (token-cmd profile {::th/type :get-error-reports :kind "old-origin"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "old shape" (:hint (first (:items (:result out)))))))
;; Filtering by kind should match new ~:kind
(let [out (token-cmd profile {::th/type :get-error-reports :kind "new-kind"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "new shape" (:hint (first (:items (:result out)))))))))
;; --- Pagination
(t/deftest get-error-reports-pagination
(let [profile (th/create-profile* 1 {:is-active true})
t1 (ct/in-past "10m")
t2 (ct/in-past "9m")
t3 (ct/in-past "8m")
t4 (ct/in-past "7m")]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "1"} :created-at t1})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "2"} :created-at t2})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "3"} :created-at t3})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "4"} :created-at t4})
;; Page 1: oldest 2 (ASC order)
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
(t/is (th/success? out))
(let [{:keys [items next-since next-id]} (:result out)]
(t/is (= 2 (count items)))
(t/is (some? next-since))
(t/is (some? next-id))
(t/is (= "1" (:hint (first items))))
(t/is (= "2" (:hint (second items))))
;; Page 2: next 2, using since and since-id from page 1
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
(t/is (th/success? out2))
(let [{:keys [items next-since]} (:result out2)]
(t/is (= 2 (count items)))
(t/is (nil? next-since))
(t/is (= "3" (:hint (first items))))
(t/is (= "4" (:hint (second items))))))))))
(t/deftest get-error-reports-pagination-same-timestamp
(let [profile (th/create-profile* 1 {:is-active true})
t (ct/in-past "5m")
id1 (uuid/next)
id2 (uuid/next)
id3 (uuid/next)]
(insert-report! th/*system* {:id id1 :source 3 :content {:hint "1"} :created-at t})
(insert-report! th/*system* {:id id2 :source 3 :content {:hint "2"} :created-at t})
(insert-report! th/*system* {:id id3 :source 3 :content {:hint "3"} :created-at t})
;; Page 1: first 2 of the 3 same-timestamp rows
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
(t/is (th/success? out))
(let [{:keys [items next-since next-id]} (:result out)]
(t/is (= 2 (count items)))
(t/is (some? next-since))
(t/is (some? next-id))
;; Page 2: should return the remaining row, not skip it
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
(t/is (th/success? out2))
(let [{:keys [items next-since]} (:result out2)]
(t/is (= 1 (count items)))
(t/is (nil? next-since))))))))
;; --- Single fetch
(t/deftest get-error-report-requires-token-auth
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)]
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
(let [out (session-cmd profile {::th/type :get-error-report :id id})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :token-auth-required (th/ex-code (:error out)))))))
(t/deftest get-error-report-requires-perm
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)]
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report"}})
(let [out (token-cmd profile {::th/type :get-error-report :id id} :perms #{})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :missing-perms (th/ex-code (:error out)))))))
(t/deftest get-error-report-success
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)]
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
(let [out (token-cmd profile {::th/type :get-error-report :id id})]
(t/is (th/success? out))
(let [result (:result out)]
(t/is (= id (:id result)))
(t/is (= "logging" (:source result)))
(t/is (= "single report" (:hint result)))
(t/is (= "devenv" (:tenant result)))))))
(t/deftest get-error-report-not-found
(let [profile (th/create-profile* 1 {:is-active true})
out (token-cmd profile {::th/type :get-error-report :id (uuid/next)})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :report-not-found (th/ex-code (:error out))))))
;; --- Audit event tests
(t/deftest get-error-report-audit-event-has-uuid-profile-id
;; When get-error-report returns a report with string profile-id in content,
;; the audit event must have a proper UUID profile-id (not a string).
;; This tests the prepare-rpc-event function directly since the test RPC
;; flow doesn't include the audit middleware wrapper.
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)
orig-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
;; Simulate the result from get-error-report with string profile-id
result {:id id
:source "logging"
:hint "test error"
:profile-id orig-pid}
mdata {::sv/name "get-error-report"}
params {::rpc/profile-id (:id profile)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must be a UUID, not a string
(t/is (uuid? (:profile-id event)))
(t/is (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" (:profile-id event)))))
;; Note: The integration of access token middleware with audit context is tested
;; via unit tests in rpc_audit_test.clj and http_middleware_test.clj.
;; The middleware sets ::id and ::type on the request, and prepare-context-from-request
;; reads these values to populate :access-token-id and :access-token-type in the context.
@@ -2319,3 +2319,75 @@
(t/is (not (nil? (:error out))))
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))
;; --- Security Fix Tests ---
(t/deftest link-file-to-library-circular-reference
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true})
file2 (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true})
file3 (th/create-file* 3 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})]
(th/link-file-to-library* {:file-id (:id file3) :library-id (:id file2)})
(th/link-file-to-library* {:file-id (:id file2) :library-id (:id file1)})
(let [data {::th/type :link-file-to-library
::rpc/profile-id (:id profile)
:file-id (:id file1)
:library-id (:id file3)}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(let [edata (-> out :error ex-data)]
(t/is (= :circular-library-reference (:code edata)))))))
(t/deftest get-file-etag-includes-deleted-at
(let [profile-id (uuid/random)
file1 {:modified-at (ct/now)
:revn 1
:vern 0
:deleted-at nil
:permissions {:can-edit true}}
file2 (assoc file1 :deleted-at (ct/now))]
(t/is (not= (files/get-file-etag {::rpc/profile-id profile-id} file1)
(files/get-file-etag {::rpc/profile-id profile-id} file2)))))
(t/deftest search-files-with-permission
(let [profile (th/create-profile* 1)
_ (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
data {::th/type :search-files
::rpc/profile-id (:id profile)
:team-id (:default-team-id profile)
:search-term "test"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (vector? (:result out)))))
(t/deftest search-files-forbidden
(let [profile (th/create-profile* 1)
other (th/create-profile* 2)
data {::th/type :search-files
::rpc/profile-id (:id other)
:team-id (:default-team-id profile)
:search-term "test"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))
(t/deftest search-files-term-too-long
(let [profile (th/create-profile* 1)
data {::th/type :search-files
::rpc/profile-id (:id profile)
:team-id (:default-team-id profile)
:search-term (apply str (repeat 300 "x"))}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata))))))
+158 -69
View File
@@ -10,12 +10,14 @@
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.path-names :as cpn]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape :as cts]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]))
(log/set-level! :debug)
@@ -35,7 +37,7 @@
(assoc :width 0.01)
(assoc :height 0.01)
(cts/setup-rect)))]
(log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -48,7 +50,7 @@
(log/debug :hint " -> set to " :parent-id uuid/zero)
(assoc shape :parent-id uuid/zero))]
(log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -61,7 +63,7 @@
(log/debug :hint " -> add children to" :parent-id (:id parent-shape))
(update parent-shape :shapes conj (:id shape)))]
(log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:parent-id shape)] repair-shape))))
@@ -74,7 +76,7 @@
(log/debug :hint " -> remove duplicated children")
(update shape :shapes distinct))]
(log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -86,14 +88,14 @@
(log/debug :hint " -> remove child" :child-id (:child-id args))
(update parent-shape :shapes (fn [shapes]
(d/removev #(= (:child-id args) %) shapes))))]
(log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :invalid-parent
[_ {:keys [shape page-id args] :as error} file-data _]
(log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true})))
@@ -109,7 +111,7 @@
(log/debug :hint " -> set to " :frame-id frame-id)
(assoc shape :frame-id frame-id)))]
(log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -125,7 +127,7 @@
(log/debug :hint " -> set to " :frame-id frame-id)
(assoc shape :frame-id frame-id)))]
(log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -138,7 +140,7 @@
(log/debug :hint " -> set :main-instance")
(assoc shape :main-instance true))]
(log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -155,7 +157,7 @@
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -174,7 +176,7 @@
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes shape-ids repair-shape))))
@@ -194,7 +196,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
(if (and (some? component) (not (:deleted component)))
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
@@ -211,7 +213,7 @@
;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-page" :component-id (:id component))
(assoc component :main-instance-page page-id))]
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-component))))
@@ -224,7 +226,7 @@
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)]
(log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -237,7 +239,7 @@
(log/debug :hint " -> unset :main-instance")
(dissoc shape :main-instance))]
(log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -250,7 +252,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -263,7 +265,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -310,7 +312,7 @@
;; If the shape still refers to the remote component, try to find the corresponding near one
;; and link to it. If not, detach the shape.
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(if (some? matching-shape)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
@@ -329,7 +331,7 @@
(log/debug :hint " -> unhead shape")
(ctk/unhead-shape shape))]
(log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -354,7 +356,7 @@
(nil? (:component-file args))
(dissoc :component-file)))]
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -367,7 +369,7 @@
(log/debug :hint " -> reroot shape")
(ctk/rehead-shape shape (:component-file args) (:component-id args)))]
(log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -396,7 +398,7 @@
(assoc acc k v)))
{}
objects)))))]
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
(log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
(-> (pcb/empty-changes nil nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
@@ -409,7 +411,7 @@
(log/debug :hint " -> unset :shape-ref")
(dissoc shape :shape-ref))]
(log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -422,7 +424,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -435,7 +437,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape)
@@ -449,7 +451,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -462,7 +464,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -475,7 +477,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -488,7 +490,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -501,7 +503,7 @@
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)]
(log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -520,7 +522,7 @@
:r3 0
:r4 0))]
(log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -538,7 +540,7 @@
(log/debug :hint " -> remove :objects")
(dissoc component :objects))))]
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
@@ -554,7 +556,7 @@
(dissoc component :objects))
component))]
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
@@ -567,7 +569,7 @@
(log/debug :hint " -> add :content-group to :touched-groups")
(update shape :touched ctk/set-touched-group :content-group))]
(log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -580,7 +582,7 @@
(log/debug :hint " -> remove swap-slot")
(ctk/remove-swap-slot shape))]
(log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -603,13 +605,11 @@
(log/debug :hint " -> remove swap-slot" :child-id (:id shape))
(ctk/remove-swap-slot shape))]
(log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes (map :id child-with-duplicate) repair-shape))))
(defmethod repair-error :component-duplicate-slot
[_ {:keys [shape] :as error} file-data _]
(let [main-shape (get-in shape [:objects (:main-instance-id shape)])
@@ -633,7 +633,7 @@
(:objects component))]
(assoc component :objects objects)))]
(log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
(log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
@@ -649,50 +649,139 @@
(ctk/set-swap-slot shape slot))
shape)))]
(log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :not-a-variant
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :main-instance-not-a-variant
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape :variant-id variant-id)))]
(defmethod repair-error :invalid-variant-id
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :main-instance-invalid-variant-id
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape
:variant-id variant-id)))]
(log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :invalid-variant-properties
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id args]} file-data _]
(let [prop-names (:prop-names args)
component (get-in file-data [:components (:component-id shape)])
prop-values (into {} (map (juxt :name :value)) (:variant-properties component))
properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names)
variant-name (ctv/properties-to-name properties')
repair-component
(fn [component]
;; Rebuild component properties, removing any extra ones and adding missing ones with empty value
(log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names))
(assoc component :variant-properties properties'))
repair-shape
(fn [shape]
(log/debug :hint " -> set variant-name" :variant-name variant-name)
(assoc shape :variant-name variant-name))]
(log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-component)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :variant-not-main
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id]} file-data _]
(let [page (ctpl/get-page file-data page-id)
shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))]
(log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint " -> delete shapes" :shape-ids shape-ids)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/remove-objects shape-ids))))
(defmethod repair-error :parent-not-variant
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id]} file-data _]
(let [parent-id (:parent-id shape)
repair-fn
(fn [parent]
(log/debug :hint " -> set :is-variant-container true")
(assoc parent :is-variant-container true))]
(log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [parent-id] repair-fn))))
(defmethod repair-error :variant-bad-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-main-bad-name
[_ {:keys [shape page-id args]} file-data _]
(let [repair-fn
(fn [shape]
(log/debug :hint " -> set :name" :name (:variant-name args))
(assoc shape :name (:variant-name args)))]
(log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-fn))))
(defmethod repair-error :variant-bad-variant-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-main-bad-variant-name
[_ {:keys [shape page-id]} file-data _]
(let [component (get-in file-data [:components (:component-id shape)])
variant-name (ctv/properties-to-name (:variant-properties component))
repair-fn
(fn [shape]
(log/debug :hint " -> set :variant-name" :variant-name variant-name)
(assoc shape :variant-name variant-name))]
(log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-fn))))
(defmethod repair-error :variant-component-bad-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id args]} file-data _]
(let [[path name] (cpn/split-group-name (:variant-container-name args))
repair-fn
(fn [component]
(log/debug :hint " -> set :path and :name" :path path :name name)
(assoc component :path path :name name))]
(log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-fn))))
(defmethod repair-error :variant-component-bad-id
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape
:variant-id variant-id)))]
(log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :default
[_ error file _]
@@ -701,7 +790,7 @@
(defn repair-file
[{:keys [data id] :as file} libraries errors]
(log/dbg :hint "repairing file" :id (str id) :errors (count errors))
(log/debug :hint "repairing file" :id (str id) :errors (count errors))
(let [{:keys [redo-changes]}
(reduce (fn [changes error]
(pcb/concat-changes changes
+37 -29
View File
@@ -65,13 +65,13 @@
:misplaced-slot
:missing-slot
:shape-ref-cycle
:not-a-variant
:invalid-variant-id
:main-instance-not-a-variant
:main-instance-invalid-variant-id
:invalid-variant-properties
:variant-not-main
:parent-not-variant
:variant-bad-name
:variant-bad-variant-name
:variant-main-bad-name
:variant-main-bad-variant-name
:variant-component-bad-name
:variant-component-bad-id})
@@ -573,19 +573,23 @@
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(report-error :main-instance-not-a-variant
(str/ffmt "Main instance shape % should be a variant" (:id child))
child file page
:variant-id shape-id)
(do
(when (not= (:variant-id child) shape-id)
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(report-error :main-instance-invalid-variant-id
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
child file page
:variant-id shape-id))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page))))))
child file page
:prop-names prop-names))))))
shapes)))
(defn- check-variant
"Shape is a variant, so
-it should be a main component
@@ -594,9 +598,9 @@
-its name should be the same as its parent's
"
[shape file page]
(let [parent (ctst/get-shape page (:parent-id shape))
component (ctkl/get-component (:data file) (:component-id shape) true)
name (ctv/properties-to-name (:variant-properties component))]
(let [parent (ctst/get-shape page (:parent-id shape))
component (ctkl/get-component (:data file) (:component-id shape) true)
variant-name (ctv/properties-to-name (:variant-properties component))]
(when-not (ctk/main-instance? shape)
(report-error :variant-not-main
(str/ffmt "Variant % is not a main instance" (:id shape))
@@ -605,23 +609,26 @@
(report-error :parent-not-variant
(str/ffmt "Variant % has an invalid parent" (:id shape))
shape file page))
(when-not (= name (:variant-name shape))
(report-error :variant-bad-variant-name
(when-not (= variant-name (:variant-name shape))
(report-error :variant-main-bad-variant-name
(str/ffmt "Variant % has an invalid variant-name" (:id shape))
shape file page))
shape file page
:variant-name variant-name))
(when-not (= (:name parent) (:name shape))
(report-error :variant-bad-name
(str/ffmt "Variant % has an invalid name" (:id shape))
shape file page))
(report-error :variant-main-bad-name
(str/ffmt "Main instance inside variant % has an invalid name" (:id shape))
shape file page
:variant-name (:name parent)))
(when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component)))
(report-error :variant-component-bad-name
(str/ffmt "Component % has an invalid name" (:id shape))
shape file page))
shape file page
:variant-container-name (:name parent)))
(when-not (= (:variant-id component) (:variant-id shape))
(report-error :variant-component-bad-id
(str/ffmt "Variant % has adifferent variant-id than its component" (:id shape))
shape file page))))
shape file page
:variant-id (:variant-id component)))))
(defn- check-shape
"Validate referential integrity and semantic coherence of
@@ -740,14 +747,15 @@
-It should have at least one variant property"
[component file]
(let [component-page (ctf/get-component-page (:data file) component)
main-component (if (:deleted component)
main-instance (if (:deleted component)
(dm/get-in component [:objects (:main-instance-id component)])
(ctst/get-shape component-page (:main-instance-id component)))]
(when (and main-component
(not (ctk/is-variant? main-component)))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id main-component))
main-component file component-page))))
(when (and main-instance
(not (ctk/is-variant? main-instance)))
(report-error :main-instance-not-a-variant
(str/ffmt "Main instance shape % should be a variant" (:id main-instance))
main-instance file component-page
:variant-id (:variant-id component)))))
(defn- check-main-inside-main
[component file]
@@ -121,74 +121,79 @@
(defn layout-content-points
[bounds parent children objects]
(let [parent-id (dm/get-prop parent :id)
parent-bounds @(get bounds parent-id)
reverse? (ctl/reverse? parent)
children (cond->> children (not reverse?) reverse)]
(let [parent-id (dm/get-prop parent :id)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [reverse? (ctl/reverse? parent)
children (cond->> children (not reverse?) reverse)]
(loop [children (seq children)
result (transient [])
correct-v (gpt/point 0)]
(loop [children (seq children)
result (transient [])
correct-v (gpt/point 0)]
(if (not children)
(persistent! result)
(if (not children)
(persistent! result)
(let [child (first children)
child-id (dm/get-prop child :id)
child-bounds @(get bounds child-id)
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
(let [child (first children)
child-id (dm/get-prop child :id)
child-bounds-ref (get bounds child-id)
child-bounds (some-> child-bounds-ref deref)
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
[child-bounds correct-v]
(if (or (ctl/fill-width? child) (ctl/fill-height? child))
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
[(->> child-bounds (map #(gpt/add % correct-v))) correct-v])
[child-bounds correct-v]
(if (and child-bounds
(or (ctl/fill-width? child) (ctl/fill-height? child)))
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
[(when child-bounds
(->> child-bounds (map #(gpt/add % correct-v))))
correct-v])
child-bounds
(when (d/not-empty? child-bounds)
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
child-bounds
(when (d/not-empty? child-bounds)
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
(recur (next children)
(cond-> result (some? child-bounds) (conj! child-bounds))
correct-v))))))
(recur (next children)
(cond-> result (some? child-bounds) (conj! child-bounds))
correct-v))))))))
(defn layout-content-bounds
[bounds {:keys [layout-padding] :as parent} children objects]
(let [parent-id (:id parent)
parent-bounds @(get bounds parent-id)
(let [parent-id (:id parent)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [row? (ctl/row? parent)
col? (ctl/col? parent)
space-around? (ctl/space-around? parent)
space-evenly? (ctl/space-evenly? parent)
content-evenly? (ctl/content-evenly? parent)
[layout-gap-row layout-gap-col] (ctl/gaps parent)
row? (ctl/row? parent)
col? (ctl/col? parent)
space-around? (ctl/space-around? parent)
space-evenly? (ctl/space-evenly? parent)
content-evenly? (ctl/content-evenly? parent)
[layout-gap-row layout-gap-col] (ctl/gaps parent)
row-pad (if (or (and col? space-evenly?)
(and col? space-around?)
(and row? content-evenly?))
layout-gap-row
0)
row-pad (if (or (and col? space-evenly?)
(and col? space-around?)
(and row? content-evenly?))
layout-gap-row
0)
col-pad (if (or (and row? space-evenly?)
(and row? space-around?)
(and col? content-evenly?))
layout-gap-col
0)
col-pad (if (or (and row? space-evenly?)
(and row? space-around?)
(and col? content-evenly?))
layout-gap-col
0)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (+ (or pad-top 0) row-pad)
pad-right (+ (or pad-right 0) col-pad)
pad-bottom (+ (or pad-bottom 0) row-pad)
pad-left (+ (or pad-left 0) col-pad)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (+ (or pad-top 0) row-pad)
pad-right (+ (or pad-right 0) col-pad)
pad-bottom (+ (or pad-bottom 0) row-pad)
pad-left (+ (or pad-left 0) col-pad)
layout-points
(layout-content-points bounds parent children objects)]
layout-points
(layout-content-points bounds parent children objects)]
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))))
@@ -12,36 +12,36 @@
(defn layout-content-points
[bounds parent {:keys [row-tracks column-tracks]}]
(let [parent-id (:id parent)
parent-bounds @(get bounds parent-id)
hv #(gpo/start-hv parent-bounds %)
vv #(gpo/start-vv parent-bounds %)]
(d/concat-vec
(->> row-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (vv (:size %))))))
(->> column-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (hv (:size %)))))))))
(let [parent-id (:id parent)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [hv #(gpo/start-hv parent-bounds %)
vv #(gpo/start-vv parent-bounds %)]
(d/concat-vec
(->> row-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (vv (:size %))))))
(->> column-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (hv (:size %)))))))))))
(defn layout-content-bounds
[bounds {:keys [layout-padding] :as parent} layout-data]
(let [parent-id (:id parent)
parent-bounds @(get bounds parent-id)
(let [parent-id (:id parent)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (or pad-top 0)
pad-right (or pad-right 0)
pad-bottom (or pad-bottom 0)
pad-left (or pad-left 0)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (or pad-top 0)
pad-right (or pad-right 0)
pad-bottom (or pad-bottom 0)
pad-left (or pad-left 0)
layout-points (layout-content-points bounds parent layout-data)]
layout-points (layout-content-points bounds parent layout-data)]
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))))
@@ -31,13 +31,17 @@
(and (ctl/fill-width? child)
(ctl/grid-layout? child))
(let [children
(->> (cfh/get-immediate-children objects (:id child))
(remove ctl/position-absolute?)
(map #(vector @(get bounds (:id %)) %)))
layout-data (gd/calc-layout-data child @(get bounds (:id child)) children bounds objects true)]
(max (ctl/child-min-width child)
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
(let [child-bounds-ref (get bounds (:id child))]
(if child-bounds-ref
(let [children
(->> (cfh/get-immediate-children objects (:id child))
(remove ctl/position-absolute?)
(keep #(when-let [b (get bounds (:id %))]
[@b %])))
layout-data (gd/calc-layout-data child @child-bounds-ref children bounds objects true)]
(max (ctl/child-min-width child)
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
(ctl/child-min-width child)))
(ctl/fill-width? child)
(ctl/child-min-width child)
@@ -63,11 +67,15 @@
(let [children
(->> (cfh/get-immediate-children objects (dm/get-prop child :id))
(remove ctl/position-absolute?)
(map (fn [child] [@(get bounds (:id child)) child])))
(keep (fn [c]
(when-let [b (get bounds (:id c))]
[@b c]))))
layout-data (gd/calc-layout-data child (:points child) children bounds objects true)
auto-bounds (gb/layout-content-bounds bounds child layout-data)]
(max (ctl/child-min-height child)
(gpo/height-points auto-bounds)))
(if auto-bounds
(max (ctl/child-min-height child)
(gpo/height-points auto-bounds))
(ctl/child-min-height child)))
(ctl/fill-height? child)
(ctl/child-min-height child)
@@ -13,6 +13,11 @@
[app.common.types.text :as txt]))
(defn add-variant
"Add a variant component to a file with two variants, each with a root shape.
:variant-label [:name Board]
{:root2-label} [:name Board] # [Component :component2-label]
{:root1-label} [:name Board] # [Component :component1-label]
"
[file variant-label component1-label root1-label component2-label root2-label
& {:keys [variant1-params variant2-params]
:or {variant1-params {} variant2-params {}}}]
+2
View File
@@ -901,8 +901,10 @@
(let [shape (get objects shape-id)]
(println (str/pad (str (str/repeat " " level)
(when (:main-instance shape) "{")
(when (:is-variant-container shape) "{{")
(:name shape)
(when (:main-instance shape) "}")
(when (:is-variant-container shape) "}}")
(when (seq (:touched shape)) "*")
(when show-ids (str/format " %s" (:id shape))))
{:length 20
+15 -2
View File
@@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.flags :as flags]
[app.common.math :as mth]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[clojure.set :as set]
@@ -217,7 +218,10 @@
attributes or other things that may be attached).
- Consider nil values, empty strings or empty lists all equal.
- Normalize numeric values (legacy) into strings.
- No value is equal than the default value."
- No value is equal than the default value.
- Numeric attrs (e.g. line-height) compare with float tolerance so
editor/WASM round-trips like \"1.3333333333333333\" vs \"1.33333\"
do not count as a real style change (avoids detaching tokens)."
[key value1 value2]
(when (text-node-attr? key)
(let [default-value (get default-text-attrs key)
@@ -229,7 +233,16 @@
$)))
value1' (normalize-value value1)
value2' (normalize-value value2)]
(not= value1' value2'))))
(cond
(= value1' value2')
false
:else
(let [n1 (when (string? value1') (d/parse-double value1'))
n2 (when (string? value2') (d/parse-double value2'))]
(if (and (some? n1) (some? n2))
(not (mth/close? n1 n2))
true))))))
(defn- compare-text-content
"Given two content text structures, conformed by maps and vectors,
@@ -0,0 +1,230 @@
;; 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 common-tests.files.repair-test
"Tests for the validate / repair functions in app.common.files.validate
and app.common.files.repair.
The tests generate cases of broken files and check that the validation functions
generate accurate errors, and that the repair functions return the file to
a stable state."
(:require
[app.common.files.repair :as cfr]
[app.common.files.validate :as cfv]
[app.common.test-helpers.components :as thc]
[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.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest repair-main-instance-not-a-variant
(t/testing "detect and repair a variant component whose root shape is not a variant"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(ths/update-shape :root1 :variant-id nil))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1 :page-label :page1)]
(t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem
(t/is (= :main-instance-not-a-variant (:code (first errors))))
(t/is (nil? errors'))
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
(t/deftest repair-invalid-variant-id-variant-component-bad-id
(t/testing "detect and repair a variant component whose variant id does not match the container's id"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(ths/update-shape :root1 :variant-id (uuid/next)))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1 :page-label :page1)]
(t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem
(t/is (= :main-instance-invalid-variant-id (:code (first errors))))
(t/is (= :variant-component-bad-id (:code (second errors))))
(t/is (nil? errors'))
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
(t/deftest repair-invalid-variant-properties
(t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"]
;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3")
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
{:name "Property 2" :value "ValueA"}]})
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
{:name "Property 3" :value "ValueB"}]})
(ths/update-shape :root1 :variant-name "Value1, ValueA")
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
comp1' (thc/get-component file' :component1)
comp2' (thc/get-component file' :component2)
root1' (ths/get-shape file' :root1)
root2' (ths/get-shape file' :root2)]
(t/is (= 1 (count errors)))
(t/is (= :invalid-variant-properties (:code (first errors))))
(t/is (nil? errors'))
;; After repair, component1's properties are rebuilt to match component2's property names
;; (the first child in the variant container is root2, so prop-names come from component2)
;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed
(t/is (= [{:name "Property 1" :value "Value1"}
{:name "Property 3" :value ""}]
(:variant-properties comp1')))
(t/is (= "Value1" (:variant-name root1')))
;; Component2 is unchanged (it was the reference for the property names)
(t/is (= [{:name "Property 1" :value "Value2"}
{:name "Property 3" :value "ValueB"}]
(:variant-properties comp2')))
(t/is (= "Value2, ValueB" (:variant-name root2'))))))
(t/deftest repair-variant-not-main
(t/testing "detect and repair a non-main-instance shape inside a variant container"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Add a third child to the variant container with :variant-id but NOT a main-instance
(ths/add-sample-shape :bad-shape
:type :frame
:parent-label :variant1
:variant-id (thi/id :variant1)
:variant-name "")
;; Add a child to the bad shape (to verify the repair deletes it too)
(ths/add-sample-shape :bad-child
:type :rect
:parent-label :bad-shape))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
bad-shape' (ths/get-shape file' :bad-shape)
bad-child' (ths/get-shape file' :bad-child)]
(t/is (= 4 (count errors))) ;; The bad container also triggers other errors
(t/is (= :invalid-variant-properties (:code (nth errors 0))))
(t/is (= :variant-not-main (:code (nth errors 1))))
(t/is (= :variant-component-bad-name (:code (nth errors 2))))
(t/is (= :variant-component-bad-id (:code (nth errors 3))))
(t/is (nil? errors'))
(t/is (nil? bad-shape'))
(t/is (nil? bad-child')))))
(t/deftest repair-parent-not-variant
(t/testing "detect and repair a variant shape whose parent is not a variant-container"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Break the variant container
(ths/update-shape :variant1 :is-variant-container false))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
container' (ths/get-shape file' :variant1)]
(t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container
(t/is (= :parent-not-variant (:code (first errors))))
(t/is (= :parent-not-variant (:code (second errors))))
(t/is (nil? errors'))
(t/is (true? (:is-variant-container container'))))))
(t/deftest repair-variant-main-bad-name
(t/testing "detect and repair a main instance whose name doesn't match the variant container's name"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Change root1's name so it doesn't match the container
(ths/update-shape :root1 :name "WrongName"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-main-bad-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Board" (:name root1'))))))
(t/deftest repair-variant-main-bad-variant-name
(t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
{:name "Property 2" :value "ValueA"}]})
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
{:name "Property 2" :value "ValueB"}]})
;; Change root1's :variant-name to something wrong
(ths/update-shape :root1 :variant-name "WrongVariantName")
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-main-bad-variant-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Value1, ValueA" (:variant-name root1'))))))
(t/deftest repair-variant-component-bad-name
(t/testing "detect and repair a variant component whose path/name doesn't match the container name"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Update names to have path structure
(ths/update-shape :variant1 :name "Group / Subgroup / Component")
(ths/update-shape :root1 :name "Group / Subgroup / Component")
(ths/update-shape :root2 :name "Group / Subgroup / Component")
;; Update component paths and names
(thc/update-component :component1 {:path "Group / Subgroup" :name "Component"})
(thc/update-component :component2 {:path "Group / Subgroup" :name "Component"})
;; Break component1's name
(thc/update-component :component1 {:name "WrongName"}))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
comp1' (thc/get-component file' :component1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-component-bad-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Group / Subgroup" (:path comp1')))
(t/is (= "Component" (:name comp1'))))))
@@ -0,0 +1,213 @@
;; 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 common-tests.geom-bounds-layout-nil-test
(:require
[app.common.data :as d]
[app.common.geom.bounds-map :as gbm]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.flex-layout.bounds :as fb]
[app.common.geom.shapes.grid-layout.bounds :as gb]
[app.common.geom.shapes.min-size-layout :as msl]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[clojure.test :as t]))
;; ---- Helpers ----
(defn- make-rect
[id x y w h]
(-> (cts/setup-shape {:id id
:type :rect
:name (str "rect-" id)
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero)))
(defn- make-flex-frame
[id child-ids & {:keys [x y w h dir]
:or {x 0 y 0 w 200 h 200 dir :row}}]
(-> (cts/setup-shape {:id id
:type :frame
:name (str "flex-" id)
:layout :flex
:layout-flex-dir dir
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids))))
(defn- make-grid-frame
[id child-ids & {:keys [x y w h dir]
:or {x 0 y 0 w 200 h 200 dir :row}}]
(let [cell-id (uuid/next)]
(-> (cts/setup-shape {:id id
:type :frame
:name (str "grid-" id)
:layout :grid
:layout-grid-dir dir
:layout-grid-columns [{:type :flex :value 1}]
:layout-grid-rows [{:type :flex :value 1}]
:layout-grid-cells
{cell-id {:id cell-id
:row 1
:row-span 1
:column 1
:column-span 1
:shapes (vec child-ids)}}
:layout-padding-type :multiple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
:layout-gap {:column-gap 0 :row-gap 0}
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids)))))
(defn- make-objects
[shapes]
(let [shape-map (into {} (map (fn [s] [(:id s) s]) shapes))]
(reduce-kv (fn [m _id shape]
(if (contains? shape :shapes)
(reduce (fn [m' child-id]
(assoc-in m' [child-id :parent-id] (:id shape)))
m
(:shapes shape))
m))
shape-map
shape-map)))
(defn- bounds-map-from-objects
"Build a bounds map from objects, optionally excluding some IDs."
[objects & {:keys [exclude-ids]}]
(let [full (gbm/objects->bounds-map objects)]
(if (seq exclude-ids)
(apply dissoc full exclude-ids)
full)))
;; ---- Tests for flex layout bounds with nil bounds ----
(t/deftest layout-content-points-with-missing-parent-bounds
(t/testing "layout-content-points returns nil when parent is not in bounds map"
(let [child-id (uuid/next)
parent-id (uuid/next)
child (make-rect child-id 10 10 50 50)
parent (make-flex-frame parent-id [child-id])
objects (make-objects [parent child])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
(t/is (nil? (fb/layout-content-points bounds parent [child] objects))))))
(t/deftest layout-content-points-with-missing-child-bounds
(t/testing "layout-content-points skips children with missing bounds"
(let [child1-id (uuid/next)
child2-id (uuid/next)
parent-id (uuid/next)
child1 (make-rect child1-id 10 10 50 50)
child2 (make-rect child2-id 70 10 50 50)
parent (make-flex-frame parent-id [child1-id child2-id])
objects (make-objects [parent child1 child2])
bounds (bounds-map-from-objects objects :exclude-ids #{child1-id})]
(let [result (fb/layout-content-points bounds parent [child1 child2] objects)]
(t/is (some? result))
;; Only child2's bounds should be in the result
(t/is (pos? (count result)))))))
(t/deftest layout-content-bounds-with-missing-parent-bounds
(t/testing "layout-content-bounds returns nil when parent is not in bounds map"
(let [child-id (uuid/next)
parent-id (uuid/next)
child (make-rect child-id 10 10 50 50)
parent (make-flex-frame parent-id [child-id])
objects (make-objects [parent child])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
(t/is (nil? (fb/layout-content-bounds bounds parent [child] objects))))))
;; ---- Tests for grid layout bounds with nil bounds ----
(t/deftest grid-layout-content-points-with-missing-parent-bounds
(t/testing "grid layout-content-points returns nil when parent is not in bounds map"
(let [parent-id (uuid/next)
parent (make-grid-frame parent-id [])
objects (make-objects [parent])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
(t/is (nil? (gb/layout-content-points bounds parent layout-data))))))
(t/deftest grid-layout-content-bounds-with-missing-parent-bounds
(t/testing "grid layout-content-bounds returns nil when parent is not in bounds map"
(let [parent-id (uuid/next)
parent (make-grid-frame parent-id [])
objects (make-objects [parent])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
(t/is (nil? (gb/layout-content-bounds bounds parent layout-data))))))
;; ---- Tests for min-size-layout with nil bounds ----
(t/deftest child-min-width-grid-with-missing-child-bounds
(t/testing "child-min-width falls back when grid layout child bounds are missing"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-grid-dir :row
:layout-item-h-sizing :fill))
objects (make-objects [child grandchild])
;; Exclude grandchild from bounds to simulate missing entry
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(let [result (msl/child-min-width child child-bounds bounds objects)]
(t/is (= (ctl/child-min-width child) result))))))
(t/deftest child-min-height-grid-with-missing-child-bounds
(t/testing "child-min-height falls back when grid layout child bounds are missing"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-grid-dir :column
:layout-item-v-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(let [result (msl/child-min-height child child-bounds bounds objects)]
(t/is (= (ctl/child-min-height child) result))))))
(t/deftest child-min-width-grid-with-present-child-bounds
(t/testing "child-min-width handles bounded children in a fill-width grid"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-item-h-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects)
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(t/is (number? (msl/child-min-width child child-bounds bounds objects))))))
(t/deftest child-min-height-grid-with-present-child-bounds
(t/testing "child-min-height handles bounded children in a fill-height grid"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-item-v-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects)
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(t/is (number? (msl/child-min-height child child-bounds bounds objects))))))
+2
View File
@@ -22,6 +22,7 @@
[common-tests.files.shapes-builder-test]
[common-tests.files.validate-test]
[common-tests.geom-align-test]
[common-tests.geom-bounds-layout-nil-test]
[common-tests.geom-bounds-map-test]
[common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test]
@@ -95,6 +96,7 @@
'common-tests.files-migrations-test
'common-tests.files.validate-test
'common-tests.geom-align-test
'common-tests.geom-bounds-layout-nil-test
'common-tests.geom-bounds-map-test
'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test
@@ -78,6 +78,14 @@
(def content-changed-line-height
(assoc-in content-base [:children 0 :children 0 :line-height] "1.5"))
;; Token/WASM may store full float precision; editor round-trips often
;; truncate (e.g. CSS / f32). These must compare as equal.
(def content-line-height-full-precision
(assoc-in content-base [:children 0 :children 0 :line-height] "1.3333333333333333"))
(def content-line-height-truncated
(assoc-in content-base [:children 0 :children 0 :line-height] "1.33333"))
(def content-redundant-span-line-height
(assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5"))
@@ -208,6 +216,8 @@
;; Other text-node-attr categories
attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family)
attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height)
attrs-line-height-precision (cttx/get-diff-attrs content-line-height-full-precision
content-line-height-truncated)
attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height)
attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height
content-after-editor-roundtrip)
@@ -242,6 +252,7 @@
;; Each text-node-attr category reports correct attr key
(t/is (= #{:font-family} attrs-font-family))
(t/is (= #{:line-height} attrs-line-height))
(t/is (= #{} attrs-line-height-precision))
(t/is (= #{} attrs-span-line-height))
(t/is (= #{} attrs-roundtrip-line-height))
(t/is (= #{} attrs-nil-typography-refs))
+1 -1
View File
@@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.18.2
ENV OPENCODE_VERSION=1.18.4
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
+1
View File
@@ -86,6 +86,7 @@ http {
set $real_mtype "$upstream_http_x_mtype";
proxy_set_header Host "$redirect_host";
proxy_set_header Authorization "";
proxy_hide_header etag;
proxy_hide_header x-amz-id-2;
proxy_hide_header x-amz-request-id;
+4 -4
View File
@@ -78,7 +78,7 @@ services:
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
restart: always
ports:
- 9001:8080
@@ -111,7 +111,7 @@ services:
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
restart: always
volumes:
@@ -180,13 +180,13 @@ services:
PENPOT_SMTP_SSL: "false"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
restart: always
depends_on:
+1
View File
@@ -94,6 +94,7 @@ http {
proxy_buffering off;
proxy_set_header Host "$redirect_host";
proxy_set_header Authorization "";
proxy_hide_header etag;
proxy_hide_header x-amz-id-2;
proxy_hide_header x-amz-request-id;
+1 -1
View File
@@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
title="Quick demo: Penpot MCP server in action"
width="100%"
height="480"
src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
+1 -1
View File
@@ -588,7 +588,7 @@ PENPOT_FLAGS: [...] enable-auto-file-snapshot # Enable automatic v
# Backend
PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version?
PENPOT_AUTO_FILE_SNAPSHOT_TIIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met?
PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met?
```
Setting custom values for auto-file-snapshot does not change the behaviour for manual versions.
+6
View File
@@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Useful resources to better understand Penpot</p>
</a>
</li>
<li>
<a href="/user-guide/first-steps/migration-guide">
<h2>Migration Guide →</h2>
<p>Move a design system from Figma to Penpot</p>
</a>
</li>
</ul>
@@ -0,0 +1,31 @@
---
title: Migration Guide
order: 6
desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
---
<h1 id="migration-guide">Migration Guide</h1>
<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p>
<div class="advice">
<p><strong>Open the full guide (PDF)</strong></p>
<p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&amp;editing=false&amp;openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p>
</div>
<h2 id="what-the-guide-covers">What the guide covers</h2>
<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p>
<ul>
<li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li>
<li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li>
<li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li>
<li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li>
<li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li>
<li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li>
</ul>
<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p>
<h2 id="discuss-the-guide">Questions and discussion</h2>
<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p>
@@ -1,6 +1,6 @@
---
title: Troubleshooting WebGL
order: 5
order: 7
desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks.
---
+6 -3
View File
@@ -34,8 +34,11 @@
"watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main",
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt": "cljfmt fix --parallel=true src/",
"check-fmt": "cljfmt check --parallel=true src/",
"lint": "clj-kondo --parallel --lint src/"
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel --lint src/ test/",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js"
}
}
+1 -1
View File
@@ -5,4 +5,4 @@ set -e;
corepack enable;
corepack install;
pnpm install;
pnpx playwright install chromium
pnpm exec playwright install chromium
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -ex
corepack enable;
corepack install;
pnpm install;
pnpm run test;
+29
View File
@@ -0,0 +1,29 @@
import { spawnSync } from "node:child_process";
const BUILD_STEPS = [
{ label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] },
];
const progress = (msg) => process.stderr.write(`${msg}\n`);
for (const step of BUILD_STEPS) {
progress(`${step.label}...`);
const result = spawnSync(step.cmd, step.args, {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
progress(`${step.label} failed`);
if (result.stdout?.length) process.stdout.write(result.stdout);
if (result.stderr?.length) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
}
}
progress("Running tests...");
const result = spawnSync(
"node",
["target/tests/test.js", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);
+9 -1
View File
@@ -31,4 +31,12 @@
:pseudo-names true
:pretty-print true
:anon-fn-naming-policy :off
:source-map-detail-level :all}}}}}
:source-map-detail-level :all}}}
:test
{:target :esm
:output-dir "target/tests"
:runtime :node
:js-options {:js-provider :import}
:modules
{:test {:init-fn exporter-tests.runner/-main}}}}}
+1 -1
View File
@@ -117,7 +117,7 @@
[file-id paths]
(p/let [prefix (str/concat "penpot.pdfunite." file-id ".")
path (sh/tempfile :prefix prefix :suffix ".pdf")]
(sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path))
(apply sh/run-cmd! "pdfunite" (conj (vec paths) path))
path))
(defn- move-file
+1 -1
View File
@@ -38,7 +38,7 @@
:webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")]
;; playwright only supports jpg and png, we need to convert it afterwards
(bw/screenshot node {:omit-background? true :type :png :path png-path})
(sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path))))
(sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path))))
(on-object (assoc object :path path))))
(render [uri page]
+11 -27
View File
@@ -10,9 +10,12 @@
["xml-js" :as xml]
[app.browser :as bw]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.types.color :as ctc]
[app.common.uri :as u]
[app.config :as cf]
[app.renderer.svg-gradient :as svg-gradient]
[app.util.mime :as mime]
[app.util.shell :as sh]
[clojure.walk :as walk]
@@ -125,19 +128,23 @@
(letfn [(convert-to-ppm [pngpath]
(let [ppmpath (str/concat pngpath "origin.ppm")]
(l/trace :fn :convert-to-ppm :path ppmpath)
(-> (sh/run-cmd! (str "convert " pngpath " " ppmpath))
(-> (sh/run-cmd! "convert" pngpath ppmpath)
(p/then (constantly ppmpath)))))
(trace-color-mask [pbmpath]
(l/trace :fn :trace-color-mask :pbmpath pbmpath)
(let [svgpath (str/concat pbmpath ".svg")]
(-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath))
(-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath)
(p/then (constantly svgpath)))))
(generate-color-layer [ppmpath color]
(when-not (ctc/hex-color-string? color)
(ex/raise :type :validation
:code :invalid-color
:hint (str "invalid hex color: " color)))
(l/trace :fn :generate-color-layer :ppmpath ppmpath :color color)
(let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")]
(-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath))
(-> (sh/run-cmd! "ppmcolormask" color ppmpath)
(p/then (fn [stdout]
(-> (sh/write-file! pbmpath stdout)
(p/then (constantly pbmpath)))))
@@ -166,33 +173,11 @@
:else
(update node "attributes" assoc "fill" color))))
(get-stops [data]
(->> (get-in data ["gradient" "stops"])
(mapv (fn [stop-data]
{"type" "element"
"name" "stop"
"attributes" {"offset" (get stop-data "offset")
"stop-color" (get stop-data "color")
"stop-opacity" (get stop-data "opacity")}}))))
(data->gradient-def [id [color data]]
(let [id (str "gradient-" id "-" (subs color 1))]
(if (= type "linear")
{"type" "element"
"name" "linearGradient"
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
"elements" (get-stops data)}
{"type" "element"
"name" "radialGradient"
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
"elements" (get-stops data)})))
(get-gradients [id mapping]
(->> mapping
(filter (fn [[_color data]]
(= (get data "type") "gradient")))
(mapv (partial data->gradient-def id))))
(mapv (partial svg-gradient/data->gradient-def id))))
(join-color-layers [{:keys [id x y width height mapping] :as node} layers]
(l/trace :fn :join-color-layers :mapping mapping)
@@ -369,4 +354,3 @@
(assoc :query (u/map->query-string params)))]
(bw/exec! (prepare-options uri)
(partial render uri)))))
@@ -0,0 +1,32 @@
;; 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 app.renderer.svg-gradient)
(defn- get-stops
[data]
(->> (get-in data ["gradient" "stops"])
(mapv (fn [stop-data]
{"type" "element"
"name" "stop"
"attributes" {"offset" (get stop-data "offset")
"stop-color" (get stop-data "color")
"stop-opacity" (get stop-data "opacity")}}))))
(defn data->gradient-def
[id [color data]]
(let [id (str "gradient-" id "-" (subs color 1))
gradient-type (get-in data ["gradient" "type"])]
(if (= gradient-type "linear")
{"type" "element"
"name" "linearGradient"
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
"elements" (get-stops data)}
{"type" "element"
"name" "radialGradient"
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
"elements" (get-stops data)})))
+8 -8
View File
@@ -94,14 +94,14 @@
(.readFile fs/promises fpath))
(defn run-cmd!
[cmd]
[cmd & args]
(p/create
(fn [resolve reject]
(l/trace :fn :run-cmd :cmd cmd)
(proc/exec cmd #js {:encoding "buffer"}
(fn [error stdout _stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))
(l/trace :fn :run-cmd :cmd cmd :args args)
(proc/execFile cmd (clj->js args) #js {:encoding "buffer"}
(fn [error stdout _stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))
@@ -0,0 +1,25 @@
;; 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 exporter-tests.renderer-svg-test
(:require
[app.renderer.svg-gradient :as svg-gradient]
[cljs.test :refer [deftest is testing]]))
(def gradient-stops
[{"color" "#000000" "offset" 0 "opacity" 1}
{"color" "#ffffff" "offset" 1 "opacity" 1}])
(deftest creates-the-correct-gradient-element
(doseq [[gradient-type element-name]
[["linear" "linearGradient"]
["radial" "radialGradient"]]]
(testing gradient-type
(let [gradient-data {"type" "gradient"
"gradient" {"type" gradient-type
"stops" gradient-stops}}
result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])]
(is (= element-name (get result "name")))))))
+172
View File
@@ -0,0 +1,172 @@
;; 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 exporter-tests.runner
(:require
[app.common.logging :as l]
[cljs.test :as t]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[exporter-tests.renderer-svg-test]
[exporter-tests.shell-test]
[goog.object :as gobj]))
(enable-console-print!)
(def test-namespaces
['exporter-tests.renderer-svg-test
'exporter-tests.shell-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")
(defmethod t/report [:cljs.test/default :begin-test-var]
[m]
(let [v (:var m)]
(println (str " ▸ " (:ns (meta v)) "/" (:name (meta v))))))
(defmethod t/report [:cljs.test/default :end-run-tests]
[result]
(.exit js/process (if (cljs.test/successful? result) 0 1)))
(def ^:private log-levels
#{:trace :debug :info :warn :error})
(def cli-options
[["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"]
["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error"
:parse-fn keyword
:validate [log-levels "must be one of trace, debug, info, warn, error"]]
["-h" "--help"]])
(defn- argv
[]
(let [args (->> (.-argv js/process)
(array-seq)
(drop 2))]
;; `pnpm run test -- --focus ...` forwards the separator to the node
;; process, so drop one leading `--` before handing args to tools.cli.
(cond-> args
(= "--" (first args)) rest)))
(defn- usage
[summary]
(str "Usage: node target/tests/test.js [options]\n\n"
"Options:\n"
summary "\n\n"
"Build first with: pnpm run build:test\n\n"
"Focus examples:\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n"
"Log level example:\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn"))
(defn- fail!
[message]
(js/console.error message)
(.exit js/process 1))
(defn- parse-focus
[focus]
(let [[ns-name test-name & extra] (str/split focus #"/")]
(cond
(or (str/blank? ns-name) (seq extra))
(fail! (str "Invalid --focus value: " focus))
(some? test-name)
{:ns (symbol ns-name) :test test-name}
:else
{:ns (symbol ns-name)})))
(defn- fixture-value
[ns-obj fixture-name]
(let [value (gobj/get ns-obj (munge fixture-name))]
(when-not (undefined? value)
value)))
(defn- ns-test-vars
[ns-sym]
(when-let [ns-obj (find-ns-obj ns-sym)]
(->> (js-keys ns-obj)
(keep (fn [key]
(some-> (gobj/get ns-obj key)
(.-cljs$lang$var))))
(filter (comp :test meta))
(sort-by (comp :line meta)))))
(defn- ns-fixtures
[ns-sym vars]
(when-let [ns-obj (find-ns-obj ns-sym)]
(let [ns-key (or (some-> vars first meta :ns) ns-sym)
once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures")
each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")]
{:once (when once-fixtures {ns-key once-fixtures})
:each (when each-fixtures {ns-key each-fixtures})})))
(defn- selected-tests
[{:keys [ns test]}]
(when-not (some #{ns} test-namespaces)
(fail! (str "Unknown test namespace: " ns)))
(let [vars (vec (ns-test-vars ns))]
(when (empty? vars)
(fail! (str "No tests found in namespace: " ns)))
(if test
(let [test-sym (symbol test)
test-var (some #(when (= test-sym (:name (meta %))) %) vars)]
(if test-var
{:vars [test-var]
:fixtures (ns-fixtures ns [test-var])}
(fail! (str "Unknown test var: " ns "/" test))))
{:vars vars
:fixtures (ns-fixtures ns vars)})))
(defn- merge-fixtures
[fixtures]
{:once (apply merge (keep :once fixtures))
:each (apply merge (keep :each fixtures))})
(defn- run-test-vars!
[tests]
(let [vars (vec (mapcat :vars tests))
fixtures (merge-fixtures (map :fixtures tests))
env (assoc (t/empty-env)
:once-fixtures (:once fixtures)
:each-fixtures (:each fixtures))
summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})]
(t/set-env! env)
(t/run-block
(concat (t/test-vars-block vars)
[(fn []
(vswap! summary
(partial merge-with +)
(:report-counters (t/get-current-env))))
(fn []
(t/report @summary)
(t/report (assoc @summary :type :end-run-tests)))]))))
(defn- run-focused-test!
[focus]
(run-test-vars! [(selected-tests (parse-focus focus))]))
(defn -main
[]
(let [{:keys [options errors summary]} (parse-opts (argv) cli-options)]
(cond
(seq errors)
(fail! (str/join "\n" errors))
(:help options)
(do
(println (usage summary))
(.exit js/process 0))
:else
(do
(l/setup! {:app (or (:log-level options) :warn)})
(if (:focus options)
(run-focused-test! (:focus options))
(run-test-vars! (map #(selected-tests {:ns %}) test-namespaces)))))))
@@ -0,0 +1,70 @@
;; 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 exporter-tests.shell-test
"Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter.
These tests prove that:
1. execFile does NOT interpret shell metacharacters (safe execution)
2. Malicious colors fail validation regex
3. The injection does NOT execute commands (no RCE)"
(:require
["node:child_process" :as proc]
["node:fs" :as fs]
[cljs.test :as t :include-macros true]))
(def ^:private hex-color-rx
#"^#(?:[0-9a-fA-F]{3}){1,2}$")
(defn- valid-hex-color?
[color]
(and (string? color)
(some? (re-matches hex-color-rx color))))
(t/deftest execfile-does-not-interpret-shell-metacharacters
(t/testing "Proves execFile passes arguments literally (no shell interpretation)"
(t/async done
(let [cmd "echo"
args #js ["$(echo PWNED)"]]
(proc/execFile cmd args #js {:encoding "buffer"}
(fn [error stdout _stderr]
(if error
(do
(t/is false (str "unexpected error: " (.-message error)))
(done))
(let [output (.toString stdout "utf8")]
(t/is (= "$(echo PWNED)\n" output)
"execFile passes $(...) literally, no shell interpretation")
(done)))))))))
(t/deftest malicious-color-fails-validation
(t/testing "Proves malicious colors are rejected by validation"
(let [malicious "#000000$(echo PWNED)"
valid-color "#000000"
short-valid "#abc"]
(t/is (not (valid-hex-color? malicious))
"malicious color with $(...) fails validation")
(t/is (valid-hex-color? valid-color)
"valid 6-digit hex color passes validation")
(t/is (valid-hex-color? short-valid)
"valid 3-digit hex color passes validation"))))
(t/deftest execfile-does-not-execute-injected-commands
(t/testing "Proves execFile does NOT execute injected commands (no RCE)"
(t/async done
(let [marker "/tmp/penpot-exporter-rce-test"
malicious (str "#000000$(touch " marker ")")
cmd "echo"
args #js [malicious]]
(when (fs/existsSync marker)
(fs/unlinkSync marker))
(proc/execFile cmd args #js {:encoding "buffer"}
(fn [_error _stdout _stderr]
;; Command completes (or fails), but no injection occurs
(t/is (not (fs/existsSync marker))
"no RCE: marker file was NOT created")
(when (fs/existsSync marker)
(fs/unlinkSync marker))
(done)))))))
+2 -2
View File
@@ -29,7 +29,7 @@
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w",
"lint:clj": "clj-kondo --parallel --lint ../common/src src/",
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
"lint:js": "exit 0",
"lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'",
"build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test",
@@ -66,7 +66,7 @@
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^26.1.0",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/browser-playwright": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@zip.js/zip.js": "2.8.26",
"autoprefixer": "^10.5.2",
+7 -3
View File
@@ -29,6 +29,7 @@ function isDefined(v) {
}
function mergeBlockData(block, newData) {
if (!block) return undefined;
let data = block.getData();
for (let key of Object.keys(newData)) {
@@ -176,10 +177,12 @@ export function splitBlockPreservingData(state) {
content = Modifier.splitBlock(content, selection);
const blockData = content.blockMap.get(content.selectionBefore.getStartKey()).getData();
const startKey = content.selectionBefore.getStartKey();
const block = content.blockMap.get(startKey);
const blockData = (block && block.getData()) || new Map();
const blockKey = content.selectionAfter.getStartKey();
const blockMap = content.blockMap.update(blockKey, (block) => {
return block.set("data", blockData);
const blockMap = content.blockMap.update(blockKey, (b) => {
return b.set("data", blockData);
});
content = content.set("blockMap", blockMap);
@@ -325,6 +328,7 @@ export function updateBlockData(state, blockKey, data) {
const content = state.getCurrentContent();
const block = content.getBlockForKey(blockKey);
const newBlock = mergeBlockData(block, data);
if (!newBlock) return state;
const blockData = newBlock.getData();
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more