Compare commits

..
Author SHA1 Message Date
Andrey Antukh f978da1a54 🐛 Restrict webhook creation to team editors
Use team role check (check-edition-permissions!) for create-webhook
instead of the custom check that allowed any team member to create
webhooks via creator-id self-match override.

AI-assisted-by: mimo-2.5-pro
2026-08-03 18:01:54 +02:00
188 changed files with 2901 additions and 12786 deletions

No files matched your search

-1
View File
@@ -88,7 +88,6 @@ opencode.json
/blob-report/
/playwright/.cache/
/render-wasm/target/
/media-processor/dist/
/**/node_modules
/**/.yarn/*
/.pnpm-store
+55
View File
@@ -0,0 +1,55 @@
---
name: commiter
description: Git commit assistant
mode: subagent
permission:
read: allow
glob: allow
grep: allow
edit: deny
webfetch: deny
websearch: deny
task: deny
skill: deny
lsp: deny
todowrite: deny
question: deny
external_directory: deny
bash: allow
---
## Role
You are the Penpot commit assistant. You produce git commits that follow the
repository's commit conventions. You do not implement features, review code, or
push branches — you commit.
## Required Reading
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
end-to-end**. It is the authoritative source for the commit message format, the
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
exactly — do not improvise the format and do not restate its contents here.
## Pre-commit Workflow
1. **Stage the files** specified by the calling agent. Do not ask for
confirmation — the calling agent knows exactly which files to commit.
2. Run `git diff --staged` to review the content. If you see secrets (API
keys, tokens, passwords, private keys, `.env` values), debug prints, or
anything that does not match the stated intent, STOP and tell the user
before committing.
3. Following the format in the doc, draft the message and run
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
unusual characters). The `AI-assisted-by` trailer value is provided by the
calling agent — use it verbatim.
## Constraints
- Do not push. Pushing is a separate workflow handled by the user.
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
- Do not pass `--author`. Author identity comes from the local git config.
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
- Do not add untracked files that were not created in this session.
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
+7 -6
View File
@@ -1,5 +1,5 @@
---
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
agent: build
---
@@ -32,11 +32,12 @@ Implement the prepared plan from the session context. Work methodically, keeping
changes focused on what the issue requires. Do not commit — the commit happens in
step 4.
## 4. Commit with the create-commit skill
## 4. Commit with the commiter subagent
After the implementation is complete, load the **`create-commit`** skill and
follow its workflow to commit the changes. Provide a brief summary of what was
implemented and why, the issue reference (`issue-NNNN`), and the model name you
are running as so the `AI-assisted-by` trailer is set correctly.
After the implementation is complete, delegate the commit to the **`commiter`**
subagent. Give it a brief summary of what was implemented and why, the issue
reference (`issue-NNNN`), and the model name you are running as so it sets the
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
conventions.
Do not push. Pushing is handled separately by the user.
-47
View File
@@ -1,47 +0,0 @@
---
name: create-commit
description: Stage, review, and commit files following Penpot commit conventions.
---
# Skill: create-commit
Produce a git commit that follows Penpot's commit message conventions. This
skill owns the commit format, staging review, and safety checks — it does not
implement features or push.
## When to Use
- After code changes are complete and files need to be committed
- When delegated by a workflow step (e.g. implement-plan) to handle the commit
## Required Reading
Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
is the authoritative source for the commit message format, the emoji menu,
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
## Workflow
1. **Stage the files** specified by the calling context. Do not ask for
confirmation.
2. Run `git diff --staged` to review the content. If you see secrets (API keys,
tokens, passwords, private keys, `.env` values), debug prints, or anything
that does not match the stated intent, **STOP** and tell the user before
committing.
3. Draft the message following the format in the memory doc, wrapping the body
at 72 characters per line, and run:
```bash
git commit -m "<subject>" -m "<body>"
```
(or `git commit -F -` if the body has unusual characters).
4. The `AI-assisted-by` trailer value is provided by the calling context — use
it verbatim.
## Constraints
- Do not push. Pushing is a separate workflow handled by the user.
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`.
- Do not pass `--author`. Author identity comes from the local git config.
- Do not amend a commit you did not create in this session, unless explicitly asked.
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
- Do not add untracked files that were not created in this session.
-1
View File
@@ -39,7 +39,6 @@ This is a monorepo. Principles that apply to one module do *not* generally apply
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
- `library/`: design library workflows; core conventions: `mem:library/core`.
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`.
The memory is structured in a way that you can get the critical information about the
module. You can read it from `mem:<MODULE>/core`
-100
View File
@@ -1,100 +0,0 @@
# Media Processor
Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools).
## Tech Stack
- Language: TypeScript
- Runtime: Node.js
- Framework: Express
- Image processing: sharp (libvips)
- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress
- Upload handling: multer (hybrid storage: memory for small, disk for large)
- Logging: pino (with optional Loki transport)
- Config validation: Zod
- Testing: Vitest
- Package Manager: pnpm
## Project Structure
```
media-processor/
├── src/
│ ├── index.ts # Express app setup, routes, middleware
│ ├── config.ts # Zod-validated env config, HKDF key derivation
│ ├── types.ts # TypeScript type definitions
│ ├── upload.ts # Multer configuration, getFileBuffer helper
│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold)
│ ├── logger.ts # Pino logger setup
│ ├── middleware/
│ │ ├── auth.ts # Timing-safe shared key authentication
│ │ ├── error-handler.ts # ProcessingError class, centralized error handling
│ │ └── timeout.ts # Request timeout middleware
│ ├── routes/
│ │ ├── health.ts # GET /api/health
│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail
│ │ └── font.ts # POST /api/font/convert
│ └── services/
│ ├── image.ts # sharp-based image info/thumbnail generation
│ ├── font.ts # FontForge/woff-tools font conversion
│ └── errors.ts # throwValidation, throwRestriction, throwProcessing
├── test/ # Vitest test files
├── vitest.config.ts # Test configuration
├── tsconfig.json # TypeScript configuration
├── esbuild.config.mjs # Build configuration
└── package.json # Dependencies and scripts
```
## Key Conventions
### Auth
- Requests authenticated via `x-shared-key` header using timing-safe comparison
- When no key configured, all requests rejected with 403
- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY`
### Resource Limits
- Image: max pixels, max width/height enforced before processing
- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits
- Concurrency: p-queue limits concurrent requests (default 10)
- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD`
- Max file size: configurable (default 350MB)
### Error Handling
- `throwValidation(code, hint)` — 400 errors for invalid input
- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded
- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills)
### Image Processing
- EXIF orientation applied before dimension validation and thumbnail generation
- sharp caching disabled to prevent unbounded memory growth
- `withoutEnlargement: true` prevents upscaling small images
### Font Conversion
- Supported formats: TTF, OTF, WOFF, WOFF2
- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF)
- Temp files cleaned up in finally blocks (best-effort)
## Commands
All commands run from `media-processor/` directory:
- `pnpm run test` — Run Vitest test suite
- `pnpm run types:check` — TypeScript type checking (tsc --noEmit)
- `pnpm run fmt` — Format code with Prettier
- `pnpm run fmt:check` — Check formatting without modifying
- `pnpm run build` — Build for production (esbuild)
- `pnpm run start:dev` — Start development server (tsx)
## Docker
- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`)
- Must be deployed on internal Docker network only (not public-facing)
- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI`
## Testing Principles
Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Run `pnpm run test` after changes
- Run `pnpm run types:check` after TypeScript changes
- Run `pnpm run fmt:check` before commits
@@ -14,8 +14,6 @@ automatically pull the identity from the local git config `user.name` and `user.
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why.
Wrap lines at 72 characters — git log and tooling
render long lines poorly. Keep each line concise.
AI-assisted-by: model-name
```
@@ -27,7 +25,3 @@ AI-assisted-by: model-name
## Commit Type Emojis
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
## Referencing Issues
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
-1
View File
@@ -48,7 +48,6 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
org.passay/passay {:mvn/version "1.6.6"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
+5 -7
View File
@@ -4,25 +4,23 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"dependencies": {
"eventsource-parser": "^3.0.6",
"luxon": "^3.7.2",
"sax": "^1.6.1"
"luxon": "^3.4.4",
"sax": "^1.6.0"
},
"devDependencies": {
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21",
"ws": "^8.21.1"
"ws": "^8.21.0"
},
"scripts": {
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs"
"fmt:clj": "cljfmt fix --parallel=true src/ test/"
}
}
+16 -25
View File
@@ -8,15 +8,12 @@ importers:
.:
dependencies:
eventsource-parser:
specifier: ^3.0.6
version: 3.1.0
luxon:
specifier: ^3.7.2
specifier: ^3.4.4
version: 3.7.2
sax:
specifier: ^1.6.1
version: 1.6.1
specifier: ^1.6.0
version: 1.6.0
devDependencies:
nodemon:
specifier: ^3.1.14
@@ -25,8 +22,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.21.1
version: 8.21.1
specifier: ^8.21.0
version: 8.21.0
packages:
@@ -42,9 +39,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -66,10 +63,6 @@ packages:
supports-color:
optional: true
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -137,8 +130,8 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sax@1.6.1:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
semver@7.8.5:
@@ -172,8 +165,8 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
ws@8.21.1:
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -195,7 +188,7 @@ snapshots:
binary-extensions@2.3.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
@@ -223,8 +216,6 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
eventsource-parser@3.1.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -256,7 +247,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.7
ms@2.1.3: {}
@@ -283,7 +274,7 @@ snapshots:
dependencies:
picomatch: 2.3.2
sax@1.6.1: {}
sax@1.6.0: {}
semver@7.8.5: {}
@@ -310,4 +301,4 @@ snapshots:
undefsafe@2.0.5: {}
ws@8.21.1: {}
ws@8.21.0: {}
-2
View File
@@ -1,2 +0,0 @@
minimumReleaseAgeExclude:
- brace-expansion@5.0.8 || 5.0.9
+1 -7
View File
@@ -39,10 +39,4 @@
{:permits 3}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:send-user-feedback/global
{:permits 4}
:send-user-feedback/by-profile
{:permits 1 :queue 3}}
{:permits 1 :queue 2 :timeout 60000}}
-4
View File
@@ -4,7 +4,6 @@ export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
@@ -22,8 +21,6 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
__worker_flag="enable-backend-worker"
fi
export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-password \
@@ -39,7 +36,6 @@ export PENPOT_FLAGS="\
enable-feature-fdata-objects-map \
enable-audit-log \
enable-transit-readable-response \
disable-remote-media-processing \
enable-demo-users \
enable-user-feedback \
disable-secure-session-cookies \
+3 -2
View File
@@ -776,7 +776,7 @@
(defn prepare-organization-sso-provider
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
[cfg {:keys [client-id client-secret issuer]}]
(prepare-oidc-provider cfg
{:type "oidc"
@@ -785,7 +785,8 @@
:base-uri (some-> (non-blank-uri issuer)
(str/rtrim "/")
(str "/"))
:scopes default-oidc-scopes}))
:scopes default-oidc-scopes
:skip-ssrf-check? true}))
(defn build-organization-sso-auth-redirect-uri
"Build the OIDC authorization redirect URI for an organization SSO config.
-53
View File
@@ -1,53 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth.passwords
"Password strength validation using Passay library."
(:require
[app.common.exceptions :as ex])
(:import
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
(defonce ^:private passay-code->translation-key
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
"INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase"
"INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits"
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
(defonce ^:private character-characteristics-rule
(doto (CharacterCharacteristicsRule.)
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
(CharacterRule. EnglishCharacterData/UpperCase 1)
(CharacterRule. EnglishCharacterData/Digit 1)
(CharacterRule. EnglishCharacterData/Special 1)])
(.setNumberOfCharacteristics 4)))
(defn validate-password
"Validates password strength.
Returns nil if valid, or raises exception if invalid.
Checks:
- Minimum length of 8 characters
- At least 1 lowercase letter
- At least 1 uppercase letter
- At least 1 digit
- At least 1 special character"
[password]
(when (< (count password) 8)
(ex/raise :type :validation
:code :weak-password
:hint "password must be at least 8 characters"
:details ["errors.weak-password.too-short"]))
(let [password-data (PasswordData. password)
char-result (.validate character-characteristics-rule password-data)]
(when-not (.isValid char-result)
(ex/raise :type :validation
:code :weak-password
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
:details (->> (.getDetails char-result)
(mapv #(.getErrorCode %))
(mapv passay-code->translation-key)
(filterv some?))))))
+3 -11
View File
@@ -748,17 +748,9 @@
(fmigr/upsert-migrations! conn file))
(let [file (encode-file cfg file)]
(try
(db/insert! conn :file
(file->params file)
(assoc opts ::db/return-keys false))
(catch org.postgresql.util.PSQLException cause
(if (db/duplicate-key-error? cause)
(ex/raise :type :not-found
:code :object-not-found
:hint "file already exists"
:cause cause)
(throw cause))))
(db/insert! conn :file
(file->params file)
(assoc opts ::db/return-keys false))
(->> (file->file-data-params file)
(fdata/upsert! cfg))
-4
View File
@@ -174,10 +174,6 @@
(assert-mark m :obj)
(let [size (read-long! input)]
(assert (pos? size) "incorrect header size found on reading header")
(when (> size bfc/max-object-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (dm/str "unable to import object with size " size " bytes")))
(let [buff (byte-array size)]
(read-bytes! input buff)
(fres/decode buff)))))
-4
View File
@@ -121,7 +121,6 @@
[:exporter-shared-key {:optional true} :string]
[:admin-console-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string]
[:media-processor-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string]
@@ -148,9 +147,6 @@
[:imagemagick-width-limit {:optional true} :string]
[:imagemagick-height-limit {:optional true} :string]
[:media-processing-service-uri {:optional true} ::sm/uri]
[:media-processing-service-timeout {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
+8 -17
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]
@@ -43,7 +42,7 @@
(defn- get-file-media-object
[pool id]
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
@@ -110,21 +109,13 @@
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
[{:keys [::sto/storage] :as cfg} request kf]
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)]
(if (nil? mobj)
{::yres/status 404}
(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)]
(if-not (:can-read perms)
{::yres/status 404}
(let [sobj (sto/get-object storage (kf mobj))]
(if sobj
(serve-object cfg sobj)
{::yres/status 404})))))))
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)
sobj (sto/get-object storage (kf mobj))]
(if sobj
(serve-object cfg sobj)
{::yres/status 404})))
(defn file-objects-handler
"Handler that serves storage objects by file media id."
+4 -6
View File
@@ -335,7 +335,6 @@
::rpc/rlimit (ig/ref ::rpc/rlimit)
::setup/templates (ig/ref ::setup/templates)
::setup/props (ig/ref ::setup/props)
::setup/shared-keys (ig/ref ::setup/shared-keys)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)
@@ -468,11 +467,10 @@
::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nexus (cf/get :nexus-shared-key)
:admin-console (cf/get :admin-console-shared-key)
:exporter (cf/get :exporter-shared-key)
:media-processor (cf/get :media-processor-shared-key)}
{::setup/props (ig/ref ::setup/props)
:nexus (cf/get :nexus-shared-key)
:admin-console (cf/get :admin-console-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock
{}
+480 -37
View File
@@ -5,37 +5,316 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.media
"Media & Font postprocessing.
This namespace is the dispatch layer only. Processing implementations
live in two separate namespaces, each owning their own defmulti:
app.media.local — shell/ImageMagick/FontForge implementations
app.media.remote — HTTP delegation to media-processor service
Validation and schemas live in app.media.validation (leaf namespace,
no circular dep). When adding a new :cmd type, add defmethods in
BOTH local and remote."
"Media & Font postprocessing."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.schema.openapi :as-alias oapi]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
[app.media.local :as media.local]
[app.media.remote :as media.remote]
[app.media.sanitize :as sanitize]
[app.media.validation :as validation]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[app.util.shell :as shell]
[buddy.core.bytes :as bb]
[buddy.core.codecs :as bc]
[clojure.string]
[clojure.xml :as xml]
[cuerdas.core :as str]
[datoteka.io :as io]))
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
clojure.lang.XMLHandler
java.io.InputStream
javax.xml.parsers.SAXParserFactory
javax.xml.XMLConstants
org.apache.commons.io.IOUtils))
(def schema:upload
[:map {:title "Upload"}
[:filename :string]
[:size ::sm/int]
[:path ::fs/path]
[:mtype {:optional true} :string]
[:headers {:optional true}
[:map-of :string :string]]])
(def ^:private schema:input
[:map {:title "Input"}
[:path ::fs/path]
[:mtype {:optional true} ::sm/text]])
(def check-input
(sm/check-fn schema:input))
(defn validate-media-type!
([upload] (validate-media-type! upload cm/image-types))
([upload allowed]
(when-not (contains? allowed (:mtype upload))
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "Seems like you are uploading an invalid media object"))
upload))
(defn validate-media-size!
[upload]
(let [max-size (cf/get :media-max-file-size)]
(when (> (:size upload) max-size)
(ex/raise :type :restriction
:code :media-max-file-size-reached
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
(:size upload)
max-size)))
upload))
(defn validate-font-size!
"Validates that the font file `upload` does not exceed the configured
`:font-max-file-size` limit. Accepts the same map shape as
`validate-media-size!` — requires a `:size` key in bytes."
[upload]
(let [max-size (cf/get :font-max-file-size)]
(when (> (:size upload) max-size)
(ex/raise :type :restriction
:code :font-max-file-size-reached
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
(:size upload)
max-size)))
upload))
(defmulti process (fn [_system params] (:cmd params)))
(defmethod process :default
[_system {:keys [cmd] :as params}]
(ex/raise :type :internal
:code :not-implemented
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
(defn run
[system params]
(if (contains? cf/flags :remote-media-processing)
(media.remote/process system params)
(media.local/process system params)))
(process system params))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG PARSING
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- secure-parser-factory
[^InputStream input ^XMLHandler handler]
(.. (doto (SAXParserFactory/newInstance)
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
(newSAXParser)
(parse input handler)))
(defn- strip-doctype
[data]
(cond-> data
(str/includes? data "<!DOCTYPE")
(str/replace #"<\!DOCTYPE[^>]*>" "")))
(defn- parse-svg
[text]
(let [text (strip-doctype text)]
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
(xml/parse istream secure-parser-factory))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE THUMBNAILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:thumbnail-params
[:map {:title "ThumbnailParams"}
[:input schema:input]
[:format [:enum :jpeg :webp :png]]
[:quality [:int {:min 1 :max 100}]]
[:width :int]
[:height :int]])
(def ^:private check-thumbnail-params
(sm/check-fn schema:thumbnail-params))
;; Related info on how thumbnails generation
;; http://www.imagemagick.org/Usage/thumbnails/
(def ^:private imagemagick-default-env
"Default environment variables for ImageMagick resource limits.
These are the soft ceiling — policy.xml is the hard ceiling."
{"MAGICK_THREAD_LIMIT" "2"
"MAGICK_MEMORY_LIMIT" "256MiB"
"MAGICK_MAP_LIMIT" "512MiB"
"MAGICK_AREA_LIMIT" "128MP"
"MAGICK_DISK_LIMIT" "1GiB"
"MAGICK_TIME_LIMIT" "30"})
(defn- get-imagemagick-env
"Returns environment variables for ImageMagick commands.
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
[]
(let [thread (cf/get :imagemagick-thread-limit)
memory (cf/get :imagemagick-memory-limit)
map-l (cf/get :imagemagick-map-limit)
area (cf/get :imagemagick-area-limit)
disk (cf/get :imagemagick-disk-limit)
time (cf/get :imagemagick-time-limit)
width (cf/get :imagemagick-width-limit)
height (cf/get :imagemagick-height-limit)]
(cond-> imagemagick-default-env
thread (assoc "MAGICK_THREAD_LIMIT" thread)
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
area (assoc "MAGICK_AREA_LIMIT" area)
disk (assoc "MAGICK_DISK_LIMIT" disk)
time (assoc "MAGICK_TIME_LIMIT" time)
width (assoc "MAGICK_WIDTH_LIMIT" width)
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
(defn- exec-magick!
"Execute an ImageMagick command with resource limits.
`args` is a vector of string arguments to pass to `magick`."
[system args]
(let [cmd (into ["magick"] args)
result (shell/exec! system
:cmd cmd
:env (get-imagemagick-env)
:timeout 60)]
(when (not= 0 (:exit result))
(ex/raise :type :validation
:code :invalid-image
:hint (str "ImageMagick command failed: " (:err result))
:cmd cmd
:exit (:exit result)))
result))
(defn- generic-process
[system {:keys [input format convert-args] :as params}]
(let [{:keys [path mtype]} input
format (or format (cm/mtype->format mtype))
ext (cm/format->extension format)
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
(exec-magick! system args)
(assoc params
:format format
:mtype (cm/format->mtype format)
:size (fs/size tmp)
:data tmp)))
(defmethod process :generic-thumbnail
[system params]
(let [{:keys [quality width height] :as params}
(check-thumbnail-params params)]
(generic-process system
(assoc params
:convert-args ["-auto-orient" "-strip"
"-thumbnail" (str width "x" height ">")
"-quality" (str quality)]))))
(defmethod process :profile-thumbnail
[system params]
(let [{:keys [quality width height] :as params}
(check-thumbnail-params params)]
(generic-process system
(assoc params
:convert-args ["-auto-orient" "-strip"
"-thumbnail" (str width "x" height "^")
"-gravity" "center"
"-extent" (str width "x" height)
"-quality" (str quality)]))))
(defn get-basic-info-from-svg
[{:keys [tag attrs] :as data}]
(when (not= tag :svg)
(ex/raise :type :validation
:code :unable-to-parse-svg
:hint "uploaded svg has invalid content"))
(reduce (fn [default f]
(if-let [res (f attrs)]
(reduced res)
default))
{:width 100 :height 100}
[(fn parse-width-and-height
[{:keys [width height]}]
(when (and (string? width)
(string? height))
(let [width (d/parse-double width)
height (d/parse-double height)]
(when (and width height)
{:width (int width)
:height (int height)}))))
(fn parse-viewbox
[{:keys [viewBox]}]
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
(map d/parse-double))]
(when (and x y width height)
{:width (int width)
:height (int height)})))]))
(defn- get-dimensions-with-orientation [system ^String path]
;; Image magick doesn't give info about exif rotation so we use the identify command
;; If we are processing an animated gif we use the first frame with -scene 0
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
(when (= 0 (:exit dim-result))
(let [[w h] (-> (:out dim-result)
str/trim
(clojure.string/split #"\s+")
(->> (mapv #(Integer/parseInt %))))
orientation-exit (:exit orient-result)
orientation (-> orient-result :out str/trim)]
(if (= 0 orientation-exit)
(case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h}) ; Normal or unknown orientation
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
(defmethod process :info
[system {:keys [input] :as params}]
(let [{:keys [path mtype] :as input} (check-input input)]
(if (= mtype "image/svg+xml")
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
(when-not info
(ex/raise :type :validation
:code :invalid-svg-file
:hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now) :size (fs/size path)}))
(let [path-str (str path)
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
mtype' (if (zero? (:exit identify-res))
(-> identify-res
:out
str/trim
(str/split #"\s+" 2)
first
str/lower)
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image"))
{:keys [width height]}
(or (get-dimensions-with-orientation system path-str)
(do
(l/warn "Failed to read image dimensions with orientation" {:path path})
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image")))]
(when (and (string? mtype)
(not= (str/lower mtype) mtype'))
(ex/raise :type :validation
:code :media-type-mismatch
:hint (str "Seems like you are uploading a file whose content does not match the extension."
"Expected: " mtype ". Got: " mtype')))
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE HELPERS
@@ -59,8 +338,8 @@
:hint "seems like the url points to resource with unknown size"))
(-> {:size size :mtype mtype}
(validation/validate-media-type!)
(validation/validate-media-size!))))]
(validate-media-type!)
(validate-media-size!))))]
(let [{:keys [body] :as response}
(try
@@ -88,24 +367,188 @@
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
:cause cause)))]
:cause cause)))
(if body
(with-open [body body]
(let [{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
;; Sanitize: strip trailing data after image EOF markers
(let [new-size (sanitize/truncate-after-eof path mtype)]
{:path path
:mtype mtype
:size new-size})))
;; Sanitize: strip trailing data after image EOF markers
(let [new-size (sanitize/truncate-after-eof path mtype)]
{:path path
:mtype mtype
:size new-size}))))
;; No body - validation will raise appropriate error
(parse-and-validate response)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FONTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- get-font-prlimit
"Returns resource limits for font processing tools, read from config."
[]
{:mem (cf/get :font-process-mem)
:cpu (cf/get :font-process-cpu)})
(defn- get-font-timeout
"Returns the wall-clock timeout for font processing, read from config."
[]
(cf/get :font-process-timeout))
(defn- exec-font!
"Execute a font processing command with resource limits.
`args` is a vector of string arguments."
[system args]
(shell/exec! system
:cmd args
:prlimit (get-font-prlimit)
:timeout (get-font-timeout)))
(defmethod process :generate-fonts
[system {:keys [input] :as params}]
(letfn [(ttf->otf [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".otf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput))])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(otf->ttf [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".ttf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput))])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(ttf-or-otf->woff [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".woff"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(woff->sfnt [data]
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
(try
(io/write* finput data)
(let [res (shell/exec! system
:cmd ["woff2sfnt" (str finput)]
:out-enc :bytes
:prlimit (get-font-prlimit)
:timeout (get-font-timeout))]
(when (zero? (:exit res))
(:out res)))
(finally
(fs/delete finput)))))
(woff2->sfnt [data]
;; woff2_decompress outputs to same directory with .ttf extension
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
(if (zero? (:exit res))
foutput
(do
(when (fs/exists? foutput)
(fs/delete foutput))
nil)))
(finally
(fs/delete finput)))))
;; Documented here:
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
(get-sfnt-type [data]
(let [buff (bb/slice data 0 4)
type (bc/bytes->hex buff)]
(case type
"4f54544f" :otf
"00010000" :ttf
(ex/raise :type :internal
:code :unexpected-data
:hint "unexpected font data"))))
(gen-if-nil [val factory]
(if (nil? val)
(factory)
val))]
(let [current (into #{} (keys input))]
(cond
(contains? current "font/ttf")
(let [data (get input "font/ttf")]
(-> input
(update "font/otf" gen-if-nil #(ttf->otf data))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
(contains? current "font/otf")
(let [data (get input "font/otf")]
(-> input
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
(assoc "font/ttf" (otf->ttf data))))
(contains? current "font/woff")
(let [data (get input "font/woff")
sfnt (woff->sfnt data)]
(when-not sfnt
(ex/raise :type :validation
:code :invalid-woff-file
:hint "invalid woff file"))
(let [stype (get-sfnt-type sfnt)]
(cond-> input
true
(-> (assoc "font/woff" data))
(= stype :otf)
(-> (assoc "font/otf" sfnt)
(assoc "font/ttf" (otf->ttf sfnt)))
(= stype :ttf)
(-> (assoc "font/otf" (ttf->otf sfnt))
(assoc "font/ttf" sfnt)))))
(contains? current "font/woff2")
(let [data (get input "font/woff2")
foutput (woff2->sfnt data)]
(when-not foutput
(ex/raise :type :validation
:code :invalid-woff2-file
:hint "invalid woff2 file"))
(try
(let [sfnt (io/read* foutput)
type (get-sfnt-type sfnt)]
(cond-> input
(= type :otf)
(-> (assoc "font/otf" sfnt)
(assoc "font/ttf" (otf->ttf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
(= type :ttf)
(-> (assoc "font/ttf" sfnt)
(assoc "font/otf" (ttf->otf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
(finally
(fs/delete foutput))))))))
-366
View File
@@ -1,366 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.media.local
"Local media processing via ImageMagick and FontForge shell commands."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.media.svg :as svg]
[app.media.validation :as validation]
[app.storage.tmp :as tmp]
[app.util.shell :as shell]
[buddy.core.bytes :as bb]
[buddy.core.codecs :as bc]
[clojure.string]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]))
(defmulti process (fn [_system params] (:cmd params)))
(defmethod process :default
[_system {:keys [cmd] :as params}]
(ex/raise :type :internal
:code :not-implemented
:hint (str/fmt "No impl found for local process cmd: %s" cmd)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE THUMBNAILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:thumbnail-params
[:map {:title "ThumbnailParams"}
[:input validation/schema:input]
[:format [:enum :jpeg :webp :png]]
[:quality [:int {:min 1 :max 100}]]
[:width :int]
[:height :int]])
(def ^:private check-thumbnail-params
(sm/check-fn schema:thumbnail-params))
;; Related info on how thumbnails generation
;; http://www.imagemagick.org/Usage/thumbnails/
(def ^:private imagemagick-default-env
"Default environment variables for ImageMagick resource limits.
These are the soft ceiling — policy.xml is the hard ceiling."
{"MAGICK_THREAD_LIMIT" "2"
"MAGICK_MEMORY_LIMIT" "256MiB"
"MAGICK_MAP_LIMIT" "512MiB"
"MAGICK_AREA_LIMIT" "128MP"
"MAGICK_DISK_LIMIT" "1GiB"
"MAGICK_TIME_LIMIT" "30"})
(defn- get-imagemagick-env
"Returns environment variables for ImageMagick commands.
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
[]
(let [thread (cf/get :imagemagick-thread-limit)
memory (cf/get :imagemagick-memory-limit)
map-l (cf/get :imagemagick-map-limit)
area (cf/get :imagemagick-area-limit)
disk (cf/get :imagemagick-disk-limit)
time (cf/get :imagemagick-time-limit)
width (cf/get :imagemagick-width-limit)
height (cf/get :imagemagick-height-limit)]
(cond-> imagemagick-default-env
thread (assoc "MAGICK_THREAD_LIMIT" thread)
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
area (assoc "MAGICK_AREA_LIMIT" area)
disk (assoc "MAGICK_DISK_LIMIT" disk)
time (assoc "MAGICK_TIME_LIMIT" time)
width (assoc "MAGICK_WIDTH_LIMIT" width)
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
(defn- exec-magick!
"Execute an ImageMagick command with resource limits.
`args` is a vector of string arguments to pass to `magick`."
[system args]
(let [cmd (into ["magick"] args)
result (shell/exec! system
:cmd cmd
:env (get-imagemagick-env)
:timeout 60)]
(when (not= 0 (:exit result))
(ex/raise :type :validation
:code :invalid-image
:hint (str "ImageMagick command failed: " (:err result))
:cmd cmd
:exit (:exit result)))
result))
(defn- generic-process
[system {:keys [input format convert-args] :as params}]
(let [{:keys [path mtype]} input
format (or format (cm/mtype->format mtype))
ext (cm/format->extension format)
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
(exec-magick! system args)
(assoc params
:format format
:mtype (cm/format->mtype format)
:size (fs/size tmp)
:data tmp)))
(defmethod process :generic-thumbnail
[system params]
(let [{:keys [quality width height] :as params}
(check-thumbnail-params params)]
(generic-process system
(assoc params
:convert-args ["-auto-orient" "-strip"
"-thumbnail" (str width "x" height ">")
"-quality" (str quality)]))))
(defmethod process :profile-thumbnail
[system params]
(let [{:keys [quality width height] :as params}
(check-thumbnail-params params)]
(generic-process system
(assoc params
:convert-args ["-auto-orient" "-strip"
"-thumbnail" (str width "x" height "^")
"-gravity" "center"
"-extent" (str width "x" height)
"-quality" (str quality)]))))
(defn- get-dimensions-with-orientation [system ^String path]
;; Image magick doesn't give info about exif rotation so we use the identify command
;; If we are processing an animated gif we use the first frame with -scene 0
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
(when (= 0 (:exit dim-result))
(let [[w h] (-> (:out dim-result)
str/trim
(clojure.string/split #"\s+")
(->> (mapv #(Integer/parseInt %))))
orientation-exit (:exit orient-result)
orientation (-> orient-result :out str/trim)]
(if (= 0 orientation-exit)
(case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h}) ; Normal or unknown orientation
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
(defmethod process :info
[system {:keys [input] :as params}]
(let [{:keys [path mtype] :as input} (validation/check-input input)]
(if (= mtype "image/svg+xml")
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
(when-not info
(ex/raise :type :validation
:code :invalid-svg-file
:hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now) :size (fs/size path)}))
(let [path-str (str path)
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
mtype' (if (zero? (:exit identify-res))
(-> identify-res
:out
str/trim
(str/split #"\s+" 2)
first
str/lower)
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image"))
{:keys [width height]}
(or (get-dimensions-with-orientation system path-str)
(do
(l/warn "Failed to read image dimensions with orientation" {:path path})
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image")))]
(when (and (string? mtype)
(not= (str/lower mtype) mtype'))
(ex/raise :type :validation
:code :media-type-mismatch
:hint (str "Seems like you are uploading a file whose content does not match the extension."
"Expected: " mtype ". Got: " mtype')))
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FONTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- get-font-prlimit
"Returns resource limits for font processing tools, read from config."
[]
{:mem (cf/get :font-process-mem)
:cpu (cf/get :font-process-cpu)})
(defn- get-font-timeout
"Returns the wall-clock timeout for font processing, read from config."
[]
(cf/get :font-process-timeout))
(defn- exec-font!
"Execute a font processing command with resource limits.
`args` is a vector of string arguments."
[system args]
(shell/exec! system
:cmd args
:prlimit (get-font-prlimit)
:timeout (get-font-timeout)))
(defmethod process :generate-fonts
[system {:keys [input] :as params}]
(letfn [(ttf->otf [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".otf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput))])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(otf->ttf [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".ttf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput))])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(ttf-or-otf->woff [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".woff"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(woff->sfnt [data]
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
(try
(io/write* finput data)
(let [res (shell/exec! system
:cmd ["woff2sfnt" (str finput)]
:out-enc :bytes
:prlimit (get-font-prlimit)
:timeout (get-font-timeout))]
(when (zero? (:exit res))
(:out res)))
(finally
(fs/delete finput)))))
(woff2->sfnt [data]
;; woff2_decompress outputs to same directory with .ttf extension
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
(if (zero? (:exit res))
foutput
(do
(when (fs/exists? foutput)
(fs/delete foutput))
nil)))
(finally
(fs/delete finput)))))
;; Documented here:
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
(get-sfnt-type [data]
(let [buff (bb/slice data 0 4)
type (bc/bytes->hex buff)]
(case type
"4f54544f" :otf
"00010000" :ttf
(ex/raise :type :internal
:code :unexpected-data
:hint "unexpected font data"))))
(gen-if-nil [val factory]
(if (nil? val)
(factory)
val))]
(let [current (into #{} (keys input))]
(cond
(contains? current "font/ttf")
(let [data (get input "font/ttf")]
(-> input
(update "font/otf" gen-if-nil #(ttf->otf data))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
(contains? current "font/otf")
(let [data (get input "font/otf")]
(-> input
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
(assoc "font/ttf" (otf->ttf data))))
(contains? current "font/woff")
(let [data (get input "font/woff")
sfnt (woff->sfnt data)]
(when-not sfnt
(ex/raise :type :validation
:code :invalid-woff-file
:hint "invalid woff file"))
(let [stype (get-sfnt-type sfnt)]
(cond-> input
true
(-> (assoc "font/woff" data))
(= stype :otf)
(-> (assoc "font/otf" sfnt)
(assoc "font/ttf" (otf->ttf sfnt)))
(= stype :ttf)
(-> (assoc "font/otf" (ttf->otf sfnt))
(assoc "font/ttf" sfnt)))))
(contains? current "font/woff2")
(let [data (get input "font/woff2")
foutput (woff2->sfnt data)]
(when-not foutput
(ex/raise :type :validation
:code :invalid-woff2-file
:hint "invalid woff2 file"))
(try
(let [sfnt (io/read* foutput)
type (get-sfnt-type sfnt)]
(cond-> input
(= type :otf)
(-> (assoc "font/otf" sfnt)
(assoc "font/ttf" (otf->ttf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
(= type :ttf)
(-> (assoc "font/ttf" sfnt)
(assoc "font/otf" (ttf->otf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
(finally
(fs/delete foutput))))))))
-264
View File
@@ -1,264 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.media.remote
"Remote media processing via the media-processor HTTP service."
(:require
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.common.time :as ct]
[app.common.uri :as uri]
[app.config :as cf]
[app.http.client :as http]
[app.media.svg :as svg]
[app.media.validation :as validation]
[app.setup :as-alias setup]
[app.storage.tmp :as tmp]
[app.util.json :as json]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.ByteArrayInputStream
java.io.InputStream
java.io.SequenceInputStream
java.net.ConnectException
java.net.http.HttpTimeoutException
java.util.Collections))
(defn- service-base-url
"Returns the base URL of the media-processor service."
[]
(or (cf/get :media-processing-service-uri)
(ex/raise :type :internal
:code :media-processor-not-configured
:hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured")))
(defn- service-timeout
"Returns the HTTP timeout (ms) for media-processor requests."
[]
(or (cf/get :media-processing-service-timeout)
120000))
(defn- get-shared-key
"Returns the shared key for authenticating with the media-processor."
[system]
(-> system ::setup/shared-keys :media-processor))
(defn- parse-json-response
"Parse a JSON response body."
[body]
(json/read! body))
(defn- translate-error
"Translate a media-processor error response into a Penpot exception."
[status body]
(let [code (or (:code body) "media-processor-error")
hint (or (:hint body) "media-processor request failed")]
(case status
400 {:type :validation :code (keyword code) :hint hint}
403 {:type :authorization :code :forbidden :hint hint}
413 {:type :restriction :code (keyword code) :hint hint}
504 {:type :internal :code :media-processor-timeout :hint hint}
{:type :internal :code (keyword code) :hint hint})))
(defn service-request
"Make an HTTP request to the media-processor service."
[system {:keys [method uri body headers timeout]}]
(let [client (::http/client system)
timeout (or timeout (service-timeout))]
(try
(let [resp (http/req client
{:method method
:uri uri
:body body
:headers headers}
{:response-type :input-stream
:skip-ssrf-check? true
:timeout timeout})
status (:status resp)]
(when (not (<= 200 status 299))
(let [body (:body resp)]
(try
(let [parsed (try (parse-json-response body) (catch Exception _ nil))
err (translate-error status parsed)]
(ex/raise :type (:type err) :code (:code err) :hint (:hint err)))
(finally
(.close body)))))
resp)
(catch ConnectException _cause
(ex/raise :type :internal
:code :media-processor-unavailable
:hint "Cannot connect to media-processor service"))
(catch HttpTimeoutException _cause
(ex/raise :type :internal
:code :media-processor-timeout
:hint "media-processor service request timed out")))))
(defn- multipart-boundary
[]
(str "----PenpotBoundary" (System/currentTimeMillis)))
(defn- build-multipart-stream
"Build a streaming multipart/form-data body with a single file field.
Returns an InputStream that lazily reads from the file on demand."
[^String boundary mtype ^InputStream file-stream]
(let [header (.getBytes (str "--" boundary "\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
"Content-Type: " mtype "\r\n"
"\r\n")
"UTF-8")
footer (.getBytes (str "\r\n--" boundary "--\r\n")
"UTF-8")
parts (Collections/enumeration
[(ByteArrayInputStream. header)
file-stream
(ByteArrayInputStream. footer)])]
(SequenceInputStream. parts)))
(defn- service-multipart-request
"Send a multipart request to the media-processor service.
Accepts a file from disk via :path. The file stream is closed
after the HTTP request completes (success or failure)."
[system {:keys [endpoint path mtype query timeout]}]
(let [shared-key (get-shared-key system)
boundary (multipart-boundary)
ctype (or mtype "application/octet-stream")
base-url (service-base-url)
request-uri (cond-> (uri/join base-url endpoint)
(seq query)
(str "?" (uri/map->query-string query)))]
(with-open [file-stream (io/input-stream path)]
(let [body (build-multipart-stream boundary ctype file-stream)]
(service-request system
{:method :post
:uri request-uri
:body body
:headers {"Content-Type" (str "multipart/form-data; boundary=" boundary)
"x-shared-key" shared-key}
:timeout timeout})))))
(def ^:private known-font-types
"Priority-ordered list of font mime-types the system knows how to convert.
Order matters: when a font upload contains multiple variants, the first
match becomes the conversion source (ttf preferred for best coverage)."
["font/ttf" "font/otf" "font/woff" "font/woff2"])
(defn- font-convert
"Convert a font to the given target mime-type via the media-processor service.
Accepts source font data as a filesystem Path. Returns a tempfile Path."
[system source-mtype target-mtype data]
(let [resp (service-multipart-request system {:endpoint "api/font/convert"
:path data
:mtype source-mtype
:query {:target-type target-mtype}
:timeout 180000})
ext (cm/mtype->extension target-mtype)
tmp (tmp/tempfile :prefix "penpot.font." :suffix ext)
body (:body resp)]
(try
(io/write* tmp body)
(finally
(.close body)))
tmp))
(defn- font-missing-variants
"Return the set of target mime-types that should be generated for the given
source mime-type (excluding font/woff2, which is never generated)."
[source-mtype]
(case source-mtype
"font/ttf" #{"font/otf" "font/woff"}
"font/otf" #{"font/ttf" "font/woff"}
"font/woff" #{"font/ttf" "font/otf"}
"font/woff2" #{"font/ttf" "font/otf" "font/woff"}))
(defmulti process (fn [_system params] (:cmd params)))
(defmethod process :info
[system {:keys [input]}]
(let [{:keys [path mtype]} (validation/check-input input)]
(if (= mtype "image/svg+xml")
;; SVG: parse locally (Sharp doesn't support SVG)
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
(when-not info
(ex/raise :type :validation
:code :invalid-svg-file
:hint "uploaded svg does not provide dimensions"))
(merge input info {:ts (ct/now) :size (fs/size path)}))
;; Raster: delegate to media-processor
(let [resp (service-multipart-request system {:endpoint "api/image/info"
:path path
:mtype mtype})
body (:body resp)]
(try
(let [info (parse-json-response body)
detected-mtype (:mtype info)]
(when (and (string? mtype)
(string? detected-mtype)
(not= (str/lower mtype) (str/lower detected-mtype)))
(ex/raise :type :validation
:code :media-type-mismatch
:hint (str "File content does not match the declared type. "
"Expected: " mtype ". Got: " detected-mtype)))
(assoc input
:width (:width info)
:height (:height info)
:size (fs/size path)
:ts (ct/now)))
(finally
(.close body)))))))
(defn- thumbnail-request
"Shared implementation for generic-thumbnail and profile-thumbnail."
[system params mode]
(let [{:keys [input format quality width height]} params
{:keys [path mtype]} (validation/check-input input)
fmt (name (or format (cm/mtype->format mtype) :jpeg))
resp (service-multipart-request system {:endpoint "api/image/thumbnail"
:path path
:mtype mtype
:query {:width width
:height height
:quality quality
:format fmt
:mode mode}})
out-format (or format (cm/mtype->format mtype) :jpeg)
ext (cm/format->extension out-format)
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
body (:body resp)]
(try
(io/write* tmp body)
(finally
(.close body)))
(assoc params
:format out-format
:mtype (cm/format->mtype out-format)
:size (fs/size tmp)
:data tmp)))
(defmethod process :generic-thumbnail
[system params]
(thumbnail-request system params "fit"))
(defmethod process :profile-thumbnail
[system params]
(thumbnail-request system params "crop"))
(defmethod process :generate-fonts
[system {:keys [input]}]
(let [source-mtype (or (some #(when (contains? input %) %) known-font-types)
(ex/raise :type :validation
:code :invalid-font
:hint "No recognized font variant in input"))
data (get input source-mtype)
present (set (keys input))
targets (remove present (font-missing-variants source-mtype))]
(reduce (fn [acc target-mtype]
(assoc acc target-mtype
(font-convert system source-mtype target-mtype data)))
input
targets)))
-130
View File
@@ -1,130 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.media.svg
"SVG parsing, sanitization, and info extraction.
Centralizes all SVG-related security concerns."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[clojure.xml :as xml]
[cuerdas.core :as str])
(:import
clojure.lang.XMLHandler
java.io.InputStream
javax.xml.parsers.SAXParserFactory
javax.xml.XMLConstants
org.apache.commons.io.IOUtils))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG PARSING
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- secure-parser-factory
[^InputStream input ^XMLHandler handler]
(.. (doto (SAXParserFactory/newInstance)
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
(newSAXParser)
(parse input handler)))
(defn- strip-doctype
[data]
(cond-> data
(str/includes? data "<!DOCTYPE")
(str/replace #"<\!DOCTYPE[^>]*>" "")))
(defn parse-svg
[text]
(let [text (strip-doctype text)]
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
(xml/parse istream secure-parser-factory))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG SANITIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
(def ^:private javascript-href-pattern #"(?i)^javascript:")
(defn- sanitize-svg-element
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
[{:keys [tag attrs content] :as element}]
(when (and (map? element) tag)
(let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}]
(when-not (contains? dangerous-tags tag)
(let [clean-attrs (->> attrs
(remove (fn [[k v]]
(or (re-matches dangerous-attrs-pattern (name k))
(and (#{:href :xlink:href} k)
(string? v)
(re-find javascript-href-pattern (str/trim v))))))
(into {}))
clean-content (when content
(->> content
(filter #(or (string? %) (map? %)))
(map (fn [child]
(if (map? child)
(sanitize-svg-element child)
child)))
(filter some?)
vec))]
(cond-> {:tag tag :attrs clean-attrs}
(seq clean-content) (assoc :content clean-content)))))))
(defn sanitize-svg
"Sanitize SVG content by removing dangerous elements and attributes.
Removes <script> tags, <foreignObject> elements, event handlers (on*),
and javascript: URLs from href attributes."
[svg-text]
(try
(let [parsed (parse-svg svg-text)
sanitized (sanitize-svg-element parsed)]
(if sanitized
(with-out-str (xml/emit sanitized))
(ex/raise :type :validation
:code :invalid-svg-file
:hint "SVG sanitization produced no output")))
(catch Exception e
(l/warn :hint "SVG sanitization failed, rejecting upload" :cause e)
(ex/raise :type :validation
:code :invalid-svg-file
:hint "SVG parsing failed during sanitization"
:cause e))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG INFO EXTRACTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn get-basic-info-from-svg
[{:keys [tag attrs] :as data}]
(when (not= tag :svg)
(ex/raise :type :validation
:code :unable-to-parse-svg
:hint "uploaded svg has invalid content"))
(reduce (fn [default f]
(if-let [res (f attrs)]
(reduced res)
default))
{:width 100 :height 100}
[(fn parse-width-and-height
[{:keys [width height]}]
(when (and (string? width)
(string? height))
(let [width (d/parse-double width)
height (d/parse-double height)]
(when (and width height)
{:width (int width)
:height (int height)}))))
(fn parse-viewbox
[{:keys [viewBox]}]
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
(map d/parse-double))]
(when (and x y width height)
{:width (int width)
:height (int height)})))]))
-68
View File
@@ -1,68 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.media.validation
"Schemas and validation functions for media uploads.
Leaf namespace — depends on app.common.* and app.config only."
(:require
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.config :as cf]
[cuerdas.core :as str]
[datoteka.fs :as fs]))
(def schema:upload
[:map {:title "Upload"}
[:filename :string]
[:size ::sm/int]
[:path ::fs/path]
[:mtype {:optional true} :string]
[:headers {:optional true}
[:map-of :string :string]]])
(def schema:input
[:map {:title "Input"}
[:path ::fs/path]
[:mtype {:optional true} ::sm/text]])
(def check-input
(sm/check-fn schema:input))
(defn validate-media-type!
([upload] (validate-media-type! upload cm/image-types))
([upload allowed]
(when-not (contains? allowed (:mtype upload))
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "Seems like you are uploading an invalid media object"))
upload))
(defn validate-media-size!
[upload]
(let [max-size (cf/get :media-max-file-size)]
(when (> (:size upload) max-size)
(ex/raise :type :restriction
:code :media-max-file-size-reached
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
(:size upload)
max-size)))
upload))
(defn validate-font-size!
"Validates that the font file `upload` does not exceed the configured
`:font-max-file-size` limit. Accepts the same map shape as
`validate-media-size!` — requires a `:size` key in bytes."
[upload]
(let [max-size (cf/get :font-max-file-size)]
(when (> (:size upload) max-size)
(ex/raise :type :restriction
:code :font-max-file-size-reached
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
(:size upload)
max-size)))
upload))
+2 -11
View File
@@ -8,7 +8,6 @@
(:require
[app.auth :as auth]
[app.auth.oidc :as oidc]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
@@ -183,7 +182,6 @@
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
nil))]
(passwords/validate-password password)
(->> (validate-token token)
(update-password conn))
@@ -242,9 +240,6 @@
:code :email-as-password
:hint "you can't use your email as password"))
;; Validate password strength against common password dictionary
(passwords/validate-password (:password params))
(when (eml/has-bounce-reports? cfg (:email params))
(ex/raise :type :restriction
:code :email-has-permanent-bounces
@@ -263,8 +258,7 @@
(validate-register-attempt! cfg params)
(let [email (profile/clean-email email)
profile (profile/get-profile-by-email pool email)
fullname (d/normalize-string fullname)]
profile (profile/get-profile-by-email pool email)]
;; SECURITY: refuse to issue a prepared-register token when an active
;; profile already exists for this email.
@@ -365,9 +359,6 @@
is-active (:is-active params false)
theme (:theme params nil)
email (str/lower email)
fullname (d/normalize-string (:fullname params))
locale (d/normalize-string locale)
theme (d/normalize-string theme)
photo-id (some->> (or (:oidc/picture props)
(:google/picture props)
@@ -376,7 +367,7 @@
(import-profile-picture cfg))
params {:id id
:fullname fullname
:fullname (:fullname params)
:email email
:auth-backend backend
:lang locale
+18 -7
View File
@@ -19,7 +19,7 @@
[app.http.sse :as sse]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media.validation :as media.v]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :as media-cmd]
@@ -118,38 +118,48 @@
(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]
[:file-id {:optional true} ::sm/uuid]
[:version {:optional true} ::sm/int]
[:file {:optional true} media.v/schema:upload]
[:file {:optional true} media/schema:upload]
[:upload-id {:optional true} ::sm/uuid]]
[:fn {:error/message "one of :file or :upload-id is required"}
(fn [{:keys [file upload-id]}]
(or (some? file) (some? upload-id)))]])
(sv/defmethod ::import-binfile
"Import a penpot file in a binary format.
"Import a penpot file in a binary format. If `file-id` is provided,
an in-place import will be performed instead of creating a new file.
The in-place imports are only supported for binfile-v3 and when a
.penpot file only contains one penpot file.
The file content may be provided either as a multipart `file` upload
or as an `upload-id` referencing a completed chunked-upload session,
which allows importing files larger than the multipart size limit.
"
{::doc/added "1.15"
::doc/changes [["1.20" "Set default version to 3"]
["2.15" "Add upload-id param for chunked upload support"]]
::doc/changes ["1.20" "Add file-id param for in-place import"
"1.20" "Set default version to 3"
"2.15" "Add upload-id param for chunked upload support"]
::webhooks/event? true
::sse/stream? true
::sm/params schema:import-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [version (or version 3)
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
cfg (cond-> cfg
(uuid? file-id)
(assoc ::bfc/file-id file-id))
params
(if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
@@ -164,5 +174,6 @@
(with-meta
(sse/response (partial import-binfile cfg params))
{::audit/props {:file nil
:file-id file-id
:generated-by (:generated-by manifest)
:referer (:referer manifest)}})))
+3 -6
View File
@@ -14,25 +14,22 @@
[app.db :as db]
[app.email :as eml]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
(declare ^:private send-user-feedback!)
(def schema:send-user-feedback
(def ^:private schema:send-user-feedback
[:map {:title "send-user-feedback"}
[:subject [:string {:max 500}]]
[:content [:string {:max 2500}]]
[:type {:optional true} :string]
[:error-href {:optional true} [:string {:max 2500}]]
[:error-report {:optional true} [:string {:max 1048576}]]])
[:error-report {:optional true} :string]])
(sv/defmethod ::send-user-feedback
{::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id]
[:send-user-feedback/global]]
::doc/added "1.18"
{::doc/added "1.18"
::sm/params schema:send-user-feedback}
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
(when-not (contains? cf/flags :user-feedback)
-22
View File
@@ -1069,25 +1069,6 @@
[cfg {:keys [::rpc/profile-id] :as params}]
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
;; --- Library relation helpers
(defn- check-library-team-ownership!
"Verify that file and library belong to the same team.
Prevents cross-team library relation injection."
[conn file-id library-id]
(let [sql "SELECT EXISTS (
SELECT 1 FROM file AS f
JOIN project AS fp ON (fp.id = f.project_id)
JOIN file AS l ON (l.id = ?)
JOIN project AS lp ON (lp.id = l.project_id)
WHERE f.id = ? AND fp.team_id = lp.team_id
) AS ok"
row (db/exec-one! conn [sql library-id file-id])]
(when-not (:ok row)
(ex/raise :type :not-found
:code :object-not-found
:hint "file and library must belong to the same team"))))
;; --- MUTATION COMMAND: link-file-to-library
(def sql:link-file-to-library
@@ -1123,7 +1104,6 @@
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(check-library-team-ownership! conn file-id library-id)
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
(when (contains? transitive-deps file-id)
@@ -1155,7 +1135,6 @@
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(check-library-team-ownership! conn file-id library-id)
(unlink-file-from-library conn params)
nil)
@@ -1180,7 +1159,6 @@
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(check-library-team-ownership! conn file-id library-id)
(update-sync conn params))
;; --- MUTATION COMMAND: ignore-sync
@@ -21,7 +21,7 @@
[app.db.sql :as-alias sql]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media.validation :as media.v]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
@@ -275,7 +275,7 @@
[:map {:title "create-file-object-thumbnail"}
[:file-id ::sm/uuid]
[:object-id [:string {:max 250}]]
[:media media.v/schema:upload]
[:media media/schema:upload]
[:tag {:optional true} [:string {:max 50}]]])
(sv/defmethod ::create-file-object-thumbnail
@@ -289,8 +289,8 @@
::sm/params schema:create-file-object-thumbnail}
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
(media.v/validate-media-type! media)
(media.v/validate-media-size! media)
(media/validate-media-type! media)
(media/validate-media-size! media)
(db/run! cfg files/check-edition-permissions! profile-id file-id)
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
@@ -379,7 +379,7 @@
[:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn ::sm/int]
[:media media.v/schema:upload]])
[:media media/schema:upload]])
(sv/defmethod ::create-file-thumbnail
"Creates or updates the file thumbnail. Mainly used for paint the
@@ -394,8 +394,8 @@
::sm/params schema:create-file-thumbnail}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(media.v/validate-media-type! (:media params))
(media.v/validate-media-size! (:media params))
(media/validate-media-type! (:media params))
(media/validate-media-size! (:media params))
(db/run! cfg files/check-edition-permissions! profile-id file-id)
+59 -19
View File
@@ -21,7 +21,6 @@
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
@@ -39,7 +38,10 @@
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.InputStream
java.io.OutputStream
java.io.SequenceInputStream
java.util.Collections
java.util.zip.ZipEntry
java.util.zip.ZipOutputStream))
@@ -94,13 +96,18 @@
(declare create-font-variant)
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family types.font/schema:font-family]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:uploads [:map-of ::sm/text ::sm/uuid]]])
[:and
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family types.font/schema:font-family]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
[:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]]
[:fn {:error/message "one of :data or :uploads is required"}
(fn [{:keys [data uploads]}]
(or (seq data) (seq uploads)))]])
(defn- prepare-font-data-from-uploads
"Assembles each chunked-upload session in `uploads` (a `{mtype →
@@ -111,8 +118,8 @@
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media.v/validate-media-type! cm/font-types)
(media.v/validate-font-size!))
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
(assoc acc mtype (:path assembled))))
{}
uploads)]
@@ -121,23 +128,54 @@
(assoc :data data)
(dissoc :uploads))))
(defn- prepare-font-data-from-legacy
"Validates the media type and size of every entry in the legacy
`:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every
entry to a tempfile. Returns params with a normalised
`{mtype → path}` data map."
[{:keys [data] :as params}]
(let [data (reduce-kv
(fn [acc mtype content]
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
chunks (if (vector? content) content [content])
streams (map io/input-stream chunks)
streams (Collections/enumeration streams)]
;; Generate the tempfile from all chunks
(with-open [^OutputStream output (io/output-stream tmp)
^InputStream input (SequenceInputStream. streams)]
(io/copy input output))
;; Validate
(-> {:mtype mtype :size (fs/size tmp)}
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
(assoc acc mtype tmp)))
{}
data)]
(assoc params :data data)))
(sv/defmethod ::create-font-variant
"Upload a font variant. Font data must be provided as an `:uploads`
map (keyed by mime-type, values are upload-session UUIDs from the
chunked-upload API)."
"Upload a font variant. Font data may be provided either as a
Transit-encoded `:data` map (keyed by mime-type) for small fonts, or
as an `:uploads` map (keyed by mime-type, values are upload-session
UUIDs from the chunked-upload API) for large fonts. Exactly one of
the two must be present."
{::doc/added "1.18"
::doc/changes [["2.16" "Add :uploads param for chunked upload support"]
["2.18" "Remove :data param, use :uploads exclusively"]]
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[: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 uploads] :as params}]
(teams/check-edition-permissions! pool profile-id team-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (db/tx-run! cfg prepare-font-data-from-uploads params)]
(let [params (if (some? uploads)
(db/tx-run! cfg prepare-font-data-from-uploads params)
(prepare-font-data-from-legacy params))]
(create-font-variant cfg (assoc params :profile-id profile-id))))
(defn create-font-variant
@@ -191,7 +229,9 @@
(let [tpoint (ct/tpoint)
mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content]
(+ acc (fs/size content)))
(+ acc (if (bytes? content)
(alength ^bytes content)
(fs/size content))))
0
data)]
@@ -330,7 +370,7 @@
(defn- make-temporal-storage-object
[cfg profile-id content]
(let [storage (sto/resolve cfg)
content (media.v/check-input content)
content (media/check-input content)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
+11 -22
View File
@@ -16,8 +16,6 @@
[app.db :as db]
[app.loggers.audit :as-alias audit]
[app.media :as media]
[app.media.svg :as svg]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
[app.rpc.commands.files :as files]
@@ -46,7 +44,7 @@
[:file-id ::sm/uuid]
[:is-local ::sm/boolean]
[:name [:string {:max 250}]]
[:content media.v/schema:upload]])
[:content media/schema:upload]])
(sv/defmethod ::upload-file-media-object
{::doc/added "1.17"
@@ -55,8 +53,8 @@
[:process-image/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
(files/check-edition-permissions! pool profile-id file-id)
(media.v/validate-media-type! content)
(media.v/validate-media-size! content)
(media/validate-media-type! content)
(media/validate-media-size! content)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if
@@ -115,22 +113,13 @@
(defn- process-main-image
[info]
(let [path (:path info)
mtype (:mtype info)
path (if (= mtype "image/svg+xml")
(let [content (slurp path)
sanitized (svg/sanitize-svg content)
temp-path (tmp/tempfile :prefix "penpot-svg-" :suffix ".svg" :min-age "5m")]
(spit (str temp-path) sanitized)
temp-path)
path)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))]
(let [hash (sto/calculate-hash (:path info))
data (-> (sto/content (:path info))
(sto/wrap-with-hash hash))]
{::sto/content data
::sto/deduplicate? true
::sto/touched-at (:ts info)
:content-type mtype
:content-type (:mtype info)
:bucket "file-media-object"}))
(defn- process-thumb-image
@@ -326,7 +315,7 @@
[:map {:title "upload-chunk"}
[:session-id ::sm/uuid]
[:index ::sm/int]
[:content media.v/schema:upload]])
[:content media/schema:upload]])
(def ^:private schema:upload-chunk-result
[:map {:title "upload-chunk-result"}
@@ -397,7 +386,7 @@
(defn assemble-chunks
"Validates that all expected chunks are present for `session-id` and
concatenates them into a single temporary file. Returns a map
conforming to `media.v/schema:upload` with `:filename`, `:path` and
conforming to `media/schema:upload` with `:filename`, `:path` and
`:size`.
Raises a :validation/:missing-chunks error when the number of stored
@@ -451,8 +440,8 @@
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)
(media.v/validate-media-type!)
(media.v/validate-media-size!))
(media/validate-media-type!)
(media/validate-media-size!))
mobj (create-file-media-object cfg (assoc params
:id id
:from-chunks? true
+4 -20
View File
@@ -7,7 +7,6 @@
(ns app.rpc.commands.profile
(:require
[app.auth :as auth]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -22,7 +21,6 @@
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
[app.media.validation :as media.v]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
@@ -47,11 +45,6 @@
[:email-comments [::sm/one-of #{:all :partial :none}]]
[:email-invites [::sm/one-of #{:all :none}]]])
(def schema:nudge
[:map {:title "Nudge"}
[:big {:optional true} ::sm/number]
[:small {:optional true} ::sm/number]])
(def system-managed-props
"Props keys managed by the system (not user-writable via RPC)."
#{:subscription})
@@ -65,8 +58,6 @@
[:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid]
[:onboarding-viewed {:optional true} ::sm/boolean]
[:onboarding-questions {:optional true} [:map-of :keyword :string]]
[:onboarding-questions-answered {:optional true} ::sm/boolean]
[:nitrate-onboarding-viewed {:optional true} ::sm/boolean]
[:v2-info-shown {:optional true} ::sm/boolean]
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
@@ -75,8 +66,7 @@
[:notifications {:optional true} schema:props-notifications]
[:workspace-visited {:optional true} ::sm/boolean]
[:custom-shortcuts {:optional true}
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]
[:nudge {:optional true} schema:nudge]])
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]])
(def schema:profile
[:map {:title "Profile"}
@@ -165,9 +155,6 @@
;; it or not for explicit locking and avoid concurrent updates of
;; 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)
;; Update the profile map with direct params
profile (-> profile
(assoc :fullname fullname)
@@ -213,9 +200,6 @@
:code :email-as-password
:hint "you can't use your email as password"))
;; Validate password strength against common password dictionary
(passwords/validate-password (:password params))
(update-profile-password! cfg (assoc profile :password password))
(->> (rph/get-request params)
@@ -288,7 +272,7 @@
(def ^:private
schema:update-profile-photo
[:map {:title "update-profile-photo"}
[:file media.v/schema:upload]])
[:file media/schema:upload]])
(sv/defmethod ::update-profile-photo
{:doc/added "1.1"
@@ -296,8 +280,8 @@
::sm/result :nil}
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media.v/validate-media-size! file)
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media/validate-media-size! file)
(update-profile-photo cfg (assoc params :profile-id profile-id)))
(defn update-profile-photo
+1 -3
View File
@@ -6,7 +6,6 @@
(ns app.rpc.commands.projects
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -260,8 +259,7 @@
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
(check-edition-permissions! conn profile-id id)
(let [project (db/get-by-id conn :project id ::sql/for-update true)
name (d/normalize-string name)]
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
(db/update! conn :project
{:name name}
{:id id})
+7 -10
View File
@@ -22,7 +22,7 @@
[app.features.logical-deletion :as ldel]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media.validation :as media.v]
[app.media :as media]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@@ -652,7 +652,6 @@
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)
features (db/create-array conn "text" features)
name (d/normalize-string name)
team (db/insert! conn :team
{:id id
:name name
@@ -689,7 +688,6 @@
[conn {:keys [id team-id name is-default created-at modified-at]}]
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)
name (d/normalize-string name)
params {:id id
:name name
:team-id team-id
@@ -720,10 +718,9 @@
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
(check-edition-permissions! conn profile-id id)
(let [name (d/normalize-string name)]
(db/update! conn :team
{:name name}
{:id id}))
(db/update! conn :team
{:name name}
{:id id})
nil)
@@ -982,7 +979,7 @@
(def ^:private schema:update-team-photo
[:map {:title "update-team-photo"}
[:team-id ::sm/uuid]
[:file media.v/schema:upload]])
[:file media/schema:upload]])
(sv/defmethod ::update-team-photo
{::doc/added "1.17"
@@ -990,8 +987,8 @@
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media.v/validate-media-size! file)
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media/validate-media-size! file)
(update-team-photo cfg (assoc params :profile-id profile-id)))
(defn update-team-photo
@@ -46,29 +46,10 @@
(def sql:upsert-organization-invitation
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(def ^:private sql:check-recent-invitation
"SELECT 1 FROM team_invitation
WHERE team_id = ? AND email_to = ?
AND updated_at > now() - interval '5 minutes'
LIMIT 1")
(def ^:private sql:check-recent-org-invitation
"SELECT 1 FROM team_invitation
WHERE org_id = ? AND email_to = ?
AND updated_at > now() - interval '5 minutes'
LIMIT 1")
(defn- recently-invited?
[{:keys [::db/conn]} team-id org-id email]
(let [query (if org-id
[sql:check-recent-org-invitation org-id email]
[sql:check-recent-invitation team-id email])]
(some? (db/exec-one! conn query))))
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(defn- create-invitation-token
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
@@ -204,36 +185,35 @@
(teams/check-email-bounce conn email true)
(teams/check-email-spam conn email true)
(let [id (uuid/next)
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
recent? (recently-invited? cfg (:id team) (:id organization) email)
invitation (db/exec-one! conn (if organization
[sql:upsert-organization-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
(let [id (uuid/next)
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
invitation (db/exec-one! conn (if organization
[sql:upsert-organization-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
team-organization-id (get-in team [:organization :id])
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
:organization-id (:id organization)
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
:organization-id (:id organization)
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
audit-props
(cond-> {:invitation-id (:id invitation)
:valid-until expire
@@ -254,8 +234,8 @@
(and team-organization-id
member
(contains? all-organization-member-ids (:id member))))))
itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)]
itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken))
@@ -271,8 +251,7 @@
(assoc :props props))]
(audit/submit cfg event))
(when (and (allow-invitation-emails? member)
(not recent?))
(when (allow-invitation-emails? member)
(if organization
(when (contains? cf/flags :admin-console)
(eml/send! {::eml/conn conn
+6 -4
View File
@@ -23,9 +23,11 @@
[cuerdas.core :as str]))
(defn get-webhooks-permissions
[conn profile-id team-id]
[conn profile-id team-id creator-id]
(let [permissions (t/get-permissions conn profile-id team-id)
can-edit (boolean (:can-edit permissions))]
can-edit (boolean (or (:can-edit permissions)
(= profile-id creator-id)))]
(assoc permissions :can-edit can-edit)))
(def has-webhook-edit-permissions?
@@ -135,7 +137,7 @@
::sm/params schema:update-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
(check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
(validate-webhook! cfg whook params)
(update-webhook! cfg whook params)))
@@ -149,7 +151,7 @@
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
(check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
(db/delete! conn :webhook {:id id})
nil))
+1 -1
View File
@@ -10,7 +10,7 @@
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.media.validation :refer [schema:upload]]
[app.media :refer [schema:upload]]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.storage :as sto]
+2 -2
View File
@@ -24,7 +24,7 @@
[app.http :as-alias http]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.media.validation :as media.v]
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as rpc]
[app.rpc.commands.auth :as auth]
@@ -119,7 +119,7 @@
(def ^:private schema:upload-organization-logo
[:map
[:content media.v/schema:upload]
[:content media/schema:upload]
[:organization-id ::sm/uuid]
[:previous-id {:optional true} ::sm/uuid]])
+1 -2
View File
@@ -116,8 +116,7 @@
{}
[:exporter
:admin-console
:nexus
:media-processor])))
:nexus])))
(sm/register! ::props [:map-of :keyword ::sm/any])
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
+5 -13
View File
@@ -8,7 +8,6 @@
"A generic blob storage encoding. Mainly used for page data, page
options and txlog payload storage."
(:require
[app.common.exceptions :as ex]
[app.common.fressian :as fres]
[app.common.transit :as t]
[app.config :as cf])
@@ -59,18 +58,12 @@
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts))))
(defn decode
"A function used for decode persisted blobs in the database.
Accepts optional keyword arguments:
:max-size — maximum allowed uncompressed size in bytes"
[^bytes data & {:keys [max-size]}]
"A function used for decode persisted blobs in the database."
[^bytes data]
(with-open [bais (ByteArrayInputStream. data)
dis (DataInputStream. bais)]
(let [version (.readShort dis)
ulen (.readInt dis)]
(when (and max-size (> ulen max-size))
(ex/raise :type :validation
:code :blob-too-large
:hint "blob uncompressed size exceeds limit"))
(case version
1 (decode-v1 data ulen)
3 (decode-v3 data ulen)
@@ -79,10 +72,9 @@
(throw (ex-info "unsupported version" {:version version}))))))
(defn decode-str
"Decode a URL-safe base64 string produced by `encode-str` back to data.
Accepts the same optional keyword arguments as `decode`."
[^String s & {:as opts}]
(decode (.decode (Base64/getUrlDecoder) s) opts))
"Decode a URL-safe base64 string produced by `encode-str` back to data."
[^String s]
(decode (.decode (Base64/getUrlDecoder) s)))
;; --- IMPL
@@ -518,16 +518,3 @@
loc (redirect-location result)]
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "error=unable-to-auth")))))))
(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)]
(with-redefs [oidc/prepare-oidc-provider (fn [_cfg params]
(reset! captured-params params)
{:type "oidc" :id "test"})]
(#'oidc/prepare-organization-sso-provider {}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"})
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
"SSRF protection must be disabled for organization SSO")))))
+1 -125
View File
@@ -8,10 +8,8 @@
"Internal binfile test, no RPC involved"
(:require
[app.binfile.common :as bfc]
[app.binfile.v1 :as v1]
[app.binfile.v3 :as v3]
[app.common.features :as cfeat]
[app.common.files.validate :as cfv]
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.types.shape :as cts]
@@ -26,10 +24,7 @@
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.ByteArrayInputStream
java.io.DataInputStream))
[datoteka.io :as io]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@@ -91,102 +86,6 @@
(dissoc file :data)))
(def ^:private svg-raw-page-id (uuid/custom 1 1))
(def ^:private svg-raw-root-id (uuid/custom 3 1))
(def ^:private svg-raw-child-id (uuid/custom 3 2))
(defn- prepare-svg-raw-file
"A file containing an svg-raw subtree (an svg-raw parent with an
svg-raw child), which is what importing an SVG produces."
[profile]
(let [page-id svg-raw-page-id
root-id svg-raw-root-id
child-id svg-raw-child-id
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})]
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "page 1"
:id page-id}])
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id root-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id root-id
:name "svg-root"
:frame-id uuid/zero
:parent-id uuid/zero
:type :svg-raw
:content {:tag :svg :attrs {} :content []}})}
{:type :add-obj
:page-id page-id
:id child-id
:parent-id root-id
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id child-id
:name "svg-text"
:frame-id uuid/zero
:parent-id root-id
:type :svg-raw
:content {:tag :text :attrs {} :content []}})}])
(dissoc file :data)))
(t/deftest import-binfile-v3-preserves-svg-raw-children
(let [profile (th/create-profile* 1)
file (prepare-svg-raw-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 (:result (th/command! {::th/type :get-file
::rpc/profile-id (:id profile)
:id (first result)
:components-v2 true}))
root (get-in imported [:data :pages-index svg-raw-page-id
:objects svg-raw-root-id])]
(t/is (= (count result) 1))
;; The child ids of an svg-raw shape must survive the JSON round
;; trip as uuids; when they came back as plain strings they no
;; longer resolved against the objects map.
(t/is (every? uuid? (:shapes root)))
(t/is (= [svg-raw-child-id] (vec (:shapes root))))
;; ...so the imported file passes referential integrity instead
;; of failing with :child-not-found on the next update-file.
(t/is (nil? (cfv/validate-file imported []))))))
(t/deftest export-binfile-v3
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
@@ -206,26 +105,3 @@
(v3/import-files!))]
(t/is (= (count result) 1))
(t/is (every? uuid? result)))))
(t/deftest read-obj-rejects-oversized-buffer
;; N1-07: read-obj! must reject objects exceeding max-object-size
;; before attempting to allocate the buffer
(let [size (+ bfc/max-object-size 1)
baos (java.io.ByteArrayOutputStream. 17)
dos (java.io.DataOutputStream. baos)]
(.writeByte dos 5)
(.writeLong dos (long size))
(.flush dos)
(let [input (java.io.DataInputStream.
(ByteArrayInputStream. (.toByteArray baos)))]
(binding [v1/*position* (atom 0)]
(let [out (try
(v1/read-obj! input)
nil
(catch clojure.lang.ExceptionInfo e
(ex-data e)))]
;; Without the guard, read-obj! will either OOM or proceed
;; to read-bytes! on a truncated stream (no :max-file-size-reached).
;; With the guard, it raises :validation :max-file-size-reached.
(t/is (= :validation (:type out)))
(t/is (= :max-file-size-reached (:code out))))))))
+1 -1
View File
@@ -189,7 +189,7 @@
(let [params (merge {:id (mk-uuid "profile" i)
:fullname (str "Profile " i)
:email (str "profile" i ".test@nodomain.com")
:password "Test123!"
:password "123123"
:is-demo false}
params)]
(db/run! system
@@ -459,135 +459,6 @@
;; Tests: objects-handler — expired objects
;; ----------------------------------------------------------------
;; ----------------------------------------------------------------
;; Tests: file-objects-handler — authz required (T2-N1-01)
;; ----------------------------------------------------------------
(t/deftest file-objects-handler-unauthenticated-returns-404
;; Unauthenticated requests to file-media assets must return 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))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-no-file-perms-returns-404
;; Authenticated user without file read permissions must get 404
(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)})
stranger (th/create-profile* 2)
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id stranger)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-with-file-perms-succeeds
;; Authenticated user with file read permissions must get the object
(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)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest file-thumbnails-handler-unauthenticated-returns-404
;; Unauthenticated requests to file-thumbnail assets must return 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))}}
response (assets/file-thumbnails-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-thumbnails-handler-with-file-perms-succeeds
;; Authenticated user with file read permissions must get the thumbnail
(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)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
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 file-objects-handler-non-existent-media-returns-404
;; Request for non-existent file-media-object returns 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
request {:path-params {:id (str (uuid/next))}
::session/profile-id (:id profile)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-nil-profile-id-returns-404
;; When profile-id is nil (invalid session), must return 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))}
::session/profile-id nil}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::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*)
@@ -1,593 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.media-remote-test
(:require
[app.common.exceptions :as ex]
[app.config :as cf]
[app.media.remote :as media.remote]
[app.setup :as-alias setup]
[app.util.json :as json]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]
[mockery.core :refer [with-mocks]])
(:import
java.io.ByteArrayInputStream))
(defn- mk-system
"Minimal system map for media.remote/process tests."
[]
{::setup/shared-keys {:media-processor "test-shared-key"}})
(defn- json-stream
"Create an InputStream from a Clojure data structure (JSON-encoded)."
[data]
(ByteArrayInputStream.
(json/encode data)))
(def config-mock
"Standard config mock for media-processor service."
{:media-processing-service-uri "http://localhost:6065"
:media-processing-service-timeout 5000})
(defn- write-font-tmp
"Write font bytes to a tempfile and return the Path. Caller is responsible for cleanup."
[bytes suffix]
(let [tmp (fs/create-tempfile :prefix "penpot-test-font-" :suffix suffix)]
(io/write* tmp bytes)
tmp))
;; ---------------------------------------------------------------------------
;; :info
;; ---------------------------------------------------------------------------
(t/deftest info-happy-path
(t/testing "info returns dimensions and merges into input"
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (json-stream {:width 800 :height 600 :mtype "image/jpeg" :size 12345 :orientation 1})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
result (media.remote/process (mk-system)
{:cmd :info
:input {:path path :mtype "image/jpeg"}})]
(t/is (= 800 (:width result)))
(t/is (= 600 (:height result)))
(t/is (= (fs/size path) (:size result)))
(t/is (some? (:ts result)))
(t/is (= path (:path result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (= 1 (:call-count @mock))))))))
(t/deftest info-verifies-request-params
(t/testing "info sends correct endpoint, method, and x-shared-key header"
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")]
(media.remote/process (mk-system)
{:cmd :info :input {:path path :mtype "image/jpeg"}})
(let [[system req-map] (:call-args @mock)]
;; System passed through
(t/is (some? (::setup/shared-keys system)))
;; Request structure
(t/is (= :post (:method req-map)))
(t/is (str/includes? (str (:uri req-map)) "api/image/info"))
(t/is (= "test-shared-key" (get-in req-map [:headers "x-shared-key"])))
(t/is (str/starts-with?
(get-in req-map [:headers "Content-Type"])
"multipart/form-data"))))))))
(t/deftest info-no-content-length-header
(t/testing "info does not send Content-Length header (JDK uses chunked encoding)"
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")]
(media.remote/process (mk-system)
{:cmd :info :input {:path path :mtype "image/jpeg"}})
(let [[_ req-map] (:call-args @mock)]
(t/is (nil? (get-in req-map [:headers "Content-Length"])))))))))
(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 {})]
(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"}}))]
(t/is (ex/error? err))
(t/is (= :internal (:type (ex-data err))))
(t/is (= :media-processor-not-configured (:code (ex-data err))))))))
(t/deftest info-service-unavailable
(t/testing "info throws when service-request raises unavailable"
(with-mocks [mock {:target 'app.media.remote/service-request
:throw (ex-info "Cannot connect to media-processor service"
{:type :internal
:code :media-processor-unavailable})}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(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"}}))]
(t/is (ex/error? err))
(t/is (= :internal (:type (ex-data err))))
(t/is (= :media-processor-unavailable (:code (ex-data err)))))))))
(t/deftest info-service-timeout
(t/testing "info throws when service-request raises timeout"
(with-mocks [mock {:target 'app.media.remote/service-request
:throw (ex-info "media-processor service request timed out"
{:type :internal
:code :media-processor-timeout})}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(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"}}))]
(t/is (ex/error? err))
(t/is (= :internal (:type (ex-data err))))
(t/is (= :media-processor-timeout (:code (ex-data err)))))))))
(t/deftest info-mtype-mismatch
(t/testing "info raises :media-type-mismatch when detected mtype differs from declared"
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (json-stream {:width 100 :height 100 :size 100
:mtype "image/png"})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(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"}}))]
(t/is (ex/error? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :media-type-mismatch (:code (ex-data err)))))))))
;; ---------------------------------------------------------------------------
;; :generic-thumbnail
;; ---------------------------------------------------------------------------
(t/deftest generic-thumbnail-happy-path
(t/testing "generic-thumbnail returns tempfile with correct format"
(let [thumb-bytes (.getBytes "fake-jpeg-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. thumb-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
result (media.remote/process (mk-system)
{:cmd :generic-thumbnail
:input {:path path :mtype "image/jpeg"}
:format :jpeg
:quality 80
:width 200
:height 200})]
(t/is (= :jpeg (:format result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (pos? (:size result)))
(t/is (fs/exists? (:data result)))))))))
(t/deftest generic-thumbnail-verifies-query-params
(t/testing "generic-thumbnail sends correct query params with mode=fit"
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. (.getBytes "data" "UTF-8"))}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")]
(media.remote/process (mk-system)
{:cmd :generic-thumbnail
:input {:path path :mtype "image/jpeg"}
:format :jpeg
:quality 85
:width 300
:height 400})
(let [[_ req-map] (:call-args @mock)]
(t/is (str/includes? (str (:uri req-map)) "width=300"))
(t/is (str/includes? (str (:uri req-map)) "height=400"))
(t/is (str/includes? (str (:uri req-map)) "quality=85"))
(t/is (str/includes? (str (:uri req-map)) "format=jpeg"))
(t/is (str/includes? (str (:uri req-map)) "mode=fit"))))))))
(t/deftest generic-thumbnail-service-unavailable
(t/testing "generic-thumbnail throws on service error"
(with-mocks [mock {:target 'app.media.remote/service-request
:throw (ex-info "Cannot connect to media-processor service"
{:type :internal
:code :media-processor-unavailable})}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
err (ex/try! (media.remote/process (mk-system)
{:cmd :generic-thumbnail
:input {:path path :mtype "image/jpeg"}
:format :jpeg
:quality 85
:width 200
:height 200}))]
(t/is (ex/error? err))
(t/is (= :media-processor-unavailable (:code (ex-data err)))))))))
;; ---------------------------------------------------------------------------
;; :profile-thumbnail
;; ---------------------------------------------------------------------------
(t/deftest profile-thumbnail-happy-path
(t/testing "profile-thumbnail returns tempfile and uses mode=crop"
(let [thumb-bytes (.getBytes "fake-png-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. thumb-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
result (media.remote/process (mk-system)
{:cmd :profile-thumbnail
:input {:path path :mtype "image/jpeg"}
:format :jpeg
:quality 85
:width 128
:height 128})]
(t/is (some? (:data result)))
(t/is (fs/exists? (:data result)))
;; Verify mode=crop in URI
(let [[_ req-map] (:call-args @mock)]
(t/is (str/includes? (str (:uri req-map)) "mode=crop")))))))))
(t/deftest profile-thumbnail-service-unavailable
(t/testing "profile-thumbnail throws on service error"
(with-mocks [mock {:target 'app.media.remote/service-request
:throw (ex-info "Cannot connect to media-processor service"
{:type :internal
:code :media-processor-unavailable})}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
err (ex/try! (media.remote/process (mk-system)
{:cmd :profile-thumbnail
:input {:path path :mtype "image/jpeg"}
:format :jpeg
:quality 85
:width 128
:height 128}))]
(t/is (ex/error? err))
(t/is (= :media-processor-unavailable (:code (ex-data err)))))))))
;; ---------------------------------------------------------------------------
;; :generate-fonts
;; ---------------------------------------------------------------------------
(t/deftest generate-fonts-ttf-happy-path
(t/testing "generate-fonts with TTF path makes per-variant calls"
(let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf"))
ttfpath (write-font-tmp ttfbytes ".ttf")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [result (media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/ttf" ttfpath}})]
;; Original path preserved
(t/is (= ttfpath (get result "font/ttf")))
;; Variants written to tempfiles
(t/is (fs/exists? (get result "font/otf")))
(t/is (fs/exists? (get result "font/woff")))
;; Two calls: one for otf, one for woff
(t/is (= 2 (:call-count @mock))))
(finally
(fs/delete ttfpath))))))))
(t/deftest generate-fonts-ttf-as-path
(t/testing "generate-fonts with TTF as tempfile Path works"
(let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf"))
tmp-path (write-font-tmp ttfbytes ".ttf")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [result (media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/ttf" tmp-path}})]
;; Path preserved
(t/is (= tmp-path (get result "font/ttf")))
;; Variant written
(t/is (fs/exists? (get result "font/otf"))))
(finally
(fs/delete tmp-path))))))))
(t/deftest generate-fonts-otf-happy-path
(t/testing "generate-fonts with OTF path"
(let [otfbytes (io/read* (io/resource "backend_tests/test_files/font-1.otf"))
otfpath (write-font-tmp otfbytes ".otf")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [result (media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/otf" otfpath}})]
(t/is (= otfpath (get result "font/otf")))
(t/is (fs/exists? (get result "font/ttf")))
(t/is (fs/exists? (get result "font/woff")))
;; Two calls: one for ttf, one for woff
(t/is (= 2 (:call-count @mock))))
(finally
(fs/delete otfpath))))))))
(t/deftest generate-fonts-woff-happy-path
(t/testing "generate-fonts with WOFF path"
(let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff"))
woffpath (write-font-tmp woffbytes ".woff")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [result (media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/woff" woffpath}})]
(t/is (= woffpath (get result "font/woff")))
(t/is (fs/exists? (get result "font/ttf")))
(t/is (fs/exists? (get result "font/otf")))
;; Two calls: one for ttf, one for otf
(t/is (= 2 (:call-count @mock))))
(finally
(fs/delete woffpath)))))))
(t/deftest generate-fonts-woff2-happy-path
(t/testing "generate-fonts with WOFF2 path"
(let [woff2bytes (io/read* (io/resource "backend_tests/test_files/font-1.woff2"))
woff2path (write-font-tmp woff2bytes ".woff2")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [result (media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/woff2" woff2path}})]
(t/is (= woff2path (get result "font/woff2")))
(t/is (fs/exists? (get result "font/ttf")))
(t/is (fs/exists? (get result "font/otf")))
(t/is (fs/exists? (get result "font/woff")))
;; Three calls: one for ttf, one for otf, one for woff
(t/is (= 3 (:call-count @mock))))
(finally
(fs/delete woff2path)))))))))
(t/deftest generate-fonts-verifies-query-params
(t/testing "generate-fonts sends target-type query param with 180s timeout"
(let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf"))
ttfpath (write-font-tmp ttfbytes ".ttf")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(media.remote/process (mk-system)
{:cmd :generate-fonts :input {"font/ttf" ttfpath}})
(let [[_ req-map] (:call-args @mock)]
(t/is (str/includes? (str (:uri req-map)) "target-type="))
(t/is (= 180000 (:timeout req-map))))
(finally
(fs/delete ttfpath))))))))
(t/deftest generate-fonts-woff-verifies-target-types
(t/testing "generate-fonts with WOFF sends target-type query param"
(let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff"))
woffpath (write-font-tmp woffbytes ".woff")
fake-bytes (.getBytes "fake-font-data" "UTF-8")]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (ByteArrayInputStream. fake-bytes)}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(media.remote/process (mk-system)
{:cmd :generate-fonts :input {"font/woff" woffpath}})
(let [[_ req-map] (:call-args @mock)]
(t/is (str/includes? (str (:uri req-map)) "target-type=")))
(finally
(fs/delete woffpath))))))))
(t/deftest generate-fonts-no-recognized-variant
(t/testing "generate-fonts throws when no recognized font variant"
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [err (ex/try! (media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/unknown" (.getBytes "data" "UTF-8")}}))]
(t/is (ex/error? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :invalid-font (:code (ex-data err))))))))
(t/deftest generate-fonts-connection-error
(t/testing "generate-fonts throws on service error"
(let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf"))
ttfpath (write-font-tmp ttfbytes ".ttf")]
(with-mocks [mock {:target 'app.media.remote/service-request
:throw (ex-info "Cannot connect to media-processor service"
{:type :internal
:code :media-processor-unavailable})}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [err (ex/try! (media.remote/process (mk-system)
{:cmd :generate-fonts :input {"font/ttf" ttfpath}}))]
(t/is (ex/error? err))
(t/is (= :media-processor-unavailable (:code (ex-data err)))))
(finally
(fs/delete ttfpath))))))))
(t/deftest generate-fonts-timeout-error
(t/testing "generate-fonts throws on service timeout"
(let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf"))
ttfpath (write-font-tmp ttfbytes ".ttf")]
(with-mocks [mock {:target 'app.media.remote/service-request
:throw (ex-info "media-processor service request timed out"
{:type :internal
:code :media-processor-timeout})}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(try
(let [err (ex/try! (media.remote/process (mk-system)
{:cmd :generate-fonts :input {"font/ttf" ttfpath}}))]
(t/is (ex/error? err))
(t/is (= :media-processor-timeout (:code (ex-data err)))))
(finally
(fs/delete ttfpath))))))))
;; ---------------------------------------------------------------------------
;; Status code handling (service-request)
;; ---------------------------------------------------------------------------
(t/deftest service-request-raises-on-400
(t/testing "service-request raises :validation on status 400"
(with-mocks [mock {:target 'app.http.client/req
:return {:status 400
:body (json-stream {:type "validation"
:code "invalid-image"
:hint "bad input"})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [err (ex/try! (media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}}))]
(t/is (ex/error? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :invalid-image (:code (ex-data err)))))))))
(t/deftest service-request-raises-on-500
(t/testing "service-request raises :internal on status 500"
(with-mocks [mock {:target 'app.http.client/req
:return {:status 500
:body (json-stream {:type "internal"
:code "processing-error"
:hint "Internal server error"})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [err (ex/try! (media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}}))]
(t/is (ex/error? err))
(t/is (= :internal (:type (ex-data err))))
(t/is (= :processing-error (:code (ex-data err)))))))))
(t/deftest service-request-passes-on-200
(t/testing "service-request returns response on status 200"
(with-mocks [mock {:target 'app.http.client/req
:return {:status 200
:body (json-stream {:width 100 :height 100})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [resp (media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}})]
(t/is (= 200 (:status resp))))))))
;; ---------------------------------------------------------------------------
;; Shared key
;; ---------------------------------------------------------------------------
(t/deftest shared-key-sent-correctly
(t/testing "x-shared-key header matches the system's shared key"
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body (json-stream {:width 1 :height 1 :mtype "image/jpeg" :size 1 :orientation 1})}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [system {::setup/shared-keys {:media-processor "my-secret-key-123"}}]
(media.remote/process system
{:cmd :info
:input {:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"}})
(let [[system-arg _] (:call-args @mock)]
;; System passed through correctly
(t/is (= "my-secret-key-123"
(-> system-arg ::setup/shared-keys :media-processor)))))))))
;; ---------------------------------------------------------------------------
;; Stream closure
;; ---------------------------------------------------------------------------
(defn- tracking-stream
"Create an InputStream that tracks whether it was closed.
Returns a map with :stream (the InputStream) and :closed (an atom)."
[^bytes data]
(let [closed (atom false)
delegate (ByteArrayInputStream. data)
stream (proxy [java.io.InputStream] []
(read
([] (.read delegate))
([^bytes b] (.read delegate b))
([^bytes b off len] (.read delegate b off len)))
(close []
(reset! closed true)
(.close delegate)))]
{:stream stream :closed closed}))
(t/deftest info-closes-response-stream
(t/testing "info closes the response stream after parsing JSON"
(let [json-str "{\"width\":100,\"height\":100,\"mtype\":\"image/jpeg\",\"size\":1,\"orientation\":1}"
json-data (.getBytes json-str "UTF-8")
{:keys [stream closed]} (tracking-stream json-data)]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body stream}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")]
(media.remote/process (mk-system)
{:cmd :info
:input {:path path :mtype "image/jpeg"}})
;; Stream should be closed after processing
(t/is @closed)))))))
(t/deftest font-convert-closes-response-stream
(t/testing "font-convert closes the response stream after writing"
(let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-font-data" "UTF-8"))]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body stream}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf"))
ttfpath (write-font-tmp ttfbytes ".ttf")]
(try
(media.remote/process (mk-system)
{:cmd :generate-fonts
:input {"font/ttf" ttfpath}})
;; Stream should be closed after processing
(t/is @closed)
(finally
(fs/delete ttfpath)))))))))
(t/deftest thumbnail-closes-response-stream
(t/testing "thumbnail closes the response stream after writing"
(let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-thumbnail-data" "UTF-8"))]
(with-mocks [mock {:target 'app.media.remote/service-request
:return {:status 200
:body stream}}]
(with-redefs [cf/get (th/config-get-mock config-mock)]
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")]
(media.remote/process (mk-system)
{:cmd :generic-thumbnail
:input {:path path :mtype "image/jpeg"}
:format :jpeg
:quality 85
:width 200
:height 200})
;; Stream should be closed after processing
(t/is @closed)))))))
-82
View File
@@ -8,7 +8,6 @@
(:require
[app.common.exceptions :as ex]
[app.media :as media]
[app.media.svg :as svg]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
@@ -56,87 +55,6 @@
(t/is (pos? (:width info)))
(t/is (pos? (:height info))))))
(t/deftest sanitize-svg-script-tag
(t/testing "sanitize-svg removes script tags"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><script>alert('xss')</script><rect width=\"50\" height=\"50\"/></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "<script>")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest sanitize-svg-event-handlers
(t/testing "sanitize-svg removes event handler attributes"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\" onload=\"alert('xss')\"><rect width=\"50\" height=\"50\" onmouseover=\"alert('xss')\"/></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "onload")))
(t/is (not (clojure.string/includes? result "onmouseover")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest sanitize-svg-javascript-href
(t/testing "sanitize-svg removes javascript: URLs from href attributes"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><a xlink:href=\"javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "javascript:")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<a")))))
(t/deftest sanitize-svg-foreign-object
(t/testing "sanitize-svg removes foreignObject elements"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><foreignObject width=\"100\" height=\"100\"><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert('xss')</script></body></foreignObject><rect width=\"50\" height=\"50\"/></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "foreignObject")))
(t/is (not (clojure.string/includes? result "<script>")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest sanitize-svg-clean-content
(t/testing "sanitize-svg preserves clean SVG content"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"50\" height=\"50\" fill=\"red\"/><circle cx=\"75\" cy=\"75\" r=\"20\" fill=\"blue\"/></svg>"
result (svg/sanitize-svg svg)]
(t/is (clojure.string/includes? result "<rect"))
(t/is (clojure.string/includes? result "<circle"))
(t/is (or (clojure.string/includes? result "fill=\"red\"")
(clojure.string/includes? result "fill='red'")))
(t/is (or (clojure.string/includes? result "fill=\"blue\"")
(clojure.string/includes? result "fill='blue'"))))))
(t/deftest sanitize-svg-invalid-svg-rejected
(t/testing "sanitize-svg rejects malformed SVG input"
(let [svg "<svg><not-closed>"]
(t/is (thrown-with-msg? Exception #"SVG parsing failed during sanitization"
(svg/sanitize-svg svg))))))
(t/deftest sanitize-svg-preserves-xlink
(t/testing "sanitize-svg preserves legitimate xlink:href attributes"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><use xlink:href=\"#icon\"/></svg>"
result (svg/sanitize-svg svg)]
(t/is (clojure.string/includes? result "xlink:href"))
(t/is (clojure.string/includes? result "#icon")))))
(t/deftest sanitize-svg-javascript-href-whitespace
(t/testing "sanitize-svg catches javascript: URLs with leading whitespace"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><a href=\" javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "javascript:")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<a")))))
(t/deftest sanitize-svg-nested-script
(t/testing "sanitize-svg removes script tags from nested elements"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><g><script>alert('xss')</script></g></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "<script")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<g")))))
(t/deftest sanitize-svg-smil-bypass
(t/testing "sanitize-svg removes SMIL animation elements that can set on* attrs"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"100\" height=\"100\" id=\"r\"/><set attributeName=\"onmouseover\" to=\"alert('xss')\" xlink:href=\"#r\" begin=\"0s\"/></svg>"
result (svg/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "<set")))
(t/is (not (clojure.string/includes? result "onmouseover")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest info-invalid-image
(t/testing "info on invalid image raises error"
(let [path (fs/create-tempfile :prefix "penpot-test-" :suffix ".jpg")]
@@ -1,42 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-binfile-test
(:require
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.rpc :as-alias rpc]
[app.rpc.commands.binfile :as binfile]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest import-binfile-schema-rejects-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)}
;; 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")))
@@ -1,39 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-feedback-test
(:require
[app.common.schema :as sm]
[app.rpc.commands.feedback :as feedback]
[clojure.test :as t]))
(t/deftest send-user-feedback-schema-validation
(let [schema feedback/schema:send-user-feedback]
(t/testing "accepts valid feedback with all fields"
(let [params {:subject "Test subject"
:content "Test content"
:type "bug"
:error-href "https://example.com/error"
:error-report "Error details here"}]
(t/is (sm/valid? schema params))))
(t/testing "accepts feedback without optional fields"
(let [params {:subject "Test subject"
:content "Test content"}]
(t/is (sm/valid? schema params))))
(t/testing "accepts error-report up to 1MiB"
(let [params {:subject "Test subject"
:content "Test content"
:error-report (apply str (repeat 1048576 "x"))}]
(t/is (sm/valid? schema params))))
(t/testing "rejects error-report exceeding 1MiB"
(let [params {:subject "Test subject"
:content "Test content"
:error-report (apply str (repeat 1048577 "x"))}]
(t/is (not (sm/valid? schema params)))))))
@@ -141,31 +141,6 @@
(let [result (:result out)]
(t/is (= 0 (count result))))))))
(t/deftest create-file-with-duplicate-id
(let [prof (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id prof)
file-id (uuid/next)]
(t/testing "create file with specific id"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "first-file"}
out (th/command! data)]
(t/is (nil? (:error out)))))
(t/testing "create file with duplicate id returns normalized error"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "duplicate-file"}
out (th/command! data)
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))))
(t/deftest file-gc-with-fragments
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
@@ -1008,38 +983,6 @@
(t/is (some? sync))
(t/is (some? (:synced-at sync)))))
(t/deftest link-file-to-library-rejects-cross-team
;; N1-08: A file in team2 must not be linked to a library in team1,
;; even when the user has edit permissions on both (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
team1 (th/create-team* 1 {:profile-id (:id prof1)})
team2 (th/create-team* 2 {:profile-id (:id prof2)})
proj1 (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:id team1)})
proj2 (th/create-project* 2 {:profile-id (:id prof2)
:team-id (:id team2)})
lib (th/create-file* 1 {:project-id (:id proj1)
:profile-id (:id prof1)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:id proj2)
:profile-id (:id prof2)})]
;; Add prof2 as editor to team1 so they have edit access to the library
(th/db-insert! :team-profile-rel {:team-id (:id team1)
:profile-id (:id prof2)
:is-owner false
:is-admin false
:can-edit true})
;; prof2 tries to link file2 (team2) to lib (team1) — must fail
(let [data {::th/type :link-file-to-library
::rpc/profile-id (:id prof2)
:file-id (:id file2)
:library-id (:id lib)}
out (th/command! data)]
(t/is (some? (:error out))))))
(t/deftest update-file-library-sync-status-updates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
+527 -213
View File
@@ -24,6 +24,312 @@
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest ttf-font-upload-1
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" ttfdata}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/are [k] (= (get params k)
(get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style)))))
(t/deftest ttf-font-upload-2
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff" data}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/are [k] (= (get params k)
(get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style))))
(t/deftest woff2-font-upload-1
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data (-> (io/resource "backend_tests/test_files/font-1.woff2")
(io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff2" data}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/is (uuid? (:woff2-file-id result)))
(t/are [k] (= (get params k)
(get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style))))
(t/deftest font-deletion-1
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))]
;; Create front variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff" data1}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 500
:font-style "normal"
:data {"font/woff" data2}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
::rpc/profile-id (:id prof)
:team-id team-id
:id font-id}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res)))))))
(t/deftest font-deletion-2
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))]
;; Create front variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff" data1}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id (uuid/custom 10 2)
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff" data2}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
::rpc/profile-id (:id prof)
:team-id team-id
:id font-id}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(t/deftest font-deletion-3
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))
params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 400 :font-style "normal" :data {"font/woff" data1}}
params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 500 :font-style "normal" :data {"font/woff" data2}}
out1 (th/command! params1)
out2 (th/command! params2)]
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
;; freeze with hours 3 clock
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :id (-> out1 :result :id)}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; no-op with hours 3 clock (nothing touched yet)
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(t/deftest input-sanitization-1
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" "/etc/passwd"}}
out (th/command! params)]
(t/is (= 0 (:call-count @mock)))
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))))))
;; -----------------------------------------------------------------------
;; Helpers for chunked-upload font tests
;; -----------------------------------------------------------------------
@@ -93,211 +399,119 @@
:font-weight
:font-style))
(t/deftest font-deletion-1
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
;; -----------------------------------------------------------------------
;; Path 1 Normal (direct :data bytes)
;; -----------------------------------------------------------------------
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))]
;; Create font variant
(let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 500
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
::rpc/profile-id (:id prof)
:team-id team-id
:id font-id}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res)))))))
(t/deftest font-deletion-2
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))]
;; Create font variant
(let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id (uuid/custom 10 2)
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
::rpc/profile-id (:id prof)
:team-id team-id
:id font-id}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(t/deftest font-deletion-3
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))
sid1 (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024))
sid2 (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024))
params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 400 :font-style "normal" :uploads {"font/woff" sid1}}
params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 500 :font-style "normal" :uploads {"font/woff" sid2}}
out1 (th/command! params1)
out2 (th/command! params2)]
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
;; freeze with hours 3 clock
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :id (-> out1 :result :id)}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; no-op with hours 3 clock (nothing touched yet)
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(t/deftest input-sanitization-1
(t/deftest create-font-variant-normal-ttf
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))
session-id (upload-font-chunked! prof ttfdata "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
font-id (uuid/custom 10 10)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; (th/print-result! out)
(t/is (nil? (:error out))))))
(t/deftest create-font-variant-normal-otf
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 11)
data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/otf" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-normal-woff
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 12)
data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/woff" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Chunked upload (:uploads map)
;; Path 2 Legacy chunking (:data with vector of byte-arrays per mtype)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-legacy-chunked-ttf
"Upload a TTF via the legacy :data path where each mtype value is a
vector of byte-array chunks (4 MiB each) instead of a single byte-array."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 20)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; Simulate 4 MiB legacy chunks font is small so a single chunk suffices
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "legacy-chunked"
:font-weight 700
:font-style "italic"
:data {"font/ttf" (vec chunks)}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-legacy-chunked-woff
"Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks
to exercise the SequenceInputStream concatenation path."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 21)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
;; Split into small chunks to exercise the SequenceInputStream path
chunks (split-bytes-into-chunks full-bytes 512)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "legacy-chunked-woff"
:font-weight 400
:font-style "normal"
:data {"font/woff" (vec chunks)}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Path 3 New standardized chunked upload (:uploads map)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-chunked-upload-ttf
@@ -392,8 +606,8 @@
;; Error cases
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-missing-uploads
"Missing :uploads — schema validation must reject it."
(t/deftest create-font-variant-missing-data-and-uploads
"Neither :data nor :uploads is present — schema validation must reject it."
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 40)
@@ -460,6 +674,49 @@
;; Font size validation tests
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-size-exceeded-normal
"Direct :data upload exceeding font-max-file-size must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 50)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded"
:font-weight 400
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))
(t/deftest create-font-variant-size-exceeded-legacy-chunked
"Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 51)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded-legacy"
:font-weight 400
:font-style "normal"
:data {"font/woff" (vec chunks)}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))
(t/deftest create-font-variant-size-exceeded-chunked-upload
"New :uploads path exceeding font-max-file-size must be rejected after assembly."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
@@ -481,10 +738,72 @@
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))))
(t/deftest create-font-variant-size-within-limit
"Upload exactly at the limit must succeed."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 53)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
font-size (alength ^bytes font-bytes)]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)]
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-at-limit"
:font-weight 400
:font-style "normal"
:data {"font/ttf" font-bytes}}
out (th/command! params)]
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))))
;; -----------------------------------------------------------------------
;; Font media-type validation
;; Font media-type validation tests
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-invalid-type-normal
"Direct :data upload with a disallowed mtype must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 60)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type"
:font-weight 400
:font-style "normal"
:data {"application/octet-stream" data}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
(t/deftest create-font-variant-invalid-type-legacy-chunked
"Legacy :data chunk-vector upload with a disallowed mtype must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 61)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type-legacy"
:font-weight 400
:font-style "normal"
:data {"image/png" (vec chunks)}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
(t/deftest create-font-variant-invalid-type-chunked-upload
"New :uploads path with a disallowed mtype must be rejected after assembly."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
@@ -517,50 +836,46 @@
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; name with < should fail
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil<script>alert(1)</script>"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; name with ' should fail
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil'name"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; name with } should fail
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil}name"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid name should succeed
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id (uuid/custom 10 101)
:font-family "Source Sans Pro"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (th/success? out))))))
@@ -572,13 +887,12 @@
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; Create a valid font first
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "ValidFont"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (th/success? out)))
+2 -35
View File
@@ -380,41 +380,8 @@
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
(t/deftest download-image-closes-stream
(t/testing "response body stream is closed on success"
(let [closed? (atom false)
;; Minimal valid PNG (1x1 pixel, red)
png-data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE 0x00 0x00 0x00 0x0C 0x49 0x44 0x41 0x54 0x08 0xD7 0x63 0xF8 0xCF 0xC0 0x00 0x00 0x00 0x02 0x00 0x01 0xE2 0x21 0xBC 0x33 0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82])
body (proxy [java.io.ByteArrayInputStream] [png-data]
(close [] (reset! closed? true)))]
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 200
:headers {"content-type" "image/png"
"content-length" (str (alength png-data))}
:body body}}]
(let [cfg {::http/client :mock-client}
result (media/download-image cfg "https://example.com/image.png")]
(t/is (some? result))
(t/is @closed? "body stream should be closed after successful download")))))
(t/testing "response body stream is closed on validation error"
(let [closed? (atom false)
body (proxy [java.io.ByteArrayInputStream] [(byte-array 100)]
(close [] (reset! closed? true)))]
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 404
:headers {"content-type" "text/html"
"content-length" "100"}
:body body}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/not-found.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :unable-to-download-image (:code (ex-data err))))
(t/is @closed? "body stream should be closed even on validation error"))))))
;; --------------------------------------------------------------------
;; Helpers for chunked-upload tests
;; --------------------------------------------------------------------
(defn- split-file-into-chunks
+24 -139
View File
@@ -42,7 +42,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
#_(th/print-result! out)
@@ -56,7 +56,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "Test123!"}
:password "123123"}
out (th/command! data)]
;; (th/print-result! out)
(let [error (:error out)]
@@ -69,7 +69,7 @@
(let [profile (th/create-profile* 1 {:is-active true})
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "Test123!"}
:password "123123"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
@@ -403,7 +403,7 @@
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "Foobar12!"
:password "foobar"
:utm_campaign "utma"
:mtm_campaign "mtma"}
out (th/command! data)
@@ -444,7 +444,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -463,7 +463,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -498,7 +498,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -521,7 +521,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -547,7 +547,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -576,7 +576,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -614,7 +614,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}
:password "foobar"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@@ -659,7 +659,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}
:password "foobar"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@@ -692,7 +692,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -712,7 +712,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -733,7 +733,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -754,7 +754,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -767,7 +767,7 @@
(let [data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -780,7 +780,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email (:email profile)
:password "Foobar12!"}
:password "foobar"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
@@ -793,7 +793,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}]
:password "foobar"}]
(th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
@@ -808,7 +808,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "Foobar12!"}]
:password "foobar"}]
(th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
@@ -1131,8 +1131,8 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "Foobar12!"}
:old-password "123123"
:password "foobarfoobar"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))))
@@ -1143,7 +1143,7 @@
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "badpassword"
:password "Foobar12!"}
:password "foobarfoobar"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
@@ -1154,7 +1154,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:old-password "123123"
:password "profile1.test@nodomain.com"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
@@ -1202,118 +1202,3 @@
(t/is (true? (get-in props [:props :onboarding-viewed])))
(t/is (false? (get-in props [:props :newsletter-updates])))
(t/is (= :wasm (get-in props [:props :renderer]))))))
(t/deftest update-profile-props-accepts-onboarding-questions
;; The onboarding questions flow sends these props on the final "START" step
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions-answered true
:onboarding-questions
{:expected-use "work"
:role "ux"
:start-with "prototyping"}}}
out (th/command! data)]
;; The call should succeed
(t/is (nil? (:error out)))
;; And all keys should be persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (true? (get-in props [:props :onboarding-questions-answered])))
(t/is (= {:expected-use "work"
:role "ux"
:start-with "prototyping"}
(get-in props [:props :onboarding-questions]))))))
(t/deftest update-profile-props-rejects-invalid-onboarding-questions
;; The schema is closed and :onboarding-questions only accepts string values
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:expected-use 42}}}
out (th/command! data)]
;; The call must fail with validation error
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest update-profile-props-accepts-nudge
;; Nudge settings are persisted per-profile via update-profile-props
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:nudge {:big 20 :small 0.5}}}
out (th/command! data)]
;; The call should succeed
(t/is (nil? (:error out)))
;; And the nudge values should be persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= {:big 20 :small 0.5} (get-in props [:props :nudge]))))))
(t/deftest update-profile-props-rejects-invalid-nudge
;; The nudge map only accepts :big/:small numbers
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:nudge {:big "ten"}}}
out (th/command! data)]
;; The call must fail with validation error
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest prepare-register-profile-weak-password
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "password123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-too-short
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-weak-password
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "qwerty"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
@@ -1015,46 +1015,6 @@
out (th/command! data)]
(t/is (th/success? out)))))
(t/deftest create-team-invitations-email-cooldown
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
data {::th/type :create-team-invitations
::rpc/profile-id (:id profile1)
:team-id (:id team)
:role :editor
:emails ["cooldown-test@example.com"]}]
;; First invitation sends email
(let [out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
;; Resending immediately should NOT send email (cooldown active)
(th/reset-mock! mock)
(let [out (th/command! data)]
(t/is (th/success? out))
(t/is (= 0 (:call-count @mock))))
;; Resending to a different email should send email
(th/reset-mock! mock)
(let [data (assoc data :emails ["different@example.com"])
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
;; After cooldown expires, resending should send email
(th/reset-mock! mock)
(th/db-update! :team-invitation
{:updated-at (ct/in-past "10m")}
{:team-id (:id team)
:email-to "cooldown-test@example.com"})
(let [data (assoc data :emails ["cooldown-test@example.com"])
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock)))))))
(t/deftest update-team-with-invalid-name
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})]
@@ -286,91 +286,3 @@
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :restriction))
(t/is (= (:code error-data) :webhooks-quote-reached))))))
(t/deftest removed-user-cannot-edit-webhook
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [params {::th/type :create-webhook
::rpc/profile-id (:id editor)
:team-id (:id team)
:uri (u/uri "http://example.com")
:mtype "application/json"}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [whook (:result out)]
(th/reset-mock! http-mock)
(t/testing "owner can edit editor's webhook (team owns it)"
(let [params {::th/type :update-webhook
::rpc/profile-id (:id owner)
:id (:id whook)
:uri (u/uri "http://example.com/updated")
:mtype "application/transit+json"
:is-active true}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (= 1 (:call-count @http-mock)))))
(th/reset-mock! http-mock)
(t/testing "remove editor from team"
(let [params {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)}
out (th/command! params)]
(t/is (nil? (:error out)))))
(th/reset-mock! http-mock)
(t/testing "removed editor cannot update webhook"
(let [params {::th/type :update-webhook
::rpc/profile-id (:id editor)
:id (:id whook)
:uri (u/uri "http://example.com/evil")
:mtype "application/transit+json"
:is-active true}
out (th/command! params)]
(t/is (= 0 (:call-count @http-mock)))
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))
(th/reset-mock! http-mock)
(t/testing "removed editor cannot delete webhook"
(let [params {::th/type :delete-webhook
::rpc/profile-id (:id editor)
:id (:id whook)}
out (th/command! params)]
(t/is (= 0 (:call-count @http-mock)))
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))
(th/reset-mock! http-mock)
(t/testing "owner can still delete editor's webhook"
(let [params {::th/type :delete-webhook
::rpc/profile-id (:id owner)
:id (:id whook)}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(let [rows (th/db-exec! ["select * from webhook"])]
(t/is (= 0 (count rows)))))))))))
+4 -25
View File
@@ -199,25 +199,6 @@
(let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
(t/is (= 0 (:count res)))))))
(defn- upload-font-chunked!
"Splits `font-bytes` into a single chunk, creates an upload session,
uploads the chunk, and returns the session-id UUID."
[prof ^bytes font-bytes mtype]
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")
_ (io/write* tmp font-bytes)
mfile {:filename "chunk" :path tmp :mtype mtype :size (alength font-bytes)}
session-id (-> (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})
:result :session-id)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(assert (nil? (:error out)))
session-id))
(t/deftest touched-gc-task-2
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -248,8 +229,6 @@
:name "testfile"
:content mfile}
session-id (upload-font-chunked! prof ttfdata "font/ttf")
params2 {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
@@ -257,7 +236,7 @@
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
:data {"font/ttf" ttfdata}}
out1 (th/command! params1)
out2 (th/command! params2)]
@@ -271,7 +250,7 @@
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 5 (:freeze res)))
(t/is (= 1 (:delete res)))
(t/is (= 0 (:delete res)))
(let [result-1 (:result out1)
result-2 (:result out2)]
@@ -292,7 +271,7 @@
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
(t/is (= 4 (:delete res))))
(t/is (= 3 (:delete res))))
;; now check that there are no touched objects
(let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])]
@@ -300,7 +279,7 @@
;; now check that all objects are marked to be deleted
(let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
(t/is (= 4 (:count res))))))))
(t/is (= 3 (:count res))))))))
(t/deftest touched-gc-task-3
(let [storage (-> (:app.storage/storage th/*system*)
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import {
setupTestProfile,
createAccessToken,
} from "./helpers/auth.mjs";
import { rpcPost, getAsset } from "./helpers/client.mjs";
import { parseSSE, extractResult } from "./helpers/sse.mjs";
async function createAndExport(cookie, projectId) {
const createRes = await rpcPost(
"create-file",
{ name: "E2E Asset Test", projectId },
{ cookieToken: cookie }
);
assert.equal(createRes.status, 200);
const fileId = createRes.body.id;
const exportRes = await rpcPost(
"export-binfile",
{ fileId, includeLibraries: false, embedAssets: true },
{ cookieToken: cookie }
);
assert.equal(exportRes.status, 200);
const assetUrl = extractResult(parseSSE(exportRes.body));
return assetUrl;
}
function extractAssetId(assetUrl) {
const match = assetUrl.match(/\/assets\/by-id\/([0-9a-f-]+)/);
return match ? match[1] : null;
}
describe("asset download", () => {
let profile, cookie, assetUrl, assetId;
before(async () => {
const setup = await setupTestProfile();
profile = setup.profile;
cookie = setup.cookie;
assetUrl = await createAndExport(cookie, profile.defaultProjectId);
assetId = extractAssetId(assetUrl);
assert.ok(assetId, `should extract asset id from URL: ${assetUrl}`);
});
it("asset download with cookie auth succeeds", async () => {
// In devenv, nginx's @handle_redirect intercepts the backend's 307 and
// proxies to S3 directly. The client sees 200 with file content, not 307.
const res = await getAsset(assetId, { cookieToken: cookie });
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
assert.ok(
res.body.length > 0 || typeof res.body === "object",
"response should have content"
);
});
it("asset download with access token auth succeeds", async () => {
const tokenObj = await createAccessToken(cookie, "e2e-asset-test");
const accessToken = tokenObj.token;
const res = await getAsset(assetId, { accessToken });
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
});
it("asset download without auth returns 401", async () => {
const res = await getAsset(assetId, {});
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
});
it("asset download returns file content through nginx proxy", async () => {
// The full flow: backend returns 307 with S3 presigned URL,
// nginx intercepts and proxies to S3, client gets 200 with content.
const res = await getAsset(assetId, { cookieToken: cookie });
assert.equal(res.status, 200);
// Response should be a .penpot file (binary/zip content)
assert.ok(res.body, "response should have body");
});
it("follow S3 redirect WITH auth header (bug repro)", async () => {
// In devenv, nginx's @handle_redirect intercepts the 307 and proxies to
// S3 server-side, only forwarding the Host header from X-Host. The client's
// Authorization header is NOT forwarded to S3, so the request succeeds.
//
// In production (no nginx proxy), the backend returns 307 directly. The HTTP
// client follows the redirect and forwards the Authorization: Token header to
// S3, which conflicts with the presigned URL's X-Amz-* params and returns
// 400 InvalidArgument.
//
// This test documents the devenv behavior: nginx strips the auth header
// when proxying to S3, so the download succeeds.
const res = await getAsset(assetId, { cookieToken: cookie });
assert.equal(res.status, 200, "through nginx, download succeeds");
assert.ok(res.body, "should have file content");
});
it("full export-to-download flow works end-to-end", async () => {
const url = await createAndExport(cookie, profile.defaultProjectId);
const id = extractAssetId(url);
assert.ok(id);
const res = await getAsset(id, { cookieToken: cookie });
assert.equal(res.status, 200);
});
it("asset URL is accessible immediately after export", async () => {
const url = await createAndExport(cookie, profile.defaultProjectId);
const id = extractAssetId(url);
assert.ok(id);
const res = await getAsset(id, { cookieToken: cookie });
assert.equal(res.status, 200, "asset should be accessible right after export");
});
it("token-only: export then download asset with same token", async () => {
const tokenObj = await createAccessToken(cookie, "e2e-token-export-test");
const token = tokenObj.token;
const createRes = await rpcPost(
"create-file",
{ name: "E2E Token Export Test", projectId: profile.defaultProjectId },
{ accessToken: token }
);
assert.equal(createRes.status, 200);
const fileId = createRes.body.id;
const exportRes = await rpcPost(
"export-binfile",
{ fileId, includeLibraries: false, embedAssets: true },
{ accessToken: token }
);
assert.equal(exportRes.status, 200);
const events = parseSSE(exportRes.body);
const url = extractResult(events);
assert.ok(url, "should get an asset URL from export");
const id = extractAssetId(url);
assert.ok(id, `should extract asset id from URL: ${url}`);
const res = await getAsset(id, { accessToken: token });
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
assert.ok(res.body, "response should have file content");
});
});
-78
View File
@@ -1,78 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
createDemoProfile,
login,
setupTestProfile,
} from "./helpers/auth.mjs";
import { rpcPost } from "./helpers/client.mjs";
describe("auth flow", () => {
it("creates a demo profile", async () => {
const { email, password } = await createDemoProfile();
assert.match(email, /^demo-.*\.demo@example\.com$/);
assert.ok(password.length > 0);
});
it("logs in with valid credentials", async () => {
const { email, password } = await createDemoProfile();
const { profile, cookie } = await login(email, password);
assert.equal(profile.email, email);
assert.equal(profile.isDemo, true);
assert.ok(profile.id, "profile should have id");
assert.ok(profile.defaultProjectId, "profile should have defaultProjectId");
assert.ok(profile.defaultTeamId, "profile should have defaultTeamId");
assert.ok(cookie, "cookie should be set");
});
it("login sets session cookie", async () => {
const { email, password } = await createDemoProfile();
const { cookie } = await login(email, password);
assert.ok(cookie, "auth-token cookie should be extracted");
assert.ok(cookie.length > 10, "cookie should have meaningful length");
});
it("login fails with wrong password", async () => {
const { email } = await createDemoProfile();
try {
await login(email, "wrong-password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(e.message.includes("Login failed"));
}
});
it("login fails with non-existent email", async () => {
try {
await login("nonexistent@example.com", "some-password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(e.message.includes("Login failed"));
}
});
it("authenticated RPC with cookie", async () => {
const { profile, cookie } = await setupTestProfile();
const res = await rpcPost("get-profile", {}, { cookieToken: cookie });
assert.equal(res.status, 200);
assert.equal(res.body.id, profile.id);
assert.equal(res.body.email, profile.email);
});
it("unauthenticated RPC returns anonymous profile", async () => {
const res = await rpcPost("get-profile", {});
assert.equal(res.status, 200);
// Anonymous profile has uuid/zero as id
assert.equal(res.body.id, "00000000-0000-0000-0000-000000000000");
});
it("setupTestProfile returns all fields", async () => {
const { profile, cookie, email, password } = await setupTestProfile();
assert.ok(profile.id);
assert.ok(profile.defaultProjectId);
assert.ok(cookie);
assert.ok(email);
assert.ok(password);
});
});
-7
View File
@@ -1,7 +0,0 @@
const config = Object.freeze({
baseUrl: process.env.PENPOT_BASE_URL || "http://localhost:3450",
email: process.env.PENPOT_EMAIL || null,
password: process.env.PENPOT_PASSWORD || null,
});
export default config;
-87
View File
@@ -1,87 +0,0 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { setupTestProfile } from "./helpers/auth.mjs";
import { rpcPost } from "./helpers/client.mjs";
import { parseSSE, extractResult } from "./helpers/sse.mjs";
async function createFile(cookie, projectId, name = "E2E Test File") {
const res = await rpcPost(
"create-file",
{ name, projectId },
{ cookieToken: cookie }
);
assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`);
return res.body;
}
async function exportFile(cookie, fileId) {
const res = await rpcPost(
"export-binfile",
{
fileId,
includeLibraries: false,
embedAssets: true,
},
{ cookieToken: cookie }
);
assert.equal(res.status, 200, `export-binfile failed: ${JSON.stringify(res.body)}`);
const events = parseSSE(res.body);
const assetUrl = extractResult(events);
return assetUrl;
}
describe("export-binfile", () => {
let profile, cookie;
before(async () => {
const setup = await setupTestProfile();
profile = setup.profile;
cookie = setup.cookie;
});
it("creates a file via API", async () => {
const file = await createFile(cookie, profile.defaultProjectId);
assert.ok(file.id, "file should have an id");
assert.equal(file.name, "E2E Test File");
});
it("export returns an asset URL", async () => {
const file = await createFile(cookie, profile.defaultProjectId);
const assetUrl = await exportFile(cookie, file.id);
assert.ok(
typeof assetUrl === "string" && assetUrl.includes("/assets/by-id/"),
`asset URL should contain /assets/by-id/, got: ${assetUrl}`
);
assert.match(assetUrl, /\/assets\/by-id\/[0-9a-f-]+$/);
});
it("export with invalid file-id returns error", async () => {
const fakeId = "00000000-0000-0000-0000-000000000000";
const res = await rpcPost(
"export-binfile",
{
fileId: fakeId,
includeLibraries: false,
embedAssets: true,
},
{ cookieToken: cookie }
);
assert.ok(
res.body.type || res.status !== 200,
"should return error for non-existent file"
);
});
it("export requires authentication", async () => {
const res = await rpcPost("export-binfile", {
fileId: "00000000-0000-0000-0000-000000000000",
includeLibraries: false,
embedAssets: true,
});
assert.ok(
res.body.type || res.status !== 200,
"should require authentication"
);
});
});
-38
View File
@@ -1,38 +0,0 @@
import { rpcPost, extractCookie } from "./client.mjs";
export async function createDemoProfile() {
const res = await rpcPost("create-demo-profile", {});
if (res.body.type === "validation" || res.body.type === "restriction") {
throw new Error(
`Failed to create demo profile: ${res.body.code} - ${res.body.hint || ""}`
);
}
return { email: res.body.email, password: res.body.password };
}
export async function login(email, password) {
const res = await rpcPost("login-with-password", { email, password });
if (res.status !== 200 || res.body.type) {
throw new Error(
`Login failed: ${JSON.stringify(res.body)}`
);
}
const cookie = extractCookie(res.setCookie);
return { profile: res.body, cookie };
}
export async function createAccessToken(cookie, name = "e2e-test-token") {
const res = await rpcPost("create-access-token", { name }, { cookieToken: cookie });
if (res.status !== 200 || res.body.type) {
throw new Error(
`Create access token failed: ${JSON.stringify(res.body)}`
);
}
return res.body;
}
export async function setupTestProfile() {
const { email, password } = await createDemoProfile();
const { profile, cookie } = await login(email, password);
return { profile, cookie, email, password };
}
-86
View File
@@ -1,86 +0,0 @@
import config from "../config.mjs";
async function parseResponse(response) {
const contentType = response.headers.get("content-type") || "";
const setCookie = response.headers.get("set-cookie") || null;
let body;
if (contentType.includes("application/json")) {
body = await response.json();
} else {
body = await response.text();
}
return {
status: response.status,
headers: response.headers,
body,
setCookie,
};
}
export function extractCookie(setCookieHeader, name = "auth-token") {
if (!setCookieHeader) return null;
const match = setCookieHeader.match(new RegExp(`${name}=([^;]+)`));
return match ? match[1] : null;
}
export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) {
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (cookieToken) {
headers.Cookie = `auth-token=${cookieToken}`;
}
if (accessToken) {
headers.Authorization = `Token ${accessToken}`;
}
const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return parseResponse(response);
}
export async function multipartPost(method, formData, { cookieToken } = {}) {
const headers = {
Accept: "application/json",
};
if (cookieToken) {
headers.Cookie = `auth-token=${cookieToken}`;
}
const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, {
method: "POST",
headers,
body: formData,
});
return parseResponse(response);
}
export async function getAsset(
id,
{ cookieToken, accessToken, redirect = "manual" } = {}
) {
const headers = { Accept: "application/json" };
if (cookieToken) {
headers.Cookie = `auth-token=${cookieToken}`;
}
if (accessToken) {
headers.Authorization = `Token ${accessToken}`;
}
const response = await fetch(`${config.baseUrl}/assets/by-id/${id}`, {
method: "GET",
headers,
redirect,
});
return parseResponse(response);
}
-72
View File
@@ -1,72 +0,0 @@
import { createParser } from "eventsource-parser";
export function parseSSE(text) {
const events = [];
const parser = createParser({
onEvent(event) {
events.push({ event: event.event || "message", data: event.data });
},
});
parser.feed(text);
return events;
}
export function extractResult(events) {
const endEvent = events.find((e) => e.event === "end");
if (!endEvent) {
const errEvent = events.find((e) => e.event === "error");
if (errEvent) {
throw new Error(`SSE error: ${errEvent.data}`);
}
throw new Error(`No end event found in SSE stream. Events: ${JSON.stringify(events)}`);
}
const raw = JSON.parse(endEvent.data);
// Transit JSON verbose format:
// For URIs (e.g. asset URL): {"~#uri":"https://..."}
// For objects: {"~:key":"val",...} or ["^ ","~:key","val",...]
// For strings: plain string
if (raw && typeof raw === "object") {
// Tagged URI
if ("~#uri" in raw) {
return raw["~#uri"];
}
// Transit map with ~:value key
if ("~:value" in raw) {
const value = raw["~:value"];
if (Array.isArray(value)) {
return transitArrayToObj(value);
}
return value;
}
// Direct transit map (keys starting with ~:)
const firstKey = Object.keys(raw)[0];
if (firstKey && firstKey.startsWith("~:")) {
return transitMapToObj(raw);
}
}
return raw;
}
function transitArrayToObj(arr) {
// Transit verbose object: ["^ ","~:key1","val1","~:key2","val2",...]
const obj = {};
for (let i = 1; i < arr.length; i += 2) {
const key = arr[i].replace(/^~:/, "");
const val = arr[i + 1];
obj[key] = val;
}
return obj;
}
function transitMapToObj(map) {
// Transit verbose map: {"~:key1":"val1","~:key2":"val2",...}
const obj = {};
for (const [key, val] of Object.entries(map)) {
const cleanKey = key.replace(/^~:/, "");
obj[cleanKey] = val;
}
return obj;
}
+2 -2
View File
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"type": "module",
"repository": {
"type": "git",
@@ -15,7 +15,7 @@
"nodemon": "^3.1.14",
"prettier": "3.9.6",
"source-map-support": "^0.5.21",
"ws": "^8.21.2"
"ws": "^8.21.1"
},
"dependencies": {
"date-fns": "^4.4.0"
+10 -10
View File
@@ -25,8 +25,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.21.2
version: 8.21.2
specifier: ^8.21.1
version: 8.21.1
packages:
@@ -50,9 +50,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -234,8 +234,8 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.21.2:
resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==}
ws@8.21.1:
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -273,7 +273,7 @@ snapshots:
binary-extensions@2.3.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -357,7 +357,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.6
ms@2.1.3: {}
@@ -439,7 +439,7 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
ws@8.21.2: {}
ws@8.21.1: {}
y18n@5.0.8: {}
-9
View File
@@ -1173,15 +1173,6 @@
[key coll]
(sort-by key natural-compare coll))
(defn normalize-string
"Normalizes a string by trimming leading/trailing whitespace.
Returns empty string for nil input. Non-string input is returned unchanged."
[s]
(cond
(nil? s) ""
(string? s) (str/trim s)
:else s))
(defn sanitize-string [s]
(if s
(-> s
+69 -158
View File
@@ -10,14 +10,12 @@
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.path-names :as cpn]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape :as cts]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]))
(log/set-level! :debug)
@@ -37,7 +35,7 @@
(assoc :width 0.01)
(assoc :height 0.01)
(cts/setup-rect)))]
(log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -50,7 +48,7 @@
(log/debug :hint " -> set to " :parent-id uuid/zero)
(assoc shape :parent-id uuid/zero))]
(log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -63,7 +61,7 @@
(log/debug :hint " -> add children to" :parent-id (:id parent-shape))
(update parent-shape :shapes conj (:id shape)))]
(log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:parent-id shape)] repair-shape))))
@@ -76,7 +74,7 @@
(log/debug :hint " -> remove duplicated children")
(update shape :shapes distinct))]
(log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -88,14 +86,14 @@
(log/debug :hint " -> remove child" :child-id (:child-id args))
(update parent-shape :shapes (fn [shapes]
(d/removev #(= (:child-id args) %) shapes))))]
(log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :invalid-parent
[_ {:keys [shape page-id args] :as error} file-data _]
(log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true})))
@@ -111,7 +109,7 @@
(log/debug :hint " -> set to " :frame-id frame-id)
(assoc shape :frame-id frame-id)))]
(log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -127,7 +125,7 @@
(log/debug :hint " -> set to " :frame-id frame-id)
(assoc shape :frame-id frame-id)))]
(log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -140,7 +138,7 @@
(log/debug :hint " -> set :main-instance")
(assoc shape :main-instance true))]
(log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -157,7 +155,7 @@
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -176,7 +174,7 @@
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes shape-ids repair-shape))))
@@ -196,7 +194,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
(if (and (some? component) (not (:deleted component)))
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
@@ -213,7 +211,7 @@
;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-page" :component-id (:id component))
(assoc component :main-instance-page page-id))]
(log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-component))))
@@ -226,7 +224,7 @@
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)]
(log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -239,7 +237,7 @@
(log/debug :hint " -> unset :main-instance")
(dissoc shape :main-instance))]
(log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -252,7 +250,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -265,7 +263,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -312,7 +310,7 @@
;; If the shape still refers to the remote component, try to find the corresponding near one
;; and link to it. If not, detach the shape.
(log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(if (some? matching-shape)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
@@ -331,7 +329,7 @@
(log/debug :hint " -> unhead shape")
(ctk/unhead-shape shape))]
(log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -356,7 +354,7 @@
(nil? (:component-file args))
(dissoc :component-file)))]
(log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -369,7 +367,7 @@
(log/debug :hint " -> reroot shape")
(ctk/rehead-shape shape (:component-file args) (:component-id args)))]
(log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -398,7 +396,7 @@
(assoc acc k v)))
{}
objects)))))]
(log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
(-> (pcb/empty-changes nil nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
@@ -411,7 +409,7 @@
(log/debug :hint " -> unset :shape-ref")
(dissoc shape :shape-ref))]
(log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -424,7 +422,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -437,7 +435,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape)
@@ -451,7 +449,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -464,7 +462,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -477,7 +475,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -490,7 +488,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -503,7 +501,7 @@
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)]
(log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -522,7 +520,7 @@
:r3 0
:r4 0))]
(log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -540,7 +538,7 @@
(log/debug :hint " -> remove :objects")
(dissoc component :objects))))]
(log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
@@ -556,7 +554,7 @@
(dissoc component :objects))
component))]
(log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
@@ -569,7 +567,7 @@
(log/debug :hint " -> add :content-group to :touched-groups")
(update shape :touched ctk/set-touched-group :content-group))]
(log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -582,7 +580,7 @@
(log/debug :hint " -> remove swap-slot")
(ctk/remove-swap-slot shape))]
(log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -605,11 +603,13 @@
(log/debug :hint " -> remove swap-slot" :child-id (:id shape))
(ctk/remove-swap-slot shape))]
(log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes (map :id child-with-duplicate) repair-shape))))
(defmethod repair-error :component-duplicate-slot
[_ {:keys [shape] :as error} file-data _]
(let [main-shape (get-in shape [:objects (:main-instance-id shape)])
@@ -633,7 +633,7 @@
(:objects component))]
(assoc component :objects objects)))]
(log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
(log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
@@ -649,139 +649,50 @@
(ctk/set-swap-slot shape slot))
shape)))]
(log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :main-instance-not-a-variant
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape :variant-id variant-id)))]
(defmethod repair-error :not-a-variant
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :main-instance-invalid-variant-id
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape
:variant-id variant-id)))]
(log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :invalid-variant-id
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :invalid-variant-properties
[_ {:keys [shape page-id args]} file-data _]
(let [prop-names (:prop-names args)
component (get-in file-data [:components (:component-id shape)])
prop-values (into {} (map (juxt :name :value)) (:variant-properties component))
properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names)
variant-name (ctv/properties-to-name properties')
repair-component
(fn [component]
;; Rebuild component properties, removing any extra ones and adding missing ones with empty value
(log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names))
(assoc component :variant-properties properties'))
repair-shape
(fn [shape]
(log/debug :hint " -> set variant-name" :variant-name variant-name)
(assoc shape :variant-name variant-name))]
(log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-component)
(pcb/update-shapes [(:id shape)] repair-shape))))
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-not-main
[_ {:keys [shape page-id]} file-data _]
(let [page (ctpl/get-page file-data page-id)
shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))]
(log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint " -> delete shapes" :shape-ids shape-ids)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/remove-objects shape-ids))))
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :parent-not-variant
[_ {:keys [shape page-id]} file-data _]
(let [parent-id (:parent-id shape)
repair-fn
(fn [parent]
(log/debug :hint " -> set :is-variant-container true")
(assoc parent :is-variant-container true))]
(log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [parent-id] repair-fn))))
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-main-bad-name
[_ {:keys [shape page-id args]} file-data _]
(let [repair-fn
(fn [shape]
(log/debug :hint " -> set :name" :name (:variant-name args))
(assoc shape :name (:variant-name args)))]
(log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-fn))))
(defmethod repair-error :variant-bad-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-main-bad-variant-name
[_ {:keys [shape page-id]} file-data _]
(let [component (get-in file-data [:components (:component-id shape)])
variant-name (ctv/properties-to-name (:variant-properties component))
repair-fn
(fn [shape]
(log/debug :hint " -> set :variant-name" :variant-name variant-name)
(assoc shape :variant-name variant-name))]
(log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-fn))))
(defmethod repair-error :variant-bad-variant-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-component-bad-name
[_ {:keys [shape page-id args]} file-data _]
(let [[path name] (cpn/split-group-name (:variant-container-name args))
repair-fn
(fn [component]
(log/debug :hint " -> set :path and :name" :path path :name name)
(assoc component :path path :name name))]
(log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-fn))))
(defmethod repair-error :variant-component-bad-id
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape
:variant-id variant-id)))]
(log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :default
[_ error file _]
@@ -790,7 +701,7 @@
(defn repair-file
[{:keys [data id] :as file} libraries errors]
(log/debug :hint "repairing file" :id (str id) :errors (count errors))
(log/dbg :hint "repairing file" :id (str id) :errors (count errors))
(let [{:keys [redo-changes]}
(reduce (fn [changes error]
(pcb/concat-changes changes
+29 -37
View File
@@ -65,13 +65,13 @@
:misplaced-slot
:missing-slot
:shape-ref-cycle
:main-instance-not-a-variant
:main-instance-invalid-variant-id
:not-a-variant
:invalid-variant-id
:invalid-variant-properties
:variant-not-main
:parent-not-variant
:variant-main-bad-name
:variant-main-bad-variant-name
:variant-bad-name
:variant-bad-variant-name
:variant-component-bad-name
:variant-component-bad-id})
@@ -573,23 +573,19 @@
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
(report-error :main-instance-not-a-variant
(str/ffmt "Main instance shape % should be a variant" (:id child))
child file page
:variant-id shape-id)
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(do
(when (not= (:variant-id child) shape-id)
(report-error :main-instance-invalid-variant-id
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
child file page
:variant-id shape-id))
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page
:prop-names prop-names))))))
child file page))))))
shapes)))
(defn- check-variant
"Shape is a variant, so
-it should be a main component
@@ -598,9 +594,9 @@
-its name should be the same as its parent's
"
[shape file page]
(let [parent (ctst/get-shape page (:parent-id shape))
component (ctkl/get-component (:data file) (:component-id shape) true)
variant-name (ctv/properties-to-name (:variant-properties component))]
(let [parent (ctst/get-shape page (:parent-id shape))
component (ctkl/get-component (:data file) (:component-id shape) true)
name (ctv/properties-to-name (:variant-properties component))]
(when-not (ctk/main-instance? shape)
(report-error :variant-not-main
(str/ffmt "Variant % is not a main instance" (:id shape))
@@ -609,26 +605,23 @@
(report-error :parent-not-variant
(str/ffmt "Variant % has an invalid parent" (:id shape))
shape file page))
(when-not (= variant-name (:variant-name shape))
(report-error :variant-main-bad-variant-name
(when-not (= name (:variant-name shape))
(report-error :variant-bad-variant-name
(str/ffmt "Variant % has an invalid variant-name" (:id shape))
shape file page
:variant-name variant-name))
shape file page))
(when-not (= (:name parent) (:name shape))
(report-error :variant-main-bad-name
(str/ffmt "Main instance inside variant % has an invalid name" (:id shape))
shape file page
:variant-name (:name parent)))
(report-error :variant-bad-name
(str/ffmt "Variant % has an invalid name" (:id shape))
shape file page))
(when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component)))
(report-error :variant-component-bad-name
(str/ffmt "Component % has an invalid name" (:id shape))
shape file page
:variant-container-name (:name parent)))
shape file page))
(when-not (= (:variant-id component) (:variant-id shape))
(report-error :variant-component-bad-id
(str/ffmt "Variant % has adifferent variant-id than its component" (:id shape))
shape file page
:variant-id (:variant-id component)))))
shape file page))))
(defn- check-shape
"Validate referential integrity and semantic coherence of
@@ -747,15 +740,14 @@
-It should have at least one variant property"
[component file]
(let [component-page (ctf/get-component-page (:data file) component)
main-instance (if (:deleted component)
main-component (if (:deleted component)
(dm/get-in component [:objects (:main-instance-id component)])
(ctst/get-shape component-page (:main-instance-id component)))]
(when (and main-instance
(not (ctk/is-variant? main-instance)))
(report-error :main-instance-not-a-variant
(str/ffmt "Main instance shape % should be a variant" (:id main-instance))
main-instance file component-page
:variant-id (:variant-id component)))))
(when (and main-component
(not (ctk/is-variant? main-component)))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id main-component))
main-component file component-page))))
(defn- check-main-inside-main
[component file]
+1 -2
View File
@@ -178,8 +178,7 @@
:stroke-path
:stroke-per-side
:custom-shortcuts
:remote-media-processing})
:custom-shortcuts})
(def all-flags
(set/union email login varia))
+1 -12
View File
@@ -31,11 +31,6 @@
([^String s, ^String encoding]
(.getBytes s encoding)))
;; --- DEPTH TRACKING
(def ^:dynamic *read-depth* 0)
(def ^:const max-read-depth 128)
;; --- LOW LEVEL FRESSIAN API
(defn write-object!
@@ -46,13 +41,7 @@
(defn read-object!
[^Reader r]
(when (>= *read-depth* max-read-depth)
(throw (ex-info "maximum Fressian read depth exceeded"
{:type :validation
:code :max-read-depth-reached
:hint "maximum Fressian read depth exceeded"})))
(binding [*read-depth* (inc *read-depth*)]
(.readObject r)))
(.readObject r))
(defn write-tag!
([^Writer w ^String n]
@@ -13,11 +13,6 @@
[app.common.types.text :as txt]))
(defn add-variant
"Add a variant component to a file with two variants, each with a root shape.
:variant-label [:name Board]
{:root2-label} [:name Board] # [Component :component2-label]
{:root1-label} [:name Board] # [Component :component1-label]
"
[file variant-label component1-label root1-label component2-label root2-label
& {:keys [variant1-params variant2-params]
:or {variant1-params {} variant2-params {}}}]
-2
View File
@@ -912,10 +912,8 @@
(let [shape (get objects shape-id)]
(println (str/pad (str (str/repeat " " level)
(when (:main-instance shape) "{")
(when (:is-variant-container shape) "{{")
(:name shape)
(when (:main-instance shape) "}")
(when (:is-variant-container shape) "}}")
(when (seq (:touched shape)) "*")
(when show-ids (str/format " %s" (:id shape))))
{:length 20
+1 -8
View File
@@ -259,14 +259,7 @@
[:map {:title "CircleAttrs"}])
(def ^:private schema:svg-raw-attrs
[:map {:title "SvgRawAttrs"}
;; An svg-raw shape can be a container: importing an SVG builds a
;; tree of svg-raw shapes, and `cfh/group-like-shape?` treats an
;; svg-raw with children as group-like. Declaring `:shapes` here
;; keeps the child ids typed as uuid, so a JSON round trip (binfile
;; export/import) decodes them back to uuids instead of leaving
;; strings that no longer resolve against the objects map.
[:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]])
[:map {:title "SvgRawAttrs"}])
(def schema:image-attrs
[:map {:title "ImageAttrs"}
-18
View File
@@ -36,24 +36,6 @@
(t/is (= "" (d/get-initials nil)))
(t/is (= "" (d/get-initials "!!! ???"))))
(t/deftest normalize-string-test
;; nil input returns empty string
(t/is (= "" (d/normalize-string nil)))
;; empty string returns empty string
(t/is (= "" (d/normalize-string "")))
;; leading whitespace is trimmed
(t/is (= "hello" (d/normalize-string " hello")))
;; trailing whitespace is trimmed
(t/is (= "hello" (d/normalize-string "hello ")))
;; both leading and trailing whitespace are trimmed
(t/is (= "hello" (d/normalize-string " hello ")))
;; internal whitespace is preserved
(t/is (= "hello world" (d/normalize-string " hello world ")))
;; non-string input is returned unchanged
(t/is (= 42 (d/normalize-string 42)))
(t/is (= :keyword (d/normalize-string :keyword)))
(t/is (= true (d/normalize-string true))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ordered Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -1,230 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.files.repair-test
"Tests for the validate / repair functions in app.common.files.validate
and app.common.files.repair.
The tests generate cases of broken files and check that the validation functions
generate accurate errors, and that the repair functions return the file to
a stable state."
(:require
[app.common.files.repair :as cfr]
[app.common.files.validate :as cfv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest repair-main-instance-not-a-variant
(t/testing "detect and repair a variant component whose root shape is not a variant"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(ths/update-shape :root1 :variant-id nil))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1 :page-label :page1)]
(t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem
(t/is (= :main-instance-not-a-variant (:code (first errors))))
(t/is (nil? errors'))
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
(t/deftest repair-invalid-variant-id-variant-component-bad-id
(t/testing "detect and repair a variant component whose variant id does not match the container's id"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(ths/update-shape :root1 :variant-id (uuid/next)))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1 :page-label :page1)]
(t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem
(t/is (= :main-instance-invalid-variant-id (:code (first errors))))
(t/is (= :variant-component-bad-id (:code (second errors))))
(t/is (nil? errors'))
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
(t/deftest repair-invalid-variant-properties
(t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"]
;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3")
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
{:name "Property 2" :value "ValueA"}]})
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
{:name "Property 3" :value "ValueB"}]})
(ths/update-shape :root1 :variant-name "Value1, ValueA")
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
comp1' (thc/get-component file' :component1)
comp2' (thc/get-component file' :component2)
root1' (ths/get-shape file' :root1)
root2' (ths/get-shape file' :root2)]
(t/is (= 1 (count errors)))
(t/is (= :invalid-variant-properties (:code (first errors))))
(t/is (nil? errors'))
;; After repair, component1's properties are rebuilt to match component2's property names
;; (the first child in the variant container is root2, so prop-names come from component2)
;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed
(t/is (= [{:name "Property 1" :value "Value1"}
{:name "Property 3" :value ""}]
(:variant-properties comp1')))
(t/is (= "Value1" (:variant-name root1')))
;; Component2 is unchanged (it was the reference for the property names)
(t/is (= [{:name "Property 1" :value "Value2"}
{:name "Property 3" :value "ValueB"}]
(:variant-properties comp2')))
(t/is (= "Value2, ValueB" (:variant-name root2'))))))
(t/deftest repair-variant-not-main
(t/testing "detect and repair a non-main-instance shape inside a variant container"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Add a third child to the variant container with :variant-id but NOT a main-instance
(ths/add-sample-shape :bad-shape
:type :frame
:parent-label :variant1
:variant-id (thi/id :variant1)
:variant-name "")
;; Add a child to the bad shape (to verify the repair deletes it too)
(ths/add-sample-shape :bad-child
:type :rect
:parent-label :bad-shape))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
bad-shape' (ths/get-shape file' :bad-shape)
bad-child' (ths/get-shape file' :bad-child)]
(t/is (= 4 (count errors))) ;; The bad container also triggers other errors
(t/is (= :invalid-variant-properties (:code (nth errors 0))))
(t/is (= :variant-not-main (:code (nth errors 1))))
(t/is (= :variant-component-bad-name (:code (nth errors 2))))
(t/is (= :variant-component-bad-id (:code (nth errors 3))))
(t/is (nil? errors'))
(t/is (nil? bad-shape'))
(t/is (nil? bad-child')))))
(t/deftest repair-parent-not-variant
(t/testing "detect and repair a variant shape whose parent is not a variant-container"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Break the variant container
(ths/update-shape :variant1 :is-variant-container false))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
container' (ths/get-shape file' :variant1)]
(t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container
(t/is (= :parent-not-variant (:code (first errors))))
(t/is (= :parent-not-variant (:code (second errors))))
(t/is (nil? errors'))
(t/is (true? (:is-variant-container container'))))))
(t/deftest repair-variant-main-bad-name
(t/testing "detect and repair a main instance whose name doesn't match the variant container's name"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Change root1's name so it doesn't match the container
(ths/update-shape :root1 :name "WrongName"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-main-bad-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Board" (:name root1'))))))
(t/deftest repair-variant-main-bad-variant-name
(t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
{:name "Property 2" :value "ValueA"}]})
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
{:name "Property 2" :value "ValueB"}]})
;; Change root1's :variant-name to something wrong
(ths/update-shape :root1 :variant-name "WrongVariantName")
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-main-bad-variant-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Value1, ValueA" (:variant-name root1'))))))
(t/deftest repair-variant-component-bad-name
(t/testing "detect and repair a variant component whose path/name doesn't match the container name"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Update names to have path structure
(ths/update-shape :variant1 :name "Group / Subgroup / Component")
(ths/update-shape :root1 :name "Group / Subgroup / Component")
(ths/update-shape :root2 :name "Group / Subgroup / Component")
;; Update component paths and names
(thc/update-component :component1 {:path "Group / Subgroup" :name "Component"})
(thc/update-component :component2 {:path "Group / Subgroup" :name "Component"})
;; Break component1's name
(thc/update-component :component1 {:name "WrongName"}))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
comp1' (thc/get-component file' :component1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-component-bad-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Group / Subgroup" (:path comp1')))
(t/is (= "Component" (:name comp1'))))))
+1 -17
View File
@@ -21,8 +21,7 @@
(:import
java.time.Instant
java.time.OffsetDateTime
java.time.ZoneOffset
java.util.UUID))
java.time.ZoneOffset))
;; ---------------------------------------------------------------------------
;; Helpers
@@ -525,18 +524,3 @@
(t/is (d/ordered-map? rt))
(t/is (= om rt))
(t/is (= (keys om) (keys rt)))))
(t/deftest decode-rejects-excessive-recursion-depth
;; N2-01: deeply nested structures must be rejected before stack overflow
(let [depth (+ fres/max-read-depth 50)
data (reduce (fn [acc _i] [acc])
:leaf
(range depth))
encoded (fres/encode data)]
(try
(fres/decode encoded)
(t/is false "expected exception for excessive recursion depth")
(catch clojure.lang.ExceptionInfo e
(let [d (ex-data e)]
(t/is (= :validation (:type d)))
(t/is (= :max-read-depth-reached (:code d))))))))
-1
View File
@@ -25,7 +25,6 @@ COPY $BUNDLE_PATH /var/www/app/
COPY ./files/config.js /var/www/app/js/config.js
COPY ./files/nginx.conf.template /tmp/nginx.conf.template
COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template
COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template
COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template
COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf
COPY ./files/nginx-mime.types /etc/nginx/mime.types
-86
View File
@@ -1,86 +0,0 @@
FROM ubuntu:26.04
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v24.18.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qq dist-upgrade; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
locale-gen; \
find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
RUN set -ex; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install \
fontforge \
woff-tools \
woff2 \
\
libgomp1 \
libheif1 \
libjpeg-turbo8 \
liblcms2-2 \
libopenexr-3-1-30 \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
libtiff6 \
libwebp7 \
libwebpdemux2 \
libwebpmux3 \
libxml2-16 \
libzip5 \
libzstd1 \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*;
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
mkdir -p /opt/node; \
cd /opt/node; \
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
chown -R root /opt/node; \
rm -rf /tmp/nodejs.tar.gz; \
corepack enable; \
mkdir -p /opt/penpot; \
chown -R penpot:penpot /opt/penpot;
ARG BUNDLE_PATH="./bundle-media-processor/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/
WORKDIR /opt/penpot/media-processor
USER penpot:penpot
RUN ./setup
CMD ["node", "dist/index.js"]
@@ -1,7 +0,0 @@
location /admin-console {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri;
}
+2 -9
View File
@@ -53,22 +53,15 @@ update_oidc_name /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-nitrate:3000}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server reuseport backlog=16384;"}
if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then
export PENPOT_IPV6_LISTEN_DIRECTIVE=""
fi
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then
export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000}
envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \
< /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf
else
rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf
fi
if [[ $PENPOT_FLAGS == *"enable-mcp"* ]]; then
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401}
export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402}
+8
View File
@@ -151,6 +151,14 @@ http {
proxy_pass $PENPOT_BACKEND_URI/ws/notifications;
}
location /admin-console {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri;
}
include /etc/nginx/overrides/server.d/*.conf;
location / {
+2 -2
View File
@@ -35,9 +35,9 @@
"eleventy-plugin-nesting-toc": "^1.3.0",
"eleventy-plugin-youtube-embed": "^1.13.2",
"luxon": "^3.7.2",
"markdown-it": "^15.0.0",
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.1",
"markdown-it-plantuml": "^1.4.1"
},
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee"
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
}
+21 -57
View File
@@ -42,11 +42,11 @@ importers:
specifier: ^3.7.2
version: 3.7.2
markdown-it:
specifier: ^15.0.0
version: 15.0.0
specifier: ^14.3.0
version: 14.3.0
markdown-it-anchor:
specifier: ^9.2.1
version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0)
version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0)
markdown-it-plantuml:
specifier: ^1.4.1
version: 1.4.1
@@ -150,9 +150,6 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
argparse@3.0.0:
resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@@ -175,8 +172,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -304,10 +301,6 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
errno@1.0.0:
resolution: {integrity: sha512-3zV5mFS1E8/1bPxt/B0xxzI1snsg3uSCIh6Zo1qKg6iMw93hzPANk9oBFzSFBFrwuVoQuE3rLoouAUfwOAj1wQ==}
hasBin: true
@@ -447,8 +440,8 @@ packages:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
hasBin: true
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
junk@3.1.0:
@@ -466,11 +459,8 @@ packages:
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
liquidjs@10.28.0:
resolution: {integrity: sha512-b6tmBXYMQTuGPnM5vB0CuZMo5kvmKMtSB/gvUWP6RFn2pIB5s+bJkBfIyJnattkc5bgeAiflrZVptx3iaLLioQ==}
liquidjs@10.27.0:
resolution: {integrity: sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==}
engines: {node: '>=16'}
hasBin: true
@@ -497,10 +487,6 @@ packages:
resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==}
hasBin: true
markdown-it@15.0.0:
resolution: {integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==}
hasBin: true
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
@@ -706,11 +692,8 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
uc.micro@3.0.0:
resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
unpipe@1.0.0:
@@ -830,9 +813,9 @@ snapshots:
filesize: 10.1.6
gray-matter: 4.0.3
iso-639-1: 3.1.5
js-yaml: 4.3.1
js-yaml: 4.2.0
kleur: 4.1.5
liquidjs: 10.28.0
liquidjs: 10.27.0
luxon: 3.7.2
markdown-it: 14.3.0
minimist: 1.2.8
@@ -910,8 +893,6 @@ snapshots:
argparse@2.0.1: {}
argparse@3.0.0: {}
asap@2.0.6: {}
balanced-match@1.0.2: {}
@@ -933,7 +914,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@1.1.18:
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
@@ -962,7 +943,7 @@ snapshots:
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.29.0
undici: 7.28.0
whatwg-mimetype: 4.0.0
chokidar@3.6.0:
@@ -1084,8 +1065,6 @@ snapshots:
entities@7.0.1: {}
entities@8.0.0: {}
errno@1.0.0:
dependencies:
prr: 1.0.1
@@ -1214,7 +1193,7 @@ snapshots:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.3.1:
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
@@ -1228,11 +1207,7 @@ snapshots:
dependencies:
uc.micro: 2.1.0
linkify-it@6.1.0:
dependencies:
uc.micro: 3.0.0
liquidjs@10.28.0:
liquidjs@10.27.0:
dependencies:
commander: 10.0.1
@@ -1242,10 +1217,10 @@ snapshots:
luxon@3.7.2: {}
markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0):
markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0):
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 15.0.0
markdown-it: 14.3.0
markdown-it-plantuml@1.4.1: {}
@@ -1258,15 +1233,6 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-it@15.0.0:
dependencies:
argparse: 3.0.0
entities: 8.0.0
linkify-it: 6.1.0
mdurl: 2.1.0
punycode.js: 2.3.1
uc.micro: 3.0.0
mdurl@2.1.0: {}
meta-generator@0.1.5:
@@ -1283,7 +1249,7 @@ snapshots:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.15
minimist@1.2.8: {}
@@ -1444,9 +1410,7 @@ snapshots:
uc.micro@2.1.0: {}
uc.micro@3.0.0: {}
undici@7.29.0: {}
undici@7.28.0: {}
unpipe@1.0.0: {}
+2 -4
View File
@@ -1,5 +1,3 @@
minimumReleaseAgeExclude:
- undici@7.28.0 || 7.29.0
- js-yaml@3.15.0 || 4.3.0
- brace-expansion@1.1.16 || 1.1.17 || 1.1.18
- liquidjs@10.27.1
- undici@7.28.0
- js-yaml@3.15.0
+2 -2
View File
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@@ -17,7 +17,7 @@
"date-fns": "^4.4.0",
"generic-pool": "^3.9.0",
"inflation": "^2.1.0",
"ioredis": "^6.0.0",
"ioredis": "^5.11.1",
"playwright": "1.62.1",
"raw-body": "^4.0.0",
"source-map-support": "^0.5.21",
+24 -15
View File
@@ -33,8 +33,8 @@ importers:
specifier: ^2.1.0
version: 2.1.0
ioredis:
specifier: ^6.0.0
version: 6.0.0
specifier: ^5.11.1
version: 5.11.1
playwright:
specifier: 1.62.1
version: 1.62.1
@@ -64,8 +64,8 @@ packages:
resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==}
engines: {node: '>=6.9.0'}
'@ioredis/commands@2.0.0':
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
'@ioredis/commands@1.10.0':
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
'@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021':
resolution: {gitHosted: true, integrity: sha512-hG/pgVEWhmHEFMU+evGZkB5kHauff5Zo6ZO+Ro7HY0efsQTJft6svM4isH5jDISeSVrZ1CDGnhWBXuqkztsTWw==, tarball: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021}
@@ -142,9 +142,9 @@ packages:
resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==}
engines: {node: '>=20.19.0'}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
@@ -284,9 +284,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@6.0.0:
resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==}
engines: {node: '>=20.0.0'}
ioredis@5.11.1:
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
engines: {node: '>=12.22.0'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
@@ -363,6 +363,10 @@ packages:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
@@ -456,7 +460,7 @@ snapshots:
dependencies:
core-js-pure: 3.49.0
'@ioredis/commands@2.0.0': {}
'@ioredis/commands@1.10.0': {}
'@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021':
dependencies:
@@ -528,7 +532,7 @@ snapshots:
boolbase@2.0.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -654,13 +658,14 @@ snapshots:
inherits@2.0.4: {}
ioredis@6.0.0:
ioredis@5.11.1:
dependencies:
'@ioredis/commands': 2.0.0
'@ioredis/commands': 1.10.0
cluster-key-slot: 1.1.1
debug: 4.4.3
denque: 2.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
@@ -685,7 +690,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.6
ms@2.1.3: {}
@@ -736,6 +741,10 @@ snapshots:
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
-1
View File
@@ -4,7 +4,6 @@ minimumReleaseAgeExclude:
- lodash@4.17.23 || 4.17.24
- playwright-core@1.62.1
- playwright@1.62.1
- brace-expansion@5.0.7 || 5.0.8 || 5.0.9
overrides:
lodash@<=4.17.23: ^4.17.24
lodash@>=4.0.0 <=4.17.22: ^4.17.23
+8 -8
View File
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"browserslist": [
"defaults"
],
@@ -59,10 +59,10 @@
"@penpot/tokenscript": "link:packages/tokenscript",
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
"@playwright/test": "1.62.1",
"@storybook/addon-docs": "10.5.6",
"@storybook/addon-themes": "10.5.6",
"@storybook/addon-vitest": "10.5.6",
"@storybook/react-vite": "10.5.6",
"@storybook/addon-docs": "10.5.5",
"@storybook/addon-themes": "10.5.5",
"@storybook/addon-vitest": "10.5.5",
"@storybook/react-vite": "10.5.5",
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^26.1.2",
"@vitest/browser": "4.1.10",
@@ -85,7 +85,7 @@
"lodash": "^4.18.1",
"lodash.debounce": "^4.0.8",
"map-stream": "0.0.7",
"marked": "^18.0.9",
"marked": "^18.0.7",
"mkdirp": "^3.0.1",
"mustache": "^4.2.0",
"nodemon": "^3.1.14",
@@ -112,7 +112,7 @@
"sax": "^1.6.1",
"scheduler": "^0.27.0",
"source-map-support": "^0.5.21",
"storybook": "10.5.6",
"storybook": "10.5.5",
"style-dictionary": "5.5.0",
"stylelint": "^17.14.1",
"stylelint-config-standard-scss": "^17.0.0",
@@ -131,6 +131,6 @@
},
"dependencies": {
"@penpot/ui": "link:packages/ui",
"react-aria-components": "^1.20.0"
"react-aria-components": "^1.19.0"
}
}
+1 -2
View File
@@ -22,8 +22,7 @@ export const {
} = pkg;
import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor.js';
import Immutable from "immutable";
const {Map, OrderedSet} = Immutable;
import {Map, OrderedSet} from "immutable";
function isDefined(v) {
return v !== undefined && v !== null;
+3 -2
View File
@@ -4,11 +4,12 @@
"description": "Penpot Draft-JS Wrapper",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d"
"draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d",
"immutable": "^5.1.9"
},
"peerDependencies": {
"react": ">=0.17.0",
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "Simple library for handling keyboard shortcuts",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Craig Campbell",
"license": "Apache-2.0 WITH LLVM-exception"
}
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
+4 -4
View File
@@ -20,8 +20,8 @@
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1",
"@storybook/react": "10.5.6",
"@storybook/react-vite": "10.5.6",
"@storybook/react": "10.5.5",
"@storybook/react-vite": "10.5.5",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/react": "^19.2.18",
@@ -33,11 +33,11 @@
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.5.6",
"storybook": "10.5.5",
"vite-plugin-dts": "^5.0.3"
},
"dependencies": {
"react-aria-components": "^1.20.0"
"react-aria-components": "^1.19.0"
},
"peerDependencies": {
"react": ">=19.2",
+154 -247
View File
@@ -30,8 +30,8 @@ importers:
specifier: link:packages/ui
version: link:packages/ui
react-aria-components:
specifier: ^1.20.0
version: 1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
specifier: ^1.19.0
version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
devDependencies:
'@penpot/draft-js':
specifier: link:packages/draft-js
@@ -58,17 +58,17 @@ importers:
specifier: 1.62.1
version: 1.62.1
'@storybook/addon-docs':
specifier: 10.5.6
version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
specifier: 10.5.5
version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/addon-themes':
specifier: 10.5.6
version: 10.5.6(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
specifier: 10.5.5
version: 10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
'@storybook/addon-vitest':
specifier: 10.5.6
version: 10.5.6(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)
specifier: 10.5.5
version: 10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)
'@storybook/react-vite':
specifier: 10.5.6
version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
specifier: 10.5.5
version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@tokens-studio/sd-transforms':
specifier: 2.0.3
version: 2.0.3(style-dictionary@5.5.0(tslib@2.8.1))
@@ -136,8 +136,8 @@ importers:
specifier: 0.0.7
version: 0.0.7
marked:
specifier: ^18.0.9
version: 18.0.9
specifier: ^18.0.7
version: 18.0.7
mkdirp:
specifier: ^3.0.1
version: 3.0.1
@@ -217,8 +217,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
storybook:
specifier: 10.5.6
version: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
specifier: 10.5.5
version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
style-dictionary:
specifier: 5.5.0
version: 5.5.0(tslib@2.8.1)
@@ -270,6 +270,9 @@ importers:
draft-js:
specifier: penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d
version: https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
immutable:
specifier: ^5.1.9
version: 5.1.9
react:
specifier: '>=0.17.0'
version: 19.2.8
@@ -295,8 +298,8 @@ importers:
specifier: '>=19.2'
version: 19.2.8
react-aria-components:
specifier: ^1.20.0
version: 1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
specifier: ^1.19.0
version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-dom:
specifier: '>=19.2'
version: 19.2.8(react@19.2.8)
@@ -308,11 +311,11 @@ importers:
specifier: ^8.0.1
version: 8.0.1(@babel/core@8.0.1)
'@storybook/react':
specifier: 10.5.6
version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)
specifier: 10.5.5
version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)
'@storybook/react-vite':
specifier: 10.5.6
version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
specifier: 10.5.5
version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@testing-library/dom':
specifier: 10.4.1
version: 10.4.1
@@ -347,8 +350,8 @@ importers:
specifier: ^1.0.0
version: 1.0.0(react@19.2.8)
storybook:
specifier: 10.5.6
version: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
specifier: 10.5.5
version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
vite-plugin-dts:
specifier: ^5.0.3
version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
@@ -439,10 +442,6 @@ packages:
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.8':
resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
engines: {node: '>=6.9.0'}
'@babel/generator@8.0.0':
resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==}
engines: {node: ^22.18.0 || >=24.11.0}
@@ -524,11 +523,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@8.0.0':
resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==}
engines: {node: ^22.18.0 || >=24.11.0}
@@ -590,10 +584,6 @@ packages:
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.8':
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
engines: {node: '>=6.9.0'}
'@babel/traverse@8.0.0':
resolution: {integrity: sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw==}
engines: {node: ^22.18.0 || >=24.11.0}
@@ -602,10 +592,6 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@babel/types@8.0.0':
resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==}
engines: {node: ^22.18.0 || >=24.11.0}
@@ -974,14 +960,14 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@internationalized/date@3.12.3':
resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==}
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
'@internationalized/number@3.6.7':
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
'@internationalized/string@3.2.10':
resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==}
'@internationalized/string@3.2.9':
resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
@@ -1512,8 +1498,8 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@react-types/shared@3.36.1':
resolution: {integrity: sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==}
'@react-types/shared@3.36.0':
resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
@@ -1884,27 +1870,27 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@storybook/addon-docs@10.5.6':
resolution: {integrity: sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==}
'@storybook/addon-docs@10.5.5':
resolution: {integrity: sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.6
storybook: ^10.5.5
peerDependenciesMeta:
'@types/react':
optional: true
'@storybook/addon-themes@10.5.6':
resolution: {integrity: sha512-fIzu2f6xPh/mtOBUU9TnjSoO3qUIF57AVES9HX4dfhjtbz9lWd+EhNd65nZfmTXxKsS32x/7/aXDTHtuP/ObwQ==}
'@storybook/addon-themes@10.5.5':
resolution: {integrity: sha512-ENZCJkvTdGYBRuaE3tEE6jRilMRdGgfYUhnFNEUXAg4II2iVYg9mnrq6tuQfwSVjGuEOTAUen/3YV+l7U4oOOA==}
peerDependencies:
storybook: ^10.5.6
storybook: ^10.5.5
'@storybook/addon-vitest@10.5.6':
resolution: {integrity: sha512-oxq7Qi4Vujc8Etoi1TZBurMs4RiKoGnvAOCXePOLglXSJMpy95gEb7iu/hvj8E21lV+vVtQYmFvj9Z7gJeMtdg==}
'@storybook/addon-vitest@10.5.5':
resolution: {integrity: sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==}
peerDependencies:
'@vitest/browser': ^3.0.0 || ^4.0.0
'@vitest/browser-playwright': ^4.0.0
'@vitest/runner': ^3.0.0 || ^4.0.0
storybook: ^10.5.6
storybook: ^10.5.5
vitest: ^3.0.0 || ^4.0.0
peerDependenciesMeta:
'@vitest/browser':
@@ -1916,18 +1902,18 @@ packages:
vitest:
optional: true
'@storybook/builder-vite@10.5.6':
resolution: {integrity: sha512-Ts8EohKPj8okDPCkueeKVN+IRGNpI3LuddsFGupqraRvK6aRWawDKA28uc0PlsLCLWbMkMsGVw+IpFXfmoLJgQ==}
'@storybook/builder-vite@10.5.5':
resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==}
peerDependencies:
storybook: ^10.5.6
storybook: ^10.5.5
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@storybook/csf-plugin@10.5.6':
resolution: {integrity: sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==}
'@storybook/csf-plugin@10.5.5':
resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==}
peerDependencies:
esbuild: '*'
rollup: '*'
storybook: ^10.5.6
storybook: ^10.5.5
vite: '*'
webpack: '*'
peerDependenciesMeta:
@@ -1948,40 +1934,40 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/react-dom-shim@10.5.6':
resolution: {integrity: sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==}
'@storybook/react-dom-shim@10.5.5':
resolution: {integrity: sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.6
storybook: ^10.5.5
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@storybook/react-vite@10.5.6':
resolution: {integrity: sha512-DCTfNZWhQUH4Zf8LDE4zdLn6+C26QNW0KETdnFUkdrqRADlwS6oExtGWC5f/uP/GDbKb9jrGbC+/ap8nWEH/vQ==}
'@storybook/react-vite@10.5.5':
resolution: {integrity: sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.6
storybook: ^10.5.5
typescript: '>= 4.9.x'
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
typescript:
optional: true
'@storybook/react@10.5.6':
resolution: {integrity: sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==}
'@storybook/react@10.5.5':
resolution: {integrity: sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.6
storybook: ^10.5.5
typescript: '>= 4.9.x'
peerDependenciesMeta:
'@types/react':
@@ -2017,8 +2003,8 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.3':
resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==}
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
@@ -2189,11 +2175,6 @@ packages:
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@webcontainer/env@1.1.1':
resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==}
@@ -2423,8 +2404,8 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
boolbase@1.0.0:
@@ -2434,12 +2415,12 @@ packages:
resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==}
engines: {node: '>=20.19.0'}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -2663,10 +2644,6 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
content-type@2.0.0:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -3188,8 +3165,8 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fastest-levenshtein@1.0.16:
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
@@ -3997,8 +3974,8 @@ packages:
map-stream@0.0.7:
resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==}
marked@18.0.9:
resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==}
marked@18.0.7:
resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==}
engines: {node: '>= 20'}
hasBin: true
@@ -4553,14 +4530,14 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-aria-components@1.20.0:
resolution: {integrity: sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==}
react-aria-components@1.19.0:
resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-aria@3.51.0:
resolution: {integrity: sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==}
react-aria@3.50.0:
resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
@@ -4598,8 +4575,8 @@ packages:
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-stately@3.49.0:
resolution: {integrity: sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==}
react-stately@3.48.0:
resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
@@ -5035,8 +5012,8 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
storybook@10.5.6:
resolution: {integrity: sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==}
storybook@10.5.5:
resolution: {integrity: sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==}
hasBin: true
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -5221,8 +5198,8 @@ packages:
svg-tags@1.0.0:
resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
svgo@2.8.3:
resolution: {integrity: sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==}
svgo@2.8.2:
resolution: {integrity: sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==}
engines: {node: '>=10.13.0'}
hasBin: true
@@ -5365,10 +5342,6 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
type-is@2.1.0:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -5713,18 +5686,6 @@ packages:
utf-8-validate:
optional: true
ws@8.21.2:
resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@0.1.0:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
@@ -5897,14 +5858,6 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@7.29.8':
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@8.0.0':
dependencies:
'@babel/parser': 8.0.0
@@ -6005,10 +5958,6 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/parser@8.0.0':
dependencies:
'@babel/types': 8.0.0
@@ -6095,30 +6044,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.8(supports-color@10.2.2)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.8(supports-color@5.5.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@babel/traverse@8.0.0':
dependencies:
'@babel/code-frame': 8.0.0
@@ -6134,11 +6059,6 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/types@8.0.0':
dependencies:
'@babel/helper-string-parser': 8.0.0
@@ -6447,7 +6367,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@internationalized/date@3.12.3':
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.23
@@ -6455,7 +6375,7 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.23
'@internationalized/string@3.2.10':
'@internationalized/string@3.2.9':
dependencies:
'@swc/helpers': 0.5.23
@@ -6905,7 +6825,7 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@react-types/shared@3.36.1(react@19.2.8)':
'@react-types/shared@3.36.0(react@19.2.8)':
dependencies:
react: 19.2.8
@@ -7149,15 +7069,15 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@storybook/addon-docs@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
'@storybook/addon-docs@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8)
'@storybook/csf-plugin': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/icons': 2.1.0(react@19.2.8)
'@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
'@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
ts-dedent: 2.3.0
optionalDependencies:
'@types/react': 19.2.18
@@ -7168,16 +7088,16 @@ snapshots:
- vite
- webpack
'@storybook/addon-themes@10.5.6(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))':
'@storybook/addon-themes@10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))':
dependencies:
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
ts-dedent: 2.3.0
'@storybook/addon-vitest@10.5.6(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)':
'@storybook/addon-vitest@10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/icons': 2.1.0(react@19.2.8)
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
optionalDependencies:
'@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)
'@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)
@@ -7186,10 +7106,10 @@ snapshots:
transitivePeerDependencies:
- react
'@storybook/builder-vite@10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
'@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
dependencies:
'@storybook/csf-plugin': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
'@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
ts-dedent: 2.3.0
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)
transitivePeerDependencies:
@@ -7197,9 +7117,9 @@ snapshots:
- rollup
- webpack
'@storybook/csf-plugin@10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
'@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
dependencies:
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.28.1
@@ -7212,28 +7132,28 @@ snapshots:
dependencies:
react: 19.2.8
'@storybook/react-dom-shim@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))':
'@storybook/react-dom-shim@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))':
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
'@storybook/react-vite@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
'@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@rollup/pluginutils': 5.4.0(rollup@4.61.1)
'@storybook/builder-vite': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/react': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)
'@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)
empathic: 2.0.1
magic-string: 0.30.21
react: 19.2.8
react-docgen: 8.0.3(supports-color@10.2.2)
react-dom: 19.2.8(react@19.2.8)
resolve: 1.22.12
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
tsconfig-paths: 4.2.0
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)
optionalDependencies:
@@ -7246,19 +7166,19 @@ snapshots:
- supports-color
- webpack
'@storybook/react-vite@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
'@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@rollup/pluginutils': 5.4.0(rollup@4.61.1)
'@storybook/builder-vite': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/react': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)
'@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))
'@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)
empathic: 2.0.1
magic-string: 0.30.21
react: 19.2.8
react-docgen: 8.0.3(supports-color@5.5.0)
react-dom: 19.2.8(react@19.2.8)
resolve: 1.22.12
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
tsconfig-paths: 4.2.0
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)
optionalDependencies:
@@ -7271,15 +7191,15 @@ snapshots:
- supports-color
- webpack
'@storybook/react@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)':
'@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
'@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
react: 19.2.8
react-docgen: 8.0.3(supports-color@10.2.2)
react-docgen-typescript: 2.4.0(typescript@6.0.3)
react-dom: 19.2.8(react@19.2.8)
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
@@ -7287,15 +7207,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@storybook/react@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)':
'@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
'@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))
react: 19.2.8
react-docgen: 8.0.3(supports-color@5.5.0)
react-docgen-typescript: 2.4.0(typescript@6.0.3)
react-dom: 19.2.8(react@19.2.8)
storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
@@ -7337,7 +7257,7 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
'@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)':
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
@@ -7372,24 +7292,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.29.8
'@babel/types': 7.29.7
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.29.8
'@babel/types': 7.29.7
'@types/chai@5.2.3':
dependencies:
@@ -7561,13 +7481,11 @@ snapshots:
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28(typescript@6.0.3)':
'@volar/typescript@2.4.28':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.1.0
optionalDependencies:
typescript: 6.0.3
'@webcontainer/env@1.1.1': {}
@@ -7614,7 +7532,7 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
fast-uri: 3.1.4
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
optional: true
@@ -7622,7 +7540,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
fast-uri: 3.1.4
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -7826,17 +7744,17 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.3.0(supports-color@5.5.0):
body-parser@2.2.2(supports-color@5.5.0):
dependencies:
bytes: 3.1.2
content-type: 2.0.0
content-type: 1.0.5
debug: 4.4.3(supports-color@5.5.0)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.3
raw-body: 3.0.2
type-is: 2.1.0
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -7844,12 +7762,12 @@ snapshots:
boolbase@2.0.0: {}
brace-expansion@1.1.18:
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.9:
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
@@ -8083,8 +8001,6 @@ snapshots:
content-type@1.0.5: {}
content-type@2.0.0: {}
convert-source-map@2.0.0: {}
cookie-signature@1.2.2: {}
@@ -8715,7 +8631,7 @@ snapshots:
express@5.2.1(supports-color@5.5.0):
dependencies:
accepts: 2.0.0
body-parser: 2.3.0(supports-color@5.5.0)
body-parser: 2.2.2(supports-color@5.5.0)
content-disposition: 1.0.1
content-type: 1.0.5
cookie: 0.7.2
@@ -8765,7 +8681,7 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-uri@3.1.5: {}
fast-uri@3.1.4: {}
fastest-levenshtein@1.0.16: {}
@@ -9548,7 +9464,7 @@ snapshots:
map-stream@0.0.7: {}
marked@18.0.9: {}
marked@18.0.7: {}
math-intrinsics@1.1.0: {}
@@ -9610,11 +9526,11 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.7
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.15
minimist@1.2.8: {}
@@ -10120,30 +10036,29 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-aria-components@1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
react-aria-components@1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@internationalized/date': 3.12.3
'@internationalized/string': 3.2.10
'@react-types/shared': 3.36.1(react@19.2.8)
'@internationalized/date': 3.12.2
'@react-types/shared': 3.36.0(react@19.2.8)
'@swc/helpers': 0.5.23
client-only: 0.0.1
react: 19.2.8
react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
react-stately: 3.49.0(react@19.2.8)
react-stately: 3.48.0(react@19.2.8)
react-aria@3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
react-aria@3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@internationalized/date': 3.12.3
'@internationalized/date': 3.12.2
'@internationalized/number': 3.6.7
'@internationalized/string': 3.2.10
'@react-types/shared': 3.36.1(react@19.2.8)
'@internationalized/string': 3.2.9
'@react-types/shared': 3.36.0(react@19.2.8)
'@swc/helpers': 0.5.23
aria-hidden: 1.2.6
clsx: 2.1.1
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-stately: 3.49.0(react@19.2.8)
react-stately: 3.48.0(react@19.2.8)
use-sync-external-store: 1.6.0(react@19.2.8)
react-compiler-runtime@1.0.0(react@19.2.8):
@@ -10157,8 +10072,8 @@ snapshots:
react-docgen@8.0.3(supports-color@10.2.2):
dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/traverse': 7.29.8(supports-color@10.2.2)
'@babel/types': 7.29.8
'@babel/traverse': 7.29.7(supports-color@10.2.2)
'@babel/types': 7.29.7
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
'@types/doctrine': 0.0.9
@@ -10172,8 +10087,8 @@ snapshots:
react-docgen@8.0.3(supports-color@5.5.0):
dependencies:
'@babel/core': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.8(supports-color@5.5.0)
'@babel/types': 7.29.8
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
'@types/doctrine': 0.0.9
@@ -10199,12 +10114,12 @@ snapshots:
react-lifecycles-compat@3.0.4: {}
react-stately@3.49.0(react@19.2.8):
react-stately@3.48.0(react@19.2.8):
dependencies:
'@internationalized/date': 3.12.3
'@internationalized/date': 3.12.2
'@internationalized/number': 3.6.7
'@internationalized/string': 3.2.10
'@react-types/shared': 3.36.1(react@19.2.8)
'@internationalized/string': 3.2.9
'@react-types/shared': 3.36.0(react@19.2.8)
'@swc/helpers': 0.5.23
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
@@ -10704,13 +10619,13 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8):
storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8):
dependencies:
'@storybook/global': 5.0.0
'@storybook/icons': 2.1.0(react@19.2.8)
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/user-event': 14.6.3(@testing-library/dom@10.4.1)
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/expect': 3.2.4
'@vitest/spy': 3.2.4
'@webcontainer/env': 1.1.1
@@ -10722,7 +10637,7 @@ snapshots:
recast: 0.23.19
semver: 7.8.5
use-sync-external-store: 1.6.0(react@19.2.8)
ws: 8.21.2
ws: 8.21.1
optionalDependencies:
'@types/react': 19.2.18
prettier: 3.9.6
@@ -10983,7 +10898,7 @@ snapshots:
lodash.merge: 4.6.2
mustache: 4.2.0
prettysize: 2.0.0
svgo: 2.8.3
svgo: 2.8.2
vinyl: 2.2.1
winston: 3.19.0
xpath: 0.0.34
@@ -10991,7 +10906,7 @@ snapshots:
svg-tags@1.0.0: {}
svgo@2.8.3:
svgo@2.8.2:
dependencies:
commander: 7.2.0
css-select: 4.3.0
@@ -11130,12 +11045,6 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
type-is@2.1.0:
dependencies:
content-type: 2.0.0
media-typer: 1.1.0
mime-types: 3.0.2
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -11201,7 +11110,7 @@ snapshots:
unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)):
dependencies:
'@rollup/pluginutils': 5.4.0(rollup@4.61.1)
'@volar/typescript': 2.4.28(typescript@6.0.3)
'@volar/typescript': 2.4.28
compare-versions: 6.1.1
debug: 4.4.3(supports-color@10.2.2)
kolorist: 1.8.0
@@ -11487,8 +11396,6 @@ snapshots:
ws@8.21.1: {}
ws@8.21.2: {}
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.1
+2 -5
View File
@@ -462,14 +462,11 @@
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [on-error on-success]
:or {on-error identity
:or {on-error rx/throw
on-success identity}} (meta data)]
(->> (rp/cmd! :recover-profile data)
(rx/tap on-success)
(rx/catch (fn [err]
(on-error err)
(rx/empty)))
(rx/ignore)))))))
(rx/catch on-error)))))))
;; --- EVENT: fetch-team-webhooks
+14 -21
View File
@@ -204,27 +204,20 @@
(defn- bind!
[shortcuts]
(let [entries (remove #(:disabled (second %)) shortcuts)
bind-fn (fn [[key {:keys [command fn type overwrite]}]]
(let [callback (wrap-cb key fn)
commands (if (vector? command)
(into-array command)
#js [command])]
(if (vector? type)
(do (mousetrap/bind commands callback (nth type 0) overwrite)
(mousetrap/bind commands callback (nth type 1) overwrite))
(let [undefined (js* "(void 0)")]
(if type
(mousetrap/bind commands callback type overwrite)
(mousetrap/bind commands callback undefined overwrite))))))]
;; Bind non-overwrite entries first so that entries flagged with
;; `:overwrite` are bound last and can reliably splice out the
;; colliding callbacks bound earlier (mousetrap's overwrite only
;; removes callbacks that were already registered for the same
;; combo). Map iteration order is hash-based, so we must force the
;; order explicitly.
(run! bind-fn (remove (comp :overwrite second) entries))
(run! bind-fn (filter (comp :overwrite second) entries))))
(->> shortcuts
(remove #(:disabled (second %)))
(run! (fn [[key {:keys [command fn type overwrite]}]]
(let [callback (wrap-cb key fn)
commands (if (vector? command)
(into-array command)
#js [command])]
(if (vector? type)
(do (mousetrap/bind commands callback (nth type 0) overwrite)
(mousetrap/bind commands callback (nth type 1) overwrite))
(let [undefined (js* "(void 0)")]
(if type
(mousetrap/bind commands callback type overwrite)
(mousetrap/bind commands callback undefined overwrite)))))))))
(defn- reset!
([]
@@ -1217,7 +1217,7 @@
(rx/mapcat (fn [blob]
;; Resolve the deferred with the fetched blob; the browser
;; will now complete the clipboard write it started earlier.
(p/resolve deferred blob)
(p/resolve! deferred blob)
(rx/from write-promise)))
(rx/map (fn [_]
(ntf/success (tr "workspace.clipboard.image-copied"))))
@@ -1225,5 +1225,5 @@
(js/console.error "clipboard error:" e)
;; Reject the deferred in case the error occurred before the
;; blob was fetched, so the pending clipboard write is cancelled.
(p/reject deferred e)
(p/reject! deferred e)
(rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed")))))))))))
@@ -37,7 +37,6 @@
:command "p"
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit! (drp/change-edit-mode :draw))}
:add-node {:tooltip (ds/shift "+")
@@ -50,9 +49,7 @@
:command ["del" "backspace"]
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit!
(drp/remove-node))}
:fn #(st/emit! (drp/remove-node))}
:merge-nodes {:tooltip (ds/meta "J")
:command (ds/c-mod "j")
@@ -70,7 +67,6 @@
:command "k"
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit! (drp/separate-nodes))}
:make-corner {:tooltip "X"
@@ -83,7 +79,6 @@
:command "c"
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit! (drp/make-curve))}
:snap-nodes {:tooltip (ds/meta "'")
@@ -96,7 +91,6 @@
:escape {:tooltip (ds/esc)
:command ["escape" "enter" "v"]
:section [:workspace]
:overwrite true
:fn #(st/emit! (esc-pressed))}
:undo {:tooltip (ds/meta "Z")
+5 -15
View File
@@ -15,7 +15,6 @@
["react-dom/server" :as rds]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
@@ -60,7 +59,6 @@
[rumext.v2 :as mf]))
(def ^:const viewbox-decimal-precision 3)
(def ^:const max-export-dimension 100000)
(def ^:private default-color clr/canvas)
(mf/defc background
@@ -84,20 +82,12 @@
(let [bounds
(->> root-objects
(map (partial gsb/get-object-bounds objects))
(grc/join-rects))
bounds (-> bounds
(update :x mth/finite 0)
(update :y mth/finite 0)
(update :width mth/finite 100000)
(update :height mth/finite 100000))]
(when (or (> (:width bounds) max-export-dimension)
(> (:height bounds) max-export-dimension)
(> (+ (:x bounds) (:width bounds)) max-export-dimension)
(> (+ (:y bounds) (:height bounds)) max-export-dimension))
(ex/raise :type :validation
:code :export-area-too-large
:hint "export area exceeds maximum allowed dimensions"))
(grc/join-rects))]
(-> bounds
(update :x mth/finite 0)
(update :y mth/finite 0)
(update :width mth/finite 100000)
(update :height mth/finite 100000)
(grc/update-rect :position)
(grc/fix-aspect-ratio aspect-ratio))))))
+3 -13
View File
@@ -28,18 +28,8 @@
(= password-1 password-2))]])
(defn- on-error
[form error]
(let [{:keys [type code] :as edata} (ex-data error)]
(if (= [:validation :weak-password] [type code])
(let [details (:details edata)
options (when (seq details)
(mapv tr details))]
(swap! form assoc-in [:extra-errors :password-1]
{:message (tr "errors.weak-password")
:options options}))
(let [msg (tr "errors.invalid-recovery-token")]
(st/emit! (ntf/error msg))))))
[_form _error]
(st/emit! (ntf/error (tr "errors.invalid-recovery-token"))))
(defn- on-success
[_]
@@ -48,7 +38,7 @@
(defn- on-submit
[form _event]
(let [mdata {:on-error (partial on-error form)
(let [mdata {:on-error on-error
:on-success on-success}
params {:token (get-in @form [:clean-data :token])
:password (get-in @form [:clean-data :password-2])}]
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more