Compare commits

...
Author SHA1 Message Date
Andrey Antukh 113923ca17 📚 Document disable-email-verification team auto-add behavior
The configuration docs claimed it is not possible to add someone to a
team without them accepting an invitation email, but the backend
auto-adds existing profiles to the team when the email verification
is disabled (create-invitation in app.rpc.commands.teams-invitations).

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

Closes #11424

Reported in #11376

AI-assisted-by: glm-5.3-flash
2026-08-28 17:16:00 +00:00
Andrey Antukh 4c9aeab65e 📚 Add issue/PR CLI auto-trigger to agent guide
When an issue or PR is mentioned in conversation, fetch details via gh
CLI or scripts/gh.py instead of WebFetch. Use gh issue view for issues,
gh pr view for single PRs, and scripts/gh.py prs for multiple PRs.

AI-assisted-by: glm-5.3-flash
2026-08-28 14:55:30 +00:00
María Valderrama c01306734a 🐛 Fix default team and UI labels to use Personal Projects (#11418)
* 🐛 Fix default team and UI labels to use Personal Projects

* 📎 Code review
2026-08-28 14:04:56 +02:00
Pablo Alba 10e40e62a2 Add airgapped check to nitrate (#11421) 2026-08-28 13:58:18 +02:00
Marina López f06d1df5be 🐛 Align management API with payments service (#11414) 2026-08-28 11:54:02 +02:00
María Valderrama eb0c51ed74 📚 Rename personal space label from Your Penpot to Personal Projects (#11399) 2026-08-28 10:41:25 +02:00
Pablo Alba 18ebf5a92d 📚 Add highlights file (#11406) 2026-08-28 10:40:25 +02:00
María Valderrama cdfdbf9adc 🐛 Fix labels for teams navigation and personal files section (#11398) 2026-08-28 09:49:13 +02:00
Andrey AntukhandSumit Ridhal 1baa8bb29f 🐛 Center canvas and select layer when navigating layer search results (#10422) (#11391)
When using previous/next buttons in the layer search panel, the canvas
now centers on the matched layer and selects it in the layers panel.
Previously only the internal search cursor was advanced without any
visible canvas or selection update.

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
2026-08-27 18:33:48 +02:00
Luis de Dios 11224940ab 🐛 Fix wrong selection color on inputs (#11400) 2026-08-27 18:32:22 +02:00
Andrey AntukhandLuis de Dios 43bbf756b0 🐛 Fix font change regression and activate font preview by default (#11341)
*  Materialize font-preview flag to make it permanent

* 🐛 Fix font change regression when WebGL is disabled

* 📎 Add fixes for e2e tests

* 🐛 Fix font change from legacy render

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-08-27 18:30:33 +02:00
María Valderrama 6bedd5ac76 🐛 Fix move organization modal (#11390)
* 🐛 Fix move organization modal

* 📎 Code review
2026-08-27 12:41:32 +02:00
Andrés MoyaandAndrey Antukh c9a2b8f12f 🔧 Add TokensStatus data type for forward compatibility (#11314)
* 🔧 Add TokensStatus data type for forward compatibility

* 📚 Add Transit round-trip tests for TokensStatus type

Add serialization tests validating TokensStatus survives Transit
encode/decode and Fressian round-trip (JVM). Clarify tokens-source
field comment as forward-compatibility placeholder.

AI-assisted-by: mimo-v2.5-pro

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-27 11:51:47 +02:00
Andrés Moya 582187dd03 🎉 Allow to validate and repair files from /dbg (#11335) 2026-08-27 11:50:14 +02:00
Andrey Antukh b4dc8207ff Merge remote-tracking branch 'origin/main' into staging 2026-08-27 10:05:27 +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
Andrey Antukh 06491a2d24 🐛 Fix svg-raw child ids stored as strings in existing files (#11387)
* 🐛 Fix svg-raw child ids stored as strings in existing files

Before schema:svg-raw-attrs declared :shapes as a vector of uuid, the
JSON decoder had no type information for those child ids and left them
as plain strings on any round trip, so they got persisted as strings.
Once the schema was tightened those files fail schema validation on
load.

Add migration 0026-fix-svg-raw-shapes-uuids that coerces the :shapes
entries of svg-raw shapes back to uuid instances, in both pages and
components.

AI-assisted-by: deepseek-v4-flash

* ♻️ Drop redundant vector check in svg-raw shapes migration

:shapes is always a vector when present, so the guard is unnecessary.

AI-assisted-by: deepseek-v4-flash
2026-08-27 09:49:22 +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
Luis de Dios 2bce69f927 🐛 Fix increasing value clicking and dragging in a numeric input (#11334) 2026-08-26 21:35:07 +02:00
Andrey Antukh 6e173a02fb 📚 Split backend testing memory and link from testing skill
Extract the backend Testing section from backend/core into a dedicated
backend/testing memory, following the pattern of common, frontend, and
exporter. Update the testing skill and root testing memory to point at
the new location, and add exporter/testing to the skill's required
reading list.

AI-assisted-by: deepseek-v4-flash
2026-08-26 18:11:40 +00:00
Andrey Antukh 4be749d45f Add minor improvements to scripts/gh.py 2026-08-26 19:19:22 +02:00
Pablo Alba e1a2d0b932 🐛 Fix nitrate sso failure message (#11214) 2026-08-26 17:24:59 +02:00
Andrey Antukh 33e39bc7ed 🔧 Fix backend format check script in CI
The backend format check was using 'check-fmt' instead of 'check-fmt:clj',
causing CI to always fail on the fmt step.

Closes #11358

AI-assisted-by: longcat-2.0
2026-08-26 14:08:41 +02:00
Andrey Antukh 33f13f9bfd 🐛 Fix test formatting for get-team-invitation-token
AI-assisted-by: longcat-2.0
2026-08-26 14:08:41 +02:00
Andrey Antukh 4adfa5d2f2 🐛 Require edition permissions for get-team-invitation-token
The handler previously allowed any team member (including viewers) to
generate invitation tokens. Now requires at least edition-level
permissions (can-edit, admin, or owner).

Closes #11358

AI-assisted-by: longcat-2.0
2026-08-26 14:08:41 +02:00
Andrey Antukh 7079d33ae1 🐛 Enforce ownership check on tempfile bucket access (#11270)
* 🐛 Enforce ownership check on tempfile bucket access

The upload-tempfile RPC stores profile-id with tempfile objects, but
objects-handler never verified the requester was the owner. Any
authenticated user who knew the UUID could access the tempfile.

Add ownership check: tempfile bucket now requires the request's
profile-id to match the stored profile-id. Returns 404 on mismatch
(not 403) to avoid leaking object existence.

Legacy tempfiles without stored profile-id remain accessible to any
authenticated user for backward compatibility.

Closes #11269

AI-assisted-by: qwen3.7-plus

* ♻️ Extract tempfile-bucket constant and fix docstring indentation

Extract the 'tempfile' bucket string literal into a named constant
(sto/tempfile-bucket) to prevent typos and make future bucket renames
trivial. Updated 9 occurrences across 7 files.

Also fixed minor docstring indentation inconsistency in authenticated?
function.

AI-assisted-by: qwen3.7-plus

* ♻️ Refactor process-bucket! and authenticated? helpers

Replace case with cond in process-bucket! to properly resolve
sto/tempfile-bucket var from another namespace (case does not
evaluate qualified vars at compile time).

Redefine authenticated? in terms of request-profile-id to remove
duplicated lookup logic.

Closes #11269

AI-assisted-by: mimo-v2.5-pro
2026-08-26 13:05:36 +02:00
Andrey Antukh b33213787e 🐛 Add accumulated storage byte quota for media uploads (#11038)
* 🐛 Add accumulated storage byte quota for media uploads

Add media-storage-bytes-per-team quote to prevent persistent DoS via
repeated uploads. The quota sums storage_object sizes from both
file_media_object (media + thumbnails) and team_font_variant
(otf/ttf/woff1/woff2). Default limit is 20 GiB per team, configurable
via PENPOT_QUOTES_MEDIA_STORAGE_BYTES_PER_TEAM.

The check is invoked in upload-file-media-object before processing,
looking up the team-id via file -> project -> team_id join.

AI-assisted-by: mimo-v2.5-pro

* 🐛 Fix deduplicated storage overcounting in media-storage-bytes-per-team quote

The SQL query sql:get-media-storage-bytes-per-team used UNION ALL
across six SELECT branches that each produce a so_id reference.
When deduplication causes multiple file_media_object or
team_font_variant rows to point at the same storage_object, UNION ALL
counts that objects size once per reference — inflating "used bytes"
and causing false :max-quote-reached rejections.

Change all five UNION ALL to UNION so that duplicate so_id values are
collapsed before the JOIN storage_object / SUM(so.size).

Add a test (media-storage-bytes-quote-deduped) that creates one
storage_object referenced by two file_media_object rows and asserts
the computed usage reflects the deduplicated physical size, not 2x.

AI-assisted-by: mimo-v2.5-pro
2026-08-26 08:33:20 +02:00
María Valderrama 5e1ced03ea 🐛 Fix missing warning when moving a team (#11357) 2026-08-26 08:08:40 +02:00
Luis de DiosandAndrey Antukh 44dfc04300 🐛 Fix font selector dropdown takes noticeably long to open when changing font (#11073)
* 🐛 Fix font selector dropdown takes noticeably long to open when changing font

* ♻️ Refactor detach-preview-sprite! to use atomic swap

Use idiomatic atomic swap! update instead of non-atomic
read-then-write pattern. The new implementation computes
the decremented refs inside swap! and only removes the
node when the result reaches zero.

AI-assisted-by: mimo-v2.5-pro

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-25 16:55:34 +02:00
Andrey Antukh 85a68ea3b2 🐛 Normalize toast HTML prop to boolean
Ensure toast components always receive a boolean `is-html` prop so nil or
truthy notification values do not violate the Rumext schema.

AI-assisted-by: gpt-5.6-luna
2026-08-25 13:47:37 +00:00
Andrey Antukh b79680eeb7 🐛 Fix asset 404 for unauthenticated share-link viewers (#11342)
PR #11036 added a per-request permission check to the file-media asset
endpoints (/assets/by-file-media-id/:id and the /thumbnail variant) using
bfc/get-file-permissions. Anonymous share-link viewers were then rejected
because they have neither a session nor an access token, the asset URL
carries no share context, and the 2-arg get-file-permissions short-circuits
to nil when profile-id is nil.

Make the asset endpoints share-link aware, mirroring how get-view-only-bundle
already authorizes the same scenario: read the share-id from the query
string, validate it as a UUID, and call the 3-arg perms/get-file-read-permissions
(which chains the existing 2-arg bfc lookup, the bfc share-link fallback,
and the Nitrate org-owner fallback). On the frontend, extend
cf/resolve-file-media with an optional share-id arg and pass it from the
WASM viewer render path using the share-id already present in
[:viewer-local :share-id]. Non-viewer call sites (workspace, clipboard,
code-gen) keep the original URL shape because the new arg defaults to nil.

Closes #11338

AI-assisted-by: minimax-m3
2026-08-25 13:10:55 +02:00
Luis de Dios c31236947c 🐛 Fix skipped-tokens warning details show raw HTML tags instead of a bulleted list (#11325) 2026-08-25 09:37:54 +02:00
Andrey Antukh 52573be074 🐛 Enforce share-link page scope in get-page and get-file-fragment RPC commands (#11284)
The get-page RPC command did not validate that the requested page-id was within the share-link's authorized :pages set, allowing share-link holders to read out-of-scope pages. The get-file-fragment command had the same issue.

This fix adds page scope validation to get-page, rejecting requests for pages not in the share-link's :pages set with a :not-found error. For get-file-fragment, share-link access is denied entirely as fragments lack direct page-id mapping.

The fix aligns these commands with the existing correct behavior in get-view-only-bundle, which already filters pages based on share-link scope.

Closes #11281

AI-assisted-by: qwen3.7-plus
2026-08-25 09:37:42 +02:00
María Valderrama 316b58bc75 🐛 Fix team dropdown not refreshing after organization deletion (#11336) 2026-08-24 20:02:59 +02:00
María Valderrama e93c419785 🐛 Fix wrong copy on disabled invite button tooltip (#11311) 2026-08-24 15:25:31 +02:00
Pablo Alba 7732c93541 💄 Update nitrate activation code link (#11330) 2026-08-24 14:04:22 +02:00
Andrey Antukh 47d599fe34 Persist binfile manifest and emit workspace audit events (#11106) (#11138)
Persist binfile manifest metadata in file_data on import so file
statistics are available at open-workspace time. Emit a new
open-workspace-file audit event enriched with file statistics:
page count, shape count, component count, linked libraries,
design tokens, and whether the file is a shared library.

Closes #11106

AI-assisted-by: mimo-v2.5-pro
2026-08-21 14:37:21 +02:00
Andrey Antukh 0cacf9bd99 ♻️ Rename code-review-and-quality skill to code-review
Rename skill directory and update all references to follow the
same naming pattern as plan-review.

Simplify review.md command from 138 to 25 lines — remove redundant
content that duplicated what the skills already define. The command
now acts as a thin router; the skills own the methodology.

AI-assisted-by: mimo-v2.5-pro
2026-08-21 12:11:11 +00:00
Andrey Antukh 7c85837290 🐛 Fix session invalidation on logout to prevent token replay (#11317)
Logout only cleared the auth-token cookie but never deleted the
server-side row because delete-fn read ::id which wrap-authz no longer
sets since 363b4e3778. Make delete-fn delete via ::session/:id attached
by wrap-authz so replayed tokens are rejected (CWE-613, GHSA-mj9f-5cwq-7p3q).

Add regression tests covering invalidation, idempotency and isolation
of other sessions. Fix verified with Red→Green TDD and full backend
suite (677 tests).

Closes #11316

AI-assisted-by: muse-spark-1.2-contributor
2026-08-21 12:55:24 +02:00
Andrey Antukh dd4a163217 🐛 Remove internal error details from HTTP error responses (#11288)
* 🚑 Remove internal error details from HTTP error responses

PostgreSQL exceptions, I/O exceptions, and unhandled errors were
leaking raw database messages (table names, constraint names,
SQLSTATE codes), filesystem paths, and internal exception details
to API clients via :hint, :state, and :path response fields.

Remove these fields from server-error responses while keeping
full error context in server-side logs for operators.

Closes #11287

AI-assisted-by: mimo-v2.5-pro

* 🚑 Strip internal fields and map PG errors to safe messages

Complete the security fix for GHSA-r8wx-23q6-w3gf by addressing
the incomplete redaction found in code review.

Add strip-internal-fields helper to dissoc :hint, :state, :path,
and :context from error response data in three handlers that
previously passed raw ex-data through to clients:

- handle-error :internal
- handle-exception :default (else branch)
- handle-error :assertion (else branch)

Add pgsql-state->message to map PostgreSQL SQLSTATE codes to safe,
client-facing messages (e.g. 23505 → "A conflicting entry already
exists") instead of returning raw PG error text. Include :message
in all PSQLException response branches.

Add regression tests asserting :hint, :state, :path, :context are
absent from responses for :internal and unhandled ex-info errors.

Closes #11287

AI-assisted-by: mimo-v2.5-pro

* 🚑 Keep :hint in error protocol, fix unsafe sources

Refine the security fix based on code review feedback.

Keep :hint as part of the error protocol — it is essential for
controlled error communication. Remove it from strip-internal-fields
(which now only strips :state, :path, :context).

Fix the actual sources of unsafe :hint values:

- http/middleware.clj: replace (ex-message cause) with safe static
  strings for IllegalArgumentException, RequestTooBigException, and
  EOFException. These :validation errors return ex-data verbatim
  to clients, so raw exception messages were leaking internals.

- PSQLException handler: use :hint instead of :message for the
  SQLSTATE-mapped messages, staying consistent with the error
  protocol.

Update tests to assert :hint is present (with safe static values)
in :internal and unhandled ex-info responses, and absent only from
bare RuntimeException and IOException responses.

Closes #11287

AI-assisted-by: mimo-v2.5-pro
2026-08-21 12:43:23 +02:00
Andrey Antukh 9fa07e7468 ⬆️ Update opencode on devenv dockerfile 2026-08-21 12:18:33 +02:00
Andrey Antukh 77971740e6 Merge remote-tracking branch 'origin/main' into staging 2026-08-21 12:13:22 +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
María Valderrama 9f6878d118 🐛 Fix disabled invitation explanatory title (#11295) 2026-08-20 13:19:35 +02:00
Andrey Antukh 209aea8365 🐛 Add proper ownership check on managing/deleting shared link on a file (#11290)
* 🐛 Add ownership check to share-link deletion

The delete-share-link RPC command only verified file-level edit
permission but did not check if the caller owned the share-link.
This allowed any file editor to delete share-links created by
other users, disrupting collaborative workflows.

The fix adds an ownership check that allows deletion only by:
- The share-link creator (owner-id matches profile-id)
- File admins (is-admin permission)
- File owners (is-owner permission)

Implemented using TDD:
- RED: Test demonstrates IDOR vulnerability (editor can delete)
- GREEN: Ownership check prevents unauthorized deletion
- All existing tests continue to pass

Closes #11289

AI-assisted-by: qwen3.7-plus

* 🐛 Add test coverage for share-link deletion escape hatches

Address code review feedback for PR #11290:

- Add test for editor deleting their own share-link
- Add test for admin deleting editor's share-link
- Add test for owner deleting editor's share-link
- Remove redundant :is-owner check (already included in :is-admin)
- Add clarifying comment about :is-admin including :is-owner

Closes #11289

AI-assisted-by: qwen3.7-plus
2026-08-19 18:26:35 +02:00
Andrey Antukh c200a4d777 🐛 Fix HTML escaping in notification pill detail section (#11275)
The notification pill component now properly respects the `is-html`
flag when rendering the detail section, matching the behavior of the
children section. Token import error messages now escape HTML
characters in user-provided values like token names and type names
before displaying them in notifications.

AI-assisted-by: qwen3.7-plus
2026-08-19 18:21:55 +02:00
Elena Torró a91c796b0e 🐛 Fix missing zip export on tempfile types (#11292) 2026-08-19 16:19:15 +02:00
Marina López c378ec9218 🐛 Avoid swallowing fatal errors in organization sso telemetry (#11279) 2026-08-19 14:23:40 +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
Andrey AntukhandSumit Ridhal aa3bc1ae98 🐛 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:53:40 +02:00
Andrey Antukh fda6d56139 📚 Update AGENTS.md file 2026-08-19 13:00:01 +02:00
Pablo Alba 5080a90f76 💄 Change nitrate activation code texts (#11237) 2026-08-19 12:33:33 +02:00
Andrey Antukh 4d90fe9126 Add advisories access helper to gh tool 2026-08-19 12:20:37 +02:00
Marina López ddc98bdd47 Add sso events (#11265) 2026-08-19 07:58:44 +02:00
David Barragán Merino d826c7ac13 📚 Remove architectural constraints related to MCP Server HA 2026-08-18 19:18:06 +02:00
Andrey Antukh 4339d8d244 📎 Update serena documentation about backend storage 2026-08-18 18:37:58 +02:00
Andrey Antukh df664fe96b 📎 Add improvement for review command 2026-08-18 18:10:57 +02:00
Luis de Dios 7061ecae0a 🐛 Fix gitch of placeholder when switching between teams on dashboard (#10922)
* 🐛 Fix use single point for retrieving state and propagate it

* 🐛 Fix use loading message instead of placeholder when loading files
2026-08-18 17:50:15 +02:00
Alonso TorresandAndrey Antukh 4ac14cfd08 Add component synchronization to waitForLayoutUpdate (#10964)
*  Add component synchronization to waitForLayoutUpdate

* 🐛 Fix async mock leak in workspace-reflow-test

Use mock/with-mocks instead of with-redefs for http/send! mock in
failed-google-font-css-does-not-abort-shared-consumers test.

with-redefs restores bindings when the block exits synchronously,
but the RxJS subscription fires asynchronously. This caused the mock
to leak into subsequent tests (workspace-media-test), producing 3
spurious failures.

AI-assisted-by: mimo-v2.5-pro

* ♻️ Replace async with-redefs with mock/with-mocks in frontend tests

with-redefs restores bindings when the block exits synchronously,
which is too early for async code (t/async, rx/subs!, promises).
mock/with-mocks uses set! and restores in the done callback, keeping
mocks alive across async boundaries.

Converted 15 with-redefs usages across 4 test files:
- workspace_reflow_test.cljs: 2 genuinely async tests (P1)
- routes_test.cljs: 3 SSO caching tests (P2)
- main_errors_test.cljs: 8 expired-org SSO tests (P2)
- comments_test.cljs: 2 comment thread tests (P2)

36 purely sync with-redefs usages left unchanged — with-redefs
is correct for synchronous code.

AI-assisted-by: mimo-v2.5-pro

* 📎 Fix fmt issues

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-18 17:45:42 +02:00
Andrey Antukh 1671cc4fcc 🐛 Escape markdown in Mattermost error notifications (#11034)
Add escape-markdown to common/data.cljc that escapes Markdown
special characters (*, _, ~, `, [, ], >, #, @, etc.) by prefixing
them with backslash. Apply it to user-controlled fields (:hint,
:href) in the Mattermost error reporter before constructing the
notification message.

This is an internal-only feature not accessible to end users.

AI-assisted-by: mimo-v2.5-pro
2026-08-18 17:43:25 +02:00
Andrey Antukh e72c1869eb 🐛 Validate version parameter in import-binfile (#11107)
Restrict version parameter to supported values (1 or 3) via schema
validation instead of accepting any integer. Add content-based format
detection when version is not provided, using bfc/parse-file-format
to inspect file magic bytes.

Closes #11105

AI-assisted-by: qwen3.7-plus
2026-08-18 15:13:49 +02:00
Andrey Antukh 3be07ccced 🐛 Add minimum validation for total-chunks in upload session (#11104)
The create-upload-session RPC method accepted total-chunks values of 0
or negative numbers without validation, creating inconsistent session
state. Add {:min 1} constraint to the schema to reject invalid values
at input validation.

Closes #11103

AI-assisted-by: qwen3.7-plus
2026-08-18 14:37:51 +02:00
Andrey Antukh 73c0668877 🐛 Verify read access on source file in clone-file-media-object (#11090)
The clone-file-media-object RPC command only checked edit permissions
on the destination file. The source media object was fetched directly
by UUID without verifying the caller had access to the file that owns
it.

This fix adds a read permission check on the source file before
cloning. If the caller lacks read access to the source file, the
operation fails with :not-found to avoid leaking information about
the existence of files/media the caller cannot access.

Closes #11087

AI-assisted-by: qwen3.7-plus
2026-08-18 14:36:50 +02:00
Andrey Antukh 367e4d534c 🐛 Scope assemble-chunks session lookup to profile-id (#11012)
Prevent BOLA in chunked upload assembly by verifying session
ownership. The assemble-chunks function now requires a profile-id
parameter and scopes the upload_session lookup accordingly, matching
the pattern already used by upload-chunk.

All three callers (assemble-file-media-object, create-font-variant,
import-binfile) updated to pass the authenticated profile-id.

AI-assisted-by: mimo-v2.5-pro
2026-08-18 14:36:29 +02:00
Andrey Antukh 3f09f161ae Merge remote-tracking branch 'origin/main' into staging 2026-08-18 14:34:22 +02:00
Andrey Antukh 5b4a5776cb 🐛 Prevent nil theme in profile updates
Omit nil optional profile fields before frontend schema validation and RPC persistence. Preserve omitted language and theme values in backend updates, and add regression coverage for partial profile saves.

AI-assisted-by: gpt-5.6-luna
2026-08-18 11:18:44 +00:00
0xTHAC0andAndrey Antukh d745dc4a3c 🐛 Fix grid item date tooltip showing deletion text on non-deleted files (#11161)
The grid-item-metadata* component always used :will-be-deleted-at (falling
back to :modified-at) and always showed the "Will be deleted %s" tooltip,
even for files in the Recent tab that have no deletion date.

Now the component branches on the presence of :will-be-deleted-at:
- Deleted files: show the deletion timeago with the existing
  "Will be deleted %s" tooltip.
- Regular files: show :modified-at timeago with a new
  "Last modified %s" tooltip key (dashboard.grid.last-modified-at).

Closes #10873

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-18 12:44:53 +02:00
María Valderrama c72bb331ef 🐛 Fix nitrate advanced permissions error (#11255) 2026-08-18 11:20:02 +02:00
Filip SajdakandAndrey Antukh 9311737f66 🐛 Do not cache the environment generated config.js (#11146)
On self hosted installs /js/config.js is regenerated from PENPOT_FLAGS
on every container start, but nginx served it with the same
`public, max-age=604800` used for build assets, and index.html versions
it only by the build. A flags only change therefore leaves the URL
untouched, so a browser that had already loaded the app kept using its
cached copy for up to a week: enabling a flag such as
enable-login-with-google had no visible effect for returning users
until the cache expired or they cleared their site data.

Serve that one file with the same no-store headers already used for
index.html, which is the other file whose contents change without its
URL changing. Every other static asset keeps the long lived cache.

Fixes #10556.

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-17 22:35:10 +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 8acb92b782 🐛 Normalize fractional rate-limit reset durations (#11254)
Round bucket reset intervals up to whole milliseconds before adding them to an instant. This prevents Clojure ratios from reaching duration conversion and disabling rate limiting for the request.

Add a regression test for a refill rate that produces fractional milliseconds.

Closes #11253

AI-assisted-by: gpt-5.6-luna
2026-08-17 15:27:06 +02:00
Andrey Antukh c797656d17 🐛 Fix crash when pasting into an empty or element-focused caret (#11150)
Pasting text could throw "Unknown node type" and lose the paste. The
insertion paths assume the caret sits on a text node or a <br>, but the
browser can report it on a container element (the offset being a child
index, common in Firefox) or, for an empty text shape that was just
focused, on nothing at all: selectAll() returned early without ever
setting a selection.

Add resolveTextNodePosition(), which walks a (node, offset) pair down to
the addressed text node or line break and returns null instead of
throwing when it cannot. The selection controller normalizes the caret
with it before inserting text or a pasted fragment, and selectAll() now
collapses on the line break of an empty editor so the caret is always
usable.

Closes #11149

AI-assisted-by: longcat-2.0-free
2026-08-17 13:32:12 +02:00
Andrey Antukh f96d850049 📎 Add ste skill to opencode 2026-08-17 13:31:24 +02:00
Pablo Alba ed04d509ed 🐛 Fix bad managed error on backend sso failure (#11247) 2026-08-17 12:40:18 +02:00
María Valderrama 57c9c3f6a4 🐛 Fix sso error message (#11252) 2026-08-17 12:26:49 +02:00
Andrey Antukh 29dbf9ab12 🐛 Validate content-type on management upload endpoints (#11026)
Add media type validation to upload-tempfile and upload-org-logo
management endpoints. Both stored user-supplied mtype without
checking against an allowlist. Only image types and PDF are
permitted. Non-public bucket assets now also carry
Content-Disposition: attachment to prevent inline rendering.

AI-assisted-by: mimo-v2.5-pro
2026-08-17 12:13:56 +02:00
Andrey Antukh 509f5395cb 📎 Update changelog 2026-08-17 12:08:38 +02:00
Andrey Antukh 4ecd8ffb89 🐛 Fix crash when editing tokens with group nodes (#11144)
Make sd-token-uuid nil-safe when accessing .original.id to prevent
crashes when StyleDictionary emits group nodes alongside real tokens.

Group nodes have an original object but no id property, causing
undefined is not an object errors during interactive token resolution
in the edit modal.

Closes #11143

AI-assisted-by: qwen3.7-plus
2026-08-17 11:53:48 +02:00
Andrey Antukh 0797d7235a 🐛 Fix workspace crash on rapid sidebar measures input changes (#10793) (#10794)
The sidebar measures panel numeric inputs (X, Y, width, height,
rotation) emitted one full apply-modifiers commit per DOM event with
no throttle: every arrow key-repeat, wheel tick and scrub pointermove
became update-positions / update-dimensions / increase-rotation. A
sustained gesture starved the React renderer and crashed the
workspace with error #185 (Maximum update depth exceeded).

Coalesce those bursts at the data layer (potok), following the
update-position-data debounce pattern in texts.cljs:

- update-positions is now burst-coalesced in place (its only caller
  is the measures panel); new update-dimensions-coalesced and
  increase-rotation-coalesced variants are used by the measures
  panel, while the immediate events keep serving plugins, variants
  and token application (including the delta? rotation path).
- The first event of a burst commits immediately (leading edge, so
  single edits stay synchronous); further ticks commit at most once
  per 50 ms (throttle); a trailing debounced flush guarantees the
  exact final value lands. All payloads are absolute values, so
  keeping the latest queued value per shape/attribute is lossless.
- Pending payloads are drained atomically and stale shape ids
  (deleted mid-burst) are skipped. The drain stream lives until the
  workspace is finalized, so bursts reuse a single subscription.
- Fewer commits per burst also means fewer undo entries; scrub drags
  still produce a single entry via the input's outer transaction.

Tests: new frontend-tests.logic.sidebar-transform-coalescing-test (8
tests, legacy SVG and WASM renderer branches) guards the invariant
that a 20-event burst commits the exact final value in a handful of
commits. The previously unregistered update-position-test is wired
into the runner with WASM mock fixtures (it fails in full-suite
context without them due to a pre-existing global mock-state issue).

AI-assisted-by: kimi-k3
2026-08-17 11:51:24 +02:00
Andrey Antukh 5efd9cc3c5 🐛 Prevent admins from granting owner role in team invitations (#11099)
Add role-ceiling check to create-team-invitations and
update-team-invitation-role methods. These RPC methods allowed
team admins to grant or elevate invitations to :owner role,
bypassing the protection that exists in update-team-member-role.

The fix replicates the existing check from update-team-member-role:
reject promotion to :owner when the caller is not an owner.

Closes #11098

AI-assisted-by: qwen3.7-plus
2026-08-17 11:37:51 +02:00
Andrey Antukh e219ce20eb ⬆️ Update opencode version on devenv 2026-08-17 11:26:23 +02:00
Andrey Antukh 9e97477a98 ⬆️ Update to latest nodejs lts 2026-08-17 11:26:23 +02:00
Andrey Antukh c688cba8d8 🐛 Mock DNS resolution in SSRF tests for environments without public DNS (#11040)
The validate-url-allows-public-{https,http} tests relied on real DNS
resolution of example.com, which fails in containers without public
DNS access. Mock resolve-host to return a known public IP, consistent
with the pattern used by other tests in the same file.

AI-assisted-by: mimo-v2.5-pro
2026-08-17 11:21:25 +02:00
Andrey Antukh 68e1db984d Add resolve-git-conflicts opencode command 2026-08-17 11:20:04 +02:00
Andrey Antukh 3033da4409 🐛 Add concurrency limit to import-binfile RPC handler (#11024)
Apply climit with 4 global permits and 1 per-profile permit (queue 2)
to prevent connection pool exhaustion from concurrent imports. Each
import holds a DB connection for its entire duration with idle
transaction timeout disabled, so unbounded concurrency could exhaust
the pool (default 60 connections).

AI-assisted-by: mimo-v2.5-pro
2026-08-17 11:08:07 +02:00
David Barragán Merino aecfee0f02 🔧 Align MCP workflow name with the rest of CI workflows
The MCP workflow was named "MCP CI" while every other tests-*.yml
workflow uses the "CI: <Component>" pattern. Rename it to "CI: MCP"
for consistency in the GitHub Actions listing.
2026-08-14 20:15:57 +02:00
Eva Marco a3bc4b0e3a 🐛 Fix text alignment on libraries (#11243) 2026-08-14 10:56:48 +02:00
Pablo Alba 350dc14632 🐛 Show a specific error on nitrate reused activation code (#11236) 2026-08-14 09:33:58 +02:00
María Valderrama c7f036bed0 🐛 Fix organization invitation schema validation for logo URI (#11238) 2026-08-13 13:15:59 +02:00
Pablo Alba 3db7548c19 💄 Change nitrate error message (#11232) 2026-08-13 13:06:26 +02:00
Yamila MorenoandSebastien MALOT 9528400c6e 🐛 Forward internal Host in nginx proxy_pass to backend/exporter (#11233)
The global `proxy_set_header Host $http_host;` forwarded the client-facing
Host to internal proxy_pass calls (backend/exporter), breaking mTLS routing
in service-mesh setups (e.g. Istio STRICT mode), which match outbound
requests to a cluster based on Host/:authority.

Explicitly set `Host $proxy_host` on /api, /assets, /api/export, /readyz
and /ws/notifications so these calls always target the correct internal
service host, independent of the client's original Host header.

Fixes #10835

Signed-off-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
Co-authored-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
2026-08-13 12:03:27 +02:00
Pablo Alba f7fc869e52 📚 Unify doc added for nitrate apis (#11231) 2026-08-13 11:37:06 +02:00
Pablo Alba ef26231b8f 🐛 Fix nitrate organization sso expiration (#11227) 2026-08-12 17:53:02 +02:00
María Valderrama 93f02ea0b4 🐛 Fix send-invitations policy not enforced in backend RPC (#11206) 2026-08-12 16:55:20 +02:00
María Valderrama 6d49fb2be0 🐛 Fix organization dropdown alignment (#11216) 2026-08-12 16:52:27 +02:00
Belén Albeza fee416d275 🐛 Fix crash after changing typography options (v2 and v3) (#11221) 2026-08-12 15:10:23 +02:00
Eva Marco 986ee60cad 🐛 Fix invitation loop (#11223) 2026-08-12 13:20:21 +02:00
Eva Marco e5c80edbf3 🐛 Fix libraries grid layout (#11226) 2026-08-12 13:20:07 +02:00
David Barragán Merino 732162e720 🔧 Report flaky e2e tests in integration workflow
Enable Playwright's JSON reporter alongside `list` and publish a
summary of flaky tests to the job step summary. The JSON report is
kept as an artifact for 30 days so flakiness rates can be aggregated
over time.

CI already runs with `retries: 2`, so unstable tests have been passing
silently on retry. This only surfaces what the suite already absorbs;
no test behaviour changes.

The reporter in `frontend/scripts/test-e2e` becomes overridable via
`PLAYWRIGHT_REPORTER` so the local developer default stays untouched.
2026-08-12 12:52:30 +02:00
María Valderrama 985d219810 🐛 Fix confusing copy for feams in organizations (#11213) 2026-08-11 14:36:07 +02:00
Eva Marco 53985dc630 🐛 Fix setting dark theme on onboarding (#11212)
* 🐛 Fix setting dark theme on onboarding

* 🎉 Add test
2026-08-11 13:49:48 +02:00
Marina López 02c31e7348 🐛 Cache Nitrate SSO checks during navigation (#11209) 2026-08-11 13:30:04 +02:00
Eva Marco 69ef7e86cd 🐛 Fix colorpicker z-index (#11207) 2026-08-11 13:06:30 +02:00
Eva Marco d7daefafe2 🐛 Fix select shape after enter path edition (#11205) 2026-08-11 13:05:47 +02:00
Eva Marco c4d1a1bc94 🐛 Fix node deleting (#11126)
* 🐛 Fix delete path node

* 🐛 Fix typography on shortcuts list
2026-08-11 13:03:29 +02:00
María Valderrama 1e6d438257 🐛 Fix SSO failure logging user out instead of showing error page (#11129)
* 🐛 Fix SSO failure logging user out instead of showing error page

* 📎 Code review
2026-08-11 09:14:29 +02:00
María Valderrama d4294bbf1e 🐛 Fix missing membership check in create-team (#11166) 2026-08-11 09:13:13 +02:00
Filip SajdakandClaude Opus 5 83efa28b12 🐛 Keep comment bubbles from painting over the rulers (#11168)
The comments layer lives in the viewport overlays, which are absolutely
positioned above the canvas, and the container itself carries a high
z-index. A comment bubble panned into the ruler bars therefore painted
on top of them, covering the ticks and numbers.

Clip the comments container to the area outside the ruler bars while
the rulers are visible, the same thing the `clip-handlers` clip path
already does so the selection handlers stay off the rulers. Clipping
only the comments container leaves the text editing overlay, which
shares the viewport overlays, untouched.

Fixes #11163.

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:18:58 +02:00
Luis de Dios 16e52b0494 🐛 Fix error page logo not visible in dark mode (#11167) 2026-08-10 15:51:31 +02:00
Marina López 0fd2a9d26f 🐛 Secure organization invitation creation (#11164) 2026-08-10 13:45:15 +02:00
Marina López 5d2cb22966 Fetch team organization in a single batch (#11140) 2026-08-10 13:43:27 +02:00
Marina López 900a7ef498 ♻️ Show subscription section to everybody (#11007) 2026-08-10 13:42:28 +02:00
Luis de Dios 86c563f11f 🐛 Fix font family typography asset persist across files in new created text layers (#11134) 2026-08-10 11:35:51 +02:00
Jules d63d6370c0 🐛 Fix stale DNS caching in frontend nginx MCP proxy (#10947)
The generated /etc/nginx/overrides/server.d/mcp-locations.conf used a
plain proxy_pass target (e.g. `proxy_pass http://penpot-mcp:4402;`)
where $PENPOT_MCP_URI/$PENPOT_MCP_URI_WS are shell variables substituted
once by envsubst in nginx-entrypoint.sh at container startup, not nginx
variables. nginx resolves a literal proxy_pass hostname once when the
config loads and never re-checks it, so the existing
`resolver 127.0.0.11 valid=10s;` directive in
overrides/http.d/resolvers.conf has no effect on these three locations
- it only applies to nginx variables evaluated per-request.

In multi-container deployments where the penpot-mcp container restarts
or is recreated independently of penpot-frontend (image update, OOM,
orchestrator reschedule), it gets a new IP from Docker's/the
orchestrator's DNS, and the frontend's nginx keeps forwarding to the
old, now-dead address until penpot-frontend itself is restarted. This
surfaces to users as `wss://<host>/mcp/ws` failing to connect from the
browser after enabling the MCP plugin, with
`connect() failed (111: Connection refused)` in the frontend's nginx
logs.

Route each location through a `set $var ...; proxy_pass $var;` pair so
proxy_pass evaluates a real nginx variable, letting the pre-existing
resolver directive re-resolve penpot-mcp within its 10s TTL instead of
caching the address for the container's lifetime.

For /mcp/stream and /mcp/sse, the set value also appends
$is_args$args explicitly: when proxy_pass targets a variable AND that
variable's value includes a URI/path component, nginx does not
automatically forward the original request's query string the way it
does for a static proxy_pass target - it must be appended by hand, or
the userToken query parameter used for multi-user authentication is
silently dropped before reaching the MCP server. /mcp/ws has no path
component in its target so it isn't affected by this and needed no
such change.

Verified locally: force-recreated the penpot-mcp container onto a
different IP while leaving penpot-frontend untouched; the /mcp/ws
WebSocket upgrade kept returning 101 Switching Protocols throughout,
both immediately and after the resolver's TTL window. Separately
verified /mcp/stream: a POST with ?userToken=... now shows up
server-side as userTokenFp=<redacted first 8 chars> instead of <none>,
and an actual MCP client (Claude Code) using this proxy can now call
authenticated tools like execute_code successfully.

Signed-off-by: Jules LaPrairie <jules@lucidbox.ca>
2026-08-10 11:34:52 +02:00
Luis de Dios b9c92496f1 🐛 Fix overrides lost after switch (#10619) 2026-08-10 10:56:04 +02:00
Andrey Antukh e01b36b841 🐛 Add project-id guard to use-plugin-register layout effect (#10859)
Add `project-id` to the guard condition in `use-plugin-register`'s layout effect so the plugin "Try out" flow waits until projects have loaded.

Previously, only `plugin-url` was checked, which allowed the fetch to fire
before projects were available, sending a nil `project-id` and causing a 400
validation error from the backend.

AI-assisted-by: mimo-v2.5-pro
2026-08-07 19:21:32 +02:00
Andrey Antukh 2f04fcddbf 🐛 Invalidate all sessions on profile deletion (#11115)
When a profile is deleted, only the current session was being
invalidated. Other active sessions on different devices remained
functional until the background cleanup task completed.

Add session/invalidate-all helper that deletes all sessions for
a profile by profile_id, and call it from delete-profile before
the response transform. This ensures immediate access revocation
across all devices when an account is deleted.

Closes #11114

AI-assisted-by: qwen3.7-plus
2026-08-07 13:44:13 +02:00
Andrey Antukh e2d429d283 🐛 Add timeout to plugin manifest fetch (#11120)
The fetch-manifest function previously had no timeout, causing the
plugin installation flow to hang indefinitely if the server accepted
the connection but never completed the response.

Added a 15-second timeout using rx/timeout to abort the request
automatically.

Closes #11119

AI-assisted-by: qwen3.7-plus
2026-08-07 13:37:00 +02:00
Eva Marco bf9825fcfe 🐛 Fix close modal with esc (#11131) 2026-08-07 12:33:11 +02:00
Andrey Antukh a131e40a6d Add proper rlimit config and propagate limit timestamp
Replace the placeholder rlimit.edn with a real per-endpoint
configuration covering auth, SSRF, search, email, media and project
operations. The previous file only had a commented-out example, so
all limits fell back to the 200k/h default window.

Also propagate the evaluated `now` timestamp into both bucket and
window result maps, so consumers (e.g. soft-mode reports) can know
exactly when the limit was checked.

AI-assisted-by: minimax-m3
2026-08-07 11:35:05 +02:00
Andrey Antukh 5571c53502 🐛 Use random UUIDs for share link IDs (#11117)
Share link IDs function as capability secrets — anyone possessing
the ID can read a file without authentication. The previous UUIDv8
scheme is predictable (56 bits fixed per process + 48-bit timestamp).

Changed to uuid/random (UUIDv4) for genuine unpredictability.

Closes #11116

AI-assisted-by: qwen3.7-plus
2026-08-07 11:27:12 +02:00
Andrey Antukh 6951876c13 🐛 Use constant-time comparison for shared key authentication (#11122)
Replace standard '=' operator with MessageDigest/isEqual to prevent
timing attacks on shared key authentication middleware.

Closes #11121

AI-assisted-by: qwen3.7-plus
2026-08-07 11:25:15 +02:00
Andrey Antukh 399b00b86d 🐛 Add permission checks to WebSocket subscription handlers (#11054)
* 🐛 Add permission checks to WebSocket subscription handlers

Check file and team read permissions before allowing WebSocket
subscriptions to prevent resource enumeration via presence
notifications.

AI-assisted-by: mimo-v2.5-pro

* 🐛 Fix random backend test failure
2026-08-07 11:24:24 +02:00
Álvaro Tejero Cantero bc9319eac5 🐛 Port the foreign-font-id test to the uploads API
`create-font-variant-rejects-foreign-font-id` sends `:data`, which
`schema:create-font-variant` no longer accepts: the same commit that
added the test documents that param as removed in 2.18 in favour of
`:uploads`. Both of the test's requests are therefore rejected by params
validation before they reach `check-font-team-ownership!`, which is the
thing the test exists to check. It asserted nothing about ownership and
failed three assertions.

Upload the font through `upload-font-chunked!`, the helper the other
tests in this namespace already use, and pass the session id in
`:uploads`.

`backend-tests.rpc-font-test` is 16 tests, 172 assertions, 0 failures
with this applied.

AI-assisted-by: mixed models
2026-08-07 09:10:04 +02:00
Álvaro Tejero Cantero 5359ff04cf 📎 Drop an unused binding in create-font-variant
`create-font-variant` destructures `uploads` and never reads it: the
handler passes the whole `params` map to `prepare-font-data-from-uploads`.

`clj-kondo` reports it as an unused binding and exits 2, which fails the
Lint step of the Backend workflow, and the Lint step runs before the
tests, so no branch based on `develop` can run the backend suite at all.

AI-assisted-by: mixed models
2026-08-07 09:10:04 +02:00
Andrey Antukh 9875db2f82 🐛 Fix media-remote-test falling back to real config in REPL
The info-service-uri-not-configured test used config-get-mock with an
empty map, which falls back to cf/config for missing keys. In a REPL
with real config, media-processing-service-uri is set, causing the code
to attempt an HTTP call instead of raising the expected error.

Use (constantly nil) to ensure cf/get always returns nil, matching the
test intent of simulating an unconfigured service URI.

AI-assisted-by: mimo-v2.5
2026-08-06 20:53:58 +02:00
Andrey Antukh 1548748aed ♻️ Reuse organization schema in create-organization-invitation
Replace the inline organization map in schema:create-organization-invitation with cto/schema:organization-with-avatar, eliminating schema duplication and fixing mismatched validation rules for :logo and :sso-active fields.

AI-assisted-by: mimo-v2.5
2026-08-06 20:53:58 +02:00
Andrey Antukh 0702363b5c 🐛 Validate font-id team ownership in create-font-variant (#11014)
Prevent cross-team font injection by checking that when a font-id
already has variants, they belong to the same team. This closes a
BOLA gap where a user with team edit permissions could create a
font variant referencing a font-id from another team.

AI-assisted-by: mimo-v2.5-pro
2026-08-06 18:32:00 +02:00
Elena Torró 688c69b478 🐛 Fix import-binfile schema test (#11118) 2026-08-06 17:05:41 +02:00
Andrey Antukh 229d24e8f2 🐛 Fix regression on uploading binfile with incorrect schema 2026-08-06 13:30:59 +02:00
Eva Marco fdf1684565 🐛 Fix plugin modal z index (#11109)
* 🐛 Fix z-index on plugin modals

* 🐛 Fix CI
2026-08-06 12:49:52 +02:00
244 changed files with 9839 additions and 1642 deletions

No files matched your search

+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
+31
View File
@@ -80,9 +80,40 @@ jobs:
- name: Run Tests
working-directory: ./frontend
env:
PLAYWRIGHT_REPORTER: list,json
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
run: |
./scripts/test-e2e
- name: Flaky summary
if: always()
working-directory: ./frontend
run: |
if [ ! -f report.json ]; then
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
jq -r '
[ .. | objects
| select(has("tests") and has("file"))
| select(any(.tests[]; .status == "flaky"))
| "- `\(.file):\(.line)` — \(.title)"
] as $f
| "## Flaky tests: \($f | length)\n"
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
' report.json >> "$GITHUB_STEP_SUMMARY"
- name: Upload JSON report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-json-report
path: frontend/report.json
overwrite: true
retention-days: 30
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
+1 -1
View File
@@ -1,4 +1,4 @@
name: "MCP CI"
name: "CI: MCP"
on:
pull_request:
+1
View File
@@ -24,6 +24,7 @@ opencode.json
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
!HIGHLIGHTS.md
/*.png
/*.svg
/*.sql
+1 -1
View File
@@ -1 +1 @@
v24.18.1
v24.19.0
@@ -0,0 +1,40 @@
---
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
agent: build
---
# Fix Git Conflicts
Resolve conflicts in the local repository. The user handles finishing the
rebase themselves — you must **never** run `git rebase --continue`,
`git rebase --skip`, `git merge --continue`, or anything similar.
## Phase 1 — Understand the problem (read-only)
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
- Identify what each side changed and why, and how they should be combined.
## Phase 2 — Present the resolution plan
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
- What each side changed and why.
- Your proposed resolution and the reasoning behind it.
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
## Phase 3 — Execute
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
## Phase 4 — Stage and verify
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
## Phase 5 — Report
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
+10 -64
View File
@@ -1,34 +1,16 @@
Act as a senior software engineer and perform a thorough code review.
Act as a senior software engineer and perform a thorough review.
## Instructions
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.
1. **Determine what is being reviewed** from the provided context:
- **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill.
- **If it is code** (diff, PR, code change) → load the **`code-review`** skill.
2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing.
3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
4. Read the diff and the surrounding context for each changed file.
5. Review across all five axes: correctness, readability, architecture, security, performance.
6. Produce the review using this structure:
- **Summary**: One-paragraph overview of the change and its impact
- **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix)
- **Other Findings**: Medium/Low issues and suggestions
- **Testing Recommendations**: Missing test coverage or test quality issues
- **Positive Observations**: What was done well (brief, specific)
- **Verdict**: Approve / Request Changes / Needs Discussion
7. For each finding:
- State the severity (Critical / High / Medium / Low / Suggestion)
- Identify the file and line
- Describe failure circumstances
- **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code
- **For Medium/Low**: Describe the fix clearly; code snippet optional
- If multiple approaches exist, briefly note trade-offs
8. **Perform a second review pass if the change is complex:**
- **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed
- **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings
- Second pass checks:
- Validate severity assignments: Are Critical/High findings truly blockers?
- Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass
- Remove false positives: Discard findings that aren't real issues
- Verify fixes: Are the proposed solutions actually correct and complete?
4. Follow the loaded skill's process and produce its output format.
## Strong Rules
@@ -36,44 +18,8 @@ Act as a senior software engineer and perform a thorough code review.
2. Do not modify any code and do not create a commit — this command only reviews.
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
4. Prioritize by impact. One structural issue outweighs ten nits.
5. If tests are missing for new functionality, flag it as High severity.
5. Missing tests are an issue, not a suggestion. Report as a severity-tagged finding — never as a recommendation.
## Context
$ARGUMENTS
## Expected Format
```
## Review Summary
[1-2 sentences on what the change does and overall assessment]
## Critical/High Findings
### [Severity] file.ts:123
**Issue**: [Description of the problem]
**Impact**: [What could go wrong]
**Fix**:
```[language]
// Current code
[problematic code]
// Fixed code
[corrected code]
```
[Optional: note trade-offs if multiple approaches exist]
## Other Findings
### [Severity] file.ts:456
**Issue**: [Description]
**Fix**: [Clear description; code snippet optional]
## Testing Recommendations
[List specific test cases that should be added]
## Positive Observations
[2-3 specific things done well]
## Verdict
[Approve / Request Changes / Needs Discussion]
[If Request Changes: list the must-fix items]
```
@@ -1,5 +1,5 @@
---
name: code-review-and-quality
name: code-review
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
---
+315
View File
@@ -0,0 +1,315 @@
---
name: plan-review
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
---
# Plan Review
## Overview
Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality.
**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it.
## When to Use
- After the planner skill produces a plan
- Before starting implementation on any non-trivial task
- When reviewing a plan written by another agent or a human
- When a plan feels too large, vague, or risky to start
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
## The Six-Axis Review
Every plan gets evaluated across these dimensions:
### 1. Completeness
Does the plan cover everything needed to implement successfully?
- Is the **context** clear? (What problem, why now, what's the goal?)
- Are **affected modules** identified with paths?
- Are **architecture decisions** documented with rationale?
- Is there a **testing strategy**?
- Are **verification commands** explicit (not "run the tests")?
- Are **open questions** listed (not buried in someone's head)?
- Is there a **parallelization** assessment for multi-task plans?
**Missing any of these is a gap, not a nit.**
### 2. Task Quality
Are the tasks well-defined and independently executable?
- Does every task have **acceptance criteria**? (Testable, not vague)
- Does every task have **verification steps**?
- Are tasks **sized appropriately**? (XSM is ideal, L is acceptable, XL must be split)
- Are **dependencies** between tasks explicitly stated?
- Are **files likely touched** listed?
- Is each task a **single, self-contained change**? (Not "implement the whole feature")
- Could a skilled implementer pick up any task and execute it without asking clarifying questions?
### 3. Architecture & Sequencing
Is the plan structured so implementation flows correctly?
- Does implementation order follow the **dependency graph** (foundations first)?
- Are tasks **vertically sliced** (feature paths) rather than horizontally layered?
- Does each task leave the system in a **working state**?
- Are there **checkpoints** between major phases?
- Are **high-risk tasks early** (fail fast)?
- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans)
### 4. Risk Coverage
Are the hard parts acknowledged and mitigated?
- Are **edge cases** identified?
- Are **breaking changes** or **migration concerns** noted?
- Are **security implications** considered?
- Are **performance implications** considered?
- Are **external dependencies** or integration risks flagged?
- Is there a plan for **rollback** if something goes wrong?
- Are **data integrity** risks addressed (what happens if a migration fails mid-way)?
### 5. Actionability
Can an implementer actually execute this?
- Are **file paths** specific (not "update the relevant files")?
- Are **function/method names** mentioned where applicable?
- Are **verification commands** copy-pasteable (not "run the linter")?
- Are **test commands** project-specific (not generic)?
- Is the **code shape** described where the implementation isn't obvious?
- Are **conventions** referenced (naming, patterns, existing utilities to reuse)?
- Does the plan reference **existing code** the implementer should read first?
### 6. Proposed Code Quality *(when the plan includes implementation details)*
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
- **Readability:** Are proposed names descriptive and consistent with project conventions?
- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)?
- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design?
- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design?
**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis.
## Structural Remedies
When you flag a structural problem in a plan, propose the fix — not just the problem:
- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable.
- **Missing acceptance criteria:** Draft 23 specific, testable conditions for the task.
- **Wrong sequencing:** Identify the dependency and propose the correct order.
- **No checkpoints:** Suggest where checkpoints should go (typically after every 23 tasks).
- **Vague verification:** Replace "run tests" with the actual project command.
- **Horizontal slicing:** Restructure into vertical feature paths.
- **Missing risk section:** Draft the risks you can identify from the plan content.
Prefer the remedy that makes the plan immediately actionable over one that just flags the gap.
## Plan Sizing
Plans should be scoped to a single deliverable:
```
15 tasks → Good. A focused feature or bug fix.
610 tasks → Acceptable for a moderate feature.
1115 tasks → Large. Consider splitting into phases.
15+ tasks → Too large. Split into multiple plans.
```
**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan.
## 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 implementation starts |
| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach |
| **Nit:** | Minor, optional | Author may ignore — wording, formatting |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
| **FYI** | Informational only | No action needed — context for future reference |
**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review.
## Review Process
### Step 1: Understand the Goal
Before evaluating structure, understand intent:
```
- What is this plan trying to accomplish?
- What problem does it solve?
- What does "done" look like?
```
### Step 2: Check Completeness First
Scan for missing sections before diving into content:
```
- Context present?
- Affected modules listed?
- Architecture decisions documented?
- Risks acknowledged?
- Testing strategy defined?
- Verification commands explicit?
```
### Step 3: Review Task Quality
Walk through each task:
```
For each task:
1. Can I tell exactly what to build?
2. Are acceptance criteria specific and testable?
3. Is the size reasonable (not XL)?
4. Are dependencies clear?
5. Would I know which files to touch?
```
### Step 4: Validate Sequencing
Check the dependency graph:
```
- Are foundations built first?
- Does each task leave the system working?
- Are checkpoints placed correctly?
- Are high-risk items early?
- Is it vertically sliced?
```
### Step 5: Assess Actionability
Put yourself in the implementer's shoes:
```
- Could I pick up task 1 and start coding without asking any questions?
- Are the verification commands copy-pasteable?
- Are file paths and function names specific?
- Is existing code referenced where I'd need to read it?
```
### Step 6: Verify the Verification Story
Check that the plan can actually confirm it worked:
```
- What tests should pass after implementation?
- What build/compile commands are relevant?
- What manual checks are needed?
- How do we know the feature works end-to-end?
```
### Step 7: Evaluate Proposed Code Quality *(if applicable)*
If the plan includes code snippets, types, or API designs:
```
- Load code-review skill for criteria
- Check proposed signatures for edge cases
- Verify naming follows project conventions
- Confirm abstractions follow existing patterns
- Scan for security vectors in proposed APIs
- Check for performance issues in proposed data structures
```
## Review Checklist
```markdown
## Review: [Plan title]
### Completeness
- [ ] Context explains the problem and goal
- [ ] Affected modules are listed with paths
- [ ] Architecture decisions have rationale
- [ ] Testing strategy is defined
- [ ] Verification commands are explicit and project-specific
- [ ] Open questions are listed
### Task Quality
- [ ] Every task has acceptance criteria
- [ ] Every task has verification steps
- [ ] Tasks are sized XSM (L acceptable, XL must be split)
- [ ] Task dependencies are stated
- [ ] Files likely touched are listed
### Architecture & Sequencing
- [ ] Order follows dependency graph (foundations first)
- [ ] Vertically sliced (not horizontal layers)
- [ ] Each task leaves system working
- [ ] Checkpoints exist between phases
- [ ] High-risk tasks are early
### Risk Coverage
- [ ] Edge cases identified
- [ ] Breaking changes / migrations noted
- [ ] Security implications considered
- [ ] Performance implications considered
- [ ] Rollback strategy exists (if applicable)
### Actionability
- [ ] File paths are specific
- [ ] Verification commands are copy-pasteable
- [ ] Existing code to read is referenced
- [ ] Conventions and patterns are noted
### Proposed Code Quality *(if plan includes implementation details)*
- [ ] Proposed types/signatures handle edge cases
- [ ] Proposed names follow project conventions
- [ ] Proposed abstractions follow existing patterns
- [ ] No security vectors in proposed APIs
- [ ] No performance issues in proposed structures
### Verdict
- [ ] **Approve** — Ready to implement
- [ ] **Request changes** — Gaps must be addressed
```
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. |
| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. |
| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. |
| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. |
| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. |
| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. |
| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. |
| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. |
## Red Flags
- No acceptance criteria on any task
- Tasks that say "implement the feature" without specifics
- No verification steps anywhere in the plan
- All tasks are XL-sized
- No checkpoints between phases
- Dependency order isn't considered (e.g., API handler before domain model)
- No testing strategy
- Verification commands are generic ("run tests") instead of project-specific
- Plan has 20+ tasks (scope too large for one plan)
- No risk section on a plan with migrations, breaking changes, or security implications
- Horizontal slicing (all domain, then all services, then all API)
- File paths are vague ("update the relevant files")
- Missing open questions section despite stated unknowns
- Proposed code ignores project conventions or existing patterns
- Proposed types use gratuitous `any`/`unknown`/optional without justification
- Proposed APIs don't validate input at boundaries
## See Also
- For producing plans, use the `planner` skill
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
- For security-specific concerns, see `security-and-hardening`
- For testing strategy guidance, see `testing`
+78
View File
@@ -0,0 +1,78 @@
---
name: ste
description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it.
---
# ASD-STE100 Simplified Technical English
Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it.
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
## Step 0 — Classify the text
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
## Core rules
### Sentences
- Procedural: maximum **20 words** per sentence.
- Descriptive: maximum **25 words** per sentence.
- Maximum **6 sentences** per paragraph. One topic per paragraph.
- One instruction per sentence. Two actions in one sentence only if they occur at the same time.
- Put a condition BEFORE its command: "If the pressure decreases, close the valve."
- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure."
- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word.
### Verbs
- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective.
- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form.
- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging").
- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant.
- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened."
- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file."
- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur."
- No phrasal verbs: "go down" → "decrease," "set up" → "install," "carry out" → "do."
### Words
- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it.
- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill.
- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb.
- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle."
- American English spelling.
- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc."
### Punctuation
- No semicolons — write two sentences.
- Parentheses only for references, abbreviations, and item numbers.
- Hyphenate words that act as one unit; a hyphenated word counts as one word.
- No contractions.
### Warnings, cautions, notes
- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction.
- Start a warning or caution with the command or condition, then give the risk:
"WARNING: Do not touch the terminal. The terminal has a dangerous voltage."
- Notes obey the 25-word descriptive limit.
## Step 2 — Self-check pass
After drafting, scan your text once for each of these and fix every hit before you respond:
1. Any sentence over the 20/25-word limit for its type
2. Contractions, semicolons
3. "should," "would," "could," "may," "might"
4. "has been," "have been," "had been," "is being," "was being"
5. -ing words used as verbs
6. Missing articles (a/an/the/this) before nouns
7. Synonym rotation (the same object under two names)
8. Any word in the unapproved column of `references/word-substitutions.md`
9. Warnings that state the risk before the command
## Reference files
- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short.
- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies.
## What NOT to touch
Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them.
@@ -0,0 +1,67 @@
# Worked before/after examples
## Verb forms
| Before | After |
|---|---|
| We have received the technical reports from HQ. | We received the technical reports from HQ. |
| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. |
| The test is continued by the operator. | Continue the test. |
| The screws should be replaced. | Replace the screws. |
| The system is currently running diagnostics. | The system does diagnostic tests now. |
## Vocabulary and phrasing
| Before | After |
|---|---|
| Ensure file exists before running. | Make sure that the file exists before you run the command. |
| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. |
| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. |
| Make sure that these steps are followed. | Obey these steps. |
| Utilize approximately 3 liters of water. | Use about 3 liters of water. |
| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. |
## Noun clusters
| Before | After |
|---|---|
| Main gear door retraction winch handle | Main-gear-door retraction-winch handle |
| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection |
| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. |
## Procedural rewrite (condition first, one instruction per sentence)
Before:
> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air.
After:
> 1. Fill the reservoir with the correct fluid.
> 2. Attach a clear tube to the bleed screw.
> 3. Put the free end of the tube in a container of fluid.
> 4. Push the pedal three times. Hold the pedal down.
> 5. Open the bleed screw one half turn. Air and fluid flow into the tube.
> 6. Close the bleed screw. Release the pedal.
> 7. If air continues to come out, do steps 4 thru 6 again.
## Warnings and cautions (command first, then risk)
Before:
> Note that serious data loss may potentially occur if the --force flag is used against production.
After:
> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source.
Before:
> Touching the terminal could result in electrocution.
After:
> WARNING: Do not touch the terminal. The terminal has a dangerous voltage.
## Common mistakes checklist
- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket."
- Synonym rotation: check/verify/confirm for the same action → one term, everywhere.
- Hedges: "you may want to," "it is recommended that" → an imperative or "must."
- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step.
- Semicolon joining two clauses → two sentences.
- "There are three bolts on the panel" → "The panel has three bolts."
@@ -0,0 +1,68 @@
# Word substitutions and one-meaning rulings
Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative.
## Unapproved → approved
| Do not use | Use instead |
|---|---|
| utilize, leverage, employ | use |
| commence, initiate, begin, originate | start |
| terminate, cease, conclude | stop, end |
| ensure, verify, confirm, validate, check | make sure (that), examine |
| perform, conduct, execute, carry out | do |
| facilitate, assist | help |
| obtain, acquire, procure | get |
| sufficient, adequate | enough |
| approximately | about |
| prior to | before |
| subsequent to, following (prep.) | after |
| adjacent to | near |
| accomplish | do |
| additional, supplementary | more |
| attempt | try |
| require, necessitate | need, must |
| mandatory | necessary |
| indicate, signify | show |
| observe (=watch) | look at, examine |
| rotate | turn |
| deactivate | turn off, set to off |
| activate, energize (unless technical verb) | turn on, start |
| toxic | poisonous |
| in order to | to |
| via, by means of | through, with |
| due to, owing to | because of |
| in the event of/that | if |
| accessible | (rewrite: "you can get access to") |
| remainder | rest |
| demonstrate | show |
| modify, alter | change |
| construct, fabricate, build | assemble, make |
| retain | keep |
| locate (=find) | find |
| depress (a button) | push, press |
| proceed | continue, go |
## One meaning, one part of speech (canonical rulings)
- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller").
- **test** — noun only: "do a test," never "test the system."
- **check** — do not use as a verb for verification → "make sure that" or "examine."
- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions."
- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season.
- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing."
- **right** — direction only, never "correct."
- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground."
- **help** — verb only; the noun is **aid** ("with the aid of a mirror").
- **above / below** — physical position only. For quantities: **more than / less than**.
- **about** — two approved senses: "approximately" and "on the subject of." Use carefully.
- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard.
- **level** — approved as noun and adjective (documented exception to the one-POS rule).
## Frequent-offender function words
- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**.
- **etc.** — delete, or write the full list.
- **e.g. / i.e.** — "for example" / "that is."
- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant.
- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts."
+2 -1
View File
@@ -34,7 +34,8 @@ Before writing any test, read:
2. Module-specific testing memory for the affected module:
- `mem:common/testing` — CLJC unit tests
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E
- `mem:backend/core` — JVM clojure.test conventions
- `mem:backend/testing` — JVM clojure.test conventions
- `mem:exporter/testing` — exporter unit tests
## Key Rules
@@ -212,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
@@ -400,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
@@ -410,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
@@ -732,6 +769,14 @@ 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 `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
+4 -8
View File
@@ -5,7 +5,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
## Focused memories
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
@@ -101,10 +102,5 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`.
## Testing
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
Backend test commands, coverage rules, and conventions: `mem:backend/testing`.
Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
@@ -14,10 +14,7 @@
## Storage and media
- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`.
- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes.
- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows.
- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code.
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
@@ -28,4 +25,4 @@
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
+83
View File
@@ -0,0 +1,83 @@
# Backend Storage
## Abstraction
- `app.storage` stores binary objects.
- Each object has a `storage_object` database row.
- The row stores the UUID, size, backend, timestamps, and Transit metadata.
- The backend stores the binary content.
- Supported backends are `:fs` and `:s3`.
- FS uses one root directory and a UUID-derived path.
- S3 uses one configured bucket and an optional prefix.
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
- Deprecated asset-storage config keys remain supported for migration.
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
## Object Lifecycle
- `put-object!` creates the database row before it writes backend content.
- Backend content is written only when the row is new.
- A failed backend write can leave an unreferenced database row.
- Callers often set `:touched-at` so garbage collection can remove such rows.
- `get-object` excludes rows with `deleted_at`.
- Existing object values can remain readable until physical deletion.
- `:expired-at` blocks reads after the expiration time.
- `del-object!` sets `deleted_at`. It does not remove backend content.
- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
- `storage-gc-touched` finds references before it sets `deleted_at`.
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
## Deduplication
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
- The lookup does not include file ID, profile ID, team ID, or organization ID.
- Objects can therefore share content across users and files within one bucket.
- Deleted objects are not reused.
- `tempfile` objects never use deduplication, even when the caller requests it.
- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
## Bucket Rules
| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
| --- | --- | --- | --- | --- |
| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
- The valid bucket set lives in `app.storage/valid-buckets`.
- `file-media-object` is the default bucket for old rows without bucket metadata.
- Do not assign a new bucket without adding its access and cleanup behavior.
- The touched-object collector raises an internal error for an unknown bucket.
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
- It does not support `file-data-fragment` or `file-change`.
## Access Rules
- `app.http.assets` decides direct object authentication from the bucket.
- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
- Other valid buckets require a session or access-token profile ID.
- File-media routes also require file read permission.
- Non-public direct responses set `content-disposition: attachment`.
- FS responses use `x-accel-redirect` for the configured asset path.
- S3 responses use a presigned URL and an HTTP redirect.
## File Data
- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
- `db` stores encoded data in `file_data.data`.
- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
- The `file_data.metadata.storage-ref-id` value points to the storage object.
- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.
+11
View File
@@ -0,0 +1,11 @@
# Backend Testing
JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`.
- READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs.
- All CLI commands must be executed from the `backend/` subdirectory.
- Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed.
- Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
- Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var.
- Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas.
- If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`.
+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
@@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`.
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`.
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
@@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact
## See also
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`.
+25
View File
@@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
- Listing or inspecting GitHub Security Advisories (GHSA).
## Prerequisites
@@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
**Output**: JSON array to stdout; progress to stderr.
### `advisories`
List or inspect GitHub Security Advisories for the repository.
```bash
# List all advisories (summary view)
python3 scripts/gh.py advisories
# Filter by severity
python3 scripts/gh.py advisories --severity critical
# Filter by state
python3 scripts/gh.py advisories --state triage
# Get full detail for a single advisory
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
```
**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url.
**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps.
**Output**: JSON to stdout; progress to stderr.
## Key principles
- All output is JSON — pipe into `jq` or other tools for further processing.
+1 -1
View File
@@ -13,7 +13,7 @@ and helpers, consult:
builders, production-path change helpers
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests,
live browser verification via nREPL
- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
- `mem:backend/testing` — JVM `clojure.test` under `backend/test/`
## When to Use
+23
View File
@@ -34,6 +34,28 @@ Skipping this step is the #1 cause of incorrect or incomplete work.
---
## Auto-triggers
- **Security advisory URL pasted** — When the user pastes a URL matching
`github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID
from the URL and run `python3 scripts/gh.py advisories <GHSA-ID>` to fetch
full advisory details before proceeding.
- **Issue or PR mentioned** — When the user mentions a penpot/penpot issue or
PR (URL like `github.com/penpot/penpot/issues/<n>` / `.../pull/<n>`, or a
bare `#<n>` when context clearly refers to this repo), fetch details via CLI
instead of WebFetch:
- Issue → `gh issue view <n> --repo penpot/penpot` (add `--comments` when
discussion context matters).
- Single PR → `gh pr view <n> --repo penpot/penpot`.
- Multiple PRs (list, file, or milestone) → `python3 scripts/gh.py prs ...`.
Do this before proceeding. Only use WebFetch if the CLI fails.
## Writing Rules
Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100.
---
# Memory system
Memories are the **primary project guidance** — not docs or readme files.
@@ -113,4 +135,5 @@ precision while maintaining a strong focus on maintainability and performance.
- `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`.
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
+26 -4
View File
@@ -23,17 +23,39 @@
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
## 2.17.1 (Unreleased)
## 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 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 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 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
+26
View File
@@ -0,0 +1,26 @@
# HIGHLIGHTS
## 2.17.0
- Background blur is here
- WebGL rendering gets stronger
- MCP connection status and more
- Design tokens: more visible, more user-friendly
## 2.16.0
- Design tokens in the design panel
- Major community contributions
- WebGL rendering (beta)
## 2.15.0
- AI connected to real design context
- Multi-directional workflow
- Your stack, your model, your decision
@@ -191,7 +191,7 @@
file named “{{file-name|abbreviate:25}}”.
</p>
<p>
Since this file is in your Penpot team, you can provide access by sending a view-only link.
Since this file is in your Personal Projects, you can provide access by sending a view-only link.
This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
</p>
<p>To proceed, please click the button below to generate and send the view-only link:</p>
@@ -2,7 +2,7 @@ Hello!
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”.
Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
To proceed, please click the link below to generate and send the view-only link:
@@ -191,7 +191,7 @@
“{{file-name|abbreviate:25}}”.
</p>
<p>
Please note that the file is currently in Your Penpot 's team, so direct access cannot be
Please note that the file is currently in Personal Projects, so direct access cannot be
granted. However, you have two options to provide the requested access:
</p>
<ul>
@@ -5,7 +5,7 @@ Hello!
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”.
Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access:
Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access:
- Move the File to Another Team:
@@ -190,6 +190,20 @@ Debug Main Page
</div>
</form>
</fieldset>
<fieldset>
<legend>Validate file:</legend>
<desc>Given an FILE-ID, check the referential integrity.</desc>
<form method="get" action="/dbg/actions/file-validate">
<div class="row">
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
</div>
<div class="row">
<input type="submit" name="validate" value="Validate" />
</div>
</form>
</fieldset>
</section>
<section class="widget">
<fieldset>
@@ -222,6 +236,7 @@ Debug Main Page
</div>
</form>
</fieldset>
<fieldset>
<legend>Import binfile:</legend>
<desc>Import penpot file in binary format.</desc>
@@ -236,6 +251,34 @@ Debug Main Page
</div>
</form>
</fieldset>
<fieldset>
<legend>Repair file:</legend>
<desc>Given an FILE-ID, repair the referential integrity errors.
<br/>
<br/>
<b>WARNING: the reparation is not guaranteed and may cause loss of data!</b>
<br/>
<br/>
You may need to give several repair rounds until all errors are cleared.
</desc>
<form method="get" action="/dbg/actions/file-repair">
<div class="row">
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
</div>
<div class="row">
<label for="check-snapshot">Skip snapshot</label>
<input id="check-snapshot" type="checkbox" name="skip-snapshot" />
<br />
<small>
A snapshot is made just before the validation, unless skipped.
</small>
</div>
<div class="row">
<input type="submit" name="repair" value="Repair" />
</div>
</form>
</fieldset>
</section>
</main>
{% endblock %}
+7 -1
View File
@@ -45,4 +45,10 @@
{:permits 4}
:send-user-feedback/by-profile
{:permits 1 :queue 3}}
{:permits 1 :queue 3}
:import-binfile/global
{:permits 4}
:import-binfile/by-profile
{:permits 1 :queue 2}}
+303 -6
View File
@@ -1,11 +1,308 @@
;; Example rlimit.edn file
^{:refresh "30s"}
{:default
[[:default :window "200000/h"]]
;; #{:main/get-teams}
;; [[:burst :bucket "5/5/5s"]]
;; ═══════════════════════════════════════════════
;; Auth & Identity — public, unauthenticated
;; ═══════════════════════════════════════════════
#{:main/login-with-password}
[[:auth-password :bucket "100/50/1m"]]
;; #{:main/get-profile}
;; [[:burst :bucket "60/60/1m"]]
}
#{:main/login-with-ldap}
[[:auth-ldap :bucket "20/10/5m"]]
#{:main/register-profile}
[[:auth-register :bucket "20/10/15m"]]
#{:main/request-profile-recovery
:main/prepare-register-profile}
[[:auth-recovery :bucket "100/50/5m"]]
#{:main/recover-profile
:main/verify-token}
[[:auth-token :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; SSRF vectors — URL fetch endpoints
;; ═══════════════════════════════════════════════
#{:main/create-file-media-object-from-url}
[[:url-fetch :bucket "100/50/5m"]]
#{:main/create-webhook
:main/update-webhook}
[[:webhook-validation :bucket "20/10/5m"]]
;; ═══════════════════════════════════════════════
;; Search — full sequential scan risk
;; ═══════════════════════════════════════════════
#{:main/search-files}
[[:search :bucket "60/30/1m"]]
;; ═══════════════════════════════════════════════
;; Feedback & Invitations — email-sending
;; ═══════════════════════════════════════════════
#{:main/send-user-feedback
:main/create-team-invitations}
[[:email-send :bucket "30/15/5m"]]
;; ═══════════════════════════════════════════════
;; Media & File heavy ops
;; ═══════════════════════════════════════════════
#{:main/upload-file-media-object}
[[:image-upload :bucket "200/100/1m"]]
#{:main/create-file-object-thumbnail
:main/delete-file-object-thumbnails
:main/get-file-object-thumbnails}
[[:thumbnail-ops :bucket "5000/3000/1m"]]
#{:main/get-file-data-for-thumbnail
:main/create-file-thumbnail}
[[:thumbnail-data :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; UI navigation reads — high frequency
;; ═══════════════════════════════════════════════
#{:main/get-teams}
[[:get-teams :bucket "5000/2500/30s"]]
#{:main/get-team-members}
[[:get-team-members :bucket "4000/2000/30s"]]
#{:main/get-profile}
[[:get-profile :bucket "500/250/30s"]]
#{:main/get-font-variants}
[[:get-font-variants :bucket "250/125/30s"]]
#{:main/get-comment-threads}
[[:get-comment-threads :bucket "500/250/30s"]]
#{:main/get-profiles-for-file-comments}
[[:get-profiles-for-file-comments :bucket "300/150/30s"]]
#{:main/get-file-libraries}
[[:get-file-libraries :bucket "200/100/30s"]]
#{:main/get-projects}
[[:get-projects :bucket "120/60/30s"]]
#{:main/get-team-recent-files
:main/get-unread-comment-threads}
[[:get-team-recent :bucket "120/60/30s"]]
#{:main/get-page}
[[:get-page :bucket "150/75/30s"]]
#{:main/get-access-tokens
:main/get-subscription-usage}
[[:get-access-tokens :bucket "150/75/30s"]]
#{:main/get-enabled-flags}
[[:get-enabled-flags :bucket "250/125/30s"]]
#{:main/get-builtin-templates}
[[:get-builtin-templates :bucket "200/100/30s"]]
#{:main/get-project
:main/get-project-files}
[[:get-project-info :bucket "80/40/30s"]]
#{:main/get-file}
[[:get-file :bucket "180/90/1m"]]
#{:main/get-team-shared-files
:main/get-team-info
:main/get-team-users
:main/get-team-invitations
:main/get-team-deleted-files
:main/get-sso-provider}
[[:get-team-info :bucket "60/30/30s"]]
#{:main/get-comments
:main/get-file-snapshots
:main/get-library-usage
:main/has-file-libraries}
[[:get-misc-list :bucket "300/150/30s"]]
#{:main/get-comment-thread
:main/get-library-file-references}
[[:get-misc-single :bucket "60/30/30s"]]
#{:main/get-file-info
:main/get-view-only-bundle
:main/get-all-projects
:main/get-owned-teams
:main/get-team-stats
:main/get-file-summary
:main/get-file-stats
:main/get-file-fragment}
[[:get-light :bucket "60/30/30s"]]
;; ═══════════════════════════════════════════════
;; File mutations — editing active
;; ═══════════════════════════════════════════════
#{:main/update-file}
[[:update-file :bucket "1000/500/1m"]]
#{:main/create-file
:main/rename-file
:main/duplicate-file
:main/move-files}
[[:file-create :bucket "60/30/1m"]]
#{:main/delete-file}
[[:file-delete :bucket "80/40/1m"]]
#{:main/set-file-shared
:main/update-file-library-sync-status
:main/ignore-file-library-sync-status
:main/link-file-to-library
:main/unlink-file-from-library
:main/create-file-snapshot
:main/restore-file-snapshot
:main/update-file-snapshot
:main/delete-file-snapshot
:main/lock-file-snapshot
:main/unlock-file-snapshot}
[[:file-mutations :bucket "80/40/1m"]]
;; ═══════════════════════════════════════════════
;; Project mutations
;; ═══════════════════════════════════════════════
#{:main/create-project}
[[:project-create :bucket "100/50/1m"]]
#{:main/delete-project
:main/rename-project
:main/duplicate-project
:main/move-project
:main/update-project-pin}
[[:project-mutations :bucket "40/20/1m"]]
;; ═══════════════════════════════════════════════
;; Team mutations
;; ═══════════════════════════════════════════════
#{:main/create-team
:main/update-team
:main/delete-team
:main/update-team-photo
:main/update-team-member-role
:main/delete-team-member
:main/leave-team
:main/create-team-with-invitations
:main/create-team-access-request
:main/permanently-delete-team-files
:main/restore-deleted-team-files}
[[:team-mutations :bucket "60/30/1m"]]
;; ═══════════════════════════════════════════════
;; Comment operations
;; ═══════════════════════════════════════════════
#{:main/create-comment-thread
:main/create-comment
:main/update-comment
:main/delete-comment
:main/mark-all-threads-as-read}
[[:comment-basic :bucket "30/15/1m"]]
#{:main/update-comment-thread
:main/update-comment-thread-status
:main/update-comment-thread-position
:main/update-comment-thread-frame
:main/delete-comment-thread}
[[:comment-thread :bucket "80/40/1m"]]
;; ═══════════════════════════════════════════════
;; Profile operations
;; ═══════════════════════════════════════════════
#{:main/update-profile
:main/update-profile-props
:main/update-profile-photo
:main/update-profile-password
:main/update-profile-notifications
:main/delete-profile
:main/delete-profile-photo
:main/request-email-change}
[[:profile-mutations :bucket "30/15/1m"]]
;; ═══════════════════════════════════════════════
;; Font operations
;; ═══════════════════════════════════════════════
#{:main/create-font-variant
:main/delete-font
:main/delete-font-variant
:main/update-font
:main/download-font
:main/download-font-family}
[[:font-ops :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; Access tokens
;; ═══════════════════════════════════════════════
#{:main/create-access-token
:main/delete-access-token}
[[:access-token :bucket "60/30/1m"]]
;; ═══════════════════════════════════════════════
;; Export / Import
;; ═══════════════════════════════════════════════
#{:main/export-binfile
:main/import-binfile
:main/clone-template}
[[:export-import :bucket "80/40/1m"]]
;; ═══════════════════════════════════════════════
;; Upload sessions
;; ═══════════════════════════════════════════════
#{:main/create-upload-session
:main/upload-chunk
:main/assemble-file-media-object}
[[:upload-session :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; Webhooks
;; ═══════════════════════════════════════════════
#{:main/get-webhooks
:main/delete-webhook}
[[:webhook-read :bucket "20/10/1m"]]
;; ═══════════════════════════════════════════════
;; Share links
;; ═══════════════════════════════════════════════
#{:main/create-share-link
:main/delete-share-link}
[[:share-link :bucket "10/5/1m"]]
;; ═══════════════════════════════════════════════
;; Organization operations
;; ═══════════════════════════════════════════════
#{:main/add-team-to-organization
:main/remove-team-from-org
:main/all-org-members-in-team
:main/all-team-members-in-orgs
:main/get-owned-organizations-summary
:main/get-leave-org-summary
:main/leave-org
:main/check-org-members
:main/get-team-invitation-token
:main/delete-team-invitation
:main/check-team-external-invitations}
[[:org-ops :bucket "20/10/1m"]]
;; ═══════════════════════════════════════════════
;; Audit & stats
;; ═══════════════════════════════════════════════
#{:main/push-audit-events}
[[:audit-events :bucket "1000/500/1m"]]
#{:main/logout
:main/get-error-report
:main/get-error-reports
:main/get-current-mcp-token
:main/get-nitrate-connectivity
:main/check-nitrate-sso
:main/redeem-nitrate-activation-code
:main/create-demo-profile
:main/get-subscription-warning}
[[:misc-light :bucket "100/50/1m"]]}
+249 -78
View File
@@ -42,31 +42,52 @@
;; OIDC PROVIDER (GENERIC)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- raise-invalid-sso-config
"Raise a controlled validation error for OIDC provider configuration failures."
[& {:keys [hint cause] :as params}]
(throw (ex-info (or hint "invalid-sso-config")
(-> params
(dissoc :cause)
(assoc :type :validation
:code :invalid-sso-config))
cause)))
(defn- discover-oidc-config
[cfg {:keys [base-uri skip-ssrf-check?] :as provider}]
(let [uri (u/join base-uri ".well-known/openid-configuration")
rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
(let [uri (u/join base-uri ".well-known/openid-configuration")]
(try
(let [rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
token-uri (get data :token_endpoint)
auth-uri (get data :authorization_endpoint)
user-uri (get data :userinfo_endpoint)
jwks-uri (get data :jwks_uri)
logout-uri (get data :end_session_endpoint)]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
token-uri (get data :token_endpoint)
auth-uri (get data :authorization_endpoint)
user-uri (get data :userinfo_endpoint)
jwks-uri (get data :jwks_uri)
logout-uri (get data :end_session_endpoint)]
(-> provider
(assoc :token-uri token-uri)
(assoc :auth-uri auth-uri)
(assoc :user-uri user-uri)
(assoc :jwks-uri jwks-uri)
(assoc :logout-uri logout-uri)))
(-> provider
(assoc :token-uri token-uri)
(assoc :auth-uri auth-uri)
(assoc :user-uri user-uri)
(assoc :jwks-uri jwks-uri)
(assoc :logout-uri logout-uri)))
(ex/raise :type ::internal
:code :invalid-sso-config
:hint "unable to discover OIDC configuration"
:discover-uri uri
:response-status-code (:status rsp)))))
(raise-invalid-sso-config
:hint "unable to discover OIDC configuration"
:discover-uri uri
:response-status-code (:status rsp))))
(catch Throwable cause
;; Controlled raises above are ExceptionInfo and would otherwise be
;; re-wrapped by this catch, dropping fields like :response-status-code.
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
;; Wrap SSRF blocks, DNS failures, TLS errors, etc. — from the caller's
;; perspective these are all "bad/unreachable issuer URL".
(raise-invalid-sso-config
:hint "unable to discover OIDC configuration"
:discover-uri uri
:cause cause))))))
(def ^:private default-oidc-scopes
#{"openid" "profile" "email"})
@@ -107,16 +128,29 @@
(defn- fetch-oidc-jwks
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(ex/raise :type ::internal
:code :unable-to-fetch-sso-jwks
:hint "unable to retrieve JWKs (unexpected response status code)"
:response-status-code status))))
(try
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(raise-invalid-sso-config
:hint "unable to retrieve JWKs (unexpected response status code)"
:jwks-uri jwks-uri
:response-status-code status)))
(catch Throwable cause
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unable to retrieve JWKs"
:jwks-uri jwks-uri
:cause cause)))))
(defn- populate-jwks
"Fetch and Add (if possible) JWK's to the OIDC provider"
"Fetch and add JWKs to the OIDC provider.
When `:strict-jwks?` is set (organization SSO), failures raise a controlled
validation error. Otherwise JWKS is best-effort: log and continue without keys
so global OIDC/GitLab providers can still initialize if JWKS is temporarily down."
[cfg provider]
(try
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
@@ -124,20 +158,28 @@
(assoc provider :jwks jwks)
provider)
(catch Throwable cause
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
:provider (str (:id provider))
:cause cause)
provider)))
(if (:strict-jwks? provider)
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unable to retrieve JWKs"
:provider (:id provider)
:cause cause))
(do
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
:provider (str (:id provider))
:cause cause)
provider)))))
(defn- prepare-oidc-provider
[cfg params]
(when-not (and (string? (:base-uri params))
(string? (:client-id params))
(string? (:client-secret params)))
(ex/raise :type ::internal
:code :invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(raise-invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(try
(if (and (string? (:token-uri params))
@@ -150,11 +192,13 @@
(with-meta provider {::discovered true})))
(catch Throwable cause
(ex/raise :type ::internal
:type :invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause))))
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause)))))
(defmethod ig/assert-key ::providers/generic
[_ params]
@@ -322,10 +366,9 @@
[cfg params]
(when-not (and (string? (:client-id params))
(string? (:client-secret params)))
(ex/raise :type ::internal
:code :invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(raise-invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(try
(let [provider (populate-jwks cfg params)]
@@ -336,11 +379,13 @@
:client-secret (d/obfuscate-string (:client-secret provider)))
provider)
(catch Throwable cause
(ex/raise :type ::internal
:type :invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause))))
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause)))))
(defmethod ig/init-key ::providers/gitlab
[_ cfg]
@@ -620,9 +665,6 @@
(some? (:external-session-id state))
(assoc :external-session-id (:external-session-id state))
(some? (:token/expires-in tdata))
(assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
;; If state token comes with props, merge them. The state token
;; props can contain pm_ and utm_ prefixed query params.
(map? (:props state))
@@ -650,6 +692,15 @@
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
(defn- redirect-with-organization-sso-error
[{:keys [dest-url organization-id organization-name]}]
(-> (str (or dest-url (cf/get :public-uri)))
(u/append-query-param :sso-error true)
(u/append-query-param :organization-id organization-id)
(cond-> organization-name
(u/append-query-param :organization-name organization-name))
(redirect-response)))
(defn- redirect-to-register
[cfg info provider]
(let [info (assoc info
@@ -765,6 +816,82 @@
;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- organization-sso-oauth-failure-reason
[error]
(case (d/name error)
"access_denied" "access-denied"
("temporarily_unavailable" "server_error") "provider-unavailable"
("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration"
"provider-error"))
(defn- organization-sso-exception-failure-reason
[cause]
(let [data (ex-data cause)
status (or (:response-status data)
(:response-status-code data)
(:http-status data))
network-error?
(loop [current cause]
(cond
(nil? current)
false
(or (instance? java.net.ConnectException current)
(instance? java.net.UnknownHostException current)
(instance? java.net.http.HttpTimeoutException current)
(instance? javax.net.ssl.SSLException current))
true
(identical? current (ex-cause current))
false
:else
(recur (ex-cause current))))]
(if (or network-error?
(and (number? status) (<= 500 status 599)))
"provider-unavailable"
(case (:code data)
:unable-to-fetch-access-token "token-exchange-failed"
:unable-to-retrieve-user-info "user-info-failed"
:incomplete-user-info "incomplete-user-info"
:invalid-sso-config "invalid-configuration"
:unable-to-fetch-sso-jwks "provider-unavailable"
:unable-to-auth "access-denied"
"unexpected-error"))))
(defn- submit-organization-sso-auth-event
[cfg request profile-id organization-id name & {:keys [failure-reason]}]
(audit/submit cfg {:type "action"
:name name
:profile-id profile-id
:ip-addr (inet/parse-request request)
:props (d/without-nils
{:organization-id organization-id
:failure-reason failure-reason})
:context (audit/prepare-context-from-request request)}))
(defn submit-organization-sso-auth-started-event
[cfg request profile-id organization-id]
(submit-organization-sso-auth-event
cfg request profile-id organization-id "organization-sso-auth-started"))
(defn submit-organization-sso-auth-failed-event
[cfg request profile-id organization-id cause]
(submit-organization-sso-auth-event
cfg request profile-id organization-id "organization-sso-auth-failed"
:failure-reason (organization-sso-exception-failure-reason cause)))
(defn- submit-organization-sso-oauth-failed-event
[cfg request state-token error]
(try
(let [state (tokens/verify cfg {:token state-token :iss "oidc"})]
(when (:dest-url state)
(submit-organization-sso-auth-event
cfg request (some-> (session/get-session request) :profile-id)
(:organization-id state) "organization-sso-auth-failed"
:failure-reason (organization-sso-oauth-failure-reason error))))
(catch Exception _ nil)))
(defn- non-blank-uri
[value]
(when-not (str/blank? value) value))
@@ -785,7 +912,10 @@
:base-uri (some-> (non-blank-uri issuer)
(str/rtrim "/")
(str "/"))
:scopes default-oidc-scopes}))
:scopes default-oidc-scopes
;; Organization SSO is configured by customers; discovery
;; and JWKS failures must surface as controlled errors.
:strict-jwks? true}))
(defn build-organization-sso-auth-redirect-uri
"Build the OIDC authorization redirect URI for an organization SSO config.
@@ -795,16 +925,24 @@
issuer (organization-sso-discovery-uri sso)
dest-url (or dest-url (str (cf/get :public-uri)))]
(when-not issuer
(ex/raise :type :validation
:code :invalid-sso-config
:hint "missing issuer"))
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url dest-url
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})]
(build-auth-redirect-uri oidc-provider state-token))))
(raise-invalid-sso-config
:hint "missing issuer"
:organization-id organization-id))
(try
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url dest-url
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})]
(build-auth-redirect-uri oidc-provider state-token))
(catch Throwable cause
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw (ex-info (ex-message cause)
(assoc (ex-data cause) :organization-id organization-id)
(ex-cause cause)))
(throw cause))))))
(def ^:private probe-auth-code "penpot-sso-config-probe")
@@ -887,10 +1025,53 @@
{::yres/status 200
::yres/body {:redirect-uri uri}}))
(defn- organization-sso-callback-handler
"Handle the organization-SSO branch of the OIDC callback: state carries
:dest-url — exchange the authorization code with the OIDC provider to
verify authentication actually occurred, then redirect back to dest-url."
[cfg request state code]
(let [dest-url (:dest-url state)]
(try
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
_info (get-info cfg provider state code)
session (session/get-session request)
exp (ct/in-future {:minutes 15})]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(submit-organization-sso-auth-event
cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded")
(redirect-response dest-url))
(catch Throwable cause
(let [{:keys [code]} (ex-data cause)]
(binding [l/*context* (errors/request->context request)]
(if (some? code)
(l/warn :hint "organization sso callback failed"
:code code
:message (ex-message cause)
:organization-id (:organization-id state))
(l/err :hint "unexpected error on organization sso callback"
:organization-id (:organization-id state)
:cause cause))))
(submit-organization-sso-auth-failed-event
cfg request (some-> (session/get-session request) :profile-id)
(:organization-id state) cause)
(let [organization-id (:organization-id state)
organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))]
(redirect-with-organization-sso-error
{:dest-url dest-url
:organization-id organization-id
:organization-name organization-name}))))))
(defn- callback-handler
[cfg {:keys [params] :as request}]
(if-let [error (get params :error)]
(redirect-with-error "unable-to-auth" error)
(do
(submit-organization-sso-oauth-failed-event cfg request (:state params) error)
(redirect-with-error "unable-to-auth" error))
(try
(let [code (get params :code)
state (get params :state)
@@ -898,18 +1079,8 @@
;; Organization SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)]
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
info (get-info cfg provider state code)
session (session/get-session request)
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(redirect-response dest-url))
(if (:dest-url state)
(organization-sso-callback-handler cfg request state code)
(let [provider (resolve-provider cfg state)
info (get-info cfg provider state code)
+1
View File
@@ -723,6 +723,7 @@
(-> (select-keys file file-attrs)
(assoc :data nil)
(dissoc :team-id)
(dissoc :metadata)
(dissoc :migrations)))
(defn- file->file-data-params
+5 -3
View File
@@ -392,7 +392,7 @@
params {:type "penpot/export-files"
:version 1
:generated-by (str "penpot/" (:full cf/version))
:refer "penpot"
:referer "penpot"
:files (vec (vals files))
:relations rels}]
(write-entry! output "manifest.json" params))))
@@ -734,7 +734,7 @@
:plugin-data plugin-data}))
(defn- import-file
[{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}]
[{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}]
(let [file-id' (bfc/lookup-index file-id)
file (read-file cfg file-id)
media (read-file-media cfg file-id)
@@ -801,8 +801,10 @@
(assoc :data data)
(assoc :name file-name)
(assoc :project-id project-id)
(assoc :metadata (d/without-nils
{:generated-by (get manifest :generated-by)
:referer (or (get manifest :referer) (get manifest :refer))}))
(dissoc :options))
file (bfc/process-file cfg file)
file (ctf/check-file file)]
+2 -1
View File
@@ -52,7 +52,7 @@
:redis-uri "redis://redis/0"
:file-data-backend "legacy-db"
:file-data-backend "db"
:objects-storage-backend "fs"
:objects-storage-fs-directory "assets"
@@ -194,6 +194,7 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
+2 -2
View File
@@ -505,13 +505,13 @@
:schema schema:request-file-access))
(def request-file-access-yourpenpot
"File access on Your Penpot request email."
"File access on Personal Projects request email."
(template-factory
:id ::request-file-access-yourpenpot
:schema schema:request-file-access))
(def request-file-access-yourpenpot-view
"File access on Your Penpot view mode request email."
"File access on Personal Projects view mode request email."
(template-factory
:id ::request-file-access-yourpenpot-view
:schema schema:request-file-access))
+8 -12
View File
@@ -12,6 +12,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.file :as ctf]
[app.common.types.objects-map :as omap]
[app.config :as cf]
[app.db :as db]
@@ -159,15 +160,17 @@
:content-type "application/octet-stream"
:file-id file-id
:id id})
metadata {:storage-ref-id (:id sobject)}
metadata (-> (:metadata params)
(assoc :storage-ref-id (:id sobject)))
params (-> params
(assoc :metadata metadata)
(assoc :data nil))]
(upsert-in-database cfg params))
(= backend "db")
(->> (dissoc params :metadata)
(upsert-in-database cfg))
(let [metadata (dissoc (:metadata params) :storage-ref-id)
params (assoc params :metadata metadata)]
(upsert-in-database cfg params))
(= backend "legacy-db")
(cond
@@ -213,18 +216,11 @@
[backend]
(or backend (cf/get :file-data-backend)))
(def ^:private schema:metadata
[:map {:title "Metadata"}
[:storage-ref-id {:optional true} ::sm/uuid]])
(def decode-metadata-with-schema
(sm/decoder schema:metadata sm/json-transformer))
(defn decode-metadata
[metadata]
(some-> metadata
(db/decode-json-pgobject)
(decode-metadata-with-schema)))
(ctf/decode-file-metadata)))
(def ^:private schema:update-params
[:map {:closed true}
@@ -232,7 +228,7 @@
[:type [:enum "main" "snapshot" "fragment"]]
[:file-id ::sm/uuid]
[:backend {:optional true} [:enum "db" "legacy-db" "storage"]]
[:metadata {:optional true} [:maybe schema:metadata]]
[:metadata {:optional true} ctf/schema:file-metadata]
[:data {:optional true} bytes?]
[:created-at {:optional true} ::ct/inst]
[:modified-at {:optional true} [:maybe ::ct/inst]]
+46 -13
View File
@@ -7,7 +7,6 @@
(ns app.http.assets
"Assets related handlers."
(:require
[app.binfile.common :as bfc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.time :as ct]
@@ -15,6 +14,7 @@
[app.db :as db]
[app.http.access-token :as actoken]
[app.http.session :as session]
[app.rpc.permissions :as perms]
[app.storage :as sto]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
@@ -41,6 +41,12 @@
(ex/raise :type :not-found
:hint "object not found")))
(defn- get-share-id
"Extract and validate the optional `share-id` query param. Returns a UUID
or `nil` for missing/malformed values."
[{:keys [query-params]}]
(some-> query-params :share-id d/parse-uuid))
(defn- get-file-media-object
[pool id]
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
@@ -49,12 +55,16 @@
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
(let [sig-max-age (or signature-max-age default-signature-max-age)
cch-max-age (or cache-max-age default-cache-max-age)
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})
bucket (-> obj meta :bucket)
headers (cond-> {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
(not (contains? public-buckets bucket))
(assoc "content-disposition" "attachment"))]
{::yres/status 307
::yres/headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
::yres/headers headers}))
(defn- serve-object-from-fs
[{:keys [::path ::cache-max-age]} obj]
@@ -62,9 +72,12 @@
purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
bucket (:bucket mdata)
headers (cond-> {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
(not (contains? public-buckets bucket))
(assoc "content-disposition" "attachment"))]
{::yres/status 204
::yres/headers headers}))
@@ -82,17 +95,32 @@
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(defn- request-profile-id
"Extract the authenticated profile-id from the request."
[request]
(or (::session/profile-id request)
(::actoken/profile-id request)))
(defn- authenticated?
"Check if the request has an authenticated profile, either via session
or access token."
[request]
(or (some? (::session/profile-id request))
(some? (::actoken/profile-id request))))
(some? (request-profile-id request)))
(defn- tempfile-owner-match?
"Check if the request's profile-id matches the tempfile's stored owner.
Returns true if no profile-id was stored (legacy objects)."
[obj request]
(let [stored-profile-id (:profile-id (meta obj))
request-profile-id (request-profile-id request)]
(or (nil? stored-profile-id)
(= stored-profile-id request-profile-id))))
(defn objects-handler
"Handler that serves storage objects by id.
For non-public buckets (e.g. profile), requires authentication
via session cookie or access token."
via session cookie or access token.
For tempfile bucket, also requires ownership (profile-id match)."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
@@ -104,6 +132,10 @@
(not (authenticated? request)))
{::yres/status 401}
(and (= (-> obj meta :bucket) sto/tempfile-bucket)
(not (tempfile-owner-match? obj request)))
{::yres/status 404}
:else
(serve-object cfg obj))))
@@ -118,7 +150,8 @@
(let [file-id (:file-id mobj)
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
perms (bfc/get-file-permissions pool profile-id file-id)]
share-id (get-share-id request)
perms (perms/get-file-read-permissions pool profile-id file-id share-id)]
(if-not (:can-read perms)
{::yres/status 404}
(let [sobj (sto/get-object storage (kf mobj))]
+91 -2
View File
@@ -13,6 +13,9 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.files.changes :as cfc]
[app.common.files.repair :as cfr]
[app.common.files.validate :as cfv]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.time :as ct]
@@ -28,6 +31,7 @@
[app.rpc.commands.teams :as teams]
[app.setup :as-alias setup]
[app.setup.clock :as clock]
[app.srepl.helpers :as h]
[app.srepl.main :as srepl]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
@@ -130,7 +134,7 @@
:hint "invalid button"))
(ex/raise :type :not-found
:code :enpty-data
:code :empty-data
:hint "empty response"))))
(defn- is-file-exists?
@@ -484,6 +488,89 @@
{::yres/status 302
::yres/headers {"location" "/dbg"}}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; VALIDATE / REPAIR
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- validate-file
[cfg {:keys [params] :as request}]
(let [file-id (some-> params :file-id parse-uuid)]
(when-not file-id
(ex/raise :type :validation
:code :missing-arguments))
(db/tx-run! (assoc cfg ::db/rollback true)
(fn [cfg]
(let [file (bfc/get-file cfg file-id)
libs (bfc/get-resolved-file-libraries cfg file-id)]
(if file
(let [errors (cfv/validate-file file libs)]
{::yres/status 200
::yres/headers {"content-type" "text/plain"}
::yres/body (if (empty? errors)
"NO VALIDATION ERRORS FOUND"
(pp/pprint-str errors))})
(ex/raise :type :not-found
:code :empty-data
:hint "empty response")))))))
(defn- repair-file
[cfg {:keys [params] :as request}]
(let [file-id (some-> params :file-id parse-uuid)
skip-snapshot? (contains? params :skip-snapshot)
profile-id (:app.http.session/profile-id request)]
(when-not file-id
(ex/raise :type :validation
:code :missing-arguments))
(let [output (StringBuilder.)
repair-file
(fn [file libs _]
(let [errors (cfv/validate-file file libs)]
(.append output (if (empty? errors)
"NO VALIDATION ERRORS FOUND\n"
(str "VALIDATION ERRORS FOUND:\n"
(pp/pprint-str errors) "\n")))
(if (empty? errors)
file
(let [changes (cfr/repair-file file libs errors)]
(-> file
(update :revn inc)
(update :data cfc/process-changes changes))))))]
(add-watch l/log-record ::repair-watcher
(fn [_ _ _ record]
(when (= "app.common.files.repair" (::l/logger record))
(let [props (::l/props record)
hint (get props :hint "")
args (dissoc props :hint)
message (str hint " "
(when-not (empty? args)
args)
"\n")]
(.append output message)))))
(try
(db/tx-run! cfg
h/process-file!
file-id
repair-file
{::h/with-libraries? true
::h/validate? false
::h/profile-id profile-id
::h/snapshot-label (when-not skip-snapshot? "repair")})
(.append output "\nREPAIR FINISHED")
{::yres/status 200
::yres/headers {"content-type" "text/plain"}
::yres/body (.toString output)}
(finally
(remove-watch l/log-record ::repair-watcher))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; OTHER SMALL VIEWS/HANDLERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -578,5 +665,7 @@
{:handler (partial handle-team-features cfg)}]
["/file-export" {:handler (partial export-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]
["/file-validate" {:handler (partial validate-file cfg)}]
["/file-repair" {:handler (partial repair-file cfg)}]]]])
+31 -14
View File
@@ -34,6 +34,12 @@
(assoc :request/auth-data (dissoc auth :token))
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defn- strip-internal-fields
"Remove fields that leak internal implementation details from error
response data. Full context is preserved in server-side logs."
[data]
(dissoc data :state :path :context))
(defmulti handle-error
(fn [cause _ _]
(-> cause ex-data :type)))
@@ -136,6 +142,7 @@
(l/error :hint "assertion error" :cause cause)
{::yres/status 500
::yres/body (-> data
(strip-internal-fields)
(assoc :type :server-error)
(assoc :code :assertion))})))))
@@ -161,9 +168,9 @@
(l/error :hint "internal error" :cause cause)
{::yres/status 500
::yres/body (-> data
(strip-internal-fields)
(assoc :type :server-error)
(update :code #(or % :unhandled))
(assoc :hint (ex-message error)))})))
(update :code #(or % :unhandled)))})))
(defmethod handle-error :default
[error request parent-cause]
@@ -178,6 +185,20 @@
(handle-exception (:handling edata) request error)
(handle-exception error request parent-cause))))
(defn- pgsql-state->message
"Map PostgreSQL SQLSTATE codes to safe, client-facing messages.
Returns a user-friendly string that conveys the nature of the error
without exposing table names, constraint names, or other internals."
[state]
(case state
"23505" "A conflicting entry already exists"
"23503" "The referenced item does not exist"
"23502" "A required field is missing"
"23514" "The value violates a data integrity constraint"
"57014" "The operation took too long and was cancelled"
"25P03" "The transaction was idle too long and was cancelled"
"A database error occurred"))
(defmethod handle-exception org.postgresql.util.PSQLException
[error request parent-cause]
(let [state (.getSQLState ^java.sql.SQLException error)
@@ -190,20 +211,19 @@
{::yres/status 504
::yres/body {:type :server-error
:code :statement-timeout
:hint (ex-message error)}}
:hint (pgsql-state->message state)}}
(= state "25P03")
{::yres/status 504
::yres/body {:type :server-error
:code :idle-in-transaction-timeout
:hint (ex-message error)}}
:hint (pgsql-state->message state)}}
:else
{::yres/status 500
::yres/body {:type :server-error
:code :unexpected
:hint (ex-message error)
:state state}}))))
:code :database-error
:hint (pgsql-state->message state)}}))))
(defmethod handle-exception :default
[error request parent-cause]
@@ -216,17 +236,16 @@
(l/error :hint "unexpected error" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :unexpected
:hint (ex-message error)}})
:code :unexpected}})
:else
(binding [l/*context* (request->context request)]
(l/error :hint "unhandled error" :cause cause)
{::yres/status 500
::yres/body (-> edata
(strip-internal-fields)
(assoc :type :server-error)
(update :code #(or % :unhandled))
(assoc :hint (ex-message error)))}))))
(update :code #(or % :unhandled)))}))))
(defmethod handle-exception java.io.IOException
[cause request _]
@@ -234,9 +253,7 @@
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception
:hint (ex-message cause)
:path (:path request)}}))
:code :io-exception}}))
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]
+1 -1
View File
@@ -209,7 +209,7 @@
[:enum
"customer_service"
"low_quality"
"missing_feature"
"missing_features"
"other"
"switched_service"
"too_complex"
+11 -5
View File
@@ -24,7 +24,8 @@
(:import
io.undertow.server.RequestTooBigException
java.io.InputStream
java.io.OutputStream))
java.io.OutputStream
java.security.MessageDigest))
(set! *warn-on-reflection* true)
@@ -82,18 +83,18 @@
(instance? IllegalArgumentException cause)
(ex/raise :type :validation
:code :malformed-json
:hint (ex-message cause)
:hint "invalid JSON in request body"
:cause cause)
(instance? RequestTooBigException cause)
(ex/raise :type :validation
:code :request-body-too-large
:hint (ex-message cause))
:hint "request body exceeds size limit")
(instance? java.io.EOFException cause)
(ex/raise :type :validation
:code :malformed-json
:hint (ex-message cause)
:hint "unexpected end of request body"
:cause cause)
(instance? RuntimeException cause)
@@ -329,6 +330,11 @@
{:name ::auth
:compile (constantly wrap-auth)})
(defn- constant-time-eq?
"Compare strings in constant time to prevent timing attacks."
[^String a ^String b]
(MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8")))
(defn- wrap-shared-key-auth
[handler keys]
(if (seq keys)
@@ -338,7 +344,7 @@
(let [key-id (-> key-id str/lower keyword)]
(if (and (string? key)
(contains? keys key-id)
(= key (get keys key-id)))
(constant-time-eq? key (get keys key-id)))
(-> request
(assoc ::http/auth-key-id key-id)
(handler))
+9 -1
View File
@@ -204,7 +204,7 @@
[{:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request response]
(some->> (get request ::id) (delete-session manager))
(some->> (get request ::session) :id (delete-session manager))
(clear-session-cookie response)))
(defn decode-token
@@ -226,6 +226,14 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
(defn invalidate-all
"Delete all sessions for a given profile. Used when a profile is deleted
to ensure immediate access revocation across all devices."
[cfg profile-id]
(let [sql "delete from http_session_v2 where profile_id = ?"]
(-> (db/exec-one! cfg [sql profile-id])
(db/get-update-count))))
(def ^:private sql:clear-organization-sso-sessions
(str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] "
+8 -2
View File
@@ -7,6 +7,7 @@
(ns app.http.websocket
"A penpot notification service for file cooperative edition."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@@ -17,6 +18,8 @@
[app.http.session :as session]
[app.metrics :as mtx]
[app.msgbus :as mbus]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.util.websocket :as ws]
[integrant.core :as ig]
[promesa.exec.csp :as sp]
@@ -131,8 +134,9 @@
(mbus/pub! msgbus :topic topic :message msg))))
(defmethod handle-message :subscribe-team
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
(teams/check-read-permissions! pool profile-id team-id)
(let [prev-subs (get @state ::team-subscription)
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
@@ -150,8 +154,10 @@
(defmethod handle-message :subscribe-file
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
(bfc/check-file-exists pool file-id)
(files/check-read-permissions! pool profile-id file-id)
(let [psub (::file-subscription @state)
fch (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
+18
View File
@@ -36,6 +36,16 @@
(def ^:private filter-auth-events
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
(def ^:private organization-sso-failure-reasons
#{"access-denied"
"provider-unavailable"
"invalid-configuration"
"provider-error"
"token-exchange-failed"
"user-info-failed"
"incomplete-user-info"
"unexpected-error"})
(def ^:private safe-backend-context-keys
#{:version
:initiator
@@ -297,6 +307,14 @@
(defn filter-telemetry-props
[{:keys [source name props type] :as params}]
(cond
(and (= source "backend")
(= name "organization-sso-auth-failed"))
(let [props' (into {} xf:filter-telemetry-props props)
props' (cond-> props'
(contains? organization-sso-failure-reasons (:failure-reason props))
(assoc :failure-reason (:failure-reason props)))]
(assoc params :props props'))
(or (and (= source "frontend")
(= type "identify"))
(and (= source "backend")
+3 -2
View File
@@ -7,6 +7,7 @@
(ns app.loggers.mattermost
"A mattermost integration for error reporting."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@@ -25,7 +26,7 @@
(defn- send-mattermost-notification!
[cfg {:keys [id] :as report}]
(let [type (get report :type)
text (str "#" type " | " (get report :hint) "\n"
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
(when id
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
@@ -38,7 +39,7 @@
"- tenant: #" (:tenant report) "\n"
"- origin: #" (:origin report) "\n"
(when-let [href (get report :href)]
(str "- href: `" href "`\n"))
(str "- href: `" (d/escape-markdown href) "`\n"))
(when-let [version (get report :frontend-version)]
(str "- frontend-version: `" version "`\n"))
(when-let [version (get report :backend-version)]
+57 -11
View File
@@ -167,6 +167,9 @@
[:id ::sm/uuid]
[:name ::sm/text]
[:owner-id ::sm/uuid]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:sso-active {:optional true} [:maybe ::sm/boolean]]
[:teams
[:vector
[:map
@@ -242,7 +245,7 @@
[:enum
"customer_service"
"low_quality"
"missing_feature"
"missing_features"
"other"
"switched_service"
"too_complex"
@@ -259,6 +262,14 @@
(generate-nitrate-uri "api/teams/" team-id)
cto/schema:team-with-organization params))
(defn- get-teams-organizations-api
[cfg {:keys [team-ids] :as params}]
(let [params (assoc params :request-params {:team-ids team-ids})]
(request-to-nitrate cfg :post
(generate-nitrate-uri "api/teams/organizations")
[:vector cto/schema:team-with-organization]
params)))
(defn- get-organization-membership-api
[cfg {:keys [profile-id organization-id] :as params}]
(request-to-nitrate cfg :get
@@ -489,6 +500,7 @@
[_ cfg]
(when (contains? cf/flags :admin-console)
{:get-team-organization (partial get-team-organization-api cfg)
:get-teams-organizations (partial get-teams-organizations-api cfg)
:set-team-organization (partial set-team-organization-api cfg)
:get-organization-membership (partial get-organization-membership-api cfg)
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
@@ -596,22 +608,25 @@
:cause cause)
profile)))))
(defn- apply-organization-info-to-team
[team team-with-organization]
(let [organization (:organization team-with-organization)]
(if (some? organization)
(-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id organization)]
(generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team)))
(defn add-organization-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or organization data is nil.
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
[cfg team params]
(try
(let [params (assoc (or params {}) :team-id (:id team))
team-with-organization (call cfg :get-team-organization params)
organization (:organization team-with-organization)]
(if (some? organization)
(-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id organization)]
(generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team))
(let [params (assoc (or params {}) :team-id (:id team))
team-with-organization (call cfg :get-team-organization params)]
(apply-organization-info-to-team team team-with-organization))
(catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause)
@@ -621,6 +636,23 @@
:cause cause)
team)))))
(defn add-organization-info-to-teams
"Enriches teams with organization information using one batched Nitrate request.
Teams absent from the Nitrate response are returned unchanged.
Rejects the request when Nitrate does not return a valid batch response."
[cfg teams params]
(let [request-params (assoc (or params {}) :team-ids (mapv :id teams))
teams-with-organization (call cfg :get-teams-organizations request-params)]
(when (nil? teams-with-organization)
(ex/raise :type :nitrate-unavailable
:hint "nitrate did not return a valid teams organization response"))
(let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)]
(mapv (fn [{:keys [id] :as team}]
(if-let [team-with-organization (get organizations-by-team id)]
(apply-organization-info-to-team team team-with-organization)
team))
teams))))
(defn set-team-organization
"Associates a team with an organization in Nitrate.
Requires organization-id and is-default in params.
@@ -637,3 +669,17 @@
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))
(defn assert-membership
"Verifies that the user is a member of the organization.
Raises an exception if the organization doesn't exist or the user is not a member."
[cfg profile-id organization-id]
(let [membership (call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})]
(when-not (:organization-id membership)
(ex/raise :type :validation
:code :organization-does-not-exist))
(when-not (:is-member membership)
(ex/raise :type :validation
:code :user-doesnt-belong-organization))))
+26 -14
View File
@@ -261,23 +261,28 @@
(defn- wrap-nitrate-sso
"Enforce Nitrate organization SSO authentication for RPC handlers.
Resolves the organization/team context from request params using priority order:
1. Explicit :organization-id param
2. Explicit :team-id param
3. Explicit :project-id param -> lookup project.team_id
4. Explicit :file-id param -> lookup file's team via join
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
Resolves the organization/team context from request params:
1. Explicit :organization-id param identifies the organization directly
2. The team comes from the first available of: explicit :team-id, explicit
:project-id -> lookup project.team_id, explicit :file-id -> lookup file's
team via join, or the :id param dispatched by ::rpc/id-type metadata
(:team, :project, or :file)
Once the context is resolved, checks if the user is authorized within that organization's
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
SSO session using nitrate/sso-session-authorized?, against the organization when it is
known and against the team otherwise. The team is resolved either way, so the raised
error can carry it. Authorized results are cached by [profile-id cache-ref] for 15
minutes to avoid repeated lookups.
Only activates when:
- Nitrate flag is enabled
- Endpoint requires authentication (::auth true by default)
- Endpoint is not marked with ::nitrate/organization-sso false
Raises :nitrate-sso-required error if user is not authorized in the organization."
Raises :nitrate-sso-required error if user is not authorized in the organization.
The error carries the resolved :organization-id and :team-id so the client can
restart the SSO flow (via :check-nitrate-sso) instead of reporting a plain
permission failure."
[_ f mdata]
(if (and (contains? cf/flags :admin-console)
(::auth mdata true) ;; only for endpoints that needs auth
@@ -302,17 +307,22 @@
cached (cache/get organization-sso-auth-cache cache-key)
result (if (some? cached)
cached
(let [team-id (when-not organization-id
(or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
;; The team is resolved even when the organization is
;; already known: the client needs it to restart the
;; SSO flow without sending non-members through the
;; organization's identity provider.
(let [team-id (or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
(when file-id
(:id (teams/get-team-for-file cfg file-id))))
request (-> (meta params) (get ::http/request))
{:keys [authorized sso]} (if organization-id
(nitrate/sso-session-authorized? cfg organization-id nil request)
(nitrate/sso-session-authorized? cfg nil team-id request))
entry {:authorized authorized
:organization-id (:organization-id sso)}]
:organization-id (or (:organization-id sso) organization-id)
:team-id team-id}]
(when authorized
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
entry))]
@@ -320,6 +330,8 @@
(f cfg params)
(ex/raise :type :authentication
:code :nitrate-sso-required
:organization-id (:organization-id result)
:team-id (:team-id result)
:hint "organization SSO authentication required")))
(f cfg params))))
f))
+1
View File
@@ -183,6 +183,7 @@
(sv/defmethod ::get-enabled-flags
{::audit/skip true
::rpc/auth false
::doc/skip true
::doc/added "1.20"}
[_cfg _params]
+1 -1
View File
@@ -367,7 +367,7 @@
email (str/lower email)
fullname (d/normalize-string (:fullname params))
locale (d/normalize-string locale)
theme (d/normalize-string theme)
theme (some-> theme d/normalize-string not-empty)
photo-id (some->> (or (:oidc/picture props)
(:google/picture props)
+27 -13
View File
@@ -21,6 +21,7 @@
[app.loggers.webhooks :as-alias webhooks]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :as media-cmd]
[app.rpc.commands.projects :as projects]
@@ -59,7 +60,7 @@
{::sto/content data
::sto/touched-at (ct/in-future {:minutes 60})
:content-type "application/zip"
:bucket "tempfile"})]
:bucket sto/tempfile-bucket})]
(-> (cf/get :public-uri)
(u/join "/assets/by-id/")
@@ -104,7 +105,11 @@
(try
(case (int version)
1 (bf.v1/import-files! cfg)
3 (bf.v3/import-files! cfg))
3 (bf.v3/import-files! cfg)
(throw (ex-info (str "Unsupported binfile version: " version)
{:type :validation
:code :unsupported-version
:version version})))
(finally
(when owned?
(fs/delete input-path))))]
@@ -118,11 +123,11 @@
(def ^:private schema:import-binfile
[:and
[:map {:title "import-binfile" :closed true}
[:map {:title "import-binfile"}
[:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid]
[:version {:optional true} ::sm/int]
[:version {:optional true} [:enum 1 3]]
[:file {:optional true} media.v/schema:upload]
[:upload-id {:optional true} ::sm/uuid]]
[:fn {:error/message "one of :file or :upload-id is required"}
@@ -142,24 +147,33 @@
::webhooks/event? true
::sse/stream? true
::sm/params schema:import-binfile}
::sm/params schema:import-binfile
::climit/id [[:import-binfile/by-profile ::rpc/profile-id]
[:import-binfile/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [version (or version 3)
(let [params (if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)]
(assoc params :file file))
params)
version (or version
(case (bfc/parse-file-format (-> params :file :path))
:binfile-v1 1
:binfile-v3 3))
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
params
(if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
(assoc params :file file))
params)
manifest
(case (int version)
1 nil
3 (bf.v3/get-manifest (-> params :file :path)))]
3 (bf.v3/get-manifest (-> params :file :path))
(throw (ex-info (str "Unsupported binfile version: " version)
{:type :validation
:code :unsupported-version
:version version})))]
(with-meta
(sse/response (partial import-binfile cfg params))
+13 -2
View File
@@ -245,6 +245,10 @@
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(when (= :share-link (:type perms))
(ex/raise :type :not-found
:code :object-not-found
:hint "object not found"))
(check-read-permissions! perms)
(-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration))))))
@@ -392,6 +396,14 @@
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
file (bfc/get-file cfg file-id :read-only? true)
resolved-page-id (or page-id (-> file :data :pages first))
_ (when (and (= :share-link (:type perms))
(not (contains? (:pages perms) resolved-page-id)))
(ex/raise :type :not-found
:code :object-not-found
:hint "object not found"))
proj (db/get conn :project {:id (:project-id file)})
team (-> (db/get conn :team {:id (:team-id proj)})
@@ -402,8 +414,7 @@
(cfeat/check-file-features! (:features file)))
page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(let [page-id (or page-id (-> file :data :pages first))
page (dm/get-in file [:data :pages-index page-id])]
(let [page (dm/get-in file [:data :pages-index resolved-page-id])]
(if (pmap/pointer-map? page)
(deref page)
page)))]
+14 -1
View File
@@ -7,6 +7,8 @@
(ns app.rpc.commands.files-share
"Share link related rpc mutation methods."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.db :as db]
@@ -43,7 +45,7 @@
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
(let [pages (db/create-array conn "uuid" pages)
slink (db/insert! conn :share-link
{:id (uuid/next)
{:id (uuid/random)
:file-id file-id
:who-comment who-comment
:who-inspect who-inspect
@@ -66,5 +68,16 @@
[{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}]
(let [slink (db/get-by-id conn :share-link id)]
(files/check-edition-permissions! conn profile-id (:file-id slink))
;; Verify caller owns this specific share-link, OR has admin access.
;; Note: :is-admin already includes :is-owner (see bfc/get-file-permissions),
;; so we only need to check :is-admin here.
(let [perms (bfc/get-file-permissions conn profile-id (:file-id slink))]
(when-not (or (= (:owner-id slink) profile-id)
(:is-admin perms))
(ex/raise :type :authorization
:code :not-share-link-owner
:hint "You can only delete share-links you created")))
(db/delete! conn :share-link {:id id})
nil))
+17 -4
View File
@@ -93,6 +93,18 @@
(declare create-font-variant)
(defn- check-font-team-ownership!
"When font-id already has variants belonging to a different team,
raises :not-found to prevent cross-team font injection."
[conn team-id font-id]
(let [row (db/get* conn :team-font-variant
{:font-id font-id}
{::db/columns [:team-id]})]
(when (and row (not= (:team-id row) team-id))
(ex/raise :type :not-found
:code :object-not-found
:hint "font does not belong to this team"))))
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
@@ -106,10 +118,10 @@
"Assembles each chunked-upload session in `uploads` (a `{mtype →
session-id}` map) into a temp file, validates the media type and
size of every entry, and returns a `{mtype → path}` data map."
[cfg {:keys [uploads] :as params}]
[cfg {:keys [::rpc/profile-id uploads] :as params}]
(let [data (reduce-kv
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg session-id)]
(let [assembled (assemble-chunks cfg profile-id session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media.v/validate-media-type! cm/font-types)
(media.v/validate-font-size!))
@@ -132,8 +144,9 @@
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}]
(teams/check-edition-permissions! pool profile-id team-id)
(check-font-team-ownership! pool team-id font-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
@@ -340,7 +353,7 @@
::sto/touched-at (ct/in-future {:minutes 30})
:profile-id profile-id
:content-type mtype
:bucket "tempfile"}]
:bucket sto/tempfile-bucket}]
(sto/put-object! storage content)))
+24 -6
View File
@@ -40,6 +40,12 @@
(declare create-file-media-object)
(def ^:private sql:get-team-id-for-file
"SELECT p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?")
(def ^:private schema:upload-file-media-object
[:map {:title "upload-file-media-object"}
[:id {:optional true} ::sm/uuid]
@@ -58,6 +64,12 @@
(media.v/validate-media-type! content)
(media.v/validate-media-size! content)
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
(quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/incr (:size content)}))
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if
;; file is not already deleted
@@ -272,8 +284,13 @@
(clone-file-media-object cfg params))
(defn clone-file-media-object
[{:keys [::db/conn]} {:keys [id file-id is-local]}]
[{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}]
(let [mobj (db/get-by-id conn :file-media-object id)]
(when-not mobj
(ex/raise :type :not-found
:code :object-not-found
:hint "source media object not found"))
(files/check-read-permissions! conn (::rpc/profile-id params) (:file-id mobj))
(db/insert! conn :file-media-object
{:id (uuid/next)
:file-id file-id
@@ -289,7 +306,7 @@
(def ^:private schema:create-upload-session
[:map {:title "create-upload-session"}
[:total-chunks ::sm/int]])
[:total-chunks [::sm/int {:min 1}]]])
(def ^:private schema:create-upload-session-result
[:map {:title "create-upload-session-result"}
@@ -362,7 +379,7 @@
::sto/deduplicate? false
::sto/touch true
:content-type (:mtype content)
:bucket "tempfile"
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))
@@ -402,9 +419,10 @@
Raises a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
Raises :not-found when the session does not belong to `profile-id`.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} session-id]
(let [session (db/get conn :upload-session {:id session-id})
[{:keys [::db/conn] :as cfg} profile-id session-id]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
chunks (get-upload-chunks conn session-id)]
(when (not= (count chunks) (:total-chunks session))
@@ -447,7 +465,7 @@
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [content (assemble-chunks cfg session-id)
(let [content (assemble-chunks cfg profile-id session-id)
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)
+51 -46
View File
@@ -41,17 +41,6 @@
(ex/raise :type :validation
:code :cant-move-default-team))))
(defn assert-membership [cfg profile-id organization-id]
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})]
(when-not (:organization-id membership)
(ex/raise :type :validation
:code :organization-does-not-exist))
(when-not (:is-member membership)
(ex/raise :type :validation
:code :user-doesnt-belong-organization))))
(def schema:connectivity
[:map {:title "nitrate-connectivity"}
@@ -59,7 +48,7 @@
(sv/defmethod ::get-nitrate-connectivity
{::rpc/auth true
::doc/added "2.14"
::doc/added "2.18"
::sm/params [:map]
::sm/result schema:connectivity}
[cfg _params]
@@ -75,7 +64,7 @@
(sv/defmethod ::get-subscription-warning
{::rpc/auth true
::doc/added "2.14"
::doc/added "2.18"
::sm/params [:map]
::sm/result schema:subscription-warning}
[cfg {:keys [::rpc/profile-id]}]
@@ -91,7 +80,7 @@
(sv/defmethod ::redeem-nitrate-activation-code
{::rpc/auth true
::doc/added "2.14"
::doc/added "2.18"
::sm/params schema:redeem-activation-code-params
::sm/result schema:redeem-activation-code-result}
[cfg {:keys [::rpc/profile-id activation-code]}]
@@ -112,6 +101,7 @@
(ex/raise :type :validation
:code (case status
410 :expired-activation-code
409 :used-activation-code
:invalid-activation-code)
:cause cause)
(throw cause)))))))
@@ -123,7 +113,7 @@
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
Payload includes nitrateId, publicKey, email and iat."
{::rpc/auth true
::doc/added "2.20"
::doc/added "2.18"
::sm/params [:map]
::sm/result ::sm/text}
[cfg {:keys [::rpc/profile-id]}]
@@ -335,7 +325,7 @@
(when-not skip-validation
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
(assert-membership cfg profile-id id)
(nitrate/assert-membership cfg profile-id id)
;; delete only eligible teams (non-protected and without files)
(doseq [id deletable-team-ids]
@@ -346,7 +336,7 @@
(doseq [{:keys [id reassign-to]} teams-to-leave]
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete.
;; Process organization "Personal Projects" team: keep with prefix if needed, otherwise delete.
(when default-team-id
(if keep-default-team?
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
@@ -371,7 +361,7 @@
(sv/defmethod ::leave-organization
{::rpc/auth true
::doc/added "2.15"
::doc/added "2.18"
::sm/params schema:leave-organization
::db/transaction true}
[cfg {:keys [::rpc/profile-id] :as params}]
@@ -415,13 +405,13 @@
[:organization-name ::sm/text]])
(sv/defmethod ::remove-team-from-organization
{::doc/added "2.17"
{::doc/added "2.18"
::sm/params schema:remove-team-from-organization}
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization
(when (contains? cf/flags :admin-console)
(let [organization-perms (nitrate/call cfg :get-organization-permissions
@@ -468,13 +458,14 @@
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
(if (empty? emails)
{:allows-anybody false :external-emails []}
(let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
(let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
external-emails (->> profiles
(remove #(contains? organization-member-ids (:id %)))
(map :email)
(vec))]
member-emails (->> profiles
(filter #(contains? organization-member-ids (:id %)))
(map :email)
(into #{}))
external-emails (into [] (remove member-emails emails))]
{:allows-anybody false :external-emails external-emails}))))))
(def ^:private schema:add-team-to-organization
@@ -484,14 +475,14 @@
(sv/defmethod ::add-team-to-organization
{::rpc/auth true
::doc/added "2.17"
::doc/added "2.18"
::sm/params schema:add-team-to-organization
::db/transaction true}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :admin-console)
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
@@ -569,13 +560,13 @@
(sv/defmethod ::check-organization-members
{::rpc/auth true
::doc/added "2.17"
::doc/added "2.18"
::sm/params schema:check-organization-members-params
::sm/result [:map-of :string :boolean]
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :admin-console)
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
(let [emails-array (db/create-array conn "text" emails)
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
@@ -594,7 +585,7 @@
(sv/defmethod ::all-organization-members-in-team
{::rpc/auth true
::doc/added "2.17"
::doc/added "2.18"
::sm/params schema:all-organization-members-in-team-params
::sm/result ::sm/boolean}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
@@ -603,7 +594,7 @@
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
organization-member-ids (into #{} organization-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id})
@@ -618,7 +609,7 @@
(sv/defmethod ::all-team-members-in-organizations
{::rpc/auth true
::doc/added "2.17"
::doc/added "2.18"
::sm/params schema:all-team-members-in-organizations-params
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
@@ -631,7 +622,7 @@
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
;; Validate requester membership in all organizations before fetching members.
(run! #(assert-membership cfg profile-id %) organization-ids)
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
(into {}
(map (fn [organization-id]
@@ -654,7 +645,7 @@
(sv/defmethod ::check-team-external-invitations
{::rpc/auth true
::doc/added "2.17"
::doc/added "2.18"
::sm/params schema:check-team-external-invitations-params
::sm/result schema:check-team-external-invitations-result
::db/transaction true}
@@ -664,7 +655,7 @@
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
{:has-external-invitations (boolean (seq external-emails))
:allows-anybody allows-anybody}))
@@ -683,12 +674,17 @@
(sv/defmethod ::check-nitrate-sso
"Check if a user needs to login into the organization SSO.
Accepts either team-id (to look up the organization via the team) or organization-id directly.
Returns {:authorized true} when SSO is not active or the user cannot access the team.
Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the
session already holds a valid entry for the organization, and
{:authorized true :reason :no-team-access} when the gate was skipped because the
user cannot access the team; the reason lets the client tell a usable session
apart from a plain permission failure.
Returns {:authorized false :redirect-uri <url>} when SSO is active;
the client must redirect there. The OIDC provider itself handles
re-authentication transparently if the user already has an active SSO session."
re-authentication transparently if the user already has an active SSO session.
A nil :redirect-uri means SSO is required but the provider is not usable."
{::rpc/auth true
::doc/added "2.19"
::doc/added "2.18"
::sm/params schema:check-nitrate-sso
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
@@ -697,16 +693,25 @@
(not (teams/has-read-permissions? cfg profile-id team-id)))
;; Let the destination RPC enforce its own permissions. Starting SSO before
;; access is established sends unrelated users through the organization's IdP.
{:authorized true}
{:authorized true :reason :no-team-access}
(let [request (rph/get-request params)
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
(if authorized
{:authorized true}
{:authorized true :reason :sso-satisfied}
(if (oidc/organization-sso-discovery-uri sso)
{:authorized false
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
:dest-url url
:organization-id organization-id)}
(try
(let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri
cfg sso
:dest-url url
:organization-id organization-id)
organization-id (or organization-id (:organization-id sso))]
(oidc/submit-organization-sso-auth-started-event
cfg request profile-id organization-id)
{:authorized false :redirect-uri redirect-uri})
(catch Throwable cause
(oidc/submit-organization-sso-auth-failed-event
cfg request profile-id (or organization-id (:organization-id sso)) cause)
(throw cause)))
{:authorized false
:redirect-uri nil}))))
{:authorized true}))
{:authorized true :reason :sso-satisfied}))
+11 -3
View File
@@ -166,8 +166,12 @@
;; the same row/object.
(let [profile (get-profile conn profile-id ::db/for-update true)
fullname (d/normalize-string fullname)
lang (d/normalize-string lang)
theme (d/normalize-string theme)
lang (if (contains? params :lang)
(d/normalize-string lang)
(:lang profile))
theme (if (contains? params :theme)
(d/normalize-string theme)
(:theme profile))
;; Update the profile map with direct params
profile (-> profile
(assoc :fullname fullname)
@@ -520,7 +524,7 @@
;; Penpot back through two paths: ::notify-user-organizations-deletion
;; (during delete-owned-organizations) and ::notify-organization-deletion.
;; Both preserve organization teams unchanged and only prefix or delete
;; imported "Your Penpot" teams according to whether they still have files.
;; imported "Personal Projects" teams according to whether they still have files.
;; Let Nitrate clean up the data associated with the deleted Penpot user:
;; owned organizations, remaining memberships, and subscription cancellation.
(when (contains? cf/flags :admin-console)
@@ -534,6 +538,10 @@
:deleted-at deleted-at
:id profile-id}})
;; Invalidate all sessions for this profile to ensure immediate
;; access revocation across all devices
(session/invalidate-all cfg profile-id)
(-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg)))))
+10 -7
View File
@@ -196,11 +196,11 @@
::sm/params schema:get-teams}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id)
(contains? cf/flags :admin-console)
(map #(nitrate/add-organization-info-to-team cfg % params))
(contains? cf/flags :admin-console)
(remove #(get-in % [:organization :expired-license])))))
(let [teams (get-teams conn profile-id)]
(if (contains? cf/flags :admin-console)
(->> (nitrate/add-organization-info-to-teams cfg teams params)
(remove #(get-in % [:organization :expired-license])))
teams))))
(def ^:private sql:get-owned-teams
"SELECT t.id, t.name,
@@ -538,6 +538,9 @@
;; When creating inside an organization, verify the user has permission to do so.
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :admin-console))
;; Verify caller is a member of the organization
(nitrate/assert-membership cfg profile-id organization-id)
(let [organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})]
(if (nil? organization-perms)
@@ -572,7 +575,7 @@
(set/difference cfeat/frontend-only-features)
(set/difference cfeat/no-team-inheritable-features))
params {:profile-id profile-id
:name "Your Penpot"
:name "Personal Projects"
:features features
:organization-id organization-id
:is-default true}
@@ -826,7 +829,7 @@
:code :only-owner-can-delete-team))
;; Protect the user's personal default team from deletion.
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
;; Organization-scoped default teams ("Personal Projects") are allowed to be deleted when they have no files.
(when (and (:is-default team) (not in-organization?))
(ex/raise :type :validation
:code :non-deletable-team
@@ -108,14 +108,7 @@
(def ^:private schema:create-organization-invitation
[:map {:title "params:create-organization-invitation"}
[::rpc/profile-id ::sm/uuid]
[:organization
[:map
[:id ::sm/uuid]
[:name :string]
[:initials [:maybe :string]]
[:logo ::sm/uri]
[:avatar-bg-url [:maybe ::sm/uri]]
[:sso-active [:maybe ::sm/boolean]]]]
[:organization cto/schema:organization-with-avatar]
[:profile
[:map
[:id ::sm/uuid]
@@ -467,6 +460,10 @@
[cfg {:keys [::rpc/profile-id team-id role emails] :as params}]
(let [perms (teams/get-permissions cfg profile-id team-id)
profile (db/get-by-id cfg :profile profile-id)
team (db/get-by-id cfg :team team-id)
team-with-org (when (contains? cf/flags :admin-console)
(nitrate/add-organization-info-to-team cfg team {}))
organization (:organization team-with-org)
;; Determine which format is being used
using-emails-format? (and emails role)
;; Handle both parameter formats
@@ -482,6 +479,24 @@
(ex/raise :type :validation
:code :insufficient-permissions))
(when (and (contains? cf/flags :admin-console)
organization
(not (cto/allowed? :send-invitations
{:organization-perms {:owner-id (:owner-id organization)
:permissions (:permissions organization)}
:profile-id profile-id
:team-perms perms})))
(ex/raise :type :validation
:code :insufficient-permissions
:hint "Organization policy does not allow you to send invitations"))
;; Don't allow promote to owner to admin users.
(when (and (not (:is-owner perms))
(or (= role :owner)
(some #(= :owner (:role %)) (:invitations params))))
(ex/raise :type :validation
:code :cant-promote-to-owner))
(when (> invitation-count max-invitations-by-request-threshold)
(ex/raise :type :validation
:code :max-invitations-by-request
@@ -588,7 +603,7 @@
::doc/module :teams
::sm/params schema:get-team-invitation-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
(teams/check-read-permissions! cfg profile-id team-id)
(teams/check-edition-permissions! cfg profile-id team-id)
(let [email (profile/clean-email email)
invit (-> (db/get pool :team-invitation
{:team-id team-id
@@ -625,6 +640,11 @@
(ex/raise :type :validation
:code :insufficient-permissions))
;; Don't allow promote to owner to admin users.
(when (and (not (:is-owner perms)) (= role :owner))
(ex/raise :type :validation
:code :cant-promote-to-owner))
(db/update! conn :team-invitation
{:role (name role) :updated-at (ct/now)}
{:team-id team-id :email-to (profile/clean-email email)})
+5 -3
View File
@@ -6,11 +6,12 @@
(ns app.rpc.management.exporter
(:require
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.media.validation :refer [schema:upload]]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.storage :as sto]
@@ -21,7 +22,7 @@
(def ^:private
schema:upload-tempfile-params
[:map {:title "upload-templfile-params"}
[:content schema:upload]])
[:content media.v/schema:upload]])
(def ^:private
schema:upload-tempfile-result
@@ -32,6 +33,7 @@
::sm/params schema:upload-tempfile-params
::sm/result schema:upload-tempfile-result}
[cfg {:keys [::rpc/profile-id content]}]
(media.v/validate-media-type! content cm/tempfile-types)
(let [storage (sto/resolve cfg)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
@@ -41,7 +43,7 @@
::sto/touched-at (ct/in-future {:minutes 10})
:profile-id profile-id
:content-type (:mtype content)
:bucket "tempfile"}
:bucket sto/tempfile-bucket}
object (sto/put-object! storage content)]
{:id (:id object)
:uri (-> (cf/get :public-uri)
+51 -30
View File
@@ -12,11 +12,13 @@
[app.auth.oidc :as oidc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
@@ -54,7 +56,7 @@
(sv/defmethod ::authenticate
"Authenticate the current user"
{::doc/added "2.14"
{::doc/added "2.18"
::sm/params [:map]
::sm/result schema:profile
::nitrate/sso false}
@@ -94,7 +96,7 @@
(sv/defmethod ::get-penpot-version
"Get the current Penpot version"
{::doc/added "2.14"
{::doc/added "2.18"
::sm/params [:map]
::sm/result schema:get-penpot-version-result
::rpc/auth false}
@@ -106,7 +108,7 @@
(sv/defmethod ::get-teams
"List teams for which current user is owner"
{::doc/added "2.14"
{::doc/added "2.18"
::sm/params [:map]
::sm/result schema:get-teams-result
::nitrate/sso false}
@@ -130,11 +132,12 @@
"Store an organization logo in penpot storage and return its ID.
Accepts an optional previous-id to mark the old logo for garbage
collection when replacing an existing one."
{::doc/added "2.17"
{::doc/added "2.18"
::sm/params schema:upload-organization-logo
::sm/result schema:upload-organization-logo-result
::nitrate/sso false}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(media.v/validate-media-type! content cm/image-types)
(when previous-id
(sto/touch-object! storage previous-id))
(let [hash (sto/calculate-hash (:path content))
@@ -151,7 +154,7 @@
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate"
{::doc/added "2.14"
{::doc/added "2.18"
::sm/params cto/schema:team-with-organization
::rpc/auth false}
[cfg team]
@@ -168,7 +171,7 @@
(sv/defmethod ::notify-user-added-to-organization
"Notify to Penpot that an user has joined an organization from nitrate"
{::doc/added "2.14"
{::doc/added "2.18"
::sm/params schema:notify-user-added-to-organization
::rpc/auth false}
[cfg {:keys [profile-id organization-id]}]
@@ -199,7 +202,7 @@
(sv/defmethod ::get-managed-profiles
"List profiles that belong to teams for which current user is owner"
{::doc/added "2.14"
{::doc/added "2.18"
::sm/params [:map]
::sm/result schema:managed-profile-result
::nitrate/sso false}
@@ -239,7 +242,7 @@
(sv/defmethod ::get-teams-summary
"Get summary information for a list of teams"
{::doc/added "2.15"
{::doc/added "2.18"
::sm/params schema:get-teams-summary-params
::sm/result schema:get-teams-summary-result
::nitrate/sso false}
@@ -315,7 +318,7 @@ RETURNING id, deleted_at;")
(defn manage-deleted-organization-teams
"For a deleted organization, preserve organization teams unchanged and only prefix or
delete member Your Penpot teams depending on whether they still contain files."
delete member Personal Projects teams depending on whether they still contain files."
[cfg {:keys [organization-id organization-name teams]}]
(let [all-team-ids (->> teams
(map :id)
@@ -344,13 +347,13 @@ RETURNING id, deleted_at;")
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
;; Organization teams move to the fallback organization unchanged. Only imported
;; Your Penpot teams keep the organization prefix when they still have files.
;; Personal Projects teams keep the organization prefix when they still have files.
(when (seq teams-to-prefix)
(db/exec! conn [sql:prefix-teams-name-and-unset-default
organization-prefix
(db/create-array conn "uuid" teams-to-prefix)]))
;; Empty imported Your Penpot teams disappear entirely.
;; Empty imported Personal Projects teams disappear entirely.
(soft-delete-teams! cfg teams-to-delete)
(notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete)
@@ -359,8 +362,8 @@ RETURNING id, deleted_at;")
(sv/defmethod ::notify-organization-deletion
"For a deleted organization, preserve organization teams and only prefix or delete
imported Your Penpot teams before notifying connected users."
{::doc/added "2.15"
imported Personal Projects before notifying connected users."
{::doc/added "2.18"
::sm/params schema:notify-organization-deletion
::rpc/auth false}
[cfg {:keys [organization-id]}]
@@ -379,7 +382,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::notify-user-organizations-deletion
"For a given user, find all owned organizations and apply the deleted-organization
transfer rules to their imported Your Penpot teams."
transfer rules to their imported Personal Projects teams."
{::doc/added "2.18"
::sm/params schema:notify-user-organizations-deletion
::nitrate/sso false}
@@ -406,7 +409,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::get-profile-by-email
"Get profile by email"
{::doc/added "2.15"
{::doc/added "2.18"
::sm/params [:map [:email ::sm/email]]
::sm/result schema:profile
::nitrate/sso false}
@@ -430,7 +433,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::get-profile-by-id
"Get profile by email"
{::doc/added "2.15"
{::doc/added "2.18"
::sm/params [:map [:id ::sm/uuid]]
::sm/result schema:profile
::nitrate/sso false}
@@ -465,7 +468,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::get-organization-member-team-counts
"Get the number of non-default teams each profile belongs to within a set of teams."
{::doc/added "2.15"
{::doc/added "2.18"
::sm/params schema:get-organization-member-team-counts-params
::sm/result schema:get-organization-member-team-counts-result
::rpc/auth false}
@@ -488,15 +491,33 @@ RETURNING id, deleted_at;")
;; API: invite-to-organization
(defn- get-invitation-organization
[cfg profile-id organization-id]
(let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
(nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
(when-not (= profile-id owner-id)
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))
{:id id
:name name
:initials (if logo-id "" (d/get-initials name))
:logo (when logo-id (u/uri (files/resolve-public-uri logo-id)))
:avatar-bg-url (when-not logo-id avatar-bg-url)
:sso-active (true? sso-active)}))
(sv/defmethod ::invite-to-organization
"Invite to organization"
{::doc/added "2.15"
{::doc/added "2.18"
::sm/params [:map
[:email ::sm/email]
[:organization cto/schema:organization-with-avatar]]
::nitrate/sso false}
[cfg params]
(db/tx-run! cfg ti/create-organization-invitation params)
[cfg {profile-id ::rpc/profile-id
:keys [organization]
:as params}]
(let [organization (get-invitation-organization cfg profile-id (:id organization))]
(db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
nil)
@@ -519,7 +540,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::get-organization-invitations
"Get valid invitations for an organization, returning at most one invitation per email."
{::doc/added "2.16"
{::doc/added "2.18"
::sm/params schema:get-organization-invitations-params
::sm/result schema:get-organization-invitations-result
::nitrate/sso false}
@@ -547,7 +568,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::delete-organization-invitations
"Delete all invitations for one email in an organization scope (organization + organization teams)."
{::doc/added "2.16"
{::doc/added "2.18"
::sm/params schema:delete-organization-invitations-params
::nitrate/sso false}
[cfg {:keys [organization-id email]}]
@@ -612,7 +633,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::remove-from-organization
"Remove an user from an organization"
{::doc/added "2.17"
{::doc/added "2.18"
::sm/params [:map
[:profile-id ::sm/uuid]
[:organization-id ::sm/uuid]
@@ -657,7 +678,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::get-remove-from-organization-summary
"Get a summary of the teams that would be deleted, transferred, or exited
if the user were removed from the organization"
{::doc/added "2.17"
{::doc/added "2.18"
::sm/params [:map
[:profile-id ::sm/uuid]
[:organization-id ::sm/uuid]
@@ -692,7 +713,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::send-renewal-email
"Send an Enterprise subscription renewal notice email to a user."
{::doc/added "2.17"
{::doc/added "2.18"
::sm/params schema:send-renewal-email-params
::rpc/auth false}
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
@@ -805,7 +826,7 @@ RETURNING id, deleted_at;")
"Push audit events from nitrate (strictly for nitrate backend
events)"
{::doc/added "2.19"
{::doc/added "2.18"
::audit/skip true
::sm/params schema:push-audit-events-params
::rpc/auth false}
@@ -912,7 +933,7 @@ RETURNING id, deleted_at;")
(sv/defmethod ::get-teams-detail
"Get detailed information for all non-deleted teams in an organization,
including owner info and project/file/member counts."
{::doc/added "2.20"
{::doc/added "2.18"
::sm/params schema:get-teams-detail-params
::sm/result schema:get-teams-detail-result
::nitrate/sso false}
@@ -940,7 +961,7 @@ RETURNING id, deleted_at;")
"Validate an organization SSO configuration by generating a login redirect URL.
Nitrate calls this while configuring SSO to verify client credentials and OIDC
discovery before saving the settings."
{::doc/added "2.20"
{::doc/added "2.18"
::sm/params cto/schema:nitrate-sso
::sm/result schema:check-organization-sso-result
::rpc/auth false}
@@ -950,7 +971,7 @@ RETURNING id, deleted_at;")
;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
"Nitrate notifies that an organization sso values have changed"
{::doc/added "2.19"
{::doc/added "2.18"
::sm/params [:map
[:organization-id ::sm/uuid]
[:updated-props ::sm/boolean]
@@ -999,7 +1020,7 @@ RETURNING id, deleted_at;")
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled
by default so it is only available on test environments."
{::doc/added "2.19"
{::doc/added "2.18"
::sm/params schema:bulk-create-profiles-params
::sm/result schema:bulk-create-profiles-result
::rpc/auth false}
+70
View File
@@ -546,6 +546,76 @@
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:media-storage-bytes-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-media-storage-bytes-per-team-quote?
(sm/lazy-validator schema:media-storage-bytes-per-team))
(def ^:private sql:get-media-storage-bytes-per-team
"SELECT COALESCE(SUM(so.size), 0) AS total
FROM (
SELECT fmo.media_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION
SELECT fmo.thumbnail_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.thumbnail_id IS NOT NULL
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION
SELECT v.otf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.otf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.ttf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.ttf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.woff1_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff1_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.woff2_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff2_file_id IS NOT NULL
AND v.deleted_at IS NULL
) AS refs
JOIN storage_object AS so ON (so.id = refs.so_id)
WHERE so.deleted_at IS NULL")
(defmethod check-quote ::media-storage-bytes-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-media-storage-bytes-per-team
(* 20 1024 1024 1024)))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-media-storage-bytes-per-team
team-id team-id team-id team-id team-id team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: DEFAULT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+5 -2
View File
@@ -46,6 +46,7 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as uri]
@@ -180,8 +181,8 @@
result (rds/eval rconn script)
allowed? (boolean (nth result 0))
remaining (nth result 1)
reset (* (/ (inst-ms interval) rate)
(- capacity remaining))]
reset (long (mth/ceil (double (* (/ (inst-ms interval) rate)
(- capacity remaining)))))]
(l/trace :hint "limit processed"
:method method
:limit (name (::name limit))
@@ -190,6 +191,7 @@
:allowed allowed?
:remaining remaining)
(-> limit
(assoc ::lresult/now now)
(assoc ::lresult/allowed allowed?)
(assoc ::lresult/reset (ct/plus now reset))
(assoc ::lresult/remaining remaining))))
@@ -212,6 +214,7 @@
:allowed allowed?
:remaining remaining)
(-> limit
(assoc ::lresult/now now)
(assoc ::lresult/allowed allowed?)
(assoc ::lresult/timestamp ts)
(assoc ::lresult/remaining remaining)
+1 -1
View File
@@ -232,7 +232,7 @@
[:enum
"customer_service"
"low_quality"
"missing_feature"
"missing_features"
"other"
"switched_service"
"too_complex"
+3 -2
View File
@@ -153,7 +153,7 @@
(defn process-file!
[system file-id update-fn
& {:keys [::snapshot-label ::validate? ::with-libraries?]
& {:keys [::profile-id ::snapshot-label ::validate? ::with-libraries?]
:or {validate? true} :as opts}]
(let [file (bfc/get-file system file-id
:lock-for-update? true
@@ -177,8 +177,9 @@
(when (string? snapshot-label)
(fsnap/create! system file
{:label snapshot-label
:profile-id profile-id
:deleted-at (ct/in-future {:days 30})
:created-by "admin"}))
:created-by "system"}))
(let [file' (update file' :revn inc)]
(bfc/update-file! system file' opts)
+4 -4
View File
@@ -398,10 +398,6 @@
(println (sm/humanize-explain explain))
(ex/print-throwable cause))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PROCESSING
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn repair-file!
"Repair the list of errors detected by validation."
[file-id & {:keys [rollback?] :or {rollback? true} :as options}]
@@ -410,6 +406,10 @@
options (assoc options ::h/with-libraries? true)]
(db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PROCESSING
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn update-file!
"Apply a function to the file. Optionally save the changes or not.
The function receives the decoded and migrated file data."
+6 -2
View File
@@ -38,6 +38,10 @@
(def default-bucket
"file-media-object")
(def tempfile-bucket
"Bucket name for temporary file uploads (10-minute expiry)."
"tempfile")
(def valid-buckets
#{"file-media-object"
"team-font-variant"
@@ -45,7 +49,7 @@
"file-thumbnail"
"profile"
"organization"
"tempfile"
tempfile-bucket
"file-data"
"file-data-fragment"
"file-change"})
@@ -136,7 +140,7 @@
result (when (and (::deduplicate? params)
(:hash mdata)
(:bucket mdata)
(not= "tempfile" (:bucket mdata)))
(not= tempfile-bucket (:bucket mdata)))
(let [result (get-database-object-by-hash connectable backend
(:bucket mdata)
(:hash mdata))]
+11 -10
View File
@@ -149,7 +149,7 @@
:status "delete"
:bucket bucket)
(recur to-freeze (conj to-delete id) (rest objects))))
(let [deletion-delay (if (= "tempfile" bucket)
(let [deletion-delay (if (= sto/tempfile-bucket bucket)
(ct/duration {:hours 2})
(cf/get-deletion-delay))]
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
@@ -158,15 +158,16 @@
(defn- process-bucket!
[conn bucket objects]
(case bucket
"file-media-object" (process-objects! conn has-file-media-object-refs? bucket objects)
"team-font-variant" (process-objects! conn has-team-font-variant-refs? bucket objects)
"file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? bucket objects)
"file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects)
"profile" (process-objects! conn has-profile-refs? bucket objects)
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
"tempfile" (process-objects! conn (constantly false) bucket objects)
"organization" (process-objects! conn (constantly false) bucket objects)
(cond
(= bucket "file-media-object") (process-objects! conn has-file-media-object-refs? bucket objects)
(= bucket "team-font-variant") (process-objects! conn has-team-font-variant-refs? bucket objects)
(= bucket "file-object-thumbnail") (process-objects! conn has-file-object-thumbnails-refs? bucket objects)
(= bucket "file-thumbnail") (process-objects! conn has-file-thumbnails-refs? bucket objects)
(= bucket "profile") (process-objects! conn has-profile-refs? bucket objects)
(= bucket "file-data") (process-objects! conn has-file-data-refs? bucket objects)
(= bucket sto/tempfile-bucket) (process-objects! conn (constantly false) sto/tempfile-bucket objects)
(= bucket "organization") (process-objects! conn (constantly false) bucket objects)
:else
(ex/raise :type :internal
:code :unexpected-unknown-reference
:hint (dm/fmt "unknown reference '%'" bucket))))
@@ -15,6 +15,7 @@
[app.setup :as-alias setup]
[app.tokens :as tokens]
[clojure.test :as t]
[cuerdas.core :as str]
[mockery.core :refer [with-mocks]]
[yetti.response :as-alias yres]))
@@ -385,6 +386,9 @@
(def ^:private test-profile-id
#uuid "11111111-1111-1111-1111-111111111111")
(def ^:private test-organization-id
#uuid "22222222-2222-2222-2222-222222222222")
(def ^:private test-profile
{:id test-profile-id
:is-active true
@@ -519,6 +523,59 @@
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "error=unable-to-auth")))))))
(t/deftest organization-sso-callback-success-emits-succeeded
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
:organization-id test-organization-id})
request (default-request cfg :state state)
events (atom [])]
(with-redefs [app.nitrate/call (constantly {:active true})
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
app.auth.oidc/get-info (constantly {})
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
(let [result (#'oidc/callback-handler cfg request)]
(t/is (= "https://penpot.example.com/#/workspace" (redirect-location result)))
(t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events)))
(t/is (= test-organization-id (get-in (first @events) [:props :organization-id])))))))
(t/deftest organization-sso-callback-error-emits-failed
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
:organization-id test-organization-id})
request (default-request cfg :state state)
events (atom [])]
(with-redefs [app.nitrate/call (fn [_cfg method _params]
(case method
:get-organization-sso {:active true}
:get-organization-summary {:name "Organization"}))
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
app.auth.oidc/get-info (fn [& _]
(ex/raise :type :internal
:code :unable-to-retrieve-user-info))
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
(#'oidc/callback-handler cfg request)
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
(t/is (= {:organization-id test-organization-id
:failure-reason "user-info-failed"}
(:props (first @events)))))))
(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
:organization-id test-organization-id})
request (assoc-in (default-request cfg :state state) [:params :error] "access_denied")
events (atom [])]
(binding [cf/config {:public-uri "http://localhost:3449"}]
(with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "error=unable-to-auth"))
(t/is (.contains loc "hint=access_denied"))
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
(t/is (= {:organization-id test-organization-id
:failure-reason "access-denied"}
(:props (first @events)))))))))
(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check
(t/testing "organization SSO provider must use SSRF protection"
(let [captured-params (atom nil)]
@@ -531,3 +588,138 @@
:issuer "https://idp.example.com"})
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
"SSRF protection must be disabled for organization SSO")))))
(defn- ssl-handshake-failure
[]
(javax.net.ssl.SSLHandshakeException. "Remote host terminated the handshake"))
(t/deftest prepare-organization-sso-provider-raises-on-discovery-network-failure
(t/testing "SSL/network failures during OIDC discovery become controlled validation errors"
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://wrong-idp.example.com"})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
(t/deftest prepare-organization-sso-provider-raises-on-discovery-non-200
(t/testing "non-200 OIDC discovery responses become controlled validation errors"
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 404 :body "not found"}}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"})
(catch Throwable t t))
data (ex-data e)]
(t/is (ex/error? e))
(t/is (= :validation (:type data)))
(t/is (= :invalid-sso-config (:code data)))
(t/is (= 404 (:response-status-code data)))
(t/is (= "unable to discover OIDC configuration" (ex-message e)))
(t/is (str/includes? (str (:discover-uri data)) "openid-configuration"))))))
(t/deftest prepare-organization-sso-provider-raises-on-ssrf-blocked-issuer
(t/testing "SSRF/DNS failures for the issuer URL become invalid-sso-config, not ssrf-blocked-target"
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [& _]
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "uri host could not be resolved"))}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://unresolvable.invalid"})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
(t/deftest prepare-organization-sso-provider-raises-on-jwks-network-failure
(t/testing "SSL/network failures while fetching JWKs become controlled validation errors"
(let [discovery-body (str "{\"authorization_endpoint\":\"https://idp.example.com/auth\","
"\"token_endpoint\":\"https://idp.example.com/token\","
"\"userinfo_endpoint\":\"https://idp.example.com/userinfo\","
"\"jwks_uri\":\"https://idp.example.com/jwks\"}")]
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [_cfg request & _]
(if (str/includes? (str (:uri request)) "openid-configuration")
{:status 200 :body discovery-body}
(throw (ssl-handshake-failure))))}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e)))))))))
(t/deftest populate-jwks-strict-wraps-non-invalid-sso-config-errors
(t/testing "strict JWKS path wraps unrelated structured errors instead of rethrowing them"
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
:side-effect (fn [& _]
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "uri host could not be resolved"))}]
(let [e (try
(#'oidc/populate-jwks
{}
{:id "oidc"
:jwks-uri "https://idp.example.com/jwks"
:strict-jwks? true})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
(t/deftest populate-jwks-strict-rethrows-invalid-sso-config
(t/testing "strict JWKS path rethrows an already-controlled invalid-sso-config"
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
:side-effect (fn [& _]
(ex/raise :type :validation
:code :invalid-sso-config
:hint "unable to retrieve JWKs"
:jwks-uri "https://idp.example.com/jwks"))}]
(let [e (try
(#'oidc/populate-jwks
{}
{:id "oidc"
:jwks-uri "https://idp.example.com/jwks"
:strict-jwks? true})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :invalid-sso-config (:code (ex-data e))))
(t/is (= "unable to retrieve JWKs" (ex-message e)))
(t/is (= "https://idp.example.com/jwks" (:jwks-uri (ex-data e))))))))
(t/deftest build-organization-sso-auth-redirect-uri-raises-on-unreachable-provider
(t/testing "check-nitrate-sso path surfaces a controlled error when the issuer is unreachable"
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
(let [e (try
(oidc/build-organization-sso-auth-redirect-uri
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://wrong-idp.example.com"}
:dest-url "https://localhost:3449/#/dashboard"
:organization-id #uuid "00000000-0000-0000-0000-000000000001")
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
@@ -207,6 +207,29 @@
(t/is (= (count result) 1))
(t/is (every? uuid? result)))))
(t/deftest import-binfile-v3-persists-manifest-metadata
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output))
(let [result (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(v3/import-files!))
imported (bfc/get-file th/*system* (first result))]
(t/is (= (count result) 1))
(t/is (some? (get-in imported [:metadata :generated-by])))
(t/is (= "penpot" (get-in imported [:metadata :referer]))))))
(t/deftest read-obj-rejects-oversized-buffer
;; N1-07: read-obj! must reject objects exceeding max-object-size
;; before attempting to allocate the buffer
+183 -5
View File
@@ -13,6 +13,7 @@
[app.http.access-token :as actoken]
[app.http.assets :as assets]
[app.http.session :as session]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token :as access-token]
[app.storage :as sto]
[backend-tests.helpers :as th]
@@ -36,11 +37,16 @@
(assoc storage ::sto/backend :fs))
(defn- create-storage-object!
"Create a storage object with the given bucket and content."
[storage bucket content]
(sto/put-object! storage {::sto/content (sto/content content)
:bucket bucket
:content-type "text/plain"}))
"Create a storage object with the given bucket and content.
Optional opts map can include :profile-id to set the owner."
([storage bucket content]
(create-storage-object! storage bucket content {}))
([storage bucket content {:keys [profile-id]}]
(sto/put-object! storage (cond-> {::sto/content (sto/content content)
:bucket bucket
:content-type "text/plain"}
(some? profile-id)
(assoc :profile-id profile-id)))))
(defn- make-handler-cfg
"Build a minimal cfg map for the assets handlers."
@@ -588,6 +594,111 @@
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: file-objects-handler — share-link authz (issue #11338)
;; ----------------------------------------------------------------
(t/deftest file-objects-handler-anonymous-with-valid-share-id-succeeds
;; Anonymous request with a valid share-id matching the file must
;; succeed (share-link viewers are unauthenticated by definition).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
slink (:result (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "all"}))
request {:path-params {:id (str (:id media-obj))}
:query-params {:share-id (str (:id slink))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest file-objects-handler-anonymous-with-share-id-for-other-file-returns-404
;; A share-id from file A must not grant access to assets of file B.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file-a (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
file-b (th/create-file* 2 {:profile-id (:id owner)
:project-id (:id project)})
media-a (create-storage-object! storage "file-media-object" "image A")
media-obj-a (th/create-file-media-object* {:file-id (:id file-a)
:media-id (:id media-a)})
media-b (create-storage-object! storage "file-media-object" "image B")
media-obj-b (th/create-file-media-object* {:file-id (:id file-b)
:media-id (:id media-b)})
slink (:result (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file-a)
:pages #{}
:who-comment "team"
:who-inspect "all"}))
request {:path-params {:id (str (:id media-obj-b))}
:query-params {:share-id (str (:id slink))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-anonymous-with-malformed-share-id-returns-404
;; Malformed share-id must not raise; it must short-circuit to 404.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
:query-params {:share-id "not-a-uuid"}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-thumbnails-handler-anonymous-with-valid-share-id-succeeds
;; Thumbnail endpoint must also honor the share-id query param.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id thumb-storage)})
slink (:result (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "all"}))
request {:path-params {:id (str (:id media-obj))}
:query-params {:share-id (str (:id slink))}}
response (assets/file-thumbnails-handler cfg request)]
;; Falls back to media-id since no thumbnail-id, but still serves
(t/is (= 204 (::yres/status response)))))
(t/deftest objects-handler-expired-object
;; Expired objects should return 404 (get-object filters them out).
(let [storage (-> (:app.storage/storage th/*system*)
@@ -602,3 +713,70 @@
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — tempfile bucket ownership (T9-F-10)
;; ----------------------------------------------------------------
(t/deftest objects-handler-tempfile-owner-can-access
;; Owner of a tempfile should be able to access it via session auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id owner)}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest objects-handler-tempfile-non-owner-gets-404
;; Non-owner accessing a tempfile should get 404 (not 403, to avoid leaking existence).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
stranger (th/create-profile* 2)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-tempfile-access-token-owner-can-access
;; Owner of a tempfile should be able to access it via access token auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::actoken/profile-id (:id owner)}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest objects-handler-tempfile-access-token-non-owner-gets-404
;; Non-owner accessing a tempfile via access token should get 404.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
stranger (th/create-profile* 2)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::actoken/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-tempfile-no-stored-profile-id-serves
;; Legacy tempfile objects without stored profile-id should be accessible
;; to any authenticated user (backward compatibility).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
stranger (th/create-profile* 1)
object (create-storage-object! storage "tempfile" "legacy temp data")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
@@ -6,10 +6,12 @@
(ns backend-tests.http-middleware-test
(:require
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token]
[app.http.errors :as http-errors]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.main :as-alias main]
@@ -300,7 +302,7 @@
(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/is (= "invalid JSON in request body" (-> ex ex-data :hint)))))
(t/deftest parse-request-request-too-big-exception
;; When RequestTooBigException is raised (e.g. the request body
@@ -319,7 +321,7 @@
(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/is (= "request body exceeds size limit" (-> ex ex-data :hint)))))
(t/deftest parse-request-eof-exception
;; When java.io.EOFException is raised (e.g. the body stream
@@ -337,7 +339,7 @@
(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/is (= "unexpected end of request body" (-> ex ex-data :hint)))))
(t/deftest parse-request-runtime-exception-with-cause
;; When a RuntimeException with a non-nil ex-cause is raised,
@@ -377,7 +379,7 @@
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :unexpected (:code body)))
(t/is (= "boom" (:hint body)))))
(t/is (nil? (:hint body)))))
(t/deftest parse-request-non-runtime-throwable
;; When a non-RuntimeException Throwable is raised (e.g. an
@@ -397,4 +399,45 @@
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :io-exception (:code body)))
(t/is (= "network gone" (:hint body)))))
(t/is (nil? (:hint body)))))
(t/deftest internal-error-strips-sensitive-fields
;; When an :internal error is raised with :state, :path, and
;; :context, those fields must not appear in the response body.
;; :hint is part of the error protocol and is preserved.
(let [cause (ex-info "internal error"
{:type :internal
:code :test-error
:hint "safe user-facing hint"
:state "XX000"
:path "/data/penpot/storage"
:context {:backend :s3 :bucket "prod"}})
response (http-errors/handle cause {})
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :test-error (:code body)))
(t/is (= "safe user-facing hint" (:hint body)))
(t/is (nil? (:state body)))
(t/is (nil? (:path body)))
(t/is (nil? (:context body)))))
(t/deftest unhandled-exinfo-strips-sensitive-fields
;; When an ex-info with an unregistered :type (dispatches through
;; handle-exception :default :else) carries :state and :path,
;; those fields must not appear in the response body.
;; :hint is part of the error protocol and is preserved.
(let [cause (ex-info "something broke"
{:type :unregistered-type
:code :custom-code
:hint "safe user-facing hint"
:state "internal-state"
:path "/internal/path"})
response (http-errors/handle cause {})
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :custom-code (:code body)))
(t/is (= "safe user-facing hint" (:hint body)))
(t/is (nil? (:state body)))
(t/is (nil? (:path body)))))
@@ -99,7 +99,7 @@
(t/deftest info-service-uri-not-configured
(t/testing "info throws when service URI is not configured"
(with-redefs [cf/get (th/config-get-mock {})]
(with-redefs [cf/get (constantly nil)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
err (ex/try! (media.remote/process (mk-system)
{:cmd :info :input {:path path :mtype "image/jpeg"}}))]
@@ -0,0 +1,106 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-auth-test
(:require
[app.common.uuid :as uuid]
[app.http.session :as session]
[backend-tests.helpers :as th]
[clojure.test :as t]
[yetti.response :as yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest logout-invalidates-current-session
(let [prof (th/create-profile* 1)
manager (::session/manager th/*system*)
sid (uuid/random)
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid (:id prof) "test-agent"])
session (session/read-session manager sid)]
;; Arrange: session exists before logout
(t/is (some? session) "session should exist before logout")
(t/is (= sid (:id session)))
;; Act: simulate Ring request as produced by wrap-authz (has ::session/session)
;; delete-fn is used as response transform via rph/with-transform in auth/logout
(let [request {::session/session session}
response {}
delete-fn (session/delete-fn th/*system*)
result (delete-fn request response)]
;; Assert: server-side session is deleted (CWE-613)
(t/is (nil? (session/read-session manager sid))
"session must be deleted server-side after logout (GHSA-mj9f-5cwq-7p3q)")
;; Assert: cookie is cleared
(t/is (= "" (get-in result [::yres/cookies "auth-token" :value]))
"auth-token cookie should be cleared")
(t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age]))
"auth-token cookie max-age should be 0"))))
(t/deftest logout-clears-cookie-even-when-session-missing
(let [manager (::session/manager th/*system*)
sid (uuid/random)
;; No session inserted, read should be nil
_ (t/is (nil? (session/read-session manager sid)))
request {}
response {}
delete-fn (session/delete-fn th/*system*)
result (delete-fn request response)]
;; Should still clear cookie (idempotent)
(t/is (= "" (get-in result [::yres/cookies "auth-token" :value])))
(t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age])))))
(t/deftest logout-does-not-invalidate-other-sessions
(let [prof (th/create-profile* 1)
manager (::session/manager th/*system*)
sid1 (uuid/random)
sid2 (uuid/random)
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid1 (:id prof) "agent-1"])
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid2 (:id prof) "agent-2"])
s1 (session/read-session manager sid1)
s2 (session/read-session manager sid2)]
(t/is (some? s1))
(t/is (some? s2))
;; Logout only sid1
(let [request {::session/session s1}
response {}
delete-fn (session/delete-fn th/*system*)]
(delete-fn request response))
;; sid1 deleted, sid2 intact
(t/is (nil? (session/read-session manager sid1)) "current session should be deleted")
(t/is (some? (session/read-session manager sid2)) "other sessions should remain")))
(t/deftest replay-after-logout-cannot-authenticate
(let [prof (th/create-profile* 1)
manager (::session/manager th/*system*)
sid (uuid/random)
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid (:id prof) "test-agent"])
session (session/read-session manager sid)]
(t/is (some? session) "session exists before logout")
;; Simulate logout
(let [request {::session/session session}
response {}
delete-fn (session/delete-fn th/*system*)]
(delete-fn request response))
;; Replay: attempt to read session with same sid should fail (no profile attached)
(t/is (nil? (session/read-session manager sid))
"replayed token must not resolve to a valid session after logout")))
+37 -14
View File
@@ -11,32 +11,55 @@
[app.rpc :as-alias rpc]
[app.rpc.commands.binfile :as binfile]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest import-binfile-schema-rejects-file-id
(t/deftest import-binfile-schema-omits-file-id
;; N1-06: file-id parameter must be removed from schema for security
;; The schema should not accept file-id as a valid parameter
(let [schema @#'binfile/schema:import-binfile
validator (sm/lazy-validator schema)
;; Valid params without file-id
valid-params {:name "test"
:project-id (uuid/random)
:version 3
:upload-id (uuid/random)}
:upload-id (uuid/random)}]
;; Params with file-id (should be rejected after fix)
params-with-file-id (assoc valid-params :file-id (uuid/random))]
;; Valid params without file-id should pass
(t/is (true? (validator valid-params))
"params without file-id should be valid")
;; Params with file-id should fail validation after fix
;; (Currently this will fail because file-id is still in schema)
(t/is (false? (validator params-with-file-id))
"params with file-id should be rejected")))
(t/is (not (contains? (sm/keys (second schema)) :file-id))
"file-id should not be a declared parameter")))
(t/deftest import-binfile-schema-rejects-unsupported-version
;; T1-N2-03: version parameter should be restricted to supported values (1 or 3)
(let [schema @#'binfile/schema:import-binfile
validator (sm/lazy-validator schema)
base-params {:name "test"
:project-id (uuid/random)
:upload-id (uuid/random)}]
;; Version 1 should be accepted
(t/is (true? (validator (assoc base-params :version 1)))
"version 1 should be valid")
;; Version 3 should be accepted
(t/is (true? (validator (assoc base-params :version 3)))
"version 3 should be valid")
;; Version 2 should be rejected
(t/is (false? (validator (assoc base-params :version 2)))
"version 2 should be rejected")
;; Version 0 should be rejected
(t/is (false? (validator (assoc base-params :version 0)))
"version 0 should be rejected")
;; Negative version should be rejected
(t/is (false? (validator (assoc base-params :version -1)))
"negative version should be rejected")
;; Version 4 should be rejected
(t/is (false? (validator (assoc base-params :version 4)))
"version 4 should be rejected")))
+249 -3
View File
@@ -733,7 +733,7 @@
(t/is (= 2 (count rows)))
(t/is (= 1 (count (remove (comp some? :deleted-at) rows))))
(t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame")
(-> rows first :object-id))))
(->> rows (remove (comp some? :deleted-at)) first :object-id))))
;; Now that file-gc have marked for deletion the object
;; thumbnail lets execute the objects-gc task which remove
@@ -2377,8 +2377,6 @@
(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)
@@ -2448,3 +2446,251 @@
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata))))))
(t/deftest get-file-libraries-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-file-libraries
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-file-libraries-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-file-libraries
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest share-link-deletion-idor
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
admin (th/create-profile* 3 {:is-active true})
proj-id (:default-project-id owner)
team-id (:default-team-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
;; Invite editor to the team with edit permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id editor)
:role :editor})
;; Invite admin to the team with admin permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id admin)
:role :admin})
;; Owner creates a share-link
slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{(get-in file [:data :pages 0])}
:who-comment "team"
:who-inspect "all"})
slink-id (get-in slink [:result :id])]
(t/testing "owner can delete their own share-link"
(let [out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id owner)
:id slink-id})]
(t/is (nil? (:error out)))))
(t/testing "editor CANNOT delete owner's share-link (IDOR)"
;; Recreate the share-link for this test
(let [slink2 (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink2-id (get-in slink2 [:result :id])
;; Editor tries to delete owner's share-link
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id editor)
:id slink2-id})
err (:error out)
edata (ex-data err)]
;; Should be denied with authorization error
(t/is (th/ex-info? err))
(t/is (= :authorization (:type edata)))
;; Verify the share-link still exists
(let [check (th/command! {::th/type :get-view-only-bundle
::rpc/profile-id (:id owner)
:file-id (:id file)})
share-links (:share-links (:result check))]
(t/is (some #(= slink2-id (:id %)) share-links)))))))
(t/deftest share-link-page-scope-enforcement
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
proj-id (:default-project-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
page-a (get-in file [:data :pages 0])
page-b (uuid/random)
;; Add a second page to the file
_ (th/command! {::th/type :update-file
::rpc/profile-id (:id owner)
:id (:id file)
:session-id (uuid/random)
:revn 0
:vern 0
:changes [{:type :add-page
:id page-b
:page {:id page-b
:name "Page B"
:options {}
:objects {}}}]})
;; Create share-link scoped to page A only
share (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-a}
:who-comment "team"
:who-inspect "all"})
share-id (get-in share [:result :id])]
(t/testing "share-link holder can access authorized page"
(let [out (th/command! {::th/type :get-page
::rpc/profile-id (:id viewer)
:file-id (:id file)
:page-id page-a
:share-id share-id})]
(t/is (nil? (:error out)))
(t/is (some? (:result out)))))
(t/testing "share-link holder cannot access out-of-scope page"
(let [out (th/command! {::th/type :get-page
::rpc/profile-id (:id viewer)
:file-id (:id file)
:page-id page-b
:share-id share-id})
err (:error out)
edata (ex-data err)]
(t/is (th/ex-info? err))
(t/is (= :not-found (:type edata)))
(t/is (= :object-not-found (:code edata)))))
(t/testing "team member can access all pages"
(let [out-a (th/command! {::th/type :get-page
::rpc/profile-id (:id owner)
:file-id (:id file)
:page-id page-a})
out-b (th/command! {::th/type :get-page
::rpc/profile-id (:id owner)
:file-id (:id file)
:page-id page-b})]
(t/is (nil? (:error out-a)))
(t/is (nil? (:error out-b)))))))
(t/deftest share-link-deletion-escape-hatches
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
admin (th/create-profile* 3 {:is-active true})
proj-id (:default-project-id owner)
team-id (:default-team-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
;; Invite editor to the team with edit permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id editor)
:role :editor})
;; Invite admin to the team with admin permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id admin)
:role :admin})]
(t/testing "editor CAN delete their own share-link"
(let [slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id editor)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink-id (get-in slink [:result :id])
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id editor)
:id slink-id})]
(t/is (nil? (:error out)))))
(t/testing "admin CAN delete editor's share-link"
(let [slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id editor)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink-id (get-in slink [:result :id])
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id admin)
:id slink-id})]
(t/is (nil? (:error out)))))
(t/testing "owner CAN delete editor's share-link"
(let [slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id editor)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink-id (get-in slink [:result :id])
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id owner)
:id slink-id})]
(t/is (nil? (:error out)))))))
(t/deftest share-link-fragment-access-denied
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
proj-id (:default-project-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
page-a (get-in file [:data :pages 0])
;; Create share-link
share (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-a}
:who-comment "team"
:who-inspect "all"})
share-id (get-in share [:result :id])]
(t/testing "share-link holder cannot access file fragments"
(let [out (th/command! {::th/type :get-file-fragment
::rpc/profile-id (:id viewer)
:file-id (:id file)
:fragment-id (uuid/random)
:share-id share-id})
err (:error out)
edata (ex-data err)]
(t/is (th/ex-info? err))
(t/is (= :not-found (:type edata)))
(t/is (= :object-not-found (:code edata)))))))
@@ -608,3 +608,66 @@
:name "Valid Font Name"}
out (th/command! params)]
(t/is (th/success? out))))))
(t/deftest create-font-variant-rejects-foreign-font-id
;; N2-07: A user with edit permissions on their own team must not be
;; able to create a font variant using a font-id that already belongs
;; to another team (BOLA / CWE-639).
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof1 (th/create-profile* 1 {:is-active true})
prof2 (th/create-profile* 2 {:is-active true})
team1 (:default-team-id prof1)
team2 (:default-team-id prof2)
font-id (uuid/custom 10 999)
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))]
;; prof1 creates a font variant in team1 with font-id
(let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof1)
:team-id team1
:font-id font-id
:font-family "SharedFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (nil? (:error out))))
;; prof2 tries to create a variant using the same font-id but
;; in team2, which must be rejected because font-id belongs to team1
(let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id team2
:font-id font-id
:font-family "SharedFont"
:font-weight 700
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
(t/deftest get-font-variants-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-font-variants-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
@@ -50,45 +50,138 @@
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest create-and-update-organization-invitations-audit-props
(let [owner-id-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method params]
(when (= method :get-organization-summary)
{:id (:organization-id params)
:name "Acme"
:owner-id @owner-id-ref
:teams []}))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
_ (reset! owner-id-ref (:id owner))
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id))))))))
(t/deftest invite-to-organization-rejects-non-owner
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(let [owner (th/create-profile* 103 {:is-active true})
attacker (th/create-profile* 104 {:is-active true})
organization-id (uuid/random)
organization {:id organization-id
:name "Trusted Organization"
:initials "TO"
:logo nil
:avatar-bg-url nil}
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id attacker)
:email "victim@example.com"
:organization organization})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock)))))))
(t/deftest invite-to-organization-rejects-unknown-organization
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(let [profile (th/create-profile* 105 {:is-active true})
organization-id (uuid/random)
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id profile)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Organization"
:initials "FO"
:logo "https://evil.example/logo.png"
:avatar-bg-url nil}})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock))))))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id)))))))
(t/deftest invite-to-organization-uses-authoritative-branding
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 106 {:is-active true})
organization-id (uuid/random)
logo-id (uuid/random)
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:logo-id logo-id
:avatar-bg-url "https://trusted.example/avatar.svg"
:sso-active true
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Bank"
:initials "FB"
:logo "https://evil.example/logo.png"
:avatar-bg-url "https://evil.example/avatar.svg"
:sso-active false}})
email-params (first (:call-args @email-mock))
organization (:organization email-params)]
(t/is (th/success? out))
(t/is (= "Trusted Organization" (:name organization)))
(t/is (= "" (:initials organization)))
(t/is (str/ends-with? (str (:logo organization))
(str "/assets/by-id/" logo-id)))
(t/is (nil? (:avatar-bg-url organization)))
(t/is (true? (:sso-active organization))))))))
(t/deftest get-penpot-version
(let [out (th/management-command! {::th/type :get-penpot-version})
@@ -175,7 +268,7 @@
new-team (th/db-get :team {:id new-team-id})]
(t/is (th/success? out))
(t/is (= 1 (count (set/difference after-teams before-teams))))
(t/is (= "Your Penpot" (:name new-team)))
(t/is (= "Personal Projects" (:name new-team)))
(t/is (true? (:is-default new-team))))))
(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams
@@ -57,6 +57,22 @@
(t/is (not= (get-in out1 [:result :id])
(get-in out2 [:result :id])))))
(t/deftest upload-tempfile-rejects-html-content-type
;; N2-13: upload-tempfile must reject non-allowed content types
(let [profile (th/create-profile* 1 {:is-active true})
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
_ (io/write* path "<script>alert(1)</script>")
params {::th/type :upload-tempfile
::rpc/profile-id (:id profile)
:content {:filename "evil.html"
:path path
:mtype "text/html"
:size 27}}
out (th/management-command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :media-type-not-allowed (th/ex-code (:error out))))))
(t/deftest duplicate-file
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -581,6 +581,41 @@
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))))
(t/deftest chunked-upload-other-profile-cannot-assemble
;; assemble-chunks must scope the session lookup to the requesting
;; profile so that a different profile cannot assemble chunks from
;; a session they do not own (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
session-id (create-session! prof1 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; prof1 uploads a chunk into their own session
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof1)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out))))
;; prof2 tries to assemble prof1's session via create-font-variant
;; (which calls assemble-chunks without ownership check)
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id (:default-team-id prof2)
:font-id (uuid/next)
:font-family "TestFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest chunked-upload-invalid-media-type
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
@@ -683,6 +718,24 @@
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
(t/deftest chunked-upload-invalid-total-chunks
;; total-chunks must be at least 1; zero and negative values are rejected
;; with a :validation error.
(let [prof (th/create-profile* 1)]
;; zero total-chunks
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 0})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))
;; negative total-chunks
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks -1})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))))
(t/deftest chunked-upload-invalid-chunk-index
;; Both a negative index and an index >= total-chunks must be
;; rejected with a :validation / :invalid-chunk-index error.
@@ -734,3 +787,98 @@
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
;; --- Clone File Media Object BOLA tests ---
(defn- create-storage-object!
[content content-type]
(let [storage (:app.storage/storage th/*system*)]
(sto/put-object! storage {::sto/content (sto/content content)
:content-type content-type})))
(t/deftest clone-file-media-object-success
(let [prof1 (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:default-team-id prof1)})
file1 (th/create-file* 1 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
sobj (create-storage-object! "image-content" "image/png")
mobj (th/create-file-media-object* {:file-id (:id file1)
:name "test-media"
:width 100
:height 100
:mtype "image/png"
:media-id (:id sobj)})
file2 (th/create-file* 2 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof1)
:file-id (:id file2)
:is-local true
:id (:id mobj)}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:id file2) (:file-id result)))
(t/is (= (:name mobj) (:name result)))
(t/is (= (:media-id mobj) (:media-id result)))
(t/is (uuid? (:id result)))
(t/is (not= (:id mobj) (:id result))))))
(t/deftest clone-file-media-object-no-read-access
(let [prof1 (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:default-team-id prof1)})
file1 (th/create-file* 1 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
sobj (create-storage-object! "private-content" "image/png")
mobj (th/create-file-media-object* {:file-id (:id file1)
:name "private-media"
:width 100
:height 100
:mtype "image/png"
:media-id (:id sobj)})
prof2 (th/create-profile* 2)
_ (th/create-project* 2 {:profile-id (:id prof2)
:team-id (:default-team-id prof2)})
file2 (th/create-file* 2 {:profile-id (:id prof2)
:project-id (:default-project-id prof2)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof2)
:file-id (:id file2)
:is-local true
:id (:id mobj)}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data))))))
(t/deftest clone-file-media-object-source-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:id (uuid/random)}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data))))))
+260 -7
View File
@@ -7,6 +7,7 @@
(ns backend-tests.rpc-nitrate-test
(:require
[app.auth.oidc :as oidc]
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.time :as ct]
[app.common.uuid :as uuid]
@@ -14,15 +15,17 @@
[app.db :as-alias db]
[app.email :as eml]
[app.http :as-alias http]
[app.http.errors :as http-errors]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc :as rpc]
[app.rpc.commands.nitrate]
[app.rpc.commands.teams :as teams]
[app.rpc.helpers :as rph]
[backend-tests.helpers :as th]
[buddy.core.codecs :as bc]
[clojure.test :as t]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[yetti.response :as-alias yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@@ -86,6 +89,31 @@
nil)))
(defn- unauthorized-sso-mock
"Creates a mock for nitrate/sso-session-authorized? that reports an active
SSO the session does not satisfy. Pass nil to leave the organization out of
the nitrate payload."
[organization-id]
(fn [_cfg _organization-id _team-id _request]
{:authorized false
:sso (cond-> {:active true
:issuer "https://idp.example.com"}
(some? organization-id)
(assoc :organization-id organization-id))}))
(defn- sso-gate-error
"Builds the SSO gate around a handler that must never be reached, and
returns the exception it raises for `params`."
[mdata params cfg]
(let [handler (fn [_cfg _params] ::handler-called)
wrapped (binding [cf/flags (conj cf/flags :admin-console)]
(#'rpc/wrap-nitrate-sso nil handler mdata))]
(try
(wrapped cfg (with-meta params {::http/request {}}))
nil
(catch Throwable cause
cause))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -111,13 +139,17 @@
(constantly "https://idp.example.com/authorize")]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized true} (:result out))))))))
;; The reason tells the client this is a permission problem, not a
;; usable SSO session.
(t/is (= {:authorized true :reason :no-team-access} (:result out))))))))
(t/deftest check-nitrate-sso-keeps-gate-for-team-member
(let [team-owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id team-owner)})
organization-id (uuid/random)
redirect-uri "https://idp.example.com/authorize"
redirect-options (atom nil)
started-event (atom nil)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id team-owner)
@@ -131,12 +163,50 @@
organization-id
(:id team-owner))
oidc/build-organization-sso-auth-redirect-uri
(constantly redirect-uri)]
(fn [_cfg _sso & options]
(reset! redirect-options (apply hash-map options))
redirect-uri)
oidc/submit-organization-sso-auth-started-event
(fn [_cfg _request profile-id received-organization-id]
(reset! started-event {:profile-id profile-id
:organization-id received-organization-id}))]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized false
:redirect-uri redirect-uri}
(:result out))))))))
(:result out)))
(t/is (= #{:dest-url :organization-id} (set (keys @redirect-options))))
(t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options))))
(t/is (nil? (:organization-id @redirect-options)))
(t/is (= {:profile-id (:id team-owner)
:organization-id organization-id}
@started-event)))))))
(t/deftest check-nitrate-sso-reports-redirect-failure
(let [profile (th/create-profile* 1 {:is-active true})
organization-id (uuid/random)
cause (ex-info "provider unavailable" {:response-status-code 503})
reported (atom nil)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id profile)
:organization-id organization-id
:url "https://penpot.example.com/#/workspace"}
{::http/request {}})]
(binding [cf/flags (conj cf/flags :admin-console)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)
oidc/build-organization-sso-auth-redirect-uri (fn [& _] (throw cause))
oidc/submit-organization-sso-auth-failed-event
(fn [_cfg _request profile-id received-organization-id received-cause]
(reset! reported {:profile-id profile-id
:organization-id received-organization-id
:cause received-cause}))]
(let [out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (= {:profile-id (:id profile)
:organization-id organization-id
:cause cause}
@reported)))))))
(t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner
(let [team-owner (th/create-profile* 1 {:is-active true})
@@ -168,6 +238,91 @@
:redirect-uri redirect-uri}
(:result out))))))))
(t/deftest check-nitrate-sso-reports-a-satisfied-gate-for-a-valid-session
(let [team-owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id team-owner)})
organization-id (uuid/random)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id team-owner)
:team-id (:id team)
:url "https://penpot.example.com/#/workspace"}
{::http/request {}})]
(binding [cf/flags (conj cf/flags :admin-console)]
(with-redefs [nitrate/sso-session-authorized?
(fn [_cfg _organization-id _team-id _request]
{:authorized true
:sso {:active true
:issuer "https://idp.example.com"
:organization-id organization-id}})]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized true :reason :sso-satisfied} (:result out))))))))
(t/deftest nitrate-sso-required-error-resolves-the-team-from-the-file
(t/testing "the workspace path, where the file id arrives as :id, still reports the team"
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
organization-id (uuid/random)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
(let [data (ex-data (sso-gate-error {::rpc/id-type :file}
{::rpc/profile-id (:id profile)
:id (:id file)}
th/*system*))]
(t/is (= :authentication (:type data)))
(t/is (= :nitrate-sso-required (:code data)))
(t/is (= organization-id (:organization-id data)))
(t/is (= (:default-team-id profile) (:team-id data))))))))
(t/deftest nitrate-sso-required-error-keeps-the-team-known-by-the-request
(t/testing "an explicit team-id is not dropped by an explicit organization-id"
(let [profile-id (uuid/random)
team-id (uuid/random)
organization-id (uuid/random)]
;; The nitrate payload carries no organization-id here, so the one from
;; the request params is the only one left to report.
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
(let [data (ex-data (sso-gate-error {}
{::rpc/profile-id profile-id
:team-id team-id
:organization-id organization-id}
{}))]
(t/is (= organization-id (:organization-id data)))
(t/is (= team-id (:team-id data))))))))
(t/deftest nitrate-sso-required-error-resolves-the-team-with-a-known-organization
(t/testing "knowing the organization does not stop the team lookup"
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
organization-id (uuid/random)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
(let [data (ex-data (sso-gate-error {}
{::rpc/profile-id (:id profile)
:organization-id organization-id
:file-id (:id file)}
th/*system*))]
(t/is (= organization-id (:organization-id data)))
(t/is (= (:default-team-id profile) (:team-id data))))))))
(t/deftest nitrate-sso-required-error-reaches-the-client-in-the-401-body
(t/testing "the ids survive the http error response, not only the exception"
(let [profile-id (uuid/random)
team-id (uuid/random)
organization-id (uuid/random)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
(let [cause (sso-gate-error {}
{::rpc/profile-id profile-id
:team-id team-id}
{})
response (http-errors/handle cause {})
body (::yres/body response)]
(t/is (= 401 (::yres/status response)))
(t/is (= :nitrate-sso-required (:code body)))
(t/is (= organization-id (:organization-id body)))
(t/is (= team-id (:team-id body))))))))
(t/deftest leave-organization-happy-path-no-extra-teams
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
@@ -1013,12 +1168,68 @@
@set-team-params))
(let [emails (->> @sent (map :to) set)]
(t/is (= 2 (count @sent)))
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
(t/is (= 1 (count @sent)))
(t/is (= #{"member302@example.com"} emails))
(doseq [email-params @sent]
(t/is (= organization-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
(t/deftest add-team-to-organization-deletes-external-invitations-for-unregistered-users
(let [owner (th/create-profile* 305 {:is-active true
:fullname "Owner"
:email "owner305@example.com"})
member (th/create-profile* 306 {:is-active true
:fullname "Member"
:email "member306@example.com"})
team (th/create-team* 305 {:profile-id (:id owner)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id member)
:role :editor})
organization-id (uuid/random)
organization-summary {:id organization-id
:name "Test Org"
:owner-id (:id owner)
:teams []}
organization-perms {:owner-id (:id owner)
:permissions {:create-teams "any"
:move-teams "always"
:new-team-members "members"}}]
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "unregistered@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "unregistered2@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (add-team-to-organization-nitrate-mock
{:organization-id organization-id
:organization-summary organization-summary
:organization-perms organization-perms
:owner-id (:id owner)
:team-id (:id team)
:sso-active? false})
teams/initialize-user-in-organization (fn [& _] nil)]
(let [out (th/command! {::th/type :add-team-to-organization
::rpc/profile-id (:id owner)
:team-id (:id team)
:organization-id organization-id})]
(t/is (th/success? out))))
(let [remaining (th/db-query :team-invitation {:team-id (:id team)})]
(t/is (empty? remaining) "Both external invitations should be deleted"))))
(t/deftest create-team-in-organization-passes-association-to-nitrate
(let [organization-id (uuid/random)
team {:id (uuid/random)
@@ -1123,3 +1334,45 @@
::rpc/profile-id (:id profile)})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-code? (:error out) :nitrate-identity-unavailable))))))
(t/deftest redeem-nitrate-activation-code-used
(let [profile (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (fn [_cfg method _params]
(t/is (= :redeem-activation-code method))
(ex/raise :type :nitrate-http-error
:status 409
:hint "activation code already used"))]
(let [out (th/command! {::th/type :redeem-nitrate-activation-code
::rpc/profile-id (:id profile)
:activation-code "already-used-code"})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-code? (:error out) :used-activation-code))))))
(t/deftest redeem-nitrate-activation-code-expired
(let [profile (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (fn [_cfg method _params]
(t/is (= :redeem-activation-code method))
(ex/raise :type :nitrate-http-error
:status 410
:hint "activation code expired"))]
(let [out (th/command! {::th/type :redeem-nitrate-activation-code
::rpc/profile-id (:id profile)
:activation-code "expired-code"})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-code? (:error out) :expired-activation-code))))))
(t/deftest redeem-nitrate-activation-code-invalid
(let [profile (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (fn [_cfg method _params]
(t/is (= :redeem-activation-code method))
(ex/raise :type :nitrate-http-error
:status 422
:hint "invalid activation code"))]
(let [out (th/command! {::th/type :redeem-nitrate-activation-code
::rpc/profile-id (:id profile)
:activation-code "invalid-code"})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-code? (:error out) :invalid-activation-code))))))
@@ -50,7 +50,16 @@
:organization (organization-data organization-id organization-owner-id)}
{:id (:team-id params)
:is-your-penpot false
:organization nil})))]
:organization nil})
:get-teams-organizations
(->> (:team-ids params)
(keep (fn [candidate-team-id]
(when (= team-id candidate-team-id)
{:id team-id
:is-your-penpot false
:organization (organization-data organization-id organization-owner-id)})))
vec)))]
(f)))
(defn- with-captured-messages
@@ -125,6 +125,17 @@
(t/is (= "en" (:lang result)))
(t/is (= "dark" (:theme result))))))
(t/testing "update profile preserves omitted optional fields"
(let [data {::th/type :update-profile
::rpc/profile-id (:id profile)
:fullname "Updated Name"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (= "Updated Name" (get-in out [:result :fullname])))
(t/is (= "en" (get-in out [:result :lang])))
(t/is (= "dark" (get-in out [:result :theme])))))
(t/testing "update photo"
(let [data {::th/type :update-profile-photo
::rpc/profile-id (:id profile)
@@ -388,6 +399,31 @@
(let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 10 (:processed result))))))
(t/deftest profile-deletion-invalidates-all-sessions
(let [prof (th/create-profile* 1)
;; Insert 3 sessions for this profile directly into the database
session-ids (doall
(for [i (range 3)]
(let [sid (uuid/random)]
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid (:id prof) (str "user-agent-" i)])
sid)))]
;; Verify sessions exist
(let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
(t/is (= 3 count-before)))
;; Request profile to be deleted
(let [params {::th/type :delete-profile
::rpc/profile-id (:id prof)}
out (th/command! params)]
(t/is (nil? (:error out))))
;; Verify ALL sessions were invalidated (not just one)
(let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
(t/is (= 0 count-after)))))
(t/deftest email-blacklist-1
(t/is (false? (email.blacklist/enabled? th/*system*)))
@@ -241,3 +241,24 @@
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))))
(t/deftest get-project-nonexistent
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-project
::rpc/profile-id (:id prof)
:id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-project-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
proj (th/create-project* 1 {:profile-id (:id owner)
:team-id (:default-team-id owner)})
out (th/command! {::th/type :get-project
::rpc/profile-id (:id other)
:id (:id proj)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
@@ -338,3 +338,137 @@
(check-ok! 4)
(check-ko! 5))))
(t/deftest media-storage-bytes-per-team-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 1000})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
team-id (:default-team-id profile-1)
data {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id (:id profile-1)
::quotes/team-id team-id
::quotes/incr 500}
check-ok! (fn [msg]
(quotes/check! th/*system* data)
(t/is (true? true) msg))
check-ko! (fn [msg]
(try
(quotes/check! th/*system* data)
(t/is false (str msg " — expected exception but none thrown"))
(catch Exception e
(let [ed (ex-data e)]
(t/is (= :restriction (:type ed)))
(t/is (= :max-quote-reached (:code ed)))
(t/is (= "media-storage-bytes-per-team" (:target ed)))))))]
;; Under default limit (1000) with incr=500 and no existing storage — ok
(check-ok! "first check under limit")
;; Insert a quote row for another profile on the same team — does not help
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "media-storage-bytes-per-team"
:quote 100})
;; Insert a team+profile quote that is still too low
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-2)
:target "media-storage-bytes-per-team"
:quote 200})
;; Insert a team-level quote (no profile) that is still too low
(th/db-insert! :usage-quote
{:team-id team-id
:target "media-storage-bytes-per-team"
:quote 400})
;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked
(check-ko! "blocked by team-level quote")
;; Insert a team+profile quote that allows it
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-1)
:target "media-storage-bytes-per-team"
:quote 1000})
;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok
(check-ok! "allowed by team+profile quote"))))
(t/deftest media-storage-bytes-quote-deduped
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 1100})}]
(let [prof (th/create-profile* 1)
team-id (:default-team-id prof)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id team-id})
file1 (th/create-file* 1 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
file2 (th/create-file* 2 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
;; One physical storage object of 500 bytes
so-id (uuid/random)
_ (th/db-insert! :storage-object {:id so-id
:size 500
:backend "test"})
;; Two file_media_object rows pointing at the SAME storage object
;; (simulates the deduplication path: same content uploaded twice)
_ (th/create-file-media-object*
{:file-id (:id file1) :media-id so-id
:name "icon" :mtype "image/svg+xml"})
_ (th/create-file-media-object*
{:file-id (:id file2) :media-id so-id
:name "icon" :mtype "image/svg+xml"})
data {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id (:id prof)
::quotes/team-id team-id
::quotes/incr 200}]
;; Physical size is 500. With UNION (correct), total=500, 500+200=700 ≤ 1100 → ok.
;; With UNION ALL (buggy), total=1000, 1000+200=1200 > 1100 → rejected.
(quotes/check! th/*system* data)
(t/is (true? true) "deduped storage counted once, under quota"))))
(t/deftest media-upload-enforces-storage-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 100})}]
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
;; 312043 bytes > 100 byte limit → should be rejected
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "media-storage-bytes-per-team" (:target (ex-data error))))))))
@@ -0,0 +1,28 @@
;; 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-rlimit-test
(:require
[app.common.time :as ct]
[app.redis :as rds]
[app.rpc.rlimit :as rlimit]
[clojure.test :as t]))
(t/deftest bucket-reset-supports-fractional-milliseconds
(let [now (ct/inst 0)
limit {::rlimit/name :test
::rlimit/strategy :bucket
::rlimit/key "test"
::rlimit/method "main.test"
::rlimit/capacity 5
::rlimit/rate 3
::rlimit/interval (ct/duration 1000)
::rlimit/params [1 3 5]
::rlimit/opts "5/3/1s"}]
(with-redefs [rds/eval (fn [_ _] [true 4])]
(let [result (rlimit/process-limit nil "profile" now limit)]
(t/is (= (ct/inst 334)
(:app.rpc.rlimit.result/reset result)))))))
@@ -357,6 +357,28 @@
(t/is (= (:id profile2) (:member-id claims))))))))
(t/deftest get-team-invitation-token-requires-edition-permissions
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
pool (:app.db/pool th/*system*)]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id profile2)
:role :viewer})
(db/insert! pool :team-invitation
{:team-id (:id team)
:email-to "victim@example.com"
:role "editor"
:valid-until (ct/in-future "48h")})
(let [data {::th/type :get-team-invitation-token
::rpc/profile-id (:id profile2)
:team-id (:id team)
:email "victim@example.com"}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :not-found (-> out :error ex-data :type))))))
(t/deftest accept-invitation-tokens
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
@@ -719,6 +741,67 @@
(t/is (not= (:default-team-id profile1) (:id item1))))))
(t/deftest get-teams-fetches-organizations-in-one-batch
(let [profile (th/create-profile* 1 {:is-active true})
organization-team (th/create-team* 1 {:profile-id (:id profile)})
plain-team (th/create-team* 2 {:profile-id (:id profile)})
expired-team (th/create-team* 3 {:profile-id (:id profile)})
organization-id (uuid/random)
calls (atom [])
organization {:id organization-id
:name "Acme"
:slug "acme"
:owner-id (:id profile)
:avatar-bg-url "https://example.com/avatar.svg"}
nitrate-call (fn [_cfg method params]
(swap! calls conj [method params])
[{:id (:id organization-team)
:is-your-penpot false
:organization organization}
{:id (:id expired-team)
:is-your-penpot false
:organization (assoc organization :expired-license true)}])
params {::th/type :get-teams
::rpc/profile-id (:id profile)}]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call nitrate-call]
(let [out (th/command! params)
teams (:result out)]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= :get-teams-organizations (ffirst @calls)))
(t/is (= #{(:default-team-id profile)
(:id organization-team)
(:id plain-team)
(:id expired-team)}
(-> @calls first second :team-ids set)))
(t/is (= #{(:default-team-id profile)
(:id organization-team)
(:id plain-team)}
(into #{} (map :id) teams)))
(t/is (= organization
(->> teams
(filter #(= (:id organization-team) (:id %)))
first
:organization)))))))
(t/deftest get-teams-rejects-invalid-organization-batch-response
(let [profile (th/create-profile* 1 {:is-active true})
calls (atom [])
params {::th/type :get-teams
::rpc/profile-id (:id profile)}]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (fn [_cfg method call-params]
(swap! calls conj [method call-params])
nil)]
(let [out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (= :nitrate-unavailable (th/ex-type (:error out))))
(t/is (= 1 (count @calls)))
(t/is (= :get-teams-organizations (ffirst @calls)))))))
(t/deftest team-deletion-1
(let [profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
@@ -1096,3 +1179,152 @@
:name "My Valid Team"}
out (th/command! data)]
(t/is (th/success? out)))))
(t/deftest create-team-in-organization-regression
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
(let [owner (th/create-profile* 401 {:is-active true})
non-member (th/create-profile* 402 {:is-active true})
organization-id (uuid/random)
params {::th/type :create-team
::rpc/profile-id (:id owner)
:name "Test Team"
:organization-id organization-id}
nitrate-call-fn
(fn [_cfg method p]
(case method
:get-organization-membership
(if (= (:profile-id p) (:id non-member))
{:organization-id organization-id :is-member false}
{:organization-id organization-id :is-member true})
:get-organization-permissions
{:owner-id (:id owner)
:permissions {:create-teams "any"}}
:set-team-organization
(let [team-id (:team-id p)]
{:id team-id
:name "Test Team"
:organization-id organization-id
:default-project-id (uuid/random)})
nil))]
;; Non-member should be denied with :user-doesnt-belong-organization
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call nitrate-call-fn]
(let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))]
(t/is (not (th/success? out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata)))
(t/is (= :user-doesnt-belong-organization (:code edata))))))
;; Authorized member should succeed
(th/reset-mock! audit-mock)
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call nitrate-call-fn]
(let [out (th/command! params)]
(t/is (th/success? out))
(let [team (:result out)]
(t/is (uuid? (:id team)))
(t/is (= "Test Team" (:name team)))))))))
;; --- T7-F-01: Role ceiling in team invitations ---
(t/deftest admin-cannot-create-invitation-with-owner-role
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [owner (th/create-profile* 1 {:is-active true})
admin (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
;; Add admin as team member with :admin role
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin)
:role :admin})
;; Admin tries to create invitation with :owner role (emails+role format)
;; This should FAIL with :cant-promote-to-owner
(let [data {::th/type :create-team-invitations
::rpc/profile-id (:id admin)
:team-id (:id team)
:role :owner
:emails ["invitee@example.com"]}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))
(t/is (= 0 (:call-count @mock)))))))
(t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [owner (th/create-profile* 1 {:is-active true})
admin (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
;; Add admin as team member with :admin role
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin)
:role :admin})
;; Admin tries to create invitation with :owner role (invitations format)
;; This should FAIL with :cant-promote-to-owner
(let [data {::th/type :create-team-invitations
::rpc/profile-id (:id admin)
:team-id (:id team)
:invitations [{:email "invitee@example.com" :role :owner}]}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))
(t/is (= 0 (:call-count @mock)))))))
(t/deftest admin-cannot-update-invitation-role-to-owner
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [owner (th/create-profile* 1 {:is-active true})
admin (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
;; Add admin as team member with :admin role
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin)
:role :admin})
;; Owner creates an invitation with :editor role
(let [data {::th/type :create-team-invitations
::rpc/profile-id (:id owner)
:team-id (:id team)
:role :editor
:emails ["invitee@example.com"]}
out (th/command! data)]
(t/is (th/success? out)))
(th/reset-mock! mock)
;; Admin tries to update invitation role to :owner
;; This should FAIL with :cant-promote-to-owner
(let [data {::th/type :update-team-invitation-role
::rpc/profile-id (:id admin)
:team-id (:id team)
:email "invitee@example.com"
:role :owner}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))))))
(t/deftest owner-can-create-invitation-with-owner-role
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
;; Owner creates invitation with :owner role
;; This should SUCCEED (owner has full privileges)
(let [data {::th/type :create-team-invitations
::rpc/profile-id (:id owner)
:team-id (:id team)
:role :owner
:emails ["invitee@example.com"]}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock)))))))
@@ -710,6 +710,19 @@
(t/is (not (contains? (:props result) :route)))
(t/is (not (contains? (:props result) :label)))))
(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
organization-id (uuid/next)
result (ftp {:source "backend"
:name "organization-sso-auth-failed"
:type "action"
:props {:organization-id organization-id
:failure-reason "access-denied"
:unsafe-label "should-be-stripped"}})]
(t/is (= {:organization-id organization-id
:failure-reason "access-denied"}
(:props result)))))
(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids
;; Frontend navigate events keep specific routing keys: :route,
;; :file-id, :team-id, :page-id. These ids are strings because
+17 -3
View File
@@ -13,11 +13,25 @@
[clojure.test :as t]))
(t/deftest validate-url-allows-public-https
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
(let [original ssrf/resolve-host]
(with-redefs [ssrf/resolve-host
(fn [hostname]
(if (= hostname "example.com")
(into-array java.net.InetAddress
[(java.net.InetAddress/getByName "93.184.216.34")])
(original hostname)))]
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))))
(t/deftest validate-url-allows-public-http
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
(let [original ssrf/resolve-host]
(with-redefs [ssrf/resolve-host
(fn [hostname]
(if (= hostname "example.com")
(into-array java.net.InetAddress
[(java.net.InetAddress/getByName "93.184.216.34")])
(original hostname)))]
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))))
(t/deftest validate-url-blocks-disallowed-schemes
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
+9
View File
@@ -1192,6 +1192,15 @@
str/trim)
""))
(defn escape-markdown
"Escapes Markdown special characters by prefixing them with backslash.
Intended for user-controlled values embedded in Markdown messages
(e.g. Mattermost notifications)."
[s]
(if s
(str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c)))
""))
(defn get-initials
"Returns up to two uppercase initials extracted from a string.
Non-letter prefixes in each token are ignored."
+22 -1
View File
@@ -1978,6 +1978,26 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0026-fix-svg-raw-shapes-uuids"
;; Before the svg-raw schema declared :shapes as a vector of uuid,
;; the JSON decoder had no type information for those child ids and
;; left them as plain strings on any round trip, so they got
;; persisted as strings. Once the schema was tightened, such files
;; fail schema validation; this migration parses the strings back
;; into uuid instances.
[data _]
(letfn [(update-object [object]
(cond-> object
(cfh/svg-raw-shape? object)
(d/update-when :shapes #(mapv uuid/coerce %))))
(update-container [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@@ -2060,4 +2080,5 @@
"0022-normalize-component-root-and-resync"
"0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"
"0025-repair-empty-text-content"]))
"0025-repair-empty-text-content"
"0026-fix-svg-raw-shapes-uuids"]))
-1
View File
@@ -147,7 +147,6 @@
:render-switch
:hide-release-modal
:subscriptions
:subscriptions-old
:inspect-styles
;; Enable performance logs in devconsole (disabled by default)
:perf-logs
+6 -1
View File
@@ -2345,7 +2345,12 @@
updated-sync-groups (into #{}
(keep #(ctk/resolve-sync-group (:type previous-shape) %))
updated-attrs)
new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups)
text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure}
new-touched (set/union (or (:touched current-shape) #{})
updated-sync-groups
(when (contains? updated-sync-groups :content-group)
(set/intersection (or (:touched previous-shape) #{})
text-sub-touched)))
roperations (into [{:type :set-touched :touched new-touched}] roperations)
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
(cond-> changes
+3
View File
@@ -22,6 +22,9 @@
"image/gif"
"image/svg+xml"})
(def tempfile-types
(conj image-types "application/pdf" "application/zip"))
(defn format->extension
[format]
(case format
+16 -2
View File
@@ -28,7 +28,8 @@
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.text :as txt]
[app.common.types.tokens-lib :refer [schema:tokens-lib]]
[app.common.types.tokens-lib :as ctob]
[app.common.types.tokens-status :as ctos]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as cty]
[app.common.uuid :as uuid]
@@ -86,7 +87,15 @@
[:components {:optional true} schema:components]
[:typographies {:optional true} schema:typographies]
[:plugin-data {:optional true} schema:plugin-data]
[:tokens-lib {:optional true} schema:tokens-lib]])
[:tokens-source {:optional true} ::sm/uuid] ;; Forward-compat: UUID of external library containing tokens-lib (full support in follow-up PR)
[:tokens-lib {:optional true} ctob/schema:tokens-lib]
[:tokens-status {:optional true} ctos/schema:tokens-status]])
(def schema:file-metadata
[:map {:title "Metadata"}
[:storage-ref-id {:optional true} ::sm/uuid]
[:generated-by {:optional true} :string]
[:referer {:optional true} :string]])
(def schema:file
"A schema for validate a file data structure; data is optional
@@ -106,6 +115,7 @@
[:data {:optional true} schema:data]
[:version :int]
[:features ::cfeat/features]
[:metadata {:optional true} schema:file-metadata]
[:migrations {:optional true}
[::sm/set {:ordered true} :string]]])
@@ -123,6 +133,9 @@
(def check-file-media
(sm/check-fn schema:media))
(def decode-file-metadata
(sm/decoder schema:file-metadata sm/json-transformer))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -306,6 +319,7 @@
(update-objects-tree container f)))))
;; Asset helpers
(defn find-component-file
[file libraries component-file]
(if (and (some? file) (= component-file (:id file)))
+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,147 @@
;; 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.common.types.tokens-status
(:require
#?(:clj [app.common.fressian :as fres])
#?(:clj [clojure.data.json :as c.json])
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.transit :as t]
[clojure.core.protocols :as cp]
[clojure.datafy :refer [datafy]]
[clojure.pprint :as pp]))
;; TokensStatus datatype contains the activation status of the themes and sets
;; in a tokens library.
(defprotocol ITokensStatus
(get-active-theme-ids [_] "Return a clojure set of active theme ids")
(get-active-set-ids [_] "Return a clojure set of active set ids")
(theme-active? [_ theme-id] "Check if a theme is active")
(set-active? [_ set-id] "Check if a set is active")
(set-tokens-status [_ theme-ids set-ids] "Set the activation status of the themes and sets"))
(deftype TokensStatus [active-theme-ids active-set-ids]
cp/Datafiable
(datafy [_]
{:active-theme-ids active-theme-ids
:active-set-ids active-set-ids})
#?@(:clj
[c.json/JSONWriter
(-write [this writter options]
(c.json/-write (datafy this) writter options))])
ITokensStatus
(get-active-theme-ids [_]
active-theme-ids)
(get-active-set-ids [_]
active-set-ids)
(theme-active? [_ theme-id]
(assert (uuid? theme-id))
(contains? active-theme-ids theme-id))
(set-active? [_ set-id]
(assert (uuid? set-id))
(contains? active-set-ids set-id))
(set-tokens-status [_ theme-ids set-ids]
(assert (set? theme-ids))
(assert (set? set-ids))
(TokensStatus. theme-ids set-ids)))
;; === Helper & Predicate ===
(defn map->TokensStatus
[{:keys [active-theme-ids active-set-ids]}]
(TokensStatus. active-theme-ids active-set-ids))
(defn tokens-status?
[o]
(instance? TokensStatus o))
;; === Schemas, Check functions & Constructor ===
(declare make-tokens-status)
(def schema:tokens-status-attrs
[:map {:title "TokensStatus"}
[:active-theme-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]
[:active-set-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]])
(def schema:tokens-status
[:and {:gen/gen (->> (sg/generator schema:tokens-status-attrs)
(sg/fmap #(make-tokens-status %)))}
[:fn tokens-status?]])
(def ^:private check-tokens-status-attrs
(sm/check-fn schema:tokens-status-attrs
:hint "expected valid params for tokens-status"))
(def check-tokens-status
(sm/check-fn schema:tokens-status
:hint "expected valid tokens-status"))
(defn make-tokens-status
[& {:as attrs}]
(-> attrs
(update :active-theme-ids #(or % #{}))
(update :active-set-ids #(or % #{}))
(check-tokens-status-attrs)
(map->TokensStatus)))
;; === Pretty-print for debugging ===
(defmethod pp/simple-dispatch TokensStatus [^TokensStatus obj]
(.write *out* "#penpot/tokens-status ")
(pp/pprint-newline :miser)
(pp/pprint (datafy obj)))
#?(:clj
(do
(defmethod print-method TokensStatus
[^TokensStatus this ^java.io.Writer w]
(.write w "#penpot/tokens-status ")
(print-method (datafy this) w))
(defmethod print-dup TokensStatus
[^TokensStatus this ^java.io.Writer w]
(print-method this w)))
:cljs
(extend-type TokensStatus
cljs.core/IPrintWithWriter
(-pr-writer [this writer opts]
(-write writer "#penpot/tokens-status ")
(-pr-writer (datafy this) writer opts))
cljs.core/IEncodeJS
(-clj->js [this]
(clj->js (datafy this)))))
;; === Transit serialization ===
(t/add-handlers!
{:id "penpot/tokens-status"
:class TokensStatus
:wfn datafy
:rfn #(make-tokens-status %)})
;; === Fressian serialization ===
#?(:clj
(fres/add-handlers!
{:name "penpot/tokens-status/v1"
:class TokensStatus
:wfn (fn [n w o]
(fres/write-tag! w n 1)
(fres/write-object! w (datafy o)))
:rfn (fn [r]
(let [obj (fres/read-object! r)]
(make-tokens-status obj)))}))
+30
View File
@@ -67,6 +67,36 @@
path
(str path "/")))))
(defn- update-query-params
"Apply `f` to the query-params map of `url`, returning the updated URL string.
Handles both plain query strings and fragment-based (hash) URLs."
[url f]
(let [transform (fn [parsed]
(update parsed :query
(fn [q]
(-> (query-string->map (or q ""))
f
map->query-string))))
parsed (uri url)
fragment (:fragment parsed)]
(if (str/blank? fragment)
(str (transform parsed))
(-> parsed
(assoc :fragment (str (transform (parse fragment))))
str))))
(defn append-query-param
"Return a new URL string with the given query parameter added or replaced.
Handles both plain query strings and fragment-based (hash) URLs."
[url key value]
(update-query-params url #(assoc % key value)))
(defn remove-query-param
"Return a new URL string with the given query parameter removed.
Handles both plain query strings and fragment-based (hash) URLs."
[url key]
(update-query-params url #(dissoc % key)))
#?(:clj
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
(.write writer "#")
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more