diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index d48e401a869..3ba45267a5b 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -19,16 +19,16 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Build and push DevEnv Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'penpotapp/devenv' with: diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index ff6375b13e6..18ac6aec9f3 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -63,10 +63,10 @@ jobs: popd - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -76,14 +76,14 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: frontend @@ -95,7 +95,7 @@ jobs: bundle_version=${{ steps.bundles.outputs.bundle_version }} - name: Build and push Backend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'backend' BUNDLE_PATH: './bundle-backend' @@ -110,7 +110,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Frontend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'frontend' BUNDLE_PATH: './bundle-frontend' @@ -125,7 +125,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Exporter Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'exporter' BUNDLE_PATH: './bundle-exporter' @@ -140,7 +140,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Storybook Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'storybook' BUNDLE_PATH: './bundle-storybook' @@ -155,7 +155,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push MCP Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'mcp' BUNDLE_PATH: './bundle-mcp' diff --git a/.github/workflows/build-main-staging.yml b/.github/workflows/build-main-staging.yml new file mode 100644 index 00000000000..33ee46947c2 --- /dev/null +++ b/.github/workflows/build-main-staging.yml @@ -0,0 +1,22 @@ +name: _MAIN-STAGING + +on: + workflow_dispatch: + schedule: + - cron: '26 5-20 * * 1-5' + +jobs: + build-bundle: + uses: ./.github/workflows/build-bundle.yml + secrets: inherit + with: + gh_ref: "main-staging" + build_wasm: "yes" + build_storybook: "yes" + + build-docker: + needs: build-bundle + uses: ./.github/workflows/build-docker.yml + secrets: inherit + with: + gh_ref: "main-staging" diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index f7126a40cb0..a80e6e4cc09 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -6,12 +6,14 @@ on: - edited - reopened - synchronize + - ready_for_review pull_request_target: types: - opened - edited - reopened - synchronize + - ready_for_review push: branches: - main @@ -20,6 +22,7 @@ on: jobs: check-commit-message: + if: ${{ !github.event.pull_request.draft }} name: Check Commit Message runs-on: ubuntu-latest steps: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 815553749d9..51be85e45e7 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index f8f558569d5..137ba6f7fa5 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 01f92849725..943e4b790dc 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - id: filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | colors_to_tokens: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 9fbcac880e3..47f0d1cc24d 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -60,7 +60,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 538cd9d5a09..c0890324c83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,14 +63,15 @@ jobs: echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io - IMAGES=("frontend" "backend" "exporter" "storybook") + IMAGES=("frontend" "backend" "exporter" "mcp" "storybook") + SHORT_TAG=${TAG%.*} for image in "${IMAGES[@]}"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$TAG - for alias in main latest; do + for alias in main latest "$SHORT_TAG"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$alias @@ -93,7 +94,7 @@ jobs: # --- Create GitHub release --- - name: Create GitHub release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9f2a4ed5898..0ab2909b723 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -10,6 +10,7 @@ on: types: - opened - synchronize + - ready_for_review paths: - 'mcp/**' @@ -24,8 +25,9 @@ on: - 'mcp/**' jobs: - test: - name: "Test" + test-mcp: + if: ${{ !github.event.pull_request.draft }} + name: "Test MCP" runs-on: penpot-runner-02 container: penpotapp/devenv:latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 472fc366569..e4b2d49efc6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: types: - opened - synchronize + - ready_for_review push: branches: - develop @@ -20,9 +21,14 @@ concurrency: jobs: lint: + if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -79,9 +85,14 @@ jobs: pnpm run lint test-common: + if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -93,9 +104,14 @@ jobs: ./scripts/test test-plugins: + if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - uses: actions/checkout@v6 @@ -143,9 +159,14 @@ jobs: run: pnpm run build:styles-example test-frontend: + if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -164,9 +185,14 @@ jobs: ./scripts/test-components test-render-wasm: + if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -188,9 +214,14 @@ jobs: ./test test-backend: + if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs services: postgres: @@ -227,9 +258,14 @@ jobs: clojure -M:dev:test --reporter kaocha.report/documentation test-library: + if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -241,9 +277,14 @@ jobs: ./scripts/test build-integration: + if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -255,16 +296,21 @@ jobs: ./scripts/build - name: Store Bundle Cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public - test-integration-1: - name: "Integration Tests 1/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 1/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs + needs: build-integration steps: @@ -272,7 +318,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -280,10 +326,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="1/4"; + ./scripts/test-e2e --shard="1/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-1 @@ -292,9 +338,15 @@ jobs: retention-days: 3 test-integration-2: - name: "Integration Tests 2/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 2/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs + needs: build-integration steps: @@ -302,7 +354,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -310,10 +362,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="2/4"; + ./scripts/test-e2e --shard="2/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-2 @@ -322,39 +374,15 @@ jobs: retention-days: 3 test-integration-3: - name: "Integration Tests 3/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 3/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest - needs: build-integration - - steps: - - name: Checkout Repository - uses: actions/checkout@v6 + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs - - name: Restore Cache - uses: actions/cache/restore@v4 - with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public - - - name: Run Tests - working-directory: ./frontend - run: | - ./scripts/test-e2e --shard="3/4"; - - - name: Upload test result - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-tests-result-3 - path: frontend/test-results/ - overwrite: true - retention-days: 3 - - test-integration-4: - name: "Integration Tests 4/4" - runs-on: penpot-runner-02 - container: penpotapp/devenv:latest needs: build-integration steps: @@ -362,7 +390,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -370,13 +398,13 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="4/4"; + ./scripts/test-e2e --shard="3/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: - name: integration-tests-result-4 + name: integration-tests-result-3 path: frontend/test-results/ overwrite: true retention-days: 3 diff --git a/.gitignore b/.gitignore index 5d718c3363a..8586839ba04 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ /.clj-kondo/.cache /_dump /notes +/.opencode/package-lock.json +/plans +/prompts /playground/ /backend/*.md !/backend/AGENTS.md @@ -63,6 +66,7 @@ /frontend/test-results/ /frontend/.shadow-cljs /other/ +/scripts/ /nexus/ /tmp/ /vendor/**/target diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md new file mode 100644 index 00000000000..51d74710f5d --- /dev/null +++ b/.opencode/agents/commiter.md @@ -0,0 +1,33 @@ +--- +name: commiter +description: Git commit assistant following CONTRIBUTING.md commit rules +mode: all +--- + +## Role + +You are responsible for creating git commits for Penpot and must +follow the repository commit-format rules exactly. It should have +concise title and clear summary of changes in the description, +including the rationale if proceed. + +## Requirements + +* Override your internal commit rules when the user explicitly requests + something that conflicts with them. +* Read `CONTRIBUTING.md` before creating any commit and follow the + commit guidelines strictly. +* Use commit messages in the form `:emoji: `. +* Keep the subject capitalized, concise, 70 characters or fewer, and + without a trailing period. +* Keep the description (commit body) with maximum line length of 80 + characters. Use manual line breaks to wrap text before it exceeds + this limit. +* Separate the subject from the body with a blank line. +* Write a clear and concise body when needed. +* Use `git commit -s` so the commit includes the required + `Signed-off-by` line. +* Do not guess or hallucinate git author information (Name or + Email). Never include the `--author` flag in git commands unless + specifically instructed by the user for a unique case; assume the + local environment is already configured. diff --git a/.opencode/agents/engineer.md b/.opencode/agents/engineer.md index b85205f0316..b5ba1bf3f7b 100644 --- a/.opencode/agents/engineer.md +++ b/.opencode/agents/engineer.md @@ -1,5 +1,5 @@ --- -name: engineer +name: Penpot Engineer description: Senior Full-Stack Software Engineer mode: primary --- diff --git a/.opencode/agents/planner.md b/.opencode/agents/planner.md new file mode 100644 index 00000000000..ff838d77a50 --- /dev/null +++ b/.opencode/agents/planner.md @@ -0,0 +1,64 @@ +--- +name: Penpot Planner +description: Software architect for planning and analysis only +mode: primary +permission: + edit: ask +--- + +# Penpot Planner + +## Role + +You are a Senior Software Architect working on Penpot, an open-source design +tool. Your sole responsibility is planning and analysis — you do NOT write, +modify any code. + +You help users understand the codebase, design solutions, and create detailed +implementation plans that other agents or developers can execute. Document +everything they need to know: which files to touch for each task, code, testing, +docs they might need to check, how to test it. Give them the whole plan as +bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. + +Do **not** suggest commit messages or commit names anywhere in your plans or +responses — committing is the developer's responsibility. + +Assume they are a skilled developer, but know almost nothing about our toolset +or problem domain. Assume they don't know good test design very well. + +## Requirements + +* Analyze the codebase architecture and identify affected modules. +* Read `AGENTS.md` files (root and per-module) to understand structure and + conventions. +* Search code using `ripgrep` skill (`rg`) to trace dependencies, find patterns, + and understand existing implementations. +* Break down complex features or bugs into atomic, actionable steps. +* Propose solutions with clear rationale, trade-offs, and sequencing. +* Identify risks, edge cases, and testing considerations. + +Save plans to: plans/YYYY-MM-DD-.md + +## Constraints + +* You are **read-only** — never create, edit, or delete files. +* You do **not** run builds, tests, linters, or any commands that modify state. +* You do **not** create git commits or interact with version control. +* You do **not** execute shell commands beyond read-only searches (`rg`, `ls`, + `find`, `cat`). +* Your output is a structured plan or analysis, ready for handoff to an + engineer agent or developer. + +## Output format + +When producing a plan, structure it as: + +1. **Context** — What is the problem or feature request? +2. **Affected modules** — Which parts of the codebase are involved? +3. **Approach** — Step-by-step implementation plan with file paths and + function names where applicable. +4. **Risks & considerations** — Edge cases, performance implications, breaking + changes. +5. **Testing strategy** — How to verify the implementation works correctly. + + diff --git a/.opencode/agents/prompt-assistant.md b/.opencode/agents/prompt-assistant.md new file mode 100644 index 00000000000..9e6141e7685 --- /dev/null +++ b/.opencode/agents/prompt-assistant.md @@ -0,0 +1,59 @@ +--- +name: Prompt Assistant +description: Refines and improves prompts for maximum clarity and effectiveness +mode: all +--- + +# Prompt Assistant + +## Role + +You are an expert Prompt Engineer with strong knowledge of +penpot. Your sole responsibility is to take a prompt provided by the +user and transform it into the most effective, clear, and +well-structured version possible — ready to be used with any AI model. + +## Requirements + +* You do NOT execute tasks. You do NOT write code. You only design and + refine prompts +* Read the root `AGENTS.md` to understand the repository and application + architecture. Then read the `AGENTS.md` **only** for each affected module. +* Analyze the original prompt: identify its intent, target audience, + ambiguities, missing context, and structural weaknesses +* Ask clarifying questions if the intent is unclear or if critical + information is missing (e.g. target model, expected output format, + tone, constraints). Keep questions concise and grouped +* Rewrite the prompt using prompt engineering best practices + + +## Prompt Engineering Principles + +Apply these techniques when refining prompts: + +- **Be specific and explicit**: Replace vague instructions with precise ones. +- **Set the context**: Include background information the model needs to + perform well. +- **Specify the output format**: State the desired structure, length, tone, + or format (e.g. bullet list, JSON, step-by-step). +- **Add constraints**: Include what the model should avoid or not do. +- **Use examples** (few-shot): When applicable, suggest adding examples to + anchor the model's behaviour. +- **Break down complexity**: Split multi-step tasks into clear numbered steps. +- **Avoid ambiguity**: Remove pronouns and references that could be + misinterpreted. +- **Chain of thought**: For reasoning tasks, include "Think step by step." + +## Constraints + +- Do NOT execute the prompt yourself. +- Do NOT answer the question inside the prompt. +- Do NOT add unnecessary verbosity — prompts should be as short as they can + be while remaining complete. +- Always preserve the user's original intent. + +## Output + +Refined Prompt: The improved, ready-to-use prompt. Print it for +immediate use and save it to +prompts/YYYY-MM-DD-N-.md for future use. diff --git a/.opencode/agents/testing.md b/.opencode/agents/testing.md deleted file mode 100644 index 17c19aade1f..00000000000 --- a/.opencode/agents/testing.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: testing -description: Senior Software Engineer specialized on testing -mode: primary ---- - -Role: You are a Senior Software Engineer specialized in testing Clojure and -ClojureScript codebases. You work on Penpot, an open-source design tool. - -Tech stack: Clojure (backend/JVM), ClojureScript (frontend/Node.js), shared -Cljc (common module), Rust (render-wasm). - -Requirements: - -* Read the root `AGENTS.md` to understand the repository and application - architecture. Then read the `AGENTS.md` **only** for each affected module. Not all - modules have one — verify before reading. -* Before writing code, describe your plan. If the task is complex, break it down into - atomic steps. -* Tests should be exhaustive and include edge cases relevant to Penpot's domain: - nil/missing fields, empty collections, invalid UUIDs, boundary geometries, Malli schema - violations, concurrent state mutations, and timeouts. -* Tests must be deterministic — do not use `setTimeout`, real network calls, or rely on - execution order. Use synchronous mocks for asynchronous workflows. -* Use `with-redefs` or equivalent mocking utilities to isolate the logic under test. Avoid - testing through the UI (DOM); e2e tests cover that. -* Only reference functions, namespaces, or test utilities that actually exist in the - codebase. Verify their existence before citing them. -* After adding or modifying tests, run the applicable lint and format checks for the - affected module before considering the work done (see module `AGENTS.md` for exact - commands). -* Make small and logical commits following the commit guideline described in - `CONTRIBUTING.md`. Commit only when explicitly asked. -- Do not guess or hallucinate git author information (Name or Email). Never include the - `--author` flag in git commands unless specifically instructed by the user for a unique - case; assume the local environment is already configured. Allow git commit to - automatically pull the identity from the local git config `user.name` and `user.email`. diff --git a/.opencode/skills/backport-commit/SKILL.md b/.opencode/skills/backport-commit/SKILL.md new file mode 100644 index 00000000000..c8092402db4 --- /dev/null +++ b/.opencode/skills/backport-commit/SKILL.md @@ -0,0 +1,90 @@ +--- +name: backport-commit +description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts. +--- + +# Backport Commit + +Port changes from a specific Git commit to the current branch by manually +applying the diff, avoiding `git cherry-pick` when it would introduce +complex conflicts. + +## When to Use + +Use this skill whenever the user asks to backport a commit, especially when: + +- The commit touches multiple modules or files with significant divergence +- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick") +- The target commit is old enough that conflicts are likely +- The commit introduces both source changes AND new files (tests, etc.) +- You need full control over how each hunk is applied + +## Workflow + +### 1. Identify the target commit + +```bash +# Verify the commit exists and understand what it does +git log --oneline -1 + +# Get the full diff (including new/deleted files) +git show + +# Capture the original commit message for later reuse +git log --format='%B' -1 +``` + +### 2. Identify affected modules + +From the file paths in the diff, determine which Penpot modules are affected +(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md` +files **before** making any changes. If a module has no `AGENTS.md`, skip +that step — verify with `ls /AGENTS.md` first. + +### 3. Read the current state of each affected file + +For every file the diff touches, read the current version on disk to understand +context and ensure correct placement before editing. + +### 4. Apply changes manually (the core of this approach) + +Process every hunk in the diff using the appropriate tool: + +| Diff action | Tool to use | +|-------------|-------------| +| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location | +| Add new file | `write` — include proper license header and namespace conventions matching project style | +| Delete file | `bash rm ` | +| Rename/move file | `bash mv `, then apply any content changes with `edit` | + +> **Tip:** Group nearby hunks from the same file into a single `edit` call. +> Use separate calls when hunks are far apart to keep `oldString` short and +> unambiguous. + +Repeat until **all** hunks in the diff are ported. + +### 5. Validate + +Run **lint**, **check-fmt**, and **tests** for every affected module (see each +module's `AGENTS.md` for the exact commands). If the formatter auto-fixes +indentation, verify the logic is still semantically correct. All checks must +pass before moving on. + +### 6. Port the changelog entry (if any) + +If the original commit added or modified a `CHANGES.md` entry, port that entry +too — adapting wording and version references for the target branch. + +### 7. Commit + +Ask the `commiter` sub-agent to create a commit. Stage all relevant files +(exclude unrelated untracked files) and provide the original commit message as +a reference, adapting it as needed for the target branch context. + +## Key Principles + +- **Context matters** — always read files before editing; never guess + indentation or surrounding code +- **Lint + format + test** — never skip validation before committing +- **Preserve intent** — keep the original commit message meaning; the + `commiter` agent handles formatting diff --git a/.opencode/skills/bat-cat/SKILL.md b/.opencode/skills/bat-cat/SKILL.md new file mode 100644 index 00000000000..61ca8def8fe --- /dev/null +++ b/.opencode/skills/bat-cat/SKILL.md @@ -0,0 +1,210 @@ +--- +name: bat-cat +description: A cat clone with syntax highlighting, line numbers, and Git integration - a modern replacement for cat. +homepage: https://github.com/sharkdp/bat +metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"id":"brew","kind":"brew","formula":"bat","bins":["bat"],"label":"Install bat (brew)"},{"id":"apt","kind":"apt","package":"bat","bins":["bat"],"label":"Install bat (apt)"}]}} +--- + +# bat - Better cat + +`cat` with syntax highlighting, line numbers, and Git integration. + +## Quick Start + +### Basic usage +```bash +# View file with syntax highlighting +bat README.md + +# Multiple files +bat file1.js file2.py + +# With line numbers (default) +bat script.sh + +# Without line numbers +bat -p script.sh +``` + +### Viewing modes +```bash +# Plain mode (like cat) +bat -p file.txt + +# Show non-printable characters +bat -A file.txt + +# Squeeze blank lines +bat -s file.txt + +# Paging (auto for large files) +bat --paging=always file.txt +bat --paging=never file.txt +``` + +## Syntax Highlighting + +### Language detection +```bash +# Auto-detect from extension +bat script.py + +# Force specific language +bat -l javascript config.txt + +# Show all languages +bat --list-languages +``` + +### Themes +```bash +# List available themes +bat --list-themes + +# Use specific theme +bat --theme="Monokai Extended" file.py + +# Set default theme in config +# ~/.config/bat/config: --theme="Dracula" +``` + +## Line Ranges + +```bash +# Show specific lines +bat -r 10:20 file.txt + +# From line to end +bat -r 100: file.txt + +# Start to specific line +bat -r :50 file.txt + +# Multiple ranges +bat -r 1:10 -r 50:60 file.txt +``` + +## Git Integration + +```bash +# Show Git modifications (added/removed/modified lines) +bat --diff file.txt + +# Show decorations (Git + file header) +bat --decorations=always file.txt +``` + +## Output Control + +```bash +# Output raw (no styling) +bat --style=plain file.txt + +# Customize style +bat --style=numbers,changes file.txt + +# Available styles: auto, full, plain, changes, header, grid, numbers, snip +bat --style=header,grid,numbers file.txt +``` + +## Common Use Cases + +**Quick file preview:** +```bash +bat file.json +``` + +**View logs with syntax highlighting:** +```bash +bat error.log +``` + +**Compare files visually:** +```bash +bat --diff file1.txt +bat file2.txt +``` + +**Preview before editing:** +```bash +bat config.yaml && vim config.yaml +``` + +**Cat replacement in pipes:** +```bash +bat -p file.txt | grep "pattern" +``` + +**View specific function:** +```bash +bat -r 45:67 script.py # If function is on lines 45-67 +``` + +## Integration with other tools + +**As pager for man pages:** +```bash +export MANPAGER="sh -c 'col -bx | bat -l man -p'" +man grep +``` + +**With ripgrep:** +```bash +rg "pattern" -l | xargs bat +``` + +**With fzf:** +```bash +fzf --preview 'bat --color=always --style=numbers {}' +``` + +**With diff:** +```bash +diff -u file1 file2 | bat -l diff +``` + +## Configuration + +Create `~/.config/bat/config` for defaults: + +``` +# Set theme +--theme="Dracula" + +# Show line numbers, Git modifications and file header, but no grid +--style="numbers,changes,header" + +# Use italic text on terminal +--italic-text=always + +# Add custom mapping +--map-syntax "*.conf:INI" +``` + +## Performance Tips + +- Use `-p` for plain mode when piping +- Use `--paging=never` when output is used programmatically +- `bat` caches parsed files for faster subsequent access + +## Tips + +- **Alias:** `alias cat='bat -p'` for drop-in cat replacement +- **Pager:** Use as pager with `export PAGER="bat"` +- **On Debian/Ubuntu:** Command may be `batcat` instead of `bat` +- **Custom syntaxes:** Add to `~/.config/bat/syntaxes/` +- **Performance:** For huge files, use `bat --paging=never` or plain `cat` + +## Common flags + +- `-p` / `--plain`: Plain mode (no line numbers/decorations) +- `-n` / `--number`: Only show line numbers +- `-A` / `--show-all`: Show non-printable characters +- `-l` / `--language`: Set language for syntax highlighting +- `-r` / `--line-range`: Only show specific line range(s) + +## Documentation + +GitHub: https://github.com/sharkdp/bat +Man page: `man bat` +Customization: https://github.com/sharkdp/bat#customization diff --git a/.opencode/skills/fd-find/SKILL.md b/.opencode/skills/fd-find/SKILL.md new file mode 100644 index 00000000000..e218ac9bfd6 --- /dev/null +++ b/.opencode/skills/fd-find/SKILL.md @@ -0,0 +1,194 @@ +--- +name: fd-find +description: A fast and user-friendly alternative to 'find' - simple syntax, smart defaults, respects gitignore. +homepage: https://github.com/sharkdp/fd +metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id":"brew","kind":"brew","formula":"fd","bins":["fd"],"label":"Install fd (brew)"},{"id":"apt","kind":"apt","package":"fd-find","bins":["fd"],"label":"Install fd (apt)"}]}} +--- + +# fd - Fast File Finder + +User-friendly alternative to `find` with smart defaults. + +## Quick Start + +### Basic search +```bash +# Find files by name +fd pattern + +# Find in specific directory +fd pattern /path/to/dir + +# Case-insensitive +fd -i pattern +``` + +### Common patterns +```bash +# Find all Python files +fd -e py + +# Find multiple extensions +fd -e py -e js -e ts + +# Find directories only +fd -t d pattern + +# Find files only +fd -t f pattern + +# Find symlinks +fd -t l +``` + +## Advanced Usage + +### Filtering +```bash +# Exclude patterns +fd pattern -E "node_modules" -E "*.min.js" + +# Include hidden files +fd -H pattern + +# Include ignored files (.gitignore) +fd -I pattern + +# Search all (hidden + ignored) +fd -H -I pattern + +# Maximum depth +fd pattern -d 3 +``` + +### Execution +```bash +# Execute command on results +fd -e jpg -x convert {} {.}.png + +# Parallel execution +fd -e md -x wc -l + +# Use with xargs +fd -e log -0 | xargs -0 rm +``` + +### Regex patterns +```bash +# Full regex search +fd '^test.*\.js$' + +# Match full path +fd --full-path 'src/.*/test' + +# Glob pattern +fd -g "*.{js,ts}" +``` + +## Time-based filtering +```bash +# Modified within last day +fd --changed-within 1d + +# Modified before specific date +fd --changed-before 2024-01-01 + +# Created recently +fd --changed-within 1h +``` + +## Size filtering +```bash +# Files larger than 10MB +fd --size +10m + +# Files smaller than 1KB +fd --size -1k + +# Specific size range +fd --size +100k --size -10m +``` + +## Output formatting +```bash +# Absolute paths +fd --absolute-path + +# List format (like ls -l) +fd --list-details + +# Null separator (for xargs) +fd -0 pattern + +# Color always/never/auto +fd --color always pattern +``` + +## Common Use Cases + +**Find and delete old files:** +```bash +fd --changed-before 30d -t f -x rm {} +``` + +**Find large files:** +```bash +fd --size +100m --list-details +``` + +**Copy all PDFs to directory:** +```bash +fd -e pdf -x cp {} /target/dir/ +``` + +**Count lines in all Python files:** +```bash +fd -e py -x wc -l | awk '{sum+=$1} END {print sum}' +``` + +**Find broken symlinks:** +```bash +fd -t l -x test -e {} \; -print +``` + +**Search in specific time window:** +```bash +fd --changed-within 2d --changed-before 1d +``` + +## Integration with other tools + +**With ripgrep:** +```bash +fd -e js | xargs rg "pattern" +``` + +**With fzf (fuzzy finder):** +```bash +vim $(fd -t f | fzf) +``` + +**With bat (cat alternative):** +```bash +fd -e md | xargs bat +``` + +## Performance Tips + +- `fd` is typically much faster than `find` +- Respects `.gitignore` by default (disable with `-I`) +- Uses parallel traversal automatically +- Smart case: lowercase = case-insensitive, any uppercase = case-sensitive + +## Tips + +- Use `-t` for type filtering (f=file, d=directory, l=symlink, x=executable) +- `-e` for extension is simpler than `-g "*.ext"` +- `{}` in `-x` commands represents the found path +- `{.}` strips the extension +- `{/}` gets basename, `{//}` gets directory + +## Documentation + +GitHub: https://github.com/sharkdp/fd +Man page: `man fd` diff --git a/.opencode/skills/gh-issue-from-pr/SKILL.md b/.opencode/skills/gh-issue-from-pr/SKILL.md new file mode 100644 index 00000000000..93f17345f2e --- /dev/null +++ b/.opencode/skills/gh-issue-from-pr/SKILL.md @@ -0,0 +1,230 @@ +--- +name: gh-issue-from-pr +description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type. +--- + +# Skill: gh-issue-from-pr + +Create a GitHub issue that captures the **WHAT** (user-facing feature or +bug) from an existing PR that describes the **HOW** (implementation). +Used when the project board needs an issue as the primary changelog/release unit. + +## When to Use + +- Create a tracking issue from a PR for changelog purposes +- Extract the user-facing problem/feature from a PR's implementation details +- Assign milestone, project, labels, and issue type to a new issue derived from a PR + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`) +- Permission to create issues and edit PRs in the target repository + +## Workflow + +### 1. Understand the PR + +```bash +gh pr view --repo penpot/penpot \ + --json title,body,author,labels,baseRefName,mergedAt,state,milestone +``` + +Identify: + +- **WHAT** — user-facing problem or feature. Goes into the issue. + Describe symptoms and impact, not internal mechanisms. +- **HOW** — implementation details. These belong in the PR, not the issue. + +### 2. Determine metadata + +| Field | Source | Rule | +|-------|--------|------| +| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. | +| **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). | +| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. | +| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. | +| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. | +| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. | + +### 3. Write the issue body + +**Bug template:** + +```markdown +### Description + + + +### Steps to reproduce + +1. +2. + +### Expected behavior + + + +### Affected versions + + +``` + +**Enhancement template:** + +```markdown +### Description + + + +### Use case + + + +### Affected versions + + +``` + +### 4. Create the issue + +Write the body to a temp file to avoid shell quoting issues: + +```bash +cat > /tmp/issue-body.md << 'ISSUE_BODY' + +ISSUE_BODY +``` + +Create: + +```bash +gh issue create \ + --repo penpot/penpot \ + --title "" \ + --label "<label1>" \ + --label "<label2>" \ + --milestone "<milestone>" \ + --project "Main" \ + --body-file /tmp/issue-body.md +``` + +Output: `https://github.com/penpot/penpot/issues/<NUMBER>` + +### 5. Assign to the PR author + +Assign the issue to the PR author so they're responsible for it: + +```bash +AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login') +gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR" +``` + +### 6. Set the Issue Type + +`gh issue create` can't set the Issue Type directly. Use GraphQL. + +Get the issue's GraphQL node ID: + +```bash +ISSUE_ID=$(gh api graphql -f query=' +query { repository(owner: "penpot", name: "penpot") { + issue(number: <ISSUE_NUMBER>) { id } +}}' --jq '.data.repository.issue.id') +``` + +Issue Type IDs for the Penpot repo: + +| Type | ID | +|------|----| +| Bug | `IT_kwDOAcyBPM4AX5Nb` | +| Enhancement | `IT_kwDOAcyBPM4B_IQN` | +| Feature | `IT_kwDOAcyBPM4AX5Nf` | +| Task | `IT_kwDOAcyBPM4AX5NY` | +| Question | `IT_kwDOAcyBPM4B_IQj` | +| Docs | `IT_kwDOAcyBPM4B_IQz` | + +Set it: + +```bash +gh api graphql -f query=' +mutation { + updateIssue(input: { + id: "'"$ISSUE_ID"'" + issueTypeId: "<TYPE_ID>" + }) { + issue { number issueType { name } } + } +}' +``` + +### 7. Verify + +```bash +gh issue view <ISSUE_NUMBER> --repo penpot/penpot \ + --json title,milestone,projectItems,labels \ + --jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}' + +gh api graphql -f query=' +query { repository(owner: "penpot", name: "penpot") { + issue(number: <ISSUE_NUMBER>) { issueType { name } } +}}' --jq '.data.repository.issue.issueType.name' +``` + +### 8. Link the PR to the issue + +Append `Fixes #<ISSUE_NUMBER>` to the PR body: + +```bash +gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md +printf "\n\nFixes #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md +gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md + +# Verify +gh pr view <PR_NUMBER> --repo penpot/penpot --json body \ + --jq '.body | test("Fixes #<ISSUE_NUMBER>")' +``` + +**Note:** If the PR is already merged, `Fixes` won't auto-close the issue +— it only creates the "Development" sidebar link. This is the desired +behavior since the issue is a tracking artifact. + +### 9. Clean up + +```bash +rm -f /tmp/issue-body.md /tmp/pr-body.md +``` + +## Label rules + +| PR has | Issue gets | +|--------|-----------| +| `bug` | `bug` | +| `enhancement` | `enhancement` | +| `community contribution` | `community contribution` | +| `backport candidate` | *(skip — workflow label)* | +| `team-qa` | *(skip — workflow label)* | +| No user-facing label | Infer from title: `:bug:` → `bug`, `:sparkles:` → `enhancement` | + +## Issue Type mapping + +| PR label(s) / title prefix | Issue Type | +|----------------------------|-----------| +| `bug` or `:bug:` | Bug | +| `enhancement` or `:sparkles:` or `:tada:` | Enhancement | +| Feature / epic | Feature | +| Documentation | Docs | +| None of the above | Task | + +## Key Principles + +- **Issue = WHAT, PR = HOW.** Never put implementation details in the + issue body. The issue is for users, QA, and changelog readers. +- **Copy the milestone from the PR.** Don't guess based on branch names. + If the PR has no milestone, create the issue without one. +- **Set Issue Type via GraphQL** — `gh issue create` can't set it. +- **Link via PR body** — `Fixes #<NUMBER>` creates the "Development" + sidebar link automatically. +- **One issue per PR** — even if a PR fixes multiple things, create a + single issue that summarizes the overall change. +- **Community attribution:** if the PR has the `community contribution` + label or the author is not a core team member, add the label to the issue. diff --git a/.opencode/skills/jq-json-processor/SKILL.md b/.opencode/skills/jq-json-processor/SKILL.md new file mode 100644 index 00000000000..83fe48d7bf8 --- /dev/null +++ b/.opencode/skills/jq-json-processor/SKILL.md @@ -0,0 +1,112 @@ +--- +name: jq-json-processor +description: Process, filter, and transform JSON data using jq - the lightweight and flexible command-line JSON processor. +homepage: https://jqlang.github.io/jq/ +metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id":"brew","kind":"brew","formula":"jq","bins":["jq"],"label":"Install jq (brew)"},{"id":"apt","kind":"apt","package":"jq","bins":["jq"],"label":"Install jq (apt)"}]}} +--- + +# jq JSON Processor + +Process, filter, and transform JSON data with jq. + +## Quick Examples + +### Basic filtering +```bash +# Extract a field +echo '{"name":"Alice","age":30}' | jq '.name' +# Output: "Alice" + +# Multiple fields +echo '{"name":"Alice","age":30}' | jq '{name: .name, age: .age}' + +# Array indexing +echo '[1,2,3,4,5]' | jq '.[2]' +# Output: 3 +``` + +### Working with arrays +```bash +# Map over array +echo '[{"name":"Alice"},{"name":"Bob"}]' | jq '.[].name' +# Output: "Alice" "Bob" + +# Filter array +echo '[1,2,3,4,5]' | jq 'map(select(. > 2))' +# Output: [3,4,5] + +# Length +echo '[1,2,3]' | jq 'length' +# Output: 3 +``` + +### Common operations +```bash +# Pretty print JSON +cat file.json | jq '.' + +# Compact output +cat file.json | jq -c '.' + +# Raw output (no quotes) +echo '{"name":"Alice"}' | jq -r '.name' +# Output: Alice + +# Sort keys +echo '{"z":1,"a":2}' | jq -S '.' +``` + +### Advanced filtering +```bash +# Select with conditions +jq '[.[] | select(.age > 25)]' people.json + +# Group by +jq 'group_by(.category)' items.json + +# Reduce +echo '[1,2,3,4,5]' | jq 'reduce .[] as $item (0; . + $item)' +# Output: 15 +``` + +### Working with files +```bash +# Read from file +jq '.users[0].name' users.json + +# Multiple files +jq -s '.[0] * .[1]' file1.json file2.json + +# Modify and save +jq '.version = "2.0"' package.json > package.json.tmp && mv package.json.tmp package.json +``` + +## Common Use Cases + +**Extract specific fields from API response:** +```bash +curl -s https://api.github.com/users/octocat | jq '{name: .name, repos: .public_repos, followers: .followers}' +``` + +**Convert CSV-like data:** +```bash +jq -r '.[] | [.name, .email, .age] | @csv' users.json +``` + +**Debug API responses:** +```bash +curl -s https://api.example.com/data | jq '.' +``` + +## Tips + +- Use `-r` for raw string output (removes quotes) +- Use `-c` for compact output (single line) +- Use `-S` to sort object keys +- Use `--arg name value` to pass variables +- Pipe multiple jq operations: `jq '.a' | jq '.b'` + +## Documentation + +Full manual: https://jqlang.github.io/jq/manual/ +Interactive tutorial: https://jqplay.org/ diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md new file mode 100644 index 00000000000..2cf44d88c79 --- /dev/null +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -0,0 +1,120 @@ +--- +name: nrepl-eval +description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool. +--- + +# nREPL Eval + +Evaluate Clojure (or ClojureScript) code via a running nREPL server using +`tools/nrepl-eval.mjs` — a standalone CLI application. + +Session state (defs, in-ns, etc.) persists across invocations via a stored +session ID, so you can build up state incrementally. + +## Usage + +```bash +node tools/nrepl-eval.mjs [options] [<code>] +``` + +The tool is also executable directly: +```bash +./tools/nrepl-eval.mjs [options] [<code>] +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-p, --port PORT` | nREPL server port | `6064` | +| `-H, --host HOST` | nREPL server host | `127.0.0.1` | +| `-t, --timeout MS` | Timeout in milliseconds | `120000` | +| `--reset-session` | Discard stored session and start fresh | — | +| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | +| `-h, --help` | Show help message | — | + +## When to Use + +Use this tool when you need to: + +1. **Evaluate Clojure code** during development — test functions, inspect + state, or run experiments against a running Clojure process. +2. **Verify that edited files compile** — require namespaces with `:reload` + to pick up changes. +3. **Inspect the last exception** after a failed evaluation — use `-e` to + print the error stored in `*e`. + +## Workflow + +### 1. Session management + +Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State +carries across calls automatically: + +```bash +./tools/nrepl-eval.mjs '(def x 42)' +./tools/nrepl-eval.mjs 'x' +# => 42 +``` + +Reset the session to start fresh: + +```bash +./tools/nrepl-eval.mjs --reset-session '(def x 0)' +``` + +### 2. Evaluate code + +**Single expression (inline) — uses default port 6064:** +```bash +./tools/nrepl-eval.mjs '(+ 1 2 3)' +``` + +**Multiple expressions via heredoc (recommended — avoids escaping issues):** +```bash +./tools/nrepl-eval.mjs <<'EOF' +(def x 10) +(+ x 20) +EOF +``` + +**Override with a different port:** +```bash +./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' +``` + +### 3. Inspect last exception + +After code throws an error, retrieve the full exception details: + +```bash +./tools/nrepl-eval.mjs -e +``` + +## Common Patterns + +**Require a namespace with reload:** +```bash +./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" +``` + +**Test a function:** +```bash +./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" +``` + +**Long-running operation with custom timeout:** +```bash +./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" +``` + +## Key Principles + +- **Default port is 6064** — just pass code directly, no `-p` needed when + your nREPL server is on 6064. Use `-p <PORT>` for a different port. +- **Always use `:reload`** when requiring namespaces to pick up file changes. +- **Session is reused** across invocations — defs, in-ns, and var bindings + persist. Use `--reset-session` to clear. +- **Do not start any server** — the tool connects to an existing nREPL + server, it is not the agent's responsibility to start the nREPL server + (assume the server is already running on the specified port). diff --git a/.opencode/skills/ripgrep/SKILL.md b/.opencode/skills/ripgrep/SKILL.md new file mode 100644 index 00000000000..31c3a83d5e8 --- /dev/null +++ b/.opencode/skills/ripgrep/SKILL.md @@ -0,0 +1,150 @@ +--- +name: ripgrep +description: Blazingly fast text search tool - recursively searches directories for regex patterns with respect to gitignore rules. +homepage: https://github.com/BurntSushi/ripgrep +metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id":"brew","kind":"brew","formula":"ripgrep","bins":["rg"],"label":"Install ripgrep (brew)"},{"id":"apt","kind":"apt","package":"ripgrep","bins":["rg"],"label":"Install ripgrep (apt)"}]}} +--- + +# ripgrep (rg) + +Fast, smart recursive search. Respects `.gitignore` by default. + +## Quick Start + +### Basic search +```bash +# Search for "TODO" in current directory +rg "TODO" + +# Case-insensitive search +rg -i "fixme" + +# Search specific file types +rg "error" -t py # Python files only +rg "function" -t js # JavaScript files +``` + +### Common patterns +```bash +# Whole word match +rg -w "test" + +# Show only filenames +rg -l "pattern" + +# Show with context (3 lines before/after) +rg -C 3 "function" + +# Count matches +rg -c "import" +``` + +## Advanced Usage + +### File type filtering +```bash +# Multiple file types +rg "error" -t py -t js + +# Exclude file types +rg "TODO" -T md -T txt + +# List available types +rg --type-list +``` + +### Search modifiers +```bash +# Regex search +rg "user_\d+" + +# Fixed string (no regex) +rg -F "function()" + +# Multiline search +rg -U "start.*end" + +# Only show matches, not lines +rg -o "https?://[^\s]+" +``` + +### Path filtering +```bash +# Search specific directory +rg "pattern" src/ + +# Glob patterns +rg "error" -g "*.log" +rg "test" -g "!*.min.js" + +# Include hidden files +rg "secret" --hidden + +# Search all files (ignore .gitignore) +rg "pattern" --no-ignore +``` + +## Replacement Operations + +```bash +# Preview replacements +rg "old_name" --replace "new_name" + +# Actually replace (requires extra tool like sd) +rg "old_name" -l | xargs sed -i 's/old_name/new_name/g' +``` + +## Performance Tips + +```bash +# Parallel search (auto by default) +rg "pattern" -j 8 + +# Skip large files +rg "pattern" --max-filesize 10M + +# Memory map files +rg "pattern" --mmap +``` + +## Common Use Cases + +**Find TODOs in code:** +```bash +rg "TODO|FIXME|HACK" --type-add 'code:*.{rs,go,py,js,ts}' -t code +``` + +**Search in specific branches:** +```bash +git show branch:file | rg "pattern" +``` + +**Find files containing multiple patterns:** +```bash +rg "pattern1" | rg "pattern2" +``` + +**Search with context and color:** +```bash +rg -C 2 --color always "error" | less -R +``` + +## Comparison to grep + +- **Faster:** Typically 5-10x faster than grep +- **Smarter:** Respects `.gitignore`, skips binary files +- **Better defaults:** Recursive, colored output, line numbers +- **Easier:** Simpler syntax for common tasks + +## Tips + +- `rg` is often faster than `grep -r` +- Use `-t` for file type filtering instead of `--include` +- Combine with other tools: `rg pattern -l | xargs tool` +- Add custom types in `~/.ripgreprc` +- Use `--stats` to see search performance + +## Documentation + +GitHub: https://github.com/BurntSushi/ripgrep +User Guide: https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md new file mode 100644 index 00000000000..889e77c3e60 --- /dev/null +++ b/.opencode/skills/update-changelog/SKILL.md @@ -0,0 +1,201 @@ +--- +name: update-changelog +description: Update the project CHANGES.md with issues from a given GitHub milestone, with correct categorization and references. +--- + +# Skill: update-changelog + +Update `CHANGES.md` with entries for all issues and PRs in a given GitHub +milestone. Each entry references the user-facing issue (not the PR) as the +primary link, with the fix PR on a sub-line. + +## When to Use + +- Before a new release, to populate the changelog with all fixed issues +- When new issues are added to an existing milestone and the changelog needs + to be refreshed +- To ensure every entry follows the correct format for the changelog + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`) +- Read access to the penpot/penpot repository + +## Workflow + +### 1. Determine the target version + +The version is typically a semver string like `2.15.3`. Confirm with the user +if not specified. + +### 2. Fetch all issues and PRs in the milestone + +Find the milestone number: + +```bash +gh api repos/penpot/penpot/milestones --paginate \ + --jq '.[] | select(.title=="<VERSION>") | {number: .number, title: .title, open_issues: .open_issues, closed_issues: .closed_issues}' +``` + +Then fetch all items: + +```bash +MILESTONE_NUMBER=<NUMBER> +gh api "repos/penpot/penpot/issues?milestone=$MILESTONE_NUMBER&state=all&per_page=100" \ + --jq '.[] | {number: .number, title: .title, state: .state, labels: [.labels[].name], pull_request: .pull_request != null}' +``` + +### 3. Identify issue ↔ PR relationships + +For each item, determine the relationship: + +- **Issue** (`pull_request: false`): This is the user-facing issue. It + becomes the primary link in the changelog. +- **PR** (`pull_request: true`): Check if it has `Fixes #<NUMBER>` in its + body to find which issue it closes. + +To find the linked issue for a PR: + +```bash +gh pr view <PR_NUMBER> --repo penpot/penpot \ + --json body,closingIssuesReferences --jq '{closingIssues: [.closingIssuesReferences[].number]}' +``` + +**Only closed issues are included.** An issue must have `state: "closed"` to +appear in the changelog. Open/unresolved issues are omitted, even if they are +tracked in the milestone. + +**Pairing rules:** + +| Pattern | Changelog format | +|---------|-----------------| +| Closed issue + one or more PRs fix it | Primary link = issue, sub-line with PRs comma-separated | +| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). | +| Closed issue with no fix PR in milestone | Link the issue directly, without a PR sub-line. | + +### 4. Categorize entries + +Check the labels on each issue/PR: + +```bash +gh issue view <NUMBER> --repo penpot/penpot --json labels --jq '[.labels[].name]' +``` + +| Label / Title prefix | Changelog section | +|----------------------|-------------------| +| `bug` label or `:bug:` title prefix | `### :bug: Bugs fixed` | +| `enhancement` label or `:sparkles:` prefix | `### :sparkles: New features & Enhancements` | +| No label | Infer from title convention, default to bug fix | + +**Community contribution attribution:** If the issue or its fix PR has the +`community contribution` label, add an attribution `(by @<github_username>)` +on the changelog entry line, **before** the GitHub issue/PR references. +Fetch the author: + +```bash +gh issue view <NUMBER> --repo penpot/penpot --json author --jq '.author.login' +``` + +Placement in the entry line: +```markdown +- Fix description of the bug (by @username) [Github #<ISSUE>](...) + (PR: [#<PR>](...)) +``` + +### 5. Read the current CHANGES.md + +Read the top of `CHANGES.md` to understand the existing format and find the +insertion point (newest version goes at the top, after the `# CHANGELOG` +header). + +Key format rules from the existing file: + +```markdown +## <VERSION> + +### :bug: Bugs fixed + +- Fix description of the bug [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) + (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +- Fix another bug (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) + (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) + +### :sparkles: New features & Enhancements + +- Add new feature description [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) + (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +``` + +Format details: +- Entries start with `- ` followed by a short description in imperative mood +- Primary link is **always the issue** (user-facing artifact) +- PR references are on an indented sub-line: ` (PR: [#<N>](<url>))` + If an issue has multiple fix PRs, they are comma-separated on one line: + ` (PR: [#<N>](<url>), [#<M>](<url>))` +- The description should describe the fix/feature from the user's perspective +- Community contributions get `(by @<username>)` **before** the GitHub link +- Sections are separated by a blank line between the last entry and the next + section title +- Only include a section if there are entries for it + +### 6. Build the description text + +Derive the description from the issue title, not the PR title. Strip leading +emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the +user-facing behavior. + +Examples: + +| Issue title | Changelog description | +|-------------|----------------------| +| `Plugin API token methods fail with schema validation error on PRO` | `Fix Plugin API token methods failing with schema validation error on PRO` | +| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` | +| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` | + +### 7. Insert the section into CHANGES.md + +Insert the new version section right after the `# CHANGELOG` header (before +the previous version entry). Use the `edit` tool with enough context to make +a unique match. + +### 8. Verify + +Read the top of `CHANGES.md` and confirm: +- The version header is correct +- Every entry has a GitHub link +- Entries with a fix PR have the PR sub-line +- The section ordering is correct (newest first) +- Formatting matches the surrounding entries + +## Version section template + +```markdown +## <VERSION> + +### :bug: Bugs fixed + +- <fix description> [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) + (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +- <fix description> (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) + (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +``` + +## Key Principles + +- **Issue = changelog unit.** The primary link always points to the + user-facing issue, not the implementation PR. +- **PR = implementation detail.** Reference the PR on a sub-line so readers + can find the code changes. +- **Latest version first.** New sections are inserted at the top of the + changelog, below the `# CHANGELOG` header. +- **User-facing descriptions.** Write from the user's perspective — describe + what broke and what was fixed, not internal implementation details. +- **Community attribution.** When the issue or fix PR has the + `community contribution` label, add `(by @<username>)` on the entry line + between the description and the GitHub link. +- **Only closed issues.** An issue must have `state: "closed"` to appear in + the changelog. Open unresolved issues are omitted. +- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them + comma-separated on the same sub-line: `(PR: [#A](url), [#B](url))`. +- **Re-fetch before editing.** Milestones can change — always re-fetch issues + before making edits, don't rely on cached data. diff --git a/AGENTS.md b/AGENTS.md index bcb947da479..dac88e8261a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,47 @@ precision while maintaining a strong focus on maintainability and performance. 5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects `.gitignore` by default. +## Changelogs + +The project has two changelogs: + +- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp). +- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only. + +When making changes, add a changelog entry to the appropriate file under the +`## <version> (Unreleased)` section in the correct category +(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`). + +## GitHub Operations + +To obtain the list of repository members/collaborators: + +```bash +gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' +``` + +To obtain the list of open PRs authored by members: + +```bash +MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//') +gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" ' + ($members | split("|")) as $m | + .[] | select(.author.login as $a | $m | index($a)) | + "\(.number)\t\(.author.login)\t\(.title)" +' +``` + +To obtain the list of open PRs from external contributors (non-members): + +```bash +MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//') +gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" ' + ($members | split("|")) as $m | + .[] | select(.author.login as $a | $m | index($a) | not) | + "\(.number)\t\(.author.login)\t\(.title)" +' +``` + ## Architecture Overview Penpot is an open-source design tool composed of several modules: diff --git a/CHANGES.md b/CHANGES.md index be1795e06f6..b37c175a459 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,126 @@ # CHANGELOG +## 2.15.3 + +### :bug: Bugs fixed + +- Fix Plugin API token methods failing with schema validation error on PRO [Github #9641](https://github.com/penpot/penpot/issues/9641) + (PR: [#9632](https://github.com/penpot/penpot/pull/9632)) +- Sanitize comment content on rendering [Github #9642](https://github.com/penpot/penpot/issues/9642) + (PR: [#9605](https://github.com/penpot/penpot/pull/9605)) +- Sanitize font family names on custom uploaded fonts [Github #9643](https://github.com/penpot/penpot/issues/9643) + (PR: [#9601](https://github.com/penpot/penpot/pull/9601)) + + +## 2.15.2 + +### :bug: Bugs fixed + +- Fix mcp related internal config for docker images [Github #9565](https://github.com/penpot/penpot/pull/9565) + + +## 2.15.1 + +### :sparkles: New features & Enhancements + +- Add support for chunked uploading of fonts [Github #9560](https://github.com/penpot/penpot/issues/9560) + + +## 2.15.0 + +### :sparkles: New features & Enhancements + +- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174) + (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321)) +- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #9516](https://github.com/penpot/penpot/issues/9516) + (PR: [#8909](https://github.com/penpot/penpot/pull/8909)) +- Add anonymous telemetry event collection [Github #9467](https://github.com/penpot/penpot/issues/9467) + (PR: [#9065](https://github.com/penpot/penpot/pull/9065), [#9483](https://github.com/penpot/penpot/pull/9483)) +- Improve team name validation [Github #9517](https://github.com/penpot/penpot/issues/9517) + (PR: [#9176](https://github.com/penpot/penpot/pull/9176)) +- Enhance readability of applied tokens in plugins API [Github #9175](https://github.com/penpot/penpot/issues/9175) + (PR: [#8607](https://github.com/penpot/penpot/pull/8607)) +- Encourage use of flex/grid layouts in designs generated via MCP [Github #9081](https://github.com/penpot/penpot/issues/9081) + (PR: [#9084](https://github.com/penpot/penpot/pull/9084)) +- Improve MCP server logging, adding Loki support [Github #9415](https://github.com/penpot/penpot/issues/9415) + (PR: [#9425](https://github.com/penpot/penpot/pull/9425)) +- Add security headers to Nginx on Docker images [Github #9519](https://github.com/penpot/penpot/issues/9519) + (PR: [#9473](https://github.com/penpot/penpot/pull/9473)) + +### :bug: Bugs fixed + +- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346) + (PR: [#9355](https://github.com/penpot/penpot/pull/9355)) +- Reduce memory usage of MCP server when handling images (by @opcode81) [Github #9420](https://github.com/penpot/penpot/issues/9420) + (PR: [#9431](https://github.com/penpot/penpot/pull/9431)) +- Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [Github #9162](https://github.com/penpot/penpot/issues/9162) + (PR: [#9166](https://github.com/penpot/penpot/pull/9166)) +- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296) + (PR: [#9126](https://github.com/penpot/penpot/pull/9126), [#9233](https://github.com/penpot/penpot/pull/9233)) +- Fix empty warning on login [Github #9520](https://github.com/penpot/penpot/issues/9520) + (PR: [#9056](https://github.com/penpot/penpot/pull/9056)) +- Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470) + (PR: [#9484](https://github.com/penpot/penpot/pull/9484)) +- Fix incorrect handling of version restore operation [Github #9515](https://github.com/penpot/penpot/issues/9515) + (PR: [#9041](https://github.com/penpot/penpot/pull/9041)) +- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9518](https://github.com/penpot/penpot/issues/9518) + (PR: [#9400](https://github.com/penpot/penpot/pull/9400)) +- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238) + (PR: [#9239](https://github.com/penpot/penpot/pull/9239)) +- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9496](https://github.com/penpot/penpot/issues/9496) + (PR: [#9322](https://github.com/penpot/penpot/pull/9322)) +- Fix multiple selection on shapes with token applied to stroke color [Github #9522](https://github.com/penpot/penpot/issues/9522) + (PR: [#9110](https://github.com/penpot/penpot/pull/9110)) +- Fix onboarding modals appearing behind libraries and templates panel [Github #9521](https://github.com/penpot/penpot/issues/9521) + (PR: [#9178](https://github.com/penpot/penpot/pull/9178)) +- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9430](https://github.com/penpot/penpot/issues/9430) + (PR: [#9435](https://github.com/penpot/penpot/pull/9435)) + +## 2.14.5 + +### :bug: Bugs fixed + +- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380) + +## 2.14.4 + +### :bug: Bugs fixed + +- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006) +- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122) +- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927) + + +## 2.14.3 + +### :sparkles: New features & Enhancements + +- Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) +- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) + +### :bug: Bugs fixed + +- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882) +- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) +- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) +- Fix highlight on frames after rename [Github #8938](https://github.com/penpot/penpot/pull/8938) +- Fix TypeError in sd-token-uuid when resolving tokens interactively [Github #8929](https://github.com/penpot/penpot/pull/8929) +- Fix path drawing preview passing shape instead of content to next-node +- Fix swapped arguments in CLJS PathData `-nth` with default +- Normalize PathData coordinates to safe integer bounds on read +- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962) +- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963) +- Fix "Move to" menu allowing same project as target when multiple files are selected +- Fix crash when index query param is duplicated in URL +- Fix wrong extremity point in path `calculate-extremities` for line-to segments +- Fix reversed args in DTCG shadow composite token conversion +- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?` +- Fix wrong `mapcat` call in `collect-main-shapes` +- Fix stale accumulator in `get-children-in-instance` recursion +- Fix typo `:podition` in swap-shapes grid cell +- Fix multiple selection on shapes with token applied to stroke color + + ## 2.14.2 ### :sparkles: New features & Enhancements @@ -68,11 +189,9 @@ - Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040) - Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955) - Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012) -- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) - Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051) - Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) - Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058) -- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) - Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) - Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174) - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) @@ -133,12 +252,8 @@ - Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956) - Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959) - Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865) -- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835) -- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) - Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167) - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) -- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) -- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) - Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) - Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) - Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187) @@ -231,10 +346,8 @@ example. It's still usable as before, we just removed the example. ### :bug: Bugs fixed - Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252) -- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402) - Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985) - Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974) -- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299) - Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371) - Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437) - Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265) @@ -315,7 +428,7 @@ example. It's still usable as before, we just removed the example. - Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269) - Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384) - Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299) -- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385) +- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12385](https://tree.taiga.io/project/penpot/issue/12385) - Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367) - Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397) - Fix remove flex button doesn’t work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e8f30e6052..d733ea5c7ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,17 @@ Center](https://help.penpot.app/). - [Prerequisites](#prerequisites) - [Reporting Bugs](#reporting-bugs) - [Pull Requests](#pull-requests) + - [Workflow](#workflow) + - [Title format](#title-format) + - [Description](#description) + - [Branch naming](#branch-naming) + - [Review process](#review-process) + - [What we won't accept](#what-we-wont-accept) + - [Good first issues](#good-first-issues) - [Commit Guidelines](#commit-guidelines) + - [Commit types](#commit-types) + - [Rules](#rules) + - [Examples](#examples) - [Formatting and Linting](#formatting-and-linting) - [Changelog](#changelog) - [Code of Conduct](#code-of-conduct) @@ -52,15 +62,98 @@ Advisories](https://github.com/penpot/penpot/security/advisories) 1. **Read the DCO** — see [Developer's Certificate of Origin](#developers-certificate-of-origin-dco) below. All code patches must include a `Signed-off-by` line. -2. **Discuss before building** — open a question/discussion issue before - starting work on a new feature or significant change. No PR will be - accepted without prior discussion, whether it is a new feature, a planned - one, or a quick win. +2. **Discuss before building** — open a [GitHub + Issue](https://github.com/penpot/penpot/issues) or start a [GitHub + Discussion](https://github.com/penpot/penpot/discussions) before starting + work on a new feature or significant change. For planned features on the + roadmap, reference the corresponding Taiga story. No PR will be accepted + without prior discussion, whether it is a new feature, a planned one, or a + quick win. 3. **Bug fixes** — you may submit a PR directly, but we still recommend filing an issue first so we can track it independently of your fix. 4. **Format and lint** — run the checks described in [Formatting and Linting](#formatting-and-linting) before submitting. +### Title format + +Pull request titles **must** follow the same convention as commit subjects: + +``` +:emoji: <subject> +``` + +- Use the **imperative mood** (e.g. "Fix", not "Fixed"). +- Capitalize the first letter of the subject. +- Do not end the subject with a period. +- Keep the subject to **70 characters** or fewer. +- Use one of the [commit type emojis](#commit-types) listed below. + +When a PR contains multiple unrelated commits, choose the emoji that +best represents the dominant change. + +**Examples:** + +``` +:bug: Fix unexpected error on launching modal +:sparkles: Enable new modal for profile +:zap: Improve performance of dashboard navigation +``` + +> **Note:** When a PR is squash-merged, the PR title becomes the +> commit message on the main branch. Getting the title right matters. + +### Description + +Every pull request should include a description that helps reviewers +understand the change quickly: + +1. **What and why** — describe the change and its motivation. +2. **Link related issues** — use `Closes #1234` or reference a Taiga + story (e.g. `Taiga #5678`). +3. **Screenshots or recordings** — required for any UI-visible change. +4. **Testing notes** — how did you verify the change? Any edge cases? +5. **Breaking changes** — call out anything that affects existing users + or requires migration steps. + +### Branch naming + +Use a descriptive branch name that reflects the type and scope of the +change: + +``` +<type>/<short-description> +``` + +Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`. + +Optionally include the issue number: + +``` +fix/9122-email-blacklisting +feat/export-webp +refactor/layout-sizing +``` + +### Review process + +- Maintainers review PRs when time permits. Please be patient. +- Address review feedback by **pushing new commits** — do not + force-push during review, as it breaks comment threads. +- PRs require at least **one approval** before merge. +- We use **squash-merge** by default. The PR title becomes the final + commit message, so follow the [title format](#title-format) above. + +### What we won't accept + +To save time on both sides, please avoid submitting PRs that: + +- Introduce new dependencies without prior discussion. +- Change the build system or CI configuration without maintainer + approval. +- Mix unrelated changes in a single PR — keep PRs focused on one + concern. +- Skip the [discussion step](#workflow) for non-bug-fix changes. + ### Good first issues We use the `easy fix` label to mark issues appropriate for newcomers. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b4ac2ac1dd8..a260a74c13a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -8,7 +8,10 @@ Redis for messaging/caching. ## General Guidelines To ensure consistency across the Penpot JVM stack, all contributions must adhere -to these criteria: +to these criteria. + +IMPORTANT: all CLI commands should be executed under backend/ +subdirectory for make them work correctly. ### 1. Testing & Validation @@ -21,7 +24,7 @@ to these criteria: ### 2. Code Quality & Formatting -* **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`) +* **Linting:** All code must pass linter checks (run `pnpm run lint:clj` or `pnpm run lint` on the repository root) * **Formatting:** All the code must pass the formatting check (run `pnpm run check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty" diffs caused by unrelated whitespace changes. @@ -83,7 +86,52 @@ are config maps with `::ig/ref` for dependencies. Components implement `ig/init-key` / `ig/halt-key!`. -### Database Access +### Connecting to the Database + +Two PostgreSQL databases are used in this environment: + +| Database | Purpose | Connection string | +|---------------|--------------------|----------------------------------------------------| +| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` | +| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` | + +**Interactive psql session:** + +```bash +# development DB +psql "postgresql://penpot:penpot@postgres/penpot" + +# test DB +psql "postgresql://penpot:penpot@postgres/penpot_test" +``` + +**One-shot query (non-interactive):** + +```bash +psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;" +``` + +**Useful psql meta-commands:** + +``` +\dt -- list all tables +\d <table> -- describe a table (columns, types, constraints) +\di -- list indexes +\q -- quit +``` + +> **Migrations table:** Applied migrations are tracked in the `migrations` table +> with columns `module`, `step`, and `created_at`. When renaming a migration +> logical name, update this table in both databases to match the new name; +> otherwise the runner will attempt to re-apply the migration on next startup. + +```bash +# Example: fix a renamed migration entry in the test DB +psql "postgresql://penpot:penpot@postgres/penpot_test" \ + -c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';" +``` + +### Database Access (Clojure) `app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. @@ -146,3 +194,69 @@ optimized implementations: `src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`. + + +### Background Tasks + +Background tasks live in `src/app/tasks/`. Each task is an Integrant component +that exposes a `::handler` key and follows this three-method pattern: + +```clojure +(defmethod ig/assert-key ::handler ;; validate config at startup + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/expand-key ::handler ;; inject defaults before init + [k v] + {k (assoc v ::my-option default-value)}) + +(defmethod ig/init-key ::handler ;; return the task fn + [_ cfg] + (fn [_task] ;; receives the task row from the worker + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + ;; … do work … + )))) +``` + +**Wiring a new task** requires two changes in `src/app/main.clj`: + +1. **Handler config** – add an entry in `system-config` with the dependencies: + +```clojure +:app.tasks.my-task/handler +{::db/pool (ig/ref ::db/pool)} +``` + +2. **Registry + cron** – register the handler name and schedule it: + +```clojure +;; in ::wrk/registry ::wrk/tasks map: +:my-task (ig/ref :app.tasks.my-task/handler) + +;; in worker-config ::wrk/cron ::wrk/entries vector: +{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight + :task :my-task} +``` + +**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow): + +| Expression | Meaning | +|------------------------------|--------------------| +| `"0 0 0 * * ?"` | Daily at midnight | +| `"0 0 */6 * * ?"` | Every 6 hours | +| `"0 */5 * * * ?"` | Every 5 minutes | + +**Time helpers** (`app.common.time`): + +```clojure +(ct/now) ;; current instant +(ct/duration {:hours 1}) ;; java.time.Duration +(ct/minus (ct/now) some-duration) ;; subtract duration from instant +``` + +`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL +interval object suitable for use in SQL queries: + +```clojure +(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds" +``` diff --git a/backend/deps.edn b/backend/deps.edn index af73aecbc87..d53cf9a6c2e 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -3,7 +3,7 @@ :deps {penpot/common {:local/root "../common"} - org.clojure/clojure {:mvn/version "1.12.4"} + org.clojure/clojure {:mvn/version "1.12.5"} org.clojure/tools.namespace {:mvn/version "1.5.0"} com.github.luben/zstd-jni {:mvn/version "1.5.7-4"} @@ -17,7 +17,7 @@ io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"} - io.lettuce/lettuce-core {:mvn/version "6.8.1.RELEASE"} + io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"} ;; Minimal dependencies required by lettuce, we need to include them ;; explicitly because clojure dependency management does not support ;; yet the BOM format. @@ -28,18 +28,18 @@ com.google.guava/guava {:mvn/version "33.4.8-jre"} funcool/yetti - {:git/tag "v11.9" - :git/sha "5fad7a9" + {:git/tag "v11.10" + :git/sha "88701f4" :git/url "https://github.com/funcool/yetti.git" :exclusions [org.slf4j/slf4j-api]} com.github.seancorfield/next.jdbc - {:mvn/version "1.3.1070"} + {:mvn/version "1.3.1093"} metosin/reitit-core {:mvn/version "0.9.1"} - nrepl/nrepl {:mvn/version "1.4.0"} + nrepl/nrepl {:mvn/version "1.7.0"} - org.postgresql/postgresql {:mvn/version "42.7.9"} + org.postgresql/postgresql {:mvn/version "42.7.11"} org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"} com.zaxxer/HikariCP {:mvn/version "7.0.2"} @@ -49,7 +49,7 @@ buddy/buddy-hashers {:mvn/version "2.0.167"} buddy/buddy-sign {:mvn/version "3.6.1-359"} - com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"} + com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} org.jsoup/jsoup {:mvn/version "1.21.2"} org.im4java/im4java @@ -57,7 +57,8 @@ :git/sha "e2b3e16" :git/url "https://github.com/penpot/im4java"} - org.lz4/lz4-java {:mvn/version "1.8.0"} + at.yawk.lz4/lz4-java + {:mvn/version "1.11.0"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} @@ -66,17 +67,17 @@ ;; Pretty Print specs pretty-spec/pretty-spec {:mvn/version "0.1.4"} - software.amazon.awssdk/s3 {:mvn/version "2.41.21"}} + software.amazon.awssdk/s3 {:mvn/version "2.44.4"}} :paths ["src" "resources" "target/classes"] :aliases {:dev {:extra-deps - {com.bhauman/rebel-readline {:mvn/version "RELEASE"} + {com.bhauman/rebel-readline {:mvn/version "0.1.5"} clojure-humanize/clojure-humanize {:mvn/version "0.2.2"} - org.clojure/data.csv {:mvn/version "RELEASE"} - com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} - mockery/mockery {:mvn/version "RELEASE"}} + org.clojure/data.csv {:mvn/version "1.1.1"} + com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} + mockery/mockery {:mvn/version "0.1.4"}} :extra-paths ["test" "dev"]} :build @@ -92,7 +93,7 @@ :extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}} :outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} + {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}} :main-opts ["-m" "antq.core"]} :jmx-remote diff --git a/backend/scripts/_env b/backend/scripts/_env index 0026d9f9010..5779aa01066 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -2,6 +2,7 @@ export PENPOT_NITRATE_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 # DEPRECATED: only used for subscriptions @@ -12,7 +13,7 @@ export PENPOT_PUBLIC_URI=https://localhost:3449 export PENPOT_FLAGS="\ $PENPOT_FLAGS \ - enable-login-with-password + enable-login-with-password \ disable-login-with-ldap \ disable-login-with-oidc \ disable-login-with-google \ @@ -38,12 +39,18 @@ export PENPOT_FLAGS="\ enable-auto-file-snapshot \ enable-webhooks \ enable-access-tokens \ + enable-x-auth-request-headers \ + enable-x-auth-request-auto-register \ disable-tiered-file-data-storage \ enable-file-validation \ enable-file-schema-validation \ enable-redis-cache \ enable-subscriptions"; +# Uncomment for nexus integration testing +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; + # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index fa819c5e0ca..c6ff249e39c 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -43,7 +43,7 @@ (defn- discover-oidc-config [cfg {:keys [base-uri] :as provider}] (let [uri (u/join base-uri ".well-known/openid-configuration") - rsp (http/req! cfg {:method :get :uri (dm/str uri)})] + rsp (http/req cfg {:method :get :uri (dm/str uri)})] (if (= 200 (:status rsp)) (let [data (-> rsp :body json/decode) @@ -105,7 +105,7 @@ (defn- fetch-oidc-jwks [cfg jwks-uri] - (let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})] + (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})] (if (= 200 status) (-> body json/decode :keys process-oidc-jwks) (ex/raise :type ::internal @@ -235,7 +235,7 @@ :timeout 6000 :method :get} - {:keys [status body]} (http/req! cfg params)] + {:keys [status body]} (http/req cfg params)] (when-not (int-in-range? status 200 300) (ex/raise :type :internal @@ -452,7 +452,7 @@ :grant-type (:grant_type params) :redirect-uri (:redirect_uri params)) - (let [{:keys [status body]} (http/req! cfg req)] + (let [{:keys [status body]} (http/req cfg req)] (if (= status 200) (let [data (json/decode body) data {:token/access (get data :access_token) @@ -507,7 +507,7 @@ :headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))} :timeout 6000 :method :get} - response (http/req! cfg params)] + response (http/req cfg params)] (l/trc :hint "user info response" :status (:status response) @@ -804,12 +804,12 @@ props (audit/profile->props profile) context (d/without-nils {:external-session-id (:external-session-id info)})] - (audit/submit! cfg {::audit/type "action" - ::audit/name "login-with-oidc" - ::audit/profile-id (:id profile) - ::audit/ip-addr (inet/parse-request request) - ::audit/props props - ::audit/context context}) + (audit/submit cfg {:type "action" + :name "login-with-oidc" + :profile-id (:id profile) + :ip-addr (inet/parse-request request) + :props props + :context context}) (->> (redirect-to-verify-token token) (sxf request))))) diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 04b390bb996..75f6f369946 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -40,8 +40,8 @@ [promesa.util :as pu] [yetti.adapter :as yt]) (:import - com.github.luben.zstd.ZstdIOException com.github.luben.zstd.ZstdInputStream + com.github.luben.zstd.ZstdIOException com.github.luben.zstd.ZstdOutputStream java.io.DataInputStream java.io.DataOutputStream diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 0db826e4072..b4ed065317e 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -281,7 +281,7 @@ thumbnails (bfc/get-file-object-thumbnails cfg file-id)] - (events/tap :progress {:section :file :id file-id}) + (events/tap :progress {:section :file :id file-id :name (:name file)}) (vswap! bfc/*state* update :files assoc file-id {:id file-id @@ -301,6 +301,7 @@ (write-entry! output path file)) (doseq [[index page-id] (d/enumerate pages)] + (let [path (str "files/" file-id "/pages/" page-id ".json") page (get pages-index page-id) objects (:objects page) @@ -311,6 +312,8 @@ (write-entry! output path page) + (events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id}) + (doseq [[shape-id shape] objects] (let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json") shape (assoc shape :page-id page-id) @@ -323,6 +326,8 @@ (doseq [{:keys [id] :as media} media] (let [path (str "files/" file-id "/media/" id ".json") media (encode-media media)] + + (events/tap :progress {:section :media :id id :file-id file-id}) (write-entry! output path media))) (doseq [thumbnail thumbnails] @@ -332,11 +337,13 @@ data (-> data (assoc :media-id (:media-id thumbnail)) (encode-file-thumbnail))] + (events/tap :progress {:section :thumbnails :id (:object-id thumbnail) :file-id file-id}) (write-entry! output path data))) (doseq [[id component] components] (let [path (str "files/" file-id "/components/" id ".json") component (encode-component component)] + (events/tap :progress {:section :component :id id :file-id file-id}) (write-entry! output path component))) (doseq [[id color] colors] @@ -347,17 +354,20 @@ (and (contains? color :path) (str/empty? (:path color))) (dissoc :path))] + (events/tap :progress {:section :color :id id :file-id file-id}) (write-entry! output path color))) (doseq [[id object] typographies] (let [path (str "files/" file-id "/typographies/" id ".json") typography (encode-typography object)] + (events/tap :progress {:section :typography :id id :file-id file-id}) (write-entry! output path typography))) (when (and tokens-lib (not (ctob/empty-lib? tokens-lib))) (let [path (str "files/" file-id "/tokens.json") encoded-tokens (encode-tokens-lib tokens-lib)] + (events/tap :progress {:section :tokens-lib :file-id file-id}) (write-entry! output path encoded-tokens))))) (defn- export-files @@ -600,6 +610,7 @@ (let [object (->> (read-entry input entry) (decode-color) (validate-color))] + (events/tap :progress {:section :color :id id :file-id file-id}) (if (= id (:id object)) (assoc result id object) result))) @@ -631,6 +642,7 @@ (clean-component-pre-decode) (decode-component) (clean-component-post-decode))] + (events/tap :progress {:section :component :id id :file-id file-id}) (if (= id (:id object)) (assoc result id object) result))) @@ -644,6 +656,7 @@ (let [object (->> (read-entry input entry) (decode-typography) (validate-typography))] + (events/tap :progress {:section :typography :id id :file-id file-id}) (if (= id (:id object)) (assoc result id object) result))) @@ -653,6 +666,7 @@ (defn- read-file-tokens-lib [{:keys [::bfc/input ::entries]} file-id] (when-let [entry (d/seek (match-tokens-lib-entry-fn file-id) entries)] + (events/tap :progress {:section :tokens-lib :file-id file-id}) (->> (read-plain-entry input entry) (decode-tokens-lib) (validate-tokens-lib)))) @@ -678,6 +692,7 @@ (let [page (->> (read-entry input entry) (decode-page)) page (dissoc page :options)] + (events/tap :progress {:section :page :id id :file-id file-id}) (when (= id (:id page)) (let [objects (read-file-shapes cfg file-id id)] (assoc page :objects objects)))))) @@ -693,6 +708,7 @@ (let [object (->> (read-entry input entry) (decode-file-thumbnail) (validate-file-thumbnail))] + (if (and (= frame-id (:frame-id object)) (= page-id (:page-id object)) (= tag (:tag object))) @@ -733,8 +749,6 @@ (vswap! bfc/*state* update :index bfc/update-index media :id) - (events/tap :progress {:section :media :file-id file-id}) - (doseq [item media] (let [params (-> item (update :id bfc/lookup-index) @@ -742,6 +756,8 @@ (d/update-when :media-id bfc/lookup-index) (d/update-when :thumbnail-id bfc/lookup-index))] + (events/tap :progress {:section :media :id (:id params) :file-id file-id}) + (l/dbg :hint "inserting media object" :file-id (str file-id') :id (str (:id params)) @@ -753,8 +769,6 @@ (db/insert! conn :file-media-object params ::db/on-conflict-do-nothing? (::bfc/overwrite cfg)))) - (events/tap :progress {:section :thumbnails :file-id file-id}) - (doseq [item thumbnails] (let [media-id (bfc/lookup-index (:media-id item)) object-id (-> (assoc item :file-id file-id') @@ -769,6 +783,8 @@ :media-id (str media-id) ::l/sync? true) + (events/tap :progress {:section :thumbnail :file-id file-id :object-id object-id}) + (db/insert! conn :file-tagged-object-thumbnail params ::db/on-conflict-do-nothing? true))) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index d0a80f6515e..7150a932258 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -58,6 +58,9 @@ :objects-storage-fs-directory "assets" :auth-token-cookie-name "auth-token" + ;; Defaults match FOSS devstack SESSION_COOKIE_MAX_AGE_SECONDS / SESSION_COOKIE_REFRESH_SECONDS + :auth-token-cookie-max-age (ct/duration {:days 7}) + :auth-token-cookie-renewal-max-age (ct/duration {:hours 1}) :assets-path "/internal/assets/" :smtp-default-reply-to "Penpot <no-reply@example.com>" @@ -72,6 +75,7 @@ :telemetry-uri "https://telemetry.penpot.app/" :media-max-file-size (* 1024 1024 30) ; 30MiB + :font-max-file-size (* 1024 1024 30) ; 30MiB :ldap-user-query "(|(uid=:username)(mail=:username))" :ldap-attrs-username "uid" @@ -82,7 +86,14 @@ :initial-project-skey "initial-project" ;; time to avoid email sending after profile modification - :email-verify-threshold "15m"}) + :email-verify-threshold "15m" + + :quotes-upload-sessions-per-profile 5 + :quotes-upload-chunks-per-session 20 + + ;; SSRF protection + :ssrf-allowed-hosts #{} + :ssrf-extra-blocked-cidrs #{}}) (def schema:config (do #_sm/optional-keys @@ -103,6 +114,7 @@ [:exporter-shared-key {:optional true} :string] [:nitrate-shared-key {:optional true} :string] + [:nexus-shared-key {:optional true} :string] [:management-api-key {:optional true} :string] [:telemetry-uri {:optional true} :string] @@ -112,6 +124,7 @@ [:auto-file-snapshot-timeout {:optional true} ::ct/duration] [:media-max-file-size {:optional true} ::sm/int] + [:font-max-file-size {:optional true} ::sm/int] [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] @@ -153,9 +166,12 @@ [:quotes-snapshots-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-requester {:optional true} ::sm/int] + [:quotes-upload-sessions-per-profile {:optional true} ::sm/int] + [:quotes-upload-chunks-per-session {:optional true} ::sm/int] [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] + [:auth-token-cookie-renewal-max-age {:optional true} ::ct/duration] [:registration-domain-whitelist {:optional true} [::sm/set :string]] [:email-verify-threshold {:optional true} ::ct/duration] @@ -180,6 +196,8 @@ [:oidc-roles-attr {:optional true} :string] [:oidc-email-attr {:optional true} :string] [:oidc-name-attr {:optional true} :string] + [:default-email-domain {:optional true} :string] + [:smb-default-workspace-name {:optional true} :string] [:ldap-attrs-email {:optional true} :string] [:ldap-attrs-fullname {:optional true} :string] @@ -239,17 +257,26 @@ [:objects-storage-fs-directory {:optional true} :string] [:objects-storage-s3-bucket {:optional true} :string] [:objects-storage-s3-region {:optional true} :keyword] - [:objects-storage-s3-endpoint {:optional true} ::sm/uri]])) + [:objects-storage-s3-endpoint {:optional true} ::sm/uri] + + ;; SSRF protection + [:ssrf-allowed-hosts {:optional true} [::sm/set :string]] + [:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]])) (defn- parse-flags [config] (let [public-uri (c/get config :public-uri) public-uri (some-> public-uri (u/uri)) - extra-flags (if (and public-uri - (= (:scheme public-uri) "http") - (not= (:host public-uri) "localhost")) - #{:disable-secure-session-cookies} - #{})] + extra-flags (cond-> #{} + ;; When public-uri is http (non-localhost), disable secure cookies + (and public-uri + (= (:scheme public-uri) "http") + (not= (:host public-uri) "localhost")) + (conj :disable-secure-session-cookies) + + ;; When telemetry-enabled config is true, add :telemetry flag + (true? (c/get config :telemetry-enabled)) + (conj :enable-telemetry))] (flags/parse flags/default extra-flags (:flags config)))) (defn read-env diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index b00f84e3e26..c23ea075249 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -36,11 +36,11 @@ java.sql.Connection java.sql.PreparedStatement java.sql.Savepoint - org.postgresql.PGConnection org.postgresql.geometric.PGpoint org.postgresql.jdbc.PgArray org.postgresql.largeobject.LargeObject org.postgresql.largeobject.LargeObjectManager + org.postgresql.PGConnection org.postgresql.util.PGInterval org.postgresql.util.PGobject)) diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index 44d5cd7e67a..eacd7c83c1a 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -22,15 +22,34 @@ [cuerdas.core :as str] [integrant.core :as ig]) (:import - jakarta.mail.Message$RecipientType - jakarta.mail.Session - jakarta.mail.Transport jakarta.mail.internet.InternetAddress jakarta.mail.internet.MimeBodyPart jakarta.mail.internet.MimeMessage jakarta.mail.internet.MimeMultipart + jakarta.mail.Message$RecipientType + jakarta.mail.Session + jakarta.mail.Transport java.util.Properties)) +(defn clean + "Clean and normalizes email address string" + [email] + (let [email (str/lower email) + email (if (str/starts-with? email "mailto:") + (subs email 7) + email) + email (if (or (str/starts-with? email "<") + (str/ends-with? email ">")) + (str/trim email "<>") + email)] + email)) + +(defn get-domain + [email] + (let [email (clean email) + [_ domain] (str/split email "@" 2)] + domain)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; EMAIL IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/email/blacklist.clj b/backend/src/app/email/blacklist.clj index ca80afb6c98..a07dfccf911 100644 --- a/backend/src/app/email/blacklist.clj +++ b/backend/src/app/email/blacklist.clj @@ -36,10 +36,18 @@ :cause cause))))) (defn contains? - "Check if email is in the blacklist." + "Check if email is in the blacklist. Also matches subdomains: if + 'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also + be rejected." [{:keys [::email/blacklist]} email] - (let [[_ domain] (str/split email "@" 2)] - (c/contains? blacklist (str/lower domain)))) + (let [[_ domain] (str/split email "@" 2) + parts (str/split (str/lower domain) #"\.")] + (loop [parts parts] + (if (empty? parts) + false + (if (c/contains? blacklist (str/join "." parts)) + true + (recur (rest parts))))))) (defn enabled? "Check if the blacklist is enabled" diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index c716ce8b85f..d07090e0742 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -12,43 +12,56 @@ [app.common.time :as ct] [app.common.uri :as u] [app.db :as db] + [app.http.session :as session] [app.storage :as sto] [integrant.core :as ig] [yetti.response :as-alias yres])) -(def ^:private cache-max-age +(def ^:private default-cache-max-age (ct/duration {:hours 24})) -(def ^:private signature-max-age +(def ^:private default-signature-max-age (ct/duration {:hours 24 :minutes 15})) +;; Buckets that are legitimately public and do not require authentication. +;; These are used by public shared board viewing, profile photos in UI, +;; and embedded export/binfile flows. +(def ^:private public-buckets + #{"file-media-object" + "file-object-thumbnail" + "team-font-variant" + "file-data-fragment"}) + (defn get-id [{:keys [path-params]}] (or (some-> path-params :id d/parse-uuid) (ex/raise :type :not-found - :hunt "object not found"))) + :hint "object not found"))) (defn- get-file-media-object [pool id] (db/get pool :file-media-object {:id id} {::db/remove-deleted false})) (defn- serve-object-from-s3 - [{:keys [::sto/storage] :as cfg} obj] - (let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})] + [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj] + (let [sig-max-age (or signature-max-age default-signature-max-age) + cch-max-age (or cache-max-age default-cache-max-age) + {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})] {::yres/status 307 ::yres/headers {"location" (str url) "x-host" (cond-> host port (str ":" port)) "x-mtype" (-> obj meta :content-type) - "cache-control" (str "max-age=" (inst-ms cache-max-age))}})) + "cache-control" (str "max-age=" (inst-ms cch-max-age))}})) (defn- serve-object-from-fs - [{:keys [::path]} obj] - (let [purl (u/join (u/uri path) + [{:keys [::path ::cache-max-age]} obj] + (let [cch-max-age (or cache-max-age default-cache-max-age) + purl (u/join (u/uri path) (sto/object->relative-path obj)) mdata (meta obj) headers {"x-accel-redirect" (:path purl) "content-type" (:content-type mdata) - "cache-control" (str "max-age=" (inst-ms cache-max-age))}] + "cache-control" (str "max-age=" (inst-ms cch-max-age))}] {::yres/status 204 ::yres/headers headers})) @@ -60,14 +73,28 @@ (:s3 :assets-s3) (serve-object-from-s3 cfg obj) (:fs :assets-fs) (serve-object-from-fs cfg obj))) +(defn- requires-auth? + "Check if the storage object requires authentication based on its bucket." + [obj] + (let [bucket (-> obj meta :bucket)] + (not (contains? public-buckets bucket)))) + (defn objects-handler - "Handler that servers storage objects by id." + "Handler that serves storage objects by id. + For non-public buckets (e.g. profile), requires an authenticated session." [{:keys [::sto/storage] :as cfg} request] (let [id (get-id request) obj (sto/get-object storage id)] - (if obj - (serve-object cfg obj) - {::yres/status 404}))) + (cond + (nil? obj) + {::yres/status 404} + + (and (requires-auth? obj) + (nil? (::session/profile-id request))) + {::yres/status 401} + + :else + (serve-object cfg obj)))) (defn- generic-handler "A generic handler helper/common code for file-media based handlers." @@ -96,11 +123,12 @@ (defmethod ig/assert-key ::routes [_ params] (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance") + (assert (session/manager? (::session/manager params)) "expected valid session manager") (assert (string? (::path params)))) (defmethod ig/init-key ::routes [_ cfg] - ["/assets" + ["/assets" {:middleware [[session/authz cfg]]} ["/by-id/:id" {:handler (partial objects-handler cfg)}] ["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}] ["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]]) diff --git a/backend/src/app/http/auth_request.clj b/backend/src/app/http/auth_request.clj new file mode 100644 index 00000000000..867b9c3c4bb --- /dev/null +++ b/backend/src/app/http/auth_request.clj @@ -0,0 +1,243 @@ +;; 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 + +(ns app.http.auth-request + "Middleware that trusts X-Auth-Request-* headers set by a forward-auth + proxy (e.g. oauth2-proxy, Authelia, Traefik ForwardAuth). + + Enabled via PENPOT_FLAGS: enable-x-auth-request-headers (parsed as :x-auth-request-headers). + Any request carrying an X-Auth-Request-Email header is treated as pre-authenticated. + A Penpot session cookie is created on the response so that the browser + does not need to visit the login screen. + + Optional: enable-x-auth-request-auto-register (parsed as :x-auth-request-auto-register) + automatically creates a Penpot profile (with a default team) for email addresses + that are not yet registered. After resolving a profile (new or existing), users + with no membership in any non-default team are joined to the shared team matching + PENPOT_SMB_DEFAULT_WORKSPACE_NAME (team.name); no fallback to another team." + + (:require + [app.common.logging :as l] + [app.config :as cf] + [app.db :as db] + [app.http :as-alias http] + [app.http.access-token :as-alias actoken] + [app.http.session :as session] + [app.rpc.commands.auth :as auth] + [app.rpc.commands.profile :as profile] + [cuerdas.core :as str] + [yetti.request :as yreq] + [yetti.response :as yres])) + +(set! *warn-on-reflection* true) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- valid-email? + [s] + (boolean (re-matches #"[^\s@]+@[^\s@]+\.[^\s@]+" s))) + +(defn- resolve-email + "If the claim is already a valid email, return it as-is. + Otherwise treat it as a bare username and append @<default-email-domain>." + [email-claim] + (if (valid-email? email-claim) + email-claim + (let [domain (or (cf/get :default-email-domain) "askii.ai")] + (l/wrn :hint "x-auth-request: email claim is not a valid address, constructing from default-email-domain" + :claim email-claim + :domain domain) + (str (first (str/split email-claim #"@")) "@" domain)))) + +(defn- auto-join-team! + "_auto_join_workspace: ensure a ``team_profile_rel`` row for + the non-default team whose ``name`` matches PENPOT_SMB_DEFAULT_WORKSPACE_NAME + (:smb-default-workspace-name). Runs even when the profile already belongs to another + shared team (multi-team parity with Plane workspaces). + + If config is unset or no such team exists, does nothing — no fallback. Idempotent + INSERT ON CONFLICT DO NOTHING." + + [conn {:keys [id] :as _profile}] + (let [preferred (some-> (cf/get :smb-default-workspace-name) str/trim not-empty)] + (when-not (str/blank? preferred) + (when-let [team (db/exec-one! conn + ["SELECT id FROM team + WHERE is_default = false + AND deleted_at IS NULL + AND name = ? + LIMIT 1" + preferred])] + (db/insert! conn :team-profile-rel + {:team-id (:id team) + :profile-id id + :is-owner false + :is-admin false + :can-edit true} + {::db/on-conflict-do-nothing? true}) + (l/inf :hint "x-auth-request: ensured SMB shared team membership" + :profile-id (str id) + :team-id (str (:id team))))))) + +(defn- get-or-register-profile + "Looks up a profile by email. If not found and the + :x-auth-request-auto-register flag is enabled, creates a new active + profile with a default team. Returns nil when the profile does not + exist and auto-registration is disabled." + [cfg email fullname] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [profile (or (profile/get-profile-by-email conn email) + (when (contains? cf/flags :x-auth-request-auto-register) + (let [display-name (or (not-empty fullname) + (first (str/split email #"@"))) + profile (auth/create-profile cfg + {:email email + :fullname display-name + :backend "x-auth-request" + :is-active true})] + (l/inf :hint "x-auth-request: auto-registered profile" + :email email + :profile-id (str (:id profile))) + (auth/create-profile-rels conn profile))))] + ;; Same semantics as Plane: join only provisioned SMB team by name — no fallback. + ;; Never fail auth if join fails (e.g. quotas, constraints). + (when profile + (try + (auto-join-team! conn profile) + (catch Throwable cause + (l/err :hint "x-auth-request: auto-join to shared team failed" + :profile-id (:id profile) + :cause cause)))) + profile)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; MIDDLEWARE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Perf note: this middleware removes the previous fast-path that +;; short-circuited whenever wrap-session had already set +;; ::session/profile-id. With the fix in place, every authenticated +;; SSO request resolves the header email's profile (a transaction +;; with get-profile-by-email + an idempotent auto-join check). The +;; cost is intentional — correctness over throughput on the steady- +;; state path. If profiling shows this is hot, a follow-up can +;; reintroduce a fast-path by pre-loading the session profile's +;; email (so we can compare without get-or-register-profile) or by +;; caching email→profile-id in a short-lived in-memory map. + +(defn- wrap-authz + [handler cfg] + (fn [request] + (let [atoken-pid (::actoken/profile-id request) + session-pid (::session/profile-id request) + email-claim (yreq/get-header request "x-auth-request-email")] + (cond + ;; Access-token (API key) — programmatic identity issued out-of-band + ;; by the user. Not a browser SSO session, so the header is not + ;; meaningful here. Pass through unconditionally. + (some? atoken-pid) + (handler request) + + ;; No proxy header — trust whatever wrap-session decided. Without a + ;; header we have no upstream identity to compare against. + (str/blank? email-claim) + (handler request) + + :else + (let [local-part (first (str/split email-claim #"@")) + email (resolve-email email-claim) + fullname (or (not-empty (yreq/get-header request "x-auth-request-user")) + local-part) + profile (try + (get-or-register-profile cfg email fullname) + (catch Throwable cause + (l/err :hint "x-auth-request: error resolving profile" + :email email + :cause cause) + nil))] + (cond + (nil? profile) + ;; Header email doesn't resolve to a profile (and auto-register + ;; is off). No identity to switch *to* — pass through with + ;; whatever wrap-session set. + (do + (l/wrn :hint "x-auth-request: no profile found for email, passing through unauthenticated" + :email email) + (handler request)) + + (:is-blocked profile) + (do + (l/wrn :hint "x-auth-request: profile is blocked, denying access" + :email email + :profile-id (str (:id profile))) + {::yres/status 403}) + + (not (:is-active profile)) + (do + (l/wrn :hint "x-auth-request: profile is not active, denying access" + :email email + :profile-id (str (:id profile))) + {::yres/status 403}) + + ;; Steady state — existing browser session matches the proxy- + ;; asserted identity. No work to do. + (and session-pid (= session-pid (:id profile))) + (handler request) + + ;; Either no existing session, or the session points at a + ;; *different* profile than oauth2-proxy is asserting. Re-key. + ;; + ;; This is the fix for the session-sharing bug: portal "log out + ;; of all apps" clears the shared _oauth2_proxy cookie + Cognito + ;; session but NOT Penpot's auth-token cookie on its subdomain. + ;; Without this branch, wrap-session resolves the previous + ;; user's profile-id from the stale cookie and this middleware + ;; (under the previous always-skip-when-session rule) never + ;; overrode it. + :else + (do + (when session-pid + (l/inf :hint "x-auth-request: proxy identity differs from existing session — re-keying" + :session-profile-id (str session-pid) + :header-profile-id (str (:id profile)))) + (l/dbg :hint "x-auth-request: authenticating via forwarded header" + :email email + :profile-id (str (:id profile))) + (let [create-session! (session/create-fn cfg profile) + response (-> request + (assoc ::session/profile-id (:id profile)) + ;; Drop stale identity-carrying keys + ;; so downstream code does not see the + ;; previous user's data after re-key. + ;; + ;; ::http/auth-data — errors.clj logs + ;; auth-data.claims.uid as + ;; :request/profile-id; rpc/helpers + ;; exposes the map to RPC handlers via + ;; get-auth-data. + ;; + ;; ::session/session — read indirectly + ;; by session/get-session, which is + ;; called in update-profile-password's + ;; invalidate-others path. Leaving + ;; alice's session here means a + ;; password-change RPC made on the + ;; re-keyed request would invalidate + ;; alice's sessions instead of bob's. + (dissoc ::http/auth-data ::session/session) + handler)] + ;; Fresh auth-token cookie; replaces the stale one the + ;; browser still has (if any). + (create-session! request response))))))))) + +(def authz + {:name ::authz + :compile (fn [& _] + (when (contains? cf/flags :x-auth-request-headers) + wrap-authz))}) diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 051a2ecf8bb..c90b0d7a824 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -53,7 +53,7 @@ (let [surl (get body "SubscribeURL") stopic (get body "TopicArn")] (l/info :action "subscription received" :topic stopic :url surl) - (http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true})) + (http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true})) (= mtype "Notification") (when-let [message (parse-json (get body "Message"))] diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index 7fcbd3ff007..5e10ee4c8fe 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -5,13 +5,24 @@ ;; Copyright (c) KALEIDOS INC (ns app.http.client - "Http client abstraction layer." + "Http client abstraction layer. + + All outbound requests made through `req` and `req-with-redirects` + are validated against the SSRF blocklist by default. Pass + `:skip-ssrf-check? true` in the options map only when the target + is a well-known, operator-configured endpoint that cannot be + influenced by user input (e.g. internal telemetry, error webhooks)." (:require [app.common.schema :as sm] + [app.util.ssrf :as ssrf] + [cuerdas.core :as str] [integrant.core :as ig] [java-http-clj.core :as http]) (:import - java.net.http.HttpClient)) + java.net.http.HttpClient + java.net.URI)) + +(def default-max-redirects 5) (defn client? [o] @@ -23,8 +34,8 @@ (defmethod ig/init-key ::client [_ _] - (http/build-client {:connect-timeout 30000 ;; 10s - :follow-redirects :always})) + (http/build-client {:connect-timeout 30000 + :follow-redirects :never})) (defn send! ([client req] (send! client req {})) @@ -44,14 +55,82 @@ :else (throw (UnsupportedOperationException. "invalid arguments")))) -(defn req! - "A convencience toplevel function for gradual migration to a new API - convention." +(defn req + "Issue a single HTTP request. SSRF validation is applied to the + target URI by default; pass `:skip-ssrf-check? true` in `options` + to bypass it for known-safe, operator-configured endpoints." + ([cfg-or-client request] + (req cfg-or-client request {})) + ([cfg-or-client request {:keys [skip-ssrf-check?] :as options}] + (let [request (if skip-ssrf-check? + (update request :uri str) + (update request :uri ssrf/validate-uri)) + client (resolve-client cfg-or-client)] + (send! client request (dissoc options :skip-ssrf-check?))))) + +(defn- resolve-location + "Resolve a Location header value against the original request URI. + Handles: + - Absolute URLs (http:// or https://) — returned as-is. + - Protocol-relative URLs (//host/path) — inherit the scheme from base-uri. + - Path-absolute and relative URLs — resolved against base-uri via URI.resolve." + [^String base-uri ^String location] + (cond + (or (str/starts-with? location "http://") + (str/starts-with? location "https://")) + location + + (str/starts-with? location "//") + (let [scheme (.getScheme (URI. base-uri))] + (str scheme ":" location)) + + :else + (str (.resolve (URI. base-uri) location)))) + +(defn- redirect-request + "Build the next request for a 3xx redirect. + Per RFC 7231 §6.4: + - 303 always issues GET (body dropped). + - 301/302 with non-GET/HEAD methods: downgrade to GET (body dropped). + - 307/308 preserve the original method and body. + The Location URI has already been resolved by the caller." + [orig-request ^String next-uri status] + (let [method (:method orig-request)] + (if (or (= status 303) + (and (contains? #{301 302} status) + (not (contains? #{:get :head} method)))) + ;; Downgrade to GET, drop body and content-type + (-> orig-request + (assoc :uri next-uri :method :get) + (dissoc :body) + (update :headers dissoc "content-type" "content-length")) + ;; Preserve method/body (307, 308, or GET/HEAD 301/302) + (assoc orig-request :uri next-uri)))) + +(defn req-with-redirects + "Like `req`, but follows up to `max-redirects` HTTP 3xx redirects. + SSRF validation is applied before every hop (initial request and + each redirect target) unless `:skip-ssrf-check? true` is passed. + Redirect semantics follow RFC 7231 §6.4: 301/302 POST is downgraded + to GET; 303 always uses GET; 307/308 preserve the original method." ([cfg-or-client request] - (let [client (resolve-client cfg-or-client) - request (update request :uri str)] - (send! client request {}))) - ([cfg-or-client request options] - (let [client (resolve-client cfg-or-client) - request (update request :uri str)] - (send! client request options)))) + (req-with-redirects cfg-or-client request {})) + ([cfg-or-client request {:keys [max-redirects skip-ssrf-check?] + :or {max-redirects default-max-redirects} + :as opts}] + (let [send-opts (dissoc opts :max-redirects :skip-ssrf-check?) + uri-coerce (if skip-ssrf-check? str ssrf/validate-uri)] + (loop [current-req (update request :uri uri-coerce) + hops 0] + (let [client (resolve-client cfg-or-client) + resp (send! client current-req send-opts) + status (:status resp)] + (if (and (<= 300 status 399) + (< hops max-redirects)) + (if-let [location (get-in resp [:headers "location"])] + (let [next-uri (resolve-location (str (:uri current-req)) location)] + (recur (update (redirect-request current-req next-uri status) :uri uri-coerce) + (inc hops))) + ;; No Location header on a 3xx — return the response as-is + resp) + resp)))))) diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index f9154ab1359..3c26c372dff 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -218,10 +218,11 @@ (defn- renew-session? [{:keys [id modified-at] :as session}] - (or (string? id) - (and (ct/inst? modified-at) - (let [elapsed (ct/diff modified-at (ct/now))] - (neg? (compare default-renewal-max-age elapsed)))))) + (let [renewal-max (cf/get :auth-token-cookie-renewal-max-age default-renewal-max-age)] + (or (string? id) + (and (ct/inst? modified-at) + (let [elapsed (ct/diff modified-at (ct/now))] + (neg? (compare renewal-max elapsed))))))) (defn- wrap-authz [handler {:keys [::manager] :as cfg}] @@ -247,12 +248,32 @@ (binding [ct/*clock* (clock/get-clock (:profile-id session))] (handler request))] - (if (and session (renew-session? session)) - (let [session (->> session - (update-session manager) - (assign-token cfg))] - (assign-session-cookie response session)) - response)) + ;; Renewal runs after the inner handler. Two cases where it + ;; MUST step aside: + ;; + ;; 1. The response already carries the auth-token cookie — + ;; e.g. wrap-authz re-keyed the session to a new user. + ;; Renewing alice's cookie on top of bob's freshly-issued + ;; one would silently undo the re-key. + ;; + ;; 2. The response is an error (status >= 400) — e.g. proxy + ;; identity mismatch with a blocked/inactive incoming + ;; profile produced a 403. Renewing alice's cookie on the + ;; denial response would EXTEND her session lifetime even + ;; though the upstream identity has changed. Better to let + ;; her cookie age out naturally (or get cleared on the next + ;; successful flow) than to refresh it on a mismatch. + (let [status (::yres/status response)] + (if (and session + (renew-session? session) + (or (nil? status) (< status 400)) + (not (contains? (::yres/cookies response) + (cf/get :auth-token-cookie-name)))) + (let [session (->> session + (update-session manager) + (assign-token cfg))] + (assign-session-cookie response session)) + response))) (= type :bearer) (let [session (case (:ver metadata) @@ -279,7 +300,7 @@ [response {token :token modified-at :modified-at}] (let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age) created-at modified-at - renewal (ct/plus created-at default-renewal-max-age) + renewal (ct/plus created-at (cf/get :auth-token-cookie-renewal-max-age default-renewal-max-age)) expires (ct/plus created-at max-age) secure? (contains? cf/flags :secure-session-cookies) strict? (contains? cf/flags :strict-session-cookies) diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index c374b432f9f..bc40efd78df 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -16,12 +16,12 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.email :as email] [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] - [app.rpc.retry :as rtry] [app.setup :as-alias setup] [app.util.inet :as inet] [app.util.services :as-alias sv] @@ -33,6 +33,63 @@ ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private filter-auth-events + #{"login-with-oidc" "login-with-password" "register-profile" "update-profile"}) + +(def ^:private safe-backend-context-keys + #{:version + :initiator + :client-version + :client-user-agent}) + +(def ^:private safe-frontend-context-keys + #{:version + :locale + :browser + :browser-version + :engine + :engine-version + :os + :os-version + :device-type + :device-arch + :screen-width + :screen-height + :screen-color-depth + :screen-orientation + :event-origin + :event-namespace + :event-symbol}) + +(def profile-props + [:id + :is-active + :is-muted + :auth-backend + :email + :default-team-id + :default-project-id + :fullname + :lang]) + +(def ^:private event-keys + #{:id + :name + :type + :profile-id + :ip-addr + :props + :context + :source + :tracked-at + :created-at}) + +(def reserved-props + #{:session-id + :password + :old-password + :token}) + (defn extract-utm-params "Extracts additional data from params and namespace them under `penpot` ns." @@ -47,17 +104,6 @@ (assoc (->> sk str/kebab (keyword "penpot")) v))))] (reduce-kv process-param {} params))) -(def profile-props - [:id - :is-active - :is-muted - :auth-backend - :email - :default-team-id - :default-project-id - :fullname - :lang]) - (defn profile->props [profile] (-> profile @@ -65,12 +111,6 @@ (merge (:props profile)) (d/without-nils))) -(def reserved-props - #{:session-id - :password - :old-password - :token}) - (defn clean-props [props] (into {} @@ -120,16 +160,17 @@ ;; an external storage and data cleared. (def ^:private schema:event - [:map {:title "event"} - [::type ::sm/text] - [::name ::sm/text] - [::profile-id ::sm/uuid] - [::ip-addr {:optional true} ::sm/text] - [::props {:optional true} [:map-of :keyword :any]] - [::context {:optional true} [:map-of :keyword :any]] - [::tracked-at {:optional true} ::ct/inst] - [::created-at {:optional true} ::ct/inst] - [::source {:optional true} ::sm/text] + [:map {:title "AuditEvent"} + [:id {:optional true} ::sm/uuid] + [:type ::sm/text] + [:name ::sm/text] + [:profile-id ::sm/uuid] + [:props [:map-of :keyword :any]] + [:context [:map-of :keyword :any]] + [:tracked-at ::ct/inst] + [:created-at ::ct/inst] + [:source ::sm/text] + [:ip-addr {:optional true} ::sm/text] [::webhooks/event? {:optional true} ::sm/boolean] [::webhooks/batch-timeout {:optional true} ::ct/duration] [::webhooks/batch-key {:optional true} @@ -141,7 +182,156 @@ (def valid-event? (sm/validator schema:event)) -(defn prepare-event +(defn- prepare-context-from-request + "Prepare backend event context from request" + [request] + (let [client-event-origin (get-client-event-origin request) + client-version (get-client-version request) + client-user-agent (get-client-user-agent request) + session-id (get-external-session-id request) + key-id (::http/auth-key-id request) + token-id (::actoken/id request) + token-type (::actoken/type request)] + {:external-session-id session-id + :initiator (or key-id "app") + :access-token-id (some-> token-id str) + :access-token-type (some-> token-type str) + :client-event-origin client-event-origin + :client-user-agent client-user-agent + :client-version client-version + :version (:full cf/version)})) + +(defn- append-audit-entry + [cfg params] + (let [params (-> params + (assoc :id (uuid/next)) + (update :props db/tjson) + (update :context db/tjson) + (update :ip-addr db/inet)) + params (select-keys params event-keys)] + (db/insert! cfg :audit-log params))) + +(def ^:private xf:filter-telemetry-props + "Transducer that keeps only map entries whose values are UUIDs, + booleans or numbers." + (filter (fn [[k v]] + (and (simple-keyword? k) + (or (uuid? v) (boolean? v) (number? v)))))) + +(declare filter-telemetry-props) +(declare filter-telemetry-context) + +(defn- process-event + [cfg event] + (when (contains? cf/flags :audit-log-logger) + (l/log! ::l/logger "app.audit" + ::l/level :info + :profile-id (str (:profile-id event)) + :ip-addr (str (:ip-addr event)) + :type (:type event) + :name (:name event) + :props (json/encode (:props event) :key-fn json/write-camel-key) + :context (json/encode (:context event) :key-fn json/write-camel-key))) + + (when (contains? cf/flags :audit-log) + (append-audit-entry cfg event)) + + (when (contains? cf/flags :telemetry) + ;; NOTE: when both audit-log and telemetry are enabled, events are stored + ;; twice: once with full details (above) and once stripped of props and + ;; ip-addr, tagged with source="telemetry" so the telemetry task can + ;; collect and ship them. The profile-id is preserved (UUIDs are already + ;; anonymous random identifiers). Only a safe subset of context fields + ;; is kept: initiator, version, client-version and client-user-agent. + ;; Timestamps are truncated to day precision to avoid leaking exact event + ;; timing. + (let [event (-> event + (filter-telemetry-props) + (filter-telemetry-context) + (update :created-at ct/truncate :days) + (update :tracked-at ct/truncate :days) + (assoc :source "telemetry:backend") + (assoc :ip-addr "0.0.0.0"))] + (append-audit-entry cfg event))) + + (when (and (contains? cf/flags :webhooks) + (::webhooks/event? event)) + (let [batch-key (::webhooks/batch-key event) + batch-timeout (::webhooks/batch-timeout event) + label (dm/str "rpc:" (:name event)) + label (cond + (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) + (string? batch-key) (dm/str label ":" batch-key) + :else label) + dedupe? (boolean (and batch-key batch-timeout))] + + (wrk/submit! (-> cfg + (assoc ::wrk/task :process-webhook-event) + (assoc ::wrk/queue :webhooks) + (assoc ::wrk/max-retries 0) + (assoc ::wrk/delay (or batch-timeout 0)) + (assoc ::wrk/dedupe dedupe?) + (assoc ::wrk/label label) + (assoc ::wrk/params (-> event + (d/without-qualified) + (dissoc :source) + (dissoc :context) + (dissoc :ip-addr) + (dissoc :type))))))) + event) + +(defn submit* + "A public API, lower-level than submit, assumes all required fields are filled" + [cfg event] + (try + (let [event (check-event event)] + (db/tx-run! cfg process-event event)) + (catch Throwable cause + (l/error :hint "unexpected error processing event" :cause cause)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PUBLIC API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn filter-telemetry-props + [{:keys [source name props type] :as params}] + (cond + (or (and (= source "frontend") + (= type "identify")) + (and (= source "backend") + (filter-auth-events name))) + + (let [props' (into {} xf:filter-telemetry-props props) + props' (-> props' + (assoc :lang (:lang props)) + (assoc :auth-backend (:auth-backend props)) + (assoc :email-domain (email/get-domain (:email props))) + (d/without-nils))] + (assoc params :props props')) + + (and (= source "backend") + (= type "trigger") + (= name "instance-start")) + params + + (and (= source "frontend") + (= type "action") + (= name "navigate")) + (assoc params :props (select-keys props [:route :file-id :team-id :page-id])) + + :else + (let [props (into {} xf:filter-telemetry-props props)] + (assoc params :props props)))) + +(defn filter-telemetry-context + [{:keys [source context] :as params}] + (let [context (case source + "backend" (select-keys context safe-backend-context-keys) + "frontend" (select-keys context safe-frontend-context-keys) + {})] + (assoc params :context context))) + +(defn prepare-rpc-event [cfg mdata params result] (let [resultm (meta result) request (-> params meta ::http/request) @@ -154,23 +344,29 @@ (merge params (::props resultm))) (clean-props)) - context (merge (::context resultm) - (prepare-context-from-request request)) + context (-> (::context resultm) + (merge (prepare-context-from-request request)) + (assoc :request-id (::rpc/request-id params)) + (d/without-nils)) + ip-addr (inet/parse-request request) module (get cfg ::rpc/module)] - {::type (or (::type resultm) - (::rpc/type cfg)) - ::name (or (::name resultm) - (let [sname (::sv/name mdata)] - (if (not= module "main") - (str module "-" sname) - sname))) + {:type (or (::type resultm) + (::rpc/type cfg)) + :name (or (::name resultm) + (let [sname (::sv/name mdata)] + (if (not= module "main") + (str module "-" sname) + sname))) - ::profile-id profile-id - ::ip-addr ip-addr - ::props props - ::context context + :profile-id profile-id + :ip-addr ip-addr + :props props + :context context + + :created-at (::rpc/request-at params) + :tracked-at (::rpc/request-at params) ;; NOTE: for batch-key lookup we need the params as-is ;; because the rpc api does not need to know the @@ -190,148 +386,49 @@ (::webhooks/event? resultm) false)})) -(defn- prepare-context-from-request - "Prepare backend event context from request" - [request] - (let [client-event-origin (get-client-event-origin request) - client-version (get-client-version request) - client-user-agent (get-client-user-agent request) - session-id (get-external-session-id request) - key-id (::http/auth-key-id request) - token-id (::actoken/id request) - token-type (::actoken/type request)] - (d/without-nils - {:external-session-id session-id - :initiator (or key-id "app") - :access-token-id (some-> token-id str) - :access-token-type (some-> token-type str) - :client-event-origin client-event-origin - :client-user-agent client-user-agent - :client-version client-version - :version (:full cf/version)}))) - (defn event-from-rpc-params "Create a base event skeleton with pre-filled some important data that can be extracted from RPC params object" [params] - (let [context (some-> params meta ::http/request prepare-context-from-request) - event {::type "action" - ::profile-id (or (::rpc/profile-id params) uuid/zero) - ::ip-addr (::rpc/ip-addr params)}] - (cond-> event - (some? context) - (assoc ::context context)))) - -(defn- event->params - [event] - (let [params {:id (uuid/next) - :name (::name event) - :type (::type event) - :profile-id (::profile-id event) - :ip-addr (::ip-addr event) - :context (::context event {}) - :props (::props event {}) - :source "backend"} - tnow (::tracked-at event)] - - (cond-> params - (some? tnow) - (assoc :tracked-at tnow)))) - -(defn- append-audit-entry - [cfg params] - (let [params (-> params - (update :props db/tjson) - (update :context db/tjson) - (update :ip-addr db/inet))] - (db/insert! cfg :audit-log params))) - -(defn- handle-event! + (let [context (some-> params meta ::http/request prepare-context-from-request) + context (assoc context :request-id (::rpc/request-id params)) + request-at (::rpc/request-at params)] + {:type "action" + :profile-id (::rpc/profile-id params) + :created-at request-at + :tracked-at request-at + :ip-addr (::rpc/ip-addr params) + :context (d/without-nils context)})) + +(defn submit + "Submit an event to be registered under audit-log subsystem" [cfg event] - (let [tnow (ct/now) - params (-> (event->params event) - (assoc :created-at tnow) - (update :tracked-at #(or % tnow)))] - - (when (contains? cf/flags :audit-log-logger) - (l/log! ::l/logger "app.audit" - ::l/level :info - :profile-id (str (::profile-id event)) - :ip-addr (str (::ip-addr event)) - :type (::type event) - :name (::name event) - :props (json/encode (::props event) :key-fn json/write-camel-key) - :context (json/encode (::context event) :key-fn json/write-camel-key))) - - (when (contains? cf/flags :audit-log) - ;; NOTE: this operation may cause primary key conflicts on inserts - ;; because of the timestamp precission (two concurrent requests), in - ;; this case we just retry the operation. - (append-audit-entry cfg params)) - - (when (and (or (contains? cf/flags :telemetry) - (cf/get :telemetry-enabled)) - (not (contains? cf/flags :audit-log))) - ;; NOTE: this operation may cause primary key conflicts on inserts - ;; because of the timestamp precission (two concurrent requests), in - ;; this case we just retry the operation. - ;; - ;; NOTE: this is only executed when general audit log is disabled - (let [params (-> params - (assoc :props {}) - (assoc :context {}))] - (append-audit-entry cfg params))) - - (when (and (contains? cf/flags :webhooks) - (::webhooks/event? event)) - (let [batch-key (::webhooks/batch-key event) - batch-timeout (::webhooks/batch-timeout event) - label (dm/str "rpc:" (:name params)) - label (cond - (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) - (string? batch-key) (dm/str label ":" batch-key) - :else label) - dedupe? (boolean (and batch-key batch-timeout))] - - (wrk/submit! (-> cfg - (assoc ::wrk/task :process-webhook-event) - (assoc ::wrk/queue :webhooks) - (assoc ::wrk/max-retries 0) - (assoc ::wrk/delay (or batch-timeout 0)) - (assoc ::wrk/dedupe dedupe?) - (assoc ::wrk/label label) - (assoc ::wrk/params (-> params - (dissoc :source) - (dissoc :context) - (dissoc :ip-addr) - (dissoc :type))))))) - params)) - -(defn submit! - "Submit audit event to the collector." - [cfg event] - (try - (let [event (-> (d/without-nils event) - (check-event)) - cfg (-> cfg - (assoc ::rtry/when rtry/conflict-exception?) - (assoc ::rtry/max-retries 6) - (assoc ::rtry/label "persist-audit-log"))] - (rtry/invoke! cfg db/tx-run! handle-event! event)) - (catch Throwable cause - (l/error :hint "unexpected error processing event" :cause cause)))) - -(defn insert! + (let [tnow (ct/now) + event (-> event + (assoc :created-at tnow) + (update :profile-id d/nilv uuid/zero) + (update :tracked-at d/nilv tnow) + (update :ip-addr d/nilv "0.0.0.0") + (update :props d/nilv {}) + (update :context d/nilv {}) + (assoc :source "backend") + (d/without-nils))] + (submit* cfg event))) + +(defn insert "Submit audit event to the collector, intended to be used only from command line helpers because this skips all webhooks and telemetry logic." [cfg event] (when (contains? cf/flags :audit-log) - (let [event (-> (d/without-nils event) + (let [tnow (ct/now) + event (-> event + (assoc :created-at tnow) + (update :tracked-at d/nilv tnow) + (update :profile-id d/nilv uuid/zero) + (update :props d/nilv {}) + (update :context d/nilv {}) + (assoc :source "backend") + (select-keys event-keys) (check-event))] - (db/run! cfg (fn [cfg] - (let [tnow (ct/now) - params (-> (event->params event) - (assoc :created-at tnow) - (update :tracked-at #(or % tnow)))] - (append-audit-entry cfg params))))))) + (db/run! cfg append-audit-entry event)))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 4eb87d595e5..6fa12bb7a94 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -10,14 +10,11 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.transit :as t] - [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http.client :as http] [app.setup :as-alias setup] - [app.tokens :as tokens] [integrant.core :as ig] - [lambdaisland.uri :as u] [promesa.exec :as px])) ;; This is a task responsible to send the accumulated events to @@ -52,19 +49,18 @@ (defn- send! [{:keys [::uri] :as cfg} events] - (let [token (tokens/generate cfg - {:iss "authentication" - :uid uuid/zero}) + (let [skey (-> cfg ::setup/shared-keys :nexus) body (t/encode {:events events}) headers {"content-type" "application/transit+json" "origin" (str (cf/get :public-uri)) - "cookie" (u/map->query-string {:auth-token token})} + "x-shared-key" (str "nexus " skey)} params {:uri uri :timeout 12000 :method :post :headers headers :body body} - resp (http/req! cfg params)] + resp (http/req cfg params {:skip-ssrf-check? true})] + (if (= (:status resp) 204) true (do @@ -109,7 +105,7 @@ (def ^:private schema:handler-params [:map ::db/pool - ::setup/props + ::setup/shared-keys ::http/client]) (defmethod ig/assert-key ::handler diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index aaa2e5d47d1..1cce8e5b502 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -97,7 +97,7 @@ (l/warn :hint "unexpected exception on database error logger" :cause cause)))) (defn- audit-event->report - [{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}] + [{:keys [context props ip-addr] :as record}] (let [context (reduce-kv (fn [context k v] (let [k' (keyword "frontend" (name k))] @@ -117,14 +117,14 @@ {:context (-> (into (sorted-map) context) (pp/pprint-str :length 50)) - :origin (::audit/name record) + :origin (:name record) :href (get props :href) :hint (get props :hint) :report (get props :report)})) (defn- handle-audit-event "Convert the log record into a report object and persist it on the database" - [{:keys [::db/pool]} {:keys [::audit/id] :as event}] + [{:keys [::db/pool]} {:keys [id] :as event}] (try (let [uri (cf/get :public-uri) report (-> event audit-event->report d/without-nils)] @@ -189,12 +189,12 @@ (::l/id item) (handle-log-record cfg item) - (::audit/id item) - (handle-audit-event cfg item) - (::rlimit/id item) (handle-rlimit-event cfg item) + (-> item meta ::audit/event) + (handle-audit-event cfg item) + :else (l/warn :hint "received unexpected item" :item item)) @@ -226,4 +226,3 @@ [cfg event] (when-let [{:keys [::input]} (get cfg ::reporter)] (sp/put! input event))) - diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index c2fd3d1dfb4..a19bf398366 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -52,12 +52,12 @@ trace "```"))) - resp (http/req! cfg - {:uri (cf/get :error-report-webhook) - :method :post - :headers {"content-type" "application/json"} - :body (json/encode-str {:text text})} - {:sync? true})] + resp (http/req cfg + {:uri (cf/get :error-report-webhook) + :method :post + :headers {"content-type" "application/json"} + :body (json/encode-str {:text text})} + {:sync? true})] (when (not= 200 (:status resp)) (l/warn :hint "error on sending data" @@ -83,7 +83,7 @@ :trace (ex/format-throwable cause :detail? false :header? false)})) (defn- audit-event->report - [{:keys [::audit/context ::audit/props ::audit/id] :as event}] + [{:keys [context props id] :as event}] {:id id :type "exception" :origin "audit-log" @@ -92,7 +92,7 @@ :host (cf/get :host) :backend-version (:full cf/version) :frontend-version (:version context) - :profile-id (:audit/profile-id event) + :profile-id (:profile-id event) :href (get props :href)}) (defn- rlimit-event->report @@ -148,12 +148,12 @@ (::l/id item) (handle-event cfg item log-record->report) - (::audit/id item) - (handle-event cfg item audit-event->report) - (::rlimit/id item) (handle-event cfg item rlimit-event->report) + (-> item meta ::audit/event) + (handle-event cfg item audit-event->report) + :else (l/warn :hint "received unexpected item" :item item))) diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index 8edb6462605..2f89876e04a 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -70,14 +70,14 @@ (fn [{:keys [props] :as task}] (let [items (lookup-webhooks cfg props) - event {::audit/profile-id (:profile-id props) - ::audit/name "webhook" - ::audit/type "trigger" - ::audit/props {:name (get props :name) - :event-id (get props :id) - :total-affected (count items)}}] + event {:profile-id (:profile-id props) + :name "webhook" + :type "trigger" + :props {:name (get props :name) + :event-id (get props :id) + :total-affected (count items)}}] - (audit/insert! cfg event) + (audit/insert cfg event) (when items (l/trc :hint "webhooks found for event" :total (count items)) @@ -159,7 +159,7 @@ :method :post :body body}] (try - (let [rsp (http/req! cfg req {:response-type :input-stream :sync? true}) + (let [rsp (http/req cfg req {:response-type :input-stream :sync? true}) err (interpret-response rsp)] (report-delivery! whook req rsp err) (update-webhook! whook err)) @@ -190,4 +190,11 @@ "invalid-uri" (instance? java.net.http.HttpConnectTimeoutException cause) - "timeout")) + "timeout" + + :else + (let [data (ex-data cause)] + (if (and (= :validation (:type data)) + (= :ssrf-blocked-target (:code data))) + (str "blocked-request:" (:hint data)) + nil)))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 693752080a5..018e6e301d7 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -61,21 +61,15 @@ ::mdef/help "A total number of bytes processed by update-file." ::mdef/type :counter} - :rpc-mutation-timing - {::mdef/name "penpot_rpc_mutation_timing" - ::mdef/help "RPC mutation method call timing." + :rpc-main-timing + {::mdef/name "penpot_rpc_main_timing" + ::mdef/help "RPC command method call timing for main" ::mdef/labels ["name"] ::mdef/type :histogram} - :rpc-command-timing - {::mdef/name "penpot_rpc_command_timing" - ::mdef/help "RPC command method call timing." - ::mdef/labels ["name"] - ::mdef/type :histogram} - - :rpc-query-timing - {::mdef/name "penpot_rpc_query_timing" - ::mdef/help "RPC query method call timing." + :rpc-management-timing + {::mdef/name "penpot_rpc_management_timing" + ::mdef/help "RPC command method call timing for management." ::mdef/labels ["name"] ::mdef/type :histogram} @@ -304,10 +298,11 @@ ::session/manager (ig/ref ::session/manager)} :app.http.assets/routes - {::http.assets/path (cf/get :assets-path) - ::http.assets/cache-max-age (ct/duration {:hours 24}) - ::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5}) - ::sto/storage (ig/ref ::sto/storage)} + {::http.assets/path (cf/get :assets-path) + ::http.assets/cache-max-age (ct/duration {:hours 24}) + ::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15}) + ::sto/storage (ig/ref ::sto/storage) + ::session/manager (ig/ref ::session/manager)} ::rpc/climit {::mtx/metrics (ig/ref ::mtx/metrics) @@ -388,6 +383,7 @@ :offload-file-data (ig/ref :app.tasks.offload-file-data/handler) :tasks-gc (ig/ref :app.tasks.tasks-gc/handler) :telemetry (ig/ref :app.tasks.telemetry/handler) + :upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler) :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler) :session-gc (ig/ref ::session.tasks/gc) @@ -423,6 +419,9 @@ :app.tasks.tasks-gc/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.upload-session-gc/handler + {::db/pool (ig/ref ::db/pool)} + :app.tasks.objects-gc/handler {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} @@ -466,16 +465,17 @@ ::setup/shared-keys {::setup/props (ig/ref ::setup/props) - :nitrate (cf/get :nitrate-shared-key) - :exporter (cf/get :exporter-shared-key)} + :nexus (cf/get :nexus-shared-key) + :nitrate (cf/get :nitrate-shared-key) + :exporter (cf/get :exporter-shared-key)} ::setup/clock {} :app.loggers.audit.archive-task/handler - {::setup/props (ig/ref ::setup/props) - ::db/pool (ig/ref ::db/pool) - ::http.client/client (ig/ref ::http.client/client)} + {::setup/shared-keys (ig/ref ::setup/shared-keys) + ::http.client/client (ig/ref ::http.client/client) + ::db/pool (ig/ref ::db/pool)} :app.loggers.audit.gc-task/handler {::db/pool (ig/ref ::db/pool)} @@ -543,6 +543,9 @@ {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :tasks-gc} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily + :task :upload-session-gc} + {:cron #penpot/cron "0 0 2 * * ?" ;; daily :task :file-gc-scheduler} @@ -650,9 +653,8 @@ [& _args] (try (let [p (promise)] - (when (contains? cf/flags :nrepl-server) - (l/inf :hint "start nrepl server" :port 6064) - (nrepl/start-server :bind "0.0.0.0" :port 6064)) + (l/inf :hint "start nrepl server" :port 6064) + (nrepl/start-server :bind "0.0.0.0" :port 6064) (start) (deref p)) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index d54f19ab109..ff13c3572c4 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -18,6 +18,7 @@ [app.config :as cf] [app.db :as-alias db] [app.http.client :as http] + [app.media.sanitize :as sanitize] [app.storage :as-alias sto] [app.storage.tmp :as tmp] [buddy.core.bytes :as bb] @@ -31,15 +32,12 @@ (:import clojure.lang.XMLHandler java.io.InputStream - javax.xml.XMLConstants javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants org.apache.commons.io.IOUtils org.im4java.core.ConvertCmd org.im4java.core.IMOperation)) -(def default-max-file-size - (* 1024 1024 10)) ; 10 MiB - (def schema:upload [:map {:title "Upload"} [:filename :string] @@ -78,6 +76,20 @@ 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 :cmd) (defmulti process-error class) @@ -295,9 +307,7 @@ [{:keys [::http/client]} uri] (letfn [(parse-and-validate [{:keys [status headers] :as response}] (let [size (some-> (get headers "content-length") d/parse-integer) - mtype (get headers "content-type") - format (cm/mtype->format mtype) - max-size (cf/get :media-max-file-size default-max-file-size)] + mtype (get headers "content-type")] (when-not (<= 200 status 299) (ex/raise :type :validation @@ -309,25 +319,17 @@ :code :unknown-size :hint "seems like the url points to resource with unknown size")) - (when (> size max-size) - (ex/raise :type :validation - :code :file-too-large - :hint (str/ffmt "the file size % is greater than the maximum %" - size - default-max-file-size))) - - (when (nil? format) - (ex/raise :type :validation - :code :media-type-not-allowed - :hint "seems like the url points to an invalid media object")) - - {:size size :mtype mtype :format format}))] + (-> {:size size :mtype mtype} + (validate-media-type!) + (validate-media-size!))))] (let [{:keys [body] :as response} (try - (http/req! client - {:method :get :uri uri} - {:response-type :input-stream}) + (http/req-with-redirects + client + {:method :get :uri uri} + {:response-type :input-stream + :max-redirects 3}) (catch java.net.ConnectException cause (ex/raise :type :validation :code :unable-to-download-image @@ -358,9 +360,11 @@ :code :mismatch-write-size :hint "unexpected state: unable to write to file")) - {;; :size size - :path path - :mtype mtype}))) + ;; Sanitize: strip trailing data after image EOF markers + (let [new-size (sanitize/truncate-after-eof path mtype)] + {:path path + :mtype mtype + :size new-size})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; FONTS diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj new file mode 100644 index 00000000000..4905f5b6032 --- /dev/null +++ b/backend/src/app/media/sanitize.clj @@ -0,0 +1,191 @@ +;; 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 + +(ns app.media.sanitize + "Image EOF truncation helpers — strips trailing data after image EOF + markers to prevent exfiltration of non-image bytes appended to + valid image files." + (:require + [app.common.buffer :as buf] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.util.nio :as nio]) + (:import + java.nio.ByteOrder + java.nio.channels.FileChannel)) + +(set! *warn-on-reflection* true) + +(defn- scan-backwards + "Scan byte array `arr` backwards (from the end) for the byte pattern + `marker`. Returns the index in `arr` where the marker starts, or -1 + if not found." + [^bytes arr ^bytes marker] + (let [arr-len (alength arr) + marker-len (alength marker)] + (loop [i (- arr-len marker-len)] + (if (< i 0) + -1 + (if (loop [j 0] + (if (>= j marker-len) + true + (if (= (aget arr (+ i j)) (aget marker j)) + (recur (inc j)) + false))) + i + (recur (dec i))))))) + +(defn- find-last-png-iend + "Find the byte offset of the end of the PNG IEND chunk (12 bytes: + 4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset + AFTER the CRC32, or nil if not found." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (> size 8) + (let [buf-size (min (int size) (* 1024 1024)) + marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND" + (loop [pos (max 0 (- size buf-size))] + (when (< pos size) + (let [arr (nio/read-at channel pos buf-size) + idx (scan-backwards arr marker)] + (if (neg? idx) + ;; Not found in this chunk, try earlier + (let [next-pos (max 0 (- pos (- buf-size 4)))] + (when (< next-pos pos) + (recur next-pos))) + ;; Found "IEND" at idx. Chunk starts 4 bytes before. + (let [chunk-start (- (+ pos idx) 4)] + (when (>= chunk-start 0) + ;; PNG chunk length is big-endian (network byte order). + ;; buf/wrap defaults to little-endian, so set it to big-endian. + (let [len-arr (nio/read-at channel chunk-start 4) + len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN) + chunk-len (buf/read-int len-buf 0)] + (when (zero? chunk-len) + (+ chunk-start 12))))))))))))) + +(defn- find-last-jpeg-eoi + "Find the byte offset of the last JPEG EOI marker (0xFF 0xD9). + Returns the offset AFTER the marker, or nil if not found." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (> size 2) + (let [buf-size (min (int size) (* 1024 1024)) + marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])] + (loop [pos (max 0 (- size buf-size))] + (when (< pos size) + (let [arr (nio/read-at channel pos buf-size) + idx (scan-backwards arr marker)] + (if (neg? idx) + (let [next-pos (max 0 (- pos (- buf-size 2)))] + (when (< next-pos pos) + (recur next-pos))) + (+ pos idx 2))))))))) + +(defn- find-last-gif-trailer + "Find the byte offset immediately after the last GIF trailer byte (0x3B). + Scans backwards through the file so that appended data after the real + trailer is truncated even when it ends with 0x3B. + Returns the offset AFTER the trailer byte, or nil if 0x3B is not found." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (pos? size) + (let [buf-size (min (int size) (* 1024 1024)) + marker (byte-array [(unchecked-byte 0x3B)])] + (loop [pos (max 0 (- size buf-size))] + (when (< pos size) + (let [arr (nio/read-at channel pos buf-size) + idx (scan-backwards arr marker)] + (if (neg? idx) + (let [next-pos (max 0 (- pos (- buf-size 1)))] + (when (< next-pos pos) + (recur next-pos))) + (+ pos idx 1))))))))) + +(defn- find-webp-end + "Parse the WebP RIFF header to find the declared file size. + WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian) + + 'WEBP' (4 bytes). The total size is the offset of the end of the file. + Returns nil if the RIFF or WEBP magic bytes are missing." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (>= size 12) + (let [^bytes arr (nio/read-at channel 0 12) + buf (buf/wrap arr)] + ;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11) + (when (and (= (aget arr 0) (byte 0x52)) ;; 'R' + (= (aget arr 1) (byte 0x49)) ;; 'I' + (= (aget arr 2) (byte 0x46)) ;; 'F' + (= (aget arr 3) (byte 0x46)) ;; 'F' + (= (aget arr 8) (byte 0x57)) ;; 'W' + (= (aget arr 9) (byte 0x45)) ;; 'E' + (= (aget arr 10) (byte 0x42)) ;; 'B' + (= (aget arr 11) (byte 0x50))) ;; 'P' + (let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)] + ;; RIFF size field is the size of the file minus 8 bytes + (+ riff-size 8))))))) + +(defn truncate-after-eof + "Given a `java.nio.file.Path` to a freshly-downloaded media file and a + declared MIME type, truncate the file in place to the position of the + format's EOF marker: + - image/png → end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32) + - image/jpeg → 2 bytes after FFD9 + - image/gif → immediately after the last GIF trailer byte 0x3B + - image/webp → end of RIFF chunk declared in bytes 4..8 + - image/svg+xml → no-op (text format; processed by SAX parser) + - other → no-op (return path unchanged) + Returns the new file size. Raises `:validation/:invalid-image` if no + EOF marker is found within the file." + [^java.nio.file.Path path ^String mtype] + (try + (with-open [channel (nio/open-channel path)] + (let [size (nio/channel-size channel)] + (if (zero? size) + 0 + (let [needs-eof-marker? (or (= mtype "image/png") + (= mtype "image/jpeg") + (= mtype "image/gif") + (= mtype "image/webp")) + + eof-offset + (cond + (= mtype "image/png") (find-last-png-iend channel) + (= mtype "image/jpeg") (find-last-jpeg-eoi channel) + (= mtype "image/gif") (find-last-gif-trailer channel) + (= mtype "image/webp") (find-webp-end channel) + :else nil)] + + (cond + ;; No EOF marker applicable (SVG or other) — no-op + (nil? eof-offset) + (if needs-eof-marker? + (ex/raise :type :validation + :code :invalid-image + :hint "image format EOF marker not found") + size) + + ;; Truncate if needed + (< eof-offset size) + (do + (l/dbg :hint "truncating trailing data" + :path (str path) + :mtype mtype + :original-size size + :truncated-to eof-offset) + (nio/truncate channel eof-offset) + eof-offset) + + ;; Already at correct size or marker at end + :else + eof-offset))))) + (catch Exception e + (if (ex/exception? e) + (throw e) + (ex/raise :type :validation + :code :invalid-image + :hint "failed to sanitize image" + :cause e))))) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index 1c7456b7abb..a1f816a304a 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -15,16 +15,16 @@ io.prometheus.client.CollectorRegistry io.prometheus.client.Counter io.prometheus.client.Counter$Child + io.prometheus.client.exporter.common.TextFormat io.prometheus.client.Gauge io.prometheus.client.Gauge$Child io.prometheus.client.Histogram io.prometheus.client.Histogram$Child + io.prometheus.client.hotspot.DefaultExports io.prometheus.client.SimpleCollector io.prometheus.client.Summary io.prometheus.client.Summary$Builder io.prometheus.client.Summary$Child - io.prometheus.client.exporter.common.TextFormat - io.prometheus.client.hotspot.DefaultExports java.io.StringWriter)) (set! *warn-on-reflection* true) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2a9d9eba0b9..fe517762aaf 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -463,8 +463,22 @@ :fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")} {:name "0145-fix-plugins-uri-on-profile" - :fn mg0145/migrate}]) + :fn mg0145/migrate} + {:name "0145-mod-audit-log-table" + :fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")} + + {:name "0145-mod-audit-log-table" + :fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")} + + {:name "0146-mod-audit-log-table" + :fn (mg/resource "app/migrations/sql/0146-mod-audit-log-table.sql")} + + {:name "0146-mod-access-token-table" + :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")} + + {:name "0147-add-upload-session-table" + :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql b/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql new file mode 100644 index 00000000000..6d95ecc6af6 --- /dev/null +++ b/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql @@ -0,0 +1,2 @@ +CREATE INDEX audit_log__created_at__idx ON audit_log(created_at) WHERE archived_at IS NULL; +CREATE INDEX audit_log__archived_at__idx ON audit_log(archived_at) WHERE archived_at IS NOT NULL; diff --git a/backend/src/app/migrations/sql/0146-mod-access-token-table.sql b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql new file mode 100644 index 00000000000..574257859d7 --- /dev/null +++ b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE access_token + ADD COLUMN type text NULL; diff --git a/backend/src/app/migrations/sql/0146-mod-audit-log-table.sql b/backend/src/app/migrations/sql/0146-mod-audit-log-table.sql new file mode 100644 index 00000000000..12c59a6c83b --- /dev/null +++ b/backend/src/app/migrations/sql/0146-mod-audit-log-table.sql @@ -0,0 +1,5 @@ +-- Add index on audit_log (source, created_at) to support efficient +-- queries for the telemetry batch collection mode. + +CREATE INDEX IF NOT EXISTS audit_log__source__created_at__idx + ON audit_log (source, created_at ASC); diff --git a/backend/src/app/migrations/sql/0147-add-upload-session-table.sql b/backend/src/app/migrations/sql/0147-add-upload-session-table.sql new file mode 100644 index 00000000000..eda19647853 --- /dev/null +++ b/backend/src/app/migrations/sql/0147-add-upload-session-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE upload_session ( + id uuid PRIMARY KEY, + + created_at timestamptz NOT NULL DEFAULT now(), + + profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + total_chunks integer NOT NULL +); + +CREATE INDEX upload_session__profile_id__idx + ON upload_session(profile_id); + +CREATE INDEX upload_session__created_at__idx + ON upload_session(created_at); diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 14c1da0e99e..bc649ca3cc0 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -18,13 +18,13 @@ (defn- request-builder [cfg method uri shared-key profile-id] (fn [] - (http/req! cfg {:method method - :headers {"content-type" "application/json" - "accept" "application/json" - "x-shared-key" shared-key - "x-profile-id" (str profile-id)} - :uri uri - :version :http1.1}))) + (http/req cfg {:method method + :headers {"content-type" "application/json" + "accept" "application/json" + "x-shared-key" shared-key + "x-profile-id" (str profile-id)} + :uri uri + :version :http1.1}))) (defn- with-retries diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index 96e6b07be5f..dc1bff9669e 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -24,28 +24,28 @@ [integrant.core :as ig]) (:import clojure.lang.MapEntry - io.lettuce.core.KeyValue - io.lettuce.core.RedisClient - io.lettuce.core.RedisCommandInterruptedException - io.lettuce.core.RedisCommandTimeoutException - io.lettuce.core.RedisException - io.lettuce.core.RedisURI - io.lettuce.core.ScriptOutputType - io.lettuce.core.SetArgs io.lettuce.core.api.StatefulRedisConnection io.lettuce.core.api.sync.RedisCommands io.lettuce.core.api.sync.RedisScriptingCommands io.lettuce.core.codec.RedisCodec io.lettuce.core.codec.StringCodec + io.lettuce.core.KeyValue + io.lettuce.core.pubsub.api.sync.RedisPubSubCommands io.lettuce.core.pubsub.RedisPubSubListener io.lettuce.core.pubsub.StatefulRedisPubSubConnection - io.lettuce.core.pubsub.api.sync.RedisPubSubCommands + io.lettuce.core.RedisClient + io.lettuce.core.RedisCommandInterruptedException + io.lettuce.core.RedisCommandTimeoutException + io.lettuce.core.RedisException + io.lettuce.core.RedisURI io.lettuce.core.resource.ClientResources io.lettuce.core.resource.DefaultClientResources + io.lettuce.core.ScriptOutputType + io.lettuce.core.SetArgs io.netty.channel.nio.NioEventLoopGroup + io.netty.util.concurrent.EventExecutorGroup io.netty.util.HashedWheelTimer io.netty.util.Timer - io.netty.util.concurrent.EventExecutorGroup java.lang.AutoCloseable java.time.Duration)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c5ef53aaf40..6c3d51039dc 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -19,6 +19,7 @@ [app.db :as db] [app.http :as-alias http] [app.http.access-token :as actoken] + [app.http.auth-request :as auth-request] [app.http.client :as-alias http.client] [app.http.middleware :as mw] [app.http.security :as sec] @@ -92,6 +93,7 @@ (fn [{:keys [params path-params method] :as request}] (let [handler-name (:method-name path-params) etag (yreq/get-header request "if-none-match") + session-id (yreq/get-header request "x-session-id") key-id (get request ::http/auth-key-id) profile-id (or (::session/profile-id request) @@ -104,6 +106,8 @@ (assoc ::handler-name handler-name) (assoc ::ip-addr ip-addr) (assoc ::request-at (ct/now)) + (assoc ::request-id (uuid/next)) + (assoc ::session-id (some-> session-id uuid/parse*)) (assoc ::cond/key etag) (cond-> (uuid? profile-id) (assoc ::profile-id profile-id))) @@ -159,12 +163,13 @@ (defn- wrap-audit [_ f mdata] (if (or (contains? cf/flags :webhooks) - (contains? cf/flags :audit-log)) + (contains? cf/flags :audit-log) + (contains? cf/flags :telemetry)) (if-not (::audit/skip mdata) (fn [cfg params] (let [result (f cfg params)] - (->> (audit/prepare-event cfg mdata params result) - (audit/submit! cfg)) + (->> (audit/prepare-rpc-event cfg mdata params result) + (audit/submit cfg)) result)) f) f)) @@ -374,7 +379,8 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz cfg] + [auth-request/authz cfg]] :handler (make-rpc-handler methods)}] (doc/routes :methods methods @@ -392,5 +398,6 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz cfg] + [auth-request/authz cfg]] :handler (make-rpc-handler methods)}]])) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index a302b820539..eedb119d06a 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -23,7 +23,7 @@ (dissoc row :perms)) (defn create-access-token - [{:keys [::db/conn] :as cfg} profile-id name expiration] + [{:keys [::db/conn] :as cfg} profile-id name expiration type] (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) @@ -36,6 +36,7 @@ {:id token-id :name name :token token + :type type :profile-id profile-id :created-at created-at :updated-at created-at @@ -50,17 +51,18 @@ (def ^:private schema:create-access-token [:map {:title "create-access-token"} [:name [:string {:max 250 :min 1}]] - [:expiration {:optional true} ::ct/duration]]) + [:expiration {:optional true} ::ct/duration] + [:type {:optional true} :string]]) (sv/defmethod ::create-access-token {::doc/added "1.18" ::sm/params schema:create-access-token} - [cfg {:keys [::rpc/profile-id name expiration]}] + [cfg {:keys [::rpc/profile-id name expiration type]}] (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile ::quotes/profile-id profile-id}) - (db/tx-run! cfg create-access-token profile-id name expiration)) + (db/tx-run! cfg create-access-token profile-id name expiration type)) (def ^:private schema:delete-access-token [:map {:title "delete-access-token"} @@ -83,5 +85,22 @@ (->> (db/query pool :access-token {:profile-id profile-id} {:order-by [[:expires-at :asc] [:created-at :asc]] - :columns [:id :name :perms :created-at :updated-at :expires-at]}) + :columns [:id :name :perms :type :created-at :updated-at :expires-at]}) (mapv decode-row))) + +(def ^:private schema:get-current-mcp-token + [:map {:title "get-current-mcp-token"}]) + +(sv/defmethod ::get-current-mcp-token + {::doc/added "2.15" + ::sm/params schema:get-current-mcp-token} + [{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}] + (->> (db/query pool :access-token + {:profile-id profile-id + :type "mcp"} + {:order-by [[:expires-at :asc] [:created-at :asc]] + :columns [:token :expires-at]}) + (remove #(and (some? (:expires-at %)) + (ct/is-after? request-at (:expires-at %)))) + (map decode-row) + (first))) diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 757c4fa5cb2..44e547f8cd4 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -15,7 +15,7 @@ [app.config :as cf] [app.db :as db] [app.http :as-alias http] - [app.loggers.audit :as-alias audit] + [app.loggers.audit :as audit] [app.loggers.database :as loggers.db] [app.loggers.mattermost :as loggers.mm] [app.rpc :as-alias rpc] @@ -23,7 +23,8 @@ [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.util.inet :as inet] - [app.util.services :as sv])) + [app.util.services :as sv] + [clojure.set :as set])) (def ^:private event-columns [:id @@ -38,31 +39,31 @@ :context]) (defn- event->row [event] - [(::audit/id event) - (::audit/name event) - (::audit/source event) - (::audit/type event) - (::audit/tracked-at event) - (::audit/created-at event) - (::audit/profile-id event) - (db/inet (::audit/ip-addr event)) - (db/tjson (::audit/props event)) - (db/tjson (d/without-nils (::audit/context event)))]) + [(:id event) + (:name event) + (:source event) + (:type event) + (:tracked-at event) + (:created-at event) + (:profile-id event) + (db/inet (:ip-addr event)) + (db/tjson (:props event)) + (db/tjson (d/without-nils (:context event)))]) (defn- adjust-timestamp - [{:keys [::audit/tracked-at ::audit/created-at] :as event}] + [{:keys [tracked-at created-at] :as event}] (let [margin (inst-ms (ct/diff tracked-at created-at))] (if (or (neg? margin) (> margin 3600000)) ;; If event is in future or lags more than 1 hour, we reasign ;; tracked-at to the server creation date (-> event - (assoc ::audit/tracked-at created-at) - (update ::audit/context assoc :original-tracked-at tracked-at)) + (assoc :tracked-at created-at) + (update :context assoc :original-tracked-at tracked-at)) event))) (defn- exception-event? - [{:keys [::audit/type ::audit/name] :as ev}] + [{:keys [type name] :as ev}] (and (= "action" type) (or (= "unhandled-exception" name) (= "exception-page" name)))) @@ -72,28 +73,44 @@ (map adjust-timestamp) (map event->row))) -(defn- get-events +(defn- prepare-events [{:keys [::rpc/request-at ::rpc/profile-id events] :as params}] (let [request (-> params meta ::http/request) ip-addr (inet/parse-request request) - - xform (map (fn [event] - {::audit/id (uuid/next) - ::audit/type (:type event) - ::audit/name (:name event) - ::audit/props (:props event) - ::audit/context (:context event) - ::audit/profile-id profile-id - ::audit/ip-addr ip-addr - ::audit/source "frontend" - ::audit/tracked-at (:timestamp event) - ::audit/created-at request-at}))] + xform (comp + (map (fn [event] + {:id (uuid/next) + :type (:type event) + :name (:name event) + :props (:props event) + :context (:context event) + :profile-id profile-id + :ip-addr ip-addr + :source "frontend" + :tracked-at (:timestamp event) + :created-at request-at})) + (map (fn [item] + (with-meta item {::audit/event true}))))] (sequence xform events))) +(def ^:private xf:map-telemetry-event-row + (comp + (map adjust-timestamp) + (map (fn [event] + (-> event + (assoc :id (uuid/next)) + (update :created-at ct/truncate :days) + (update :tracked-at ct/truncate :days) + (audit/filter-telemetry-props) + (audit/filter-telemetry-context) + (assoc :ip-addr "0.0.0.0") + (assoc :source "telemetry:frontend")))) + (map event->row))) + (defn- handle-events [{:keys [::db/pool] :as cfg} params] - (let [events (get-events params)] + (let [events (prepare-events params)] ;; Look for error reports and save them on internal reports table (when-let [events (->> events @@ -102,9 +119,18 @@ (run! (partial loggers.db/emit cfg) events) (run! (partial loggers.mm/emit cfg) events)) - ;; Process and save events - (when (seq events) - (let [rows (sequence xf:map-event-row events)] + (when (contains? cf/flags :audit-log) + ;; Process and save full audit events when audit-log flag is active + (when-let [rows (-> (sequence xf:map-event-row events) + (not-empty))] + (db/insert-many! pool :audit-log event-columns rows))) + + (when (contains? cf/flags :telemetry) + ;; Store anonymized frontend events so the telemetry task can ship them + ;; in batches. Runs independently from the audit-log insert above so + ;; both modes can be active simultaneously. + (when-let [rows (-> (sequence xf:map-telemetry-event-row events) + (not-empty))] (db/insert-many! pool :audit-log event-columns rows))))) (def ^:private valid-event-types @@ -138,17 +164,26 @@ ::doc/skip true ::doc/added "1.17"} [{:keys [::db/pool] :as cfg} params] - (if (or (db/read-only? pool) - (not (contains? cf/flags :audit-log))) - (do - (l/warn :hint "audit: http handler disabled or db is read-only") - (rph/wrap nil)) - - (do + (let [telemetry? (contains? cf/flags :telemetry) + audit-log? (contains? cf/flags :audit-log) + enabled? (and (not (db/read-only? pool)) + (or audit-log? telemetry?))] + (when enabled? (try (handle-events cfg params) (catch Throwable cause (l/error :hint "unexpected error on persisting audit events from frontend" - :cause cause))) + :cause cause)))) + + (rph/wrap nil))) - (rph/wrap nil)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GET-ENABLED-FLAGS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(sv/defmethod ::get-enabled-flags + {::audit/skip true + ::doc/skip true + ::doc/added "1.20"} + [_cfg _params] + (set/intersection cf/flags #{:audit-log :telemetry})) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index f3466f6d211..26082e04883 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -258,24 +258,44 @@ (validate-register-attempt! cfg params) (let [email (profile/clean-email email) - profile (profile/get-profile-by-email pool email) - props (-> (audit/extract-utm-params params) - (cond-> (:accept-newsletter-updates params) - (assoc :newsletter-updates true))) - params {:email email - :fullname fullname - :password (:password params) - :invitation-token (:invitation-token params) - :backend "penpot" - :iss :prepared-register - :profile-id (:id profile) - :exp (ct/in-future {:days 7}) - :props props} - params (d/without-nils params) - token (tokens/generate cfg params)] - - (-> {:token token} - (with-meta {::audit/profile-id uuid/zero})))) + 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. + ;; + ;; Active accounts must use the standard login flow; existing-but- + ;; not-yet-active profiles fall through to the duplicate-detection branch in + ;; `register-profile`, which never creates a session. + (when (and (some? profile) + (true? (:is-active profile))) + (ex/raise :type :validation + :code :email-already-exists + :hint "email already exists")) + + (let [props (-> (audit/extract-utm-params params) + (cond-> (:accept-newsletter-updates params) + (assoc :newsletter-updates true))) + ;; SECURITY: do NOT embed `:profile-id` of an existing + ;; profile into the prepared-register JWE. Doing so would + ;; let an anonymous caller, in possession of a valid + ;; team-invitation JWE, ask `register-profile` to load that + ;; profile by id and mint a session for it without password + ;; verification. `register-profile` independently re-detects + ;; duplicates by email and handles them in the + ;; "repeated-registry" branch. + params {:email email + :fullname fullname + :password (:password params) + :invitation-token (:invitation-token params) + :backend "penpot" + :iss :prepared-register + :exp (ct/in-future {:days 7}) + :props props} + params (d/without-nils params) + token (tokens/generate cfg params)] + + (-> {:token token} + (with-meta {::audit/profile-id uuid/zero}))))) (def schema:prepare-register-profile [:map {:title "prepare-register-profile"} @@ -387,25 +407,32 @@ (profile/decode-row)))) (defn send-email-verification! - [{:keys [::db/conn] :as cfg} profile] - (let [vtoken (tokens/generate cfg - {:iss :verify-email - :exp (ct/in-future "72h") - :profile-id (:id profile) - :email (:email profile)}) - ;; NOTE: this token is mainly used for possible complains - ;; identification on the sns webhook - ptoken (tokens/generate cfg - {:iss :profile-identity - :profile-id (:id profile) - :exp (ct/in-future {:days 30})})] - (eml/send! {::eml/conn conn - ::eml/factory eml/register - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile) - :token vtoken - :extra-data ptoken}))) + ([cfg profile] (send-email-verification! cfg profile nil)) + ([{:keys [::db/conn] :as cfg} profile invitation-token] + (let [vclaims (cond-> {:iss :verify-email + :exp (ct/in-future "72h") + :profile-id (:id profile) + :email (:email profile)} + ;; If the user registered through a team-invitation flow but + ;; their profile is not yet active, we carry the invitation + ;; token inside the verify-email JWE so the team-invitation + ;; flow can resume after the user clicks the email link. + (some? invitation-token) + (assoc :invitation-token invitation-token)) + vtoken (tokens/generate cfg vclaims) + ;; NOTE: this token is mainly used for possible complains + ;; identification on the sns webhook + ptoken (tokens/generate cfg + {:iss :profile-identity + :profile-id (:id profile) + :exp (ct/in-future {:days 30})})] + (eml/send! {::eml/conn conn + ::eml/factory eml/register + :public-uri (cf/get :public-uri) + :to (:email profile) + :name (:fullname profile) + :token vtoken + :extra-data ptoken})))) (defn register-profile [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}] @@ -414,23 +441,16 @@ (:accept-newsletter-updates params) (update :props assoc :newsletter-updates true)) - profile (if-let [profile-id (:profile-id claims)] - (profile/get-profile conn profile-id) - ;; NOTE: we first try to match existing profile - ;; by email, that in normal circumstances will - ;; not return anything, but when a user tries to - ;; reuse the same token multiple times, we need - ;; to detect if the profile is already registered - (or (profile/get-profile-by-email conn (:email claims)) - (let [is-active (or (boolean (:is-active claims)) - (boolean (:email-verified claims)) - (not (contains? cf/flags :email-verification))) - params (-> params - (assoc :is-active is-active) - (update :password auth/derive-password)) - profile (->> (create-profile cfg params) - (create-profile-rels conn))] - (vary-meta profile assoc :created true)))) + profile (or (profile/get-profile-by-email conn (:email claims)) + (let [is-active (or (boolean (:is-active claims)) + (boolean (:email-verified claims)) + (not (contains? cf/flags :email-verification))) + params (-> params + (assoc :is-active is-active) + (update :password auth/derive-password)) + profile (->> (create-profile cfg params) + (create-profile-rels cfg))] + (vary-meta profile assoc :created true))) created? (-> profile meta :created true?) @@ -446,6 +466,7 @@ (when (:create-welcome-file params) (let [cfg (dissoc cfg ::db/conn)] (wrk/submit! executor (create-welcome-file cfg profile)))))] + (cond ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -453,51 +474,74 @@ (l/wrn :hint "register attempt for already blocked profile" :profile-id (str (:id profile)) :profile-email (:email profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:id (:id profile) + :email (:email profile)} {::audit/replace-props props ::audit/context {:action "ignore-because-blocked"} ::audit/profile-id (:id profile) ::audit/name "register-profile-retry"})) - ;; If invitation token comes in params, this is because the user - ;; comes from team-invitation process; in this case, regenerate - ;; token and send back to the user a new invitation token (and - ;; mark current session as logged). This happens only if the - ;; invitation email matches with the register email. - (and (some? invitation) - (= (:email profile) - (:member-email invitation))) - (let [invitation (assoc invitation :member-id (:id profile)) - token (tokens/generate cfg invitation)] - (-> {:invitation-token token} - (rph/with-transform (session/create-fn cfg profile claims)) - (rph/with-meta {::audit/replace-props props - ::audit/context {:action "accept-invitation"} - ::audit/profile-id (:id profile)}))) - - ;; When a new user is created and it is already activated by - ;; configuration or specified by OIDC, we just mark the profile - ;; as logged-in + ;; A profile was just created in this call. Invitation handling is a + ;; sub-case of "newly created profile": we never honor invitations for + ;; pre-existing profiles via this anonymous RPC. The split below mirrors + ;; the non-invitation branches but threads the invitation through the + ;; appropriate path: + ;; + ;; - active + matching invitation → mint session and + ;; return :invitation-token. The frontend redirects to + ;; :auth-verify-token, which immediately accepts the + ;; invitation. + ;; - active + no/mismatched invitation → mint session + ;; ("login" action). New profile, no further action. + ;; - not-active + matching invitation → send the + ;; verify-email mail with the invitation token EMBEDDED + ;; into the verify-email JWE. No session yet. When the + ;; user clicks the link, verify-token activates the + ;; profile, mints a session, and propagates the + ;; invitation token to the frontend so it can complete + ;; the team-invitation flow. + ;; - not-active + no/mismatched invitation → standard + ;; "check your email" verification flow. created? - (if (:is-active profile) - (-> (profile/strip-private-attrs profile) - (rph/with-transform (session/create-fn cfg profile claims)) - (rph/with-defer create-welcome-file-when-needed) - (rph/with-meta - {::audit/replace-props props - ::audit/context {:action "login"} - ::audit/profile-id (:id profile)})) - - (do - (when-not (eml/has-reports? conn (:email profile)) - (send-email-verification! cfg profile)) - - (-> {:email (:email profile)} + (let [accept-invitation? (and (some? invitation) + (= (:email profile) + (:member-email invitation)))] + (cond + (and (:is-active profile) accept-invitation?) + (let [invitation (assoc invitation :member-id (:id profile)) + token (tokens/generate cfg invitation)] + (-> {:id (:id profile) + :email (:email profile) + :invitation-token token} + (rph/with-transform (session/create-fn cfg profile claims)) + (rph/with-defer create-welcome-file-when-needed) + (rph/with-meta {::audit/replace-props props + ::audit/context {:action "accept-invitation"} + ::audit/profile-id (:id profile)}))) + + (:is-active profile) + (-> (profile/strip-private-attrs profile) + (rph/with-transform (session/create-fn cfg profile claims)) (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props - ::audit/context {:action "email-verification"} - ::audit/profile-id (:id profile)})))) + ::audit/context {:action "login"} + ::audit/profile-id (:id profile)})) + + :else + (do + (when-not (eml/has-reports? conn (:email profile)) + (send-email-verification! cfg profile + (when accept-invitation? + (:invitation-token params)))) + + (-> {:id (:id profile) + :email (:email profile)} + (rph/with-defer create-welcome-file-when-needed) + (rph/with-meta + {::audit/replace-props props + ::audit/context {:action "email-verification"} + ::audit/profile-id (:id profile)}))))) :else (let [elapsed? (elapsed-verify-threshold? profile) @@ -519,7 +563,8 @@ {:id (:id profile)}) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:email (:email profile) + :id (:id profile)} {::audit/replace-props (audit/profile->props profile) ::audit/context {:action action} ::audit/profile-id (:id profile) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 743303c6a21..adb942bec83 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -22,6 +22,7 @@ [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.media :as media-cmd] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -80,20 +81,33 @@ ;; --- Command: import-binfile (defn- import-binfile - [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}] - (let [team (teams/get-team pool - :profile-id profile-id - :project-id project-id) - cfg (-> cfg - (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) - (assoc ::bfc/project-id project-id) - (assoc ::bfc/profile-id profile-id) - (assoc ::bfc/name name) - (assoc ::bfc/input (:path file))) - - result (case (int version) - 1 (bf.v1/import-files! cfg) - 3 (bf.v3/import-files! cfg))] + [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}] + (let [team + (teams/get-team pool + :profile-id profile-id + :project-id project-id) + + cfg + (-> cfg + (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) + (assoc ::bfc/project-id project-id) + (assoc ::bfc/profile-id profile-id) + (assoc ::bfc/name name)) + + input-path (:path file) + owned? (some? upload-id) + + cfg + (assoc cfg ::bfc/input input-path) + + result + (try + (case (int version) + 1 (bf.v1/import-files! cfg) + 3 (bf.v3/import-files! cfg)) + (finally + (when owned? + (fs/delete input-path))))] (db/update! pool :project {:modified-at (ct/now)} @@ -103,13 +117,18 @@ result)) (def ^:private schema:import-binfile - [: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 media/schema:upload]]) + [:and + [: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/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. If `file-id` is provided, @@ -117,28 +136,40 @@ 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" "Add file-id param for in-place import" - "1.20" "Set default version to 3"] + "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 file-id file] :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)) - - manifest (case (int version) - 1 nil - 3 (bf.v3/get-manifest (:path file)))] + (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)] + (assoc params :file file)) + params) + + manifest + (case (int version) + 1 nil + 3 (bf.v3/get-manifest (-> params :file :path)))] (with-meta (sse/response (partial import-binfile cfg params)) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index cd3cbcdf0fc..83257723610 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -71,7 +71,7 @@ {::doc/added "1.20" ::sm/params schema:restore-file-snapshot ::db/transaction true} - [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] + [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}] (files/check-edition-permissions! conn profile-id file-id) (let [file (bfc/get-file cfg file-id) team (teams/get-team conn @@ -88,7 +88,8 @@ ;; Send to the clients a notification to reload the file (mbus/pub! msgbus :topic (:id file) - :message {:type :file-restore + :message {:type :file-restored + :session-id session-id :file-id (:id file) :vern vern}) nil))) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 03c66a968f0..b243be5b2d6 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -9,8 +9,11 @@ [app.binfile.common :as bfc] [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.time :as ct] + [app.common.types.font :as types.font] [app.common.uuid :as uuid] [app.db :as db] [app.db.sql :as-alias sql] @@ -21,6 +24,7 @@ [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] + [app.rpc.commands.media :as cmedia] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -29,6 +33,8 @@ [app.storage :as sto] [app.storage.tmp :as tmp] [app.util.services :as sv] + [cuerdas.core :as str] + [datoteka.fs :as fs] [datoteka.io :as io]) (:import java.io.InputStream @@ -87,32 +93,92 @@ (declare create-font-variant) (def ^:private schema:create-font-variant - [:map {:title "create-font-variant"} - [:team-id ::sm/uuid] - [:data [:map-of ::sm/text [:or ::sm/bytes - [::sm/vec ::sm/bytes]]]] - [:font-id ::sm/uuid] - [:font-family ::sm/text] - [:font-weight [::sm/one-of {:format "number"} valid-weight]] - [:font-style [::sm/one-of {:format "string"} valid-style]]]) + [: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)))]]) ;; FIXME: IMPORTANT: refactor this, we should not hold a whole db ;; connection around the font creation +(defn- prepare-font-data-from-uploads + "Assembles each chunked-upload session in `uploads` (a `{mtype → + session-id}` map) into a temp file, validates the media type and + size of every entry, and returns a `{mtype → path}` data map." + [cfg {:keys [uploads] :as params}] + (let [data (reduce-kv + (fn [acc mtype session-id] + (let [assembled (cmedia/assemble-chunks cfg session-id)] + (-> {:mtype mtype :size (:size assembled)} + (media/validate-media-type! cm/font-types) + (media/validate-font-size!)) + (assoc acc mtype (:path assembled)))) + {} + uploads)] + + (-> params + (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 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"] ::climit/id [[:process-font/by-profile ::rpc/profile-id] [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [cfg {:keys [::rpc/profile-id team-id] :as params}] + [cfg {:keys [::rpc/profile-id team-id uploads] :as params}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (teams/check-edition-permissions! conn profile-id team-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (create-font-variant cfg (assoc params :profile-id profile-id))))) + (let [params (if (some? uploads) + (prepare-font-data-from-uploads cfg params) + (prepare-font-data-from-legacy params))] + (create-font-variant cfg (assoc params :profile-id profile-id)))))) (defn create-font-variant [{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}] @@ -127,23 +193,6 @@ :hint "invalid font upload, unable to generate missing font assets")) data)) - (process-chunks [chunks] - (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") - streams (map io/input-stream chunks) - streams (Collections/enumeration streams)] - (with-open [^OutputStream output (io/output-stream tmp) - ^InputStream input (SequenceInputStream. streams)] - (io/copy input output)) - tmp)) - - (join-chunks [data] - (reduce-kv (fn [data mtype content] - (if (vector? content) - (assoc data mtype (process-chunks content)) - data)) - data - data)) - (prepare-font [data mtype] (when-let [resource (get data mtype)] @@ -185,11 +234,38 @@ :otf-file-id (:id otf) :ttf-file-id (:id ttf)}))] - (let [data (join-chunks data) - data (generate-missing data) - assets (persist-fonts-files! data) - result (insert-font-variant! assets)] - (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))) + (let [tpoint (ct/tpoint) + mtypes (vec (keys data)) + total-size (reduce-kv (fn [acc _ content] + (+ acc (if (bytes? content) + (alength ^bytes content) + (fs/size content)))) + 0 + data)] + + (l/dbg :hint "create-font-variant" + :step "init" + :font-family (:font-family params) + :font-weight (:font-weight params) + :font-style (:font-style params) + :mtypes (str/join mtypes ",") + :size total-size) + + (let [data (generate-missing data) + assets (persist-fonts-files! data) + result (insert-font-variant! assets) + elapsed (tpoint)] + + (l/dbg :hint "create-font-variant" + :step "end" + :font-family (:font-family params) + :font-weight (:font-weight params) + :font-style (:font-style params) + :mtypes (str/join mtypes ",") + :size total-size + :elapsed (ct/format-duration elapsed)) + + (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))) ;; --- UPDATE FONT FAMILY @@ -198,7 +274,7 @@ [:map {:title "update-font"} [:team-id ::sm/uuid] [:id ::sm/uuid] - [:name :string]]) + [:name types.font/schema:font-family]]) (sv/defmethod ::update-font {::doc/added "1.18" diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 0908b358d76..974004ec63d 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -425,10 +425,10 @@ (doseq [file-id result] (let [props (assoc props :id file-id) event (-> (audit/event-from-rpc-params params) - (assoc ::audit/profile-id profile-id) - (assoc ::audit/name "create-file") - (assoc ::audit/props props))] - (audit/submit! cfg event)))))) + (assoc :profile-id profile-id) + (assoc :name "create-file") + (assoc :props props))] + (audit/submit cfg event)))))) result)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 80e49b63666..405bdb8117b 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -7,9 +7,12 @@ (ns app.rpc.commands.media (:require [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.loggers.audit :as-alias audit] [app.media :as media] @@ -17,8 +20,13 @@ [app.rpc.climit :as climit] [app.rpc.commands.files :as files] [app.rpc.doc :as-alias doc] + [app.rpc.quotes :as quotes] [app.storage :as sto] - [app.util.services :as sv])) + [app.storage.tmp :as tmp] + [app.util.services :as sv] + [datoteka.io :as io]) + (:import + java.io.OutputStream)) (def thumbnail-options {:width 100 @@ -51,8 +59,8 @@ (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if ;; file is not already deleted - (let [_ (files/get-minimal-file conn file-id) - mobj (create-file-media-object cfg params)] + (let [_ (files/get-minimal-file conn file-id) + mobj (create-file-media-object cfg params)] (db/update! conn :file {:modified-at (ct/now) @@ -142,20 +150,49 @@ (defn- create-file-media-object [{:keys [::sto/storage ::db/conn] :as cfg} - {:keys [id file-id is-local name content]}] - (let [result (process-image content) - image (sto/put-object! storage (::image result)) - thumb (when-let [params (::thumb result)] - (sto/put-object! storage params))] - - (db/exec-one! conn [sql:create-file-media-object - (or id (uuid/next)) - file-id is-local name - (:id image) - (:id thumb) - (:width result) - (:height result) - (:mtype result)]))) + {:keys [id file-id is-local name content from-url? from-chunks?]}] + + (let [tpoint (ct/tpoint) + id (or id (uuid/next)) + origin (cond + from-url? + "url" + from-chunks? + "chunks" + :else + "direct")] + + (l/dbg :hint "create file-media-object" + :step "init" + :id (str id) + :mtype (:mtype content) + :size (:size content) + :path (str (:path content)) + :origin origin) + + (let [result (process-image content) + image (sto/put-object! storage (::image result)) + thumb (when-let [params (::thumb result)] + (sto/put-object! storage params)) + elapsed (tpoint)] + + (l/dbg :hint "create file-media-object" + :step "end" + :id (str id) + :mtype (:mtype content) + :size (:size content) + :path (str (:path content)) + :origin origin + :elapsed (ct/format-duration elapsed)) + + (db/exec-one! conn [sql:create-file-media-object + id + file-id is-local name + (:id image) + (:id thumb) + (:width result) + (:height result) + (:mtype result)])))) ;; --- Create File Media Object (from URL) @@ -191,6 +228,7 @@ [cfg {:keys [url name] :as params}] (let [content (media/download-image cfg url) params (-> params + (assoc :from-url? true) (assoc :content content) (assoc :name (d/nilv name "unknown")))] @@ -236,3 +274,190 @@ :width (:width mobj) :height (:height mobj) :mtype (:mtype mobj)}))) + +;; --- Chunked Upload: Create an upload session + +(def ^:private schema:create-upload-session + [:map {:title "create-upload-session"} + [:total-chunks ::sm/int]]) + +(def ^:private schema:create-upload-session-result + [:map {:title "create-upload-session-result"} + [:session-id ::sm/uuid]]) + +(sv/defmethod ::create-upload-session + {::doc/added "2.16" + ::sm/params schema:create-upload-session + ::sm/result schema:create-upload-session-result} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id total-chunks]}] + + (let [max-chunks (cf/get :quotes-upload-chunks-per-session)] + (when (> total-chunks max-chunks) + (ex/raise :type :restriction + :code :max-quote-reached + :target "upload-chunks-per-session" + :quote max-chunks + :count total-chunks))) + + (quotes/check! cfg {::quotes/id ::quotes/upload-sessions-per-profile + ::quotes/profile-id profile-id}) + + (let [session-id (uuid/next)] + (db/insert! pool :upload-session + {:id session-id + :profile-id profile-id + :total-chunks total-chunks}) + {:session-id session-id})) + +;; --- Chunked Upload: Upload a single chunk + +(def ^:private schema:upload-chunk + [:map {:title "upload-chunk"} + [:session-id ::sm/uuid] + [:index ::sm/int] + [:content media/schema:upload]]) + +(def ^:private schema:upload-chunk-result + [:map {:title "upload-chunk-result"} + [:session-id ::sm/uuid] + [:index ::sm/int]]) + +(sv/defmethod ::upload-chunk + {::doc/added "2.16" + ::sm/params schema:upload-chunk + ::sm/result schema:upload-chunk-result} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id session-id index content] :as _params}] + (let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})] + (when (or (neg? index) (>= index (:total-chunks session))) + (ex/raise :type :validation + :code :invalid-chunk-index + :hint "chunk index is out of range for this session" + :session-id session-id + :total-chunks (:total-chunks session) + :index index)) + + + (l/trc :hint "upload-chunk" + :session-id session-id + :chunk (str index "/" (:total-chunks session)) + :size (:size content) + :path (:path content))) + + (let [storage (sto/resolve cfg) + data (sto/content (:path content))] + (sto/put-object! storage + {::sto/content data + ::sto/deduplicate? false + ::sto/touch true + :content-type (:mtype content) + :bucket "tempfile" + :upload-id (str session-id) + :chunk-index index})) + + {:session-id session-id + :index index}) + +;; --- Chunked Upload: shared helpers + +(def ^:private sql:get-upload-chunks + "SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index + FROM storage_object + WHERE (metadata->>'~:upload-id') = ?::text + AND deleted_at IS NULL + ORDER BY (metadata->>'~:chunk-index')::integer ASC") + +(defn- get-upload-chunks + [conn session-id] + (db/exec! conn [sql:get-upload-chunks (str session-id)])) + +(defn- concat-chunks + "Reads all chunk storage objects in order and writes them to a single + temporary file on the local filesystem. Returns a path to that file." + [storage chunks] + (let [tmp (tmp/tempfile :prefix "penpot.chunked-upload.")] + (with-open [^OutputStream out (io/output-stream tmp)] + (doseq [{:keys [id]} chunks] + (let [sobj (sto/get-object storage id) + bytes (sto/get-object-bytes storage sobj)] + (.write out ^bytes bytes)))) + tmp)) + +(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/schema:upload` with `:filename`, `:path` and + `:size`. + + Raises a :validation/:missing-chunks error when the number of stored + chunks does not match `:total-chunks` recorded in the session row. + Deletes the session row from `upload_session` on success." + [{:keys [::db/conn] :as cfg} session-id] + (let [session (db/get conn :upload-session {:id session-id}) + chunks (get-upload-chunks conn session-id)] + + (when (not= (count chunks) (:total-chunks session)) + (ex/raise :type :validation + :code :missing-chunks + :hint "number of stored chunks does not match expected total" + :session-id session-id + :expected (:total-chunks session) + :found (count chunks))) + + (let [storage (sto/resolve cfg ::db/reuse-conn true) + path (concat-chunks storage chunks) + size (reduce #(+ %1 (:size %2)) 0 chunks)] + + (db/delete! conn :upload-session {:id session-id}) + + {:filename "upload" + :path path + :size size}))) + +;; --- Chunked Upload: Assemble all chunks into a final media object + +(def ^:private schema:assemble-file-media-object + [:map {:title "assemble-file-media-object"} + [:session-id ::sm/uuid] + [:file-id ::sm/uuid] + [:is-local ::sm/boolean] + [:name [:string {:max 250}]] + [:mtype :string] + [:id {:optional true} ::sm/uuid]]) + +(sv/defmethod ::assemble-file-media-object + {::doc/added "2.16" + ::sm/params schema:assemble-file-media-object + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]]} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id session-id file-id is-local name mtype id] :as params}] + (files/check-edition-permissions! pool profile-id file-id) + + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [content (assemble-chunks cfg session-id) + content (-> content + (assoc :filename (str "upload:" name)) + (assoc :mtype mtype) + (media/validate-media-type!) + (media/validate-media-size!)) + mobj (create-file-media-object cfg (assoc params + :id id + :from-chunks? true + :content content))] + + (db/update! conn :file + {:modified-at (ct/now) + :has-media-trimmed false} + {:id file-id} + {::db/return-keys false}) + + (with-meta mobj + {::audit/replace-props + {:name name + :file-id file-id + :is-local is-local + :mtype mtype}}))))) + diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 3d2f2b13514..2f9e367188d 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,6 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] + [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] @@ -263,6 +264,7 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type (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 @@ -314,9 +316,16 @@ (sto/put-object! storage params))) ;; --- MUTATION: Request Email Change - -(declare ^:private request-email-change!) -(declare ^:private change-email-immediately!) +;; +;; Disabled in this Penpot fork. The deployment runs exclusively behind +;; oauth2-proxy / Cognito (SSO); the user's email is the identity asserted by +;; the upstream IdP via X-Auth-Request-Email and is also the lookup key in +;; auth_request.clj. A local email change would diverge from the IdP value +;; and either lock the user out of their own workspace or pre-stage a +;; profile that hijacks the next victim's first sign-in. +;; +;; The complementary refusal lives in rpc/commands/verify_token.clj +;; (process-token :change-email) so any pre-existing token cannot be redeemed. (def ^:private schema:request-email-change @@ -326,81 +335,10 @@ (sv/defmethod ::request-email-change {::doc/added "1.0" ::sm/params schema:request-email-change} - [cfg {:keys [::rpc/profile-id email] :as params}] - (db/tx-run! cfg - (fn [cfg] - (let [profile (db/get-by-id cfg :profile profile-id) - params (assoc params - :profile profile - :email (clean-email email))] - (if (contains? cf/flags :smtp) - (request-email-change! cfg params) - (change-email-immediately! cfg params)))))) - -(defn- change-email-immediately! - [{:keys [::db/conn]} {:keys [profile email] :as params}] - (when (not= email (:email profile)) - (check-profile-existence! conn params)) - - (db/update! conn :profile - {:email email} - {:id (:id profile)}) - - {:changed true}) - -(defn- request-email-change! - [{:keys [::db/conn] :as cfg} {:keys [profile email] :as params}] - (let [token (tokens/generate cfg - {:iss :change-email - :exp (ct/in-future "15m") - :profile-id (:id profile) - :email email}) - ptoken (tokens/generate cfg - {:iss :profile-identity - :profile-id (:id profile) - :exp (ct/in-future {:days 30})})] - - (when (not= email (:email profile)) - (check-profile-existence! conn params)) - - (when-not (eml/allow-send-emails? conn profile) - (ex/raise :type :validation - :code :profile-is-muted - :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) - - (when (eml/has-bounce-reports? conn email) - (ex/raise :type :restriction - :code :email-has-permanent-bounces - :email email - :hint "looks like the email has bounce reports")) - - (when (eml/has-complaint-reports? conn email) - (ex/raise :type :restriction - :code :email-has-complaints - :email email - :hint "looks like the email has spam complaint reports")) - - (when (eml/has-bounce-reports? conn (:email profile)) - (ex/raise :type :restriction - :code :email-has-permanent-bounces - :email (:email profile) - :hint "looks like the email has bounce reports")) - - (when (eml/has-complaint-reports? conn (:email profile)) - (ex/raise :type :restriction - :code :email-has-complaints - :email (:email profile) - :hint "looks like the email has spam complaint reports")) - - (eml/send! {::eml/conn conn - ::eml/factory eml/change-email - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile) - :pending-email email - :token token - :extra-data ptoken}) - nil)) + [_cfg _params] + (ex/raise :type :restriction + :code :email-managed-by-external-idp + :hint "email is managed by the upstream identity provider and cannot be changed in Penpot")) ;; --- MUTATION: Update Profile Props diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 603e12187bd..3ebd34fe00a 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -497,7 +497,7 @@ (def ^:private schema:create-team [:map {:title "create-team"} - [:name [:string {:max 250}]] + [:name types.team/schema:team-name] [:features {:optional true} ::cfeat/features] [:id {:optional true} ::sm/uuid]]) @@ -591,7 +591,7 @@ (def ^:private schema:update-team [:map {:title "update-team"} - [:name [:string {:max 250}]] + [:name types.team/schema:team-name] [:id ::sm/uuid]]) (sv/defmethod ::update-team @@ -827,6 +827,7 @@ ;; Validate incoming mime type (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 diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 5cffdd0c694..9d59b63b885 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -19,6 +19,7 @@ [app.config :as cf] [app.db :as db] [app.email :as eml] + [app.email.blacklist :as email.blacklist] [app.loggers.audit :as audit] [app.main :as-alias main] [app.rpc :as-alias rpc] @@ -91,6 +92,12 @@ (let [email (profile/clean-email email) member (profile/get-profile-by-email conn email)] + (when (and (email.blacklist/enabled? cfg) + (email.blacklist/contains? cfg email)) + (ex/raise :type :restriction + :code :email-domain-is-not-allowed + :hint "email domain is in the blacklist")) + ;; When we have email verification disabled and invitation user is ;; already present in the database, we proceed to add it to the ;; team as-is, without email roundtrip. @@ -149,9 +156,9 @@ "update-team-invitation" "create-team-invitation") event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name evname) - (assoc ::audit/props props))] - (audit/submit! cfg event)) + (assoc :name evname) + (assoc :props props))] + (audit/submit cfg event)) (when (allow-invitation-emails? member) (eml/send! {::eml/conn conn @@ -403,9 +410,9 @@ (let [props {:name name :features features} event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "create-team") - (assoc ::audit/props props))] - (audit/submit! cfg event)) + (assoc :name "create-team") + (assoc :props props))] + (audit/submit cfg event)) ;; Create invitations for all provided emails. (let [profile (db/get-by-id conn :profile profile-id) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index a3454f71355..e83d929233e 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -42,20 +42,15 @@ (db/tx-run! cfg process-token params claims))) (defmethod process-token :change-email - [{:keys [::db/conn] :as cfg} _params {:keys [profile-id email] :as claims}] - (let [email (profile/clean-email email)] - (when (profile/get-profile-by-email conn email) - (ex/raise :type :validation - :code :email-already-exists)) - - (db/update! conn :profile - {:email email} - {:id profile-id}) - - (rph/with-meta claims - {::audit/name "update-profile-email" - ::audit/props {:email email} - ::audit/profile-id profile-id}))) + [_cfg _params _claims] + ;; Belt-and-suspenders for the unconditional disable in + ;; rpc/commands/profile.clj. Even if a valid :change-email token already + ;; exists (minted via a previous deploy or a fork), the redeem path must + ;; refuse it — otherwise the same divergence vector is reachable here. + ;; The email is owned by the upstream IdP (Cognito via oauth2-proxy). + (ex/raise :type :restriction + :code :email-managed-by-external-idp + :hint "email is managed by the upstream identity provider and cannot be changed in Penpot")) (defmethod process-token :verify-email [{:keys [::db/conn] :as cfg} _ {:keys [profile-id] :as claims}] @@ -72,6 +67,11 @@ {:is-active true} {:id (:id profile)})) + ;; NOTE: `claims` is returned verbatim (besides :profile). When the + ;; verify-email JWE was minted by `register-profile` for a not-yet- + ;; active profile that came from an invitation flow, `:invitation- + ;; token` will be present here and the frontend will use it to + ;; complete the team-invitation flow after login. (-> claims (rph/with-transform (session/create-fn cfg profile)) (rph/with-meta {::audit/name "verify-profile-email" @@ -168,24 +168,22 @@ :role (:role claims) :invitation-id (:id invitation)}] - (audit/submit! - cfg - (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "accept-team-invitation") - (assoc ::audit/props props))) + (audit/submit cfg + (-> (audit/event-from-rpc-params params) + (assoc :name "accept-team-invitation") + (assoc :props props))) ;; NOTE: Backward compatibility; old invitations can ;; have the `created-by` to be nil; so in this case we ;; don't submit this event to the audit-log (when-let [created-by (:created-by invitation)] - (audit/submit! - cfg - (-> (audit/event-from-rpc-params params) - (assoc ::audit/profile-id created-by) - (assoc ::audit/name "accept-team-invitation-from") - (assoc ::audit/props (assoc props - :profile-id (:id profile) - :email (:email profile)))))) + (audit/submit cfg + (-> (audit/event-from-rpc-params params) + (assoc :profile-id created-by) + (assoc :name "accept-team-invitation-from") + (assoc :props (assoc props + :profile-id (:id profile) + :email (:email profile)))))) (accept-invitation cfg claims invitation profile) (assoc claims :state :created)) diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 5b645418248..7a3c441335c 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -50,24 +50,27 @@ (defn- validate-webhook! [cfg whook params] (when (not= (:uri whook) (:uri params)) - (let [response (ex/try! - (http/req! cfg + (try + (let [response (http/req cfg {:method :head :uri (str (:uri params)) - :timeout (ct/duration "3s")} - {:sync? true}))] - (if (ex/exception? response) - (if-let [hint (webhooks/interpret-exception response)] + :timeout (ct/duration "3s")})] + (when-let [hint (webhooks/interpret-response response)] (ex/raise :type :validation :code :webhook-validation - :hint hint) - (ex/raise :type :internal - :code :webhook-validation - :cause response)) - (when-let [hint (webhooks/interpret-response response)] + :hint hint))) + + (catch Throwable cause + (if-let [hint (webhooks/interpret-exception cause)] (ex/raise :type :validation :code :webhook-validation - :hint hint)))))) + :hint hint + :webhook-uri (str (:uri params)) + :cause cause) + (ex/raise :type :internal + :code :webhook-validation + :webhook-uri (str (:uri params)) + :cause cause)))))) (defn- validate-quotes! [{:keys [::db/pool]} {:keys [team-id]}] diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index d5903744b38..1fe00e62d9b 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -522,6 +522,30 @@ (assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id]) (generic-check!))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; QUOTE: UPLOAD-SESSIONS-PER-PROFILE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:upload-sessions-per-profile + [:map [::profile-id ::sm/uuid]]) + +(def ^:private valid-upload-sessions-per-profile-quote? + (sm/lazy-validator schema:upload-sessions-per-profile)) + +(def ^:private sql:get-upload-sessions-per-profile + "SELECT count(*) AS total + FROM upload_session + WHERE profile_id = ?") + +(defmethod check-quote ::upload-sessions-per-profile + [{:keys [::profile-id ::target] :as quote}] + (assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters") + (-> quote + (assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE)) + (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) + (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) + (generic-check!))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index 7406cbca935..66d80f1a3bd 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -11,7 +11,9 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] + [app.loggers.audit :as audit] [app.main :as-alias main] [app.setup.keys :as keys] [app.setup.templates] @@ -35,22 +37,20 @@ (into {}))) (defn- handle-instance-id - [instance-id conn read-only?] + [instance-id conn] (or instance-id (let [instance-id (uuid/random)] - (when-not read-only? - (try - (db/insert! conn :server-prop - {:id "instance-id" - :preload true - :content (db/tjson instance-id)}) - (catch Throwable cause - (l/warn :hint "unable to persist instance-id" - :instance-id instance-id - :cause cause)))) + (try + (db/insert! conn :server-prop + {:id "instance-id" + :preload true + :content (db/tjson instance-id)}) + (catch Throwable cause + (l/warn :hint "unable to persist instance-id" + :instance-id instance-id + :cause cause))) instance-id))) - (def sql:add-prop "INSERT INTO server_prop (id, content, preload) VALUES (?, ?, ?) @@ -77,50 +77,47 @@ (assert (db/pool? (::db/pool params)) "expected valid database pool")) (defmethod ig/init-key ::props - [_ {:keys [::db/pool ::key] :as cfg}] + [_ {:keys [::key] :as cfg}] + (audit/submit cfg {:type "trigger" + :name "instance-start" + :props {:version (:full cf/version) + :flags (mapv name cf/flags) + :public-uri (str (cf/get :public-uri))}}) (db/tx-run! cfg (fn [{:keys [::db/conn]}] (db/xact-lock! conn 0) (when-not key - (l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate " - "all sessions on each restart, it is highly recommended setting up the " - "PENPOT_SECRET_KEY environment variable"))) + (l/wrn :hint (str "using autogenerated secret-key, it will change " + "on each restart and will invalidate " + "all sessions on each restart, it is highly " + "recommended setting up the " + "PENPOT_SECRET_KEY environment variable"))) (let [secret (or key (generate-random-key))] (-> (get-all-props conn) (assoc :secret-key secret) (assoc :tokens-key (keys/derive secret :salt "tokens")) - (update :instance-id handle-instance-id conn (db/read-only? pool))))))) - -(sm/register! ::props [:map-of :keyword ::sm/any]) - + (update :instance-id handle-instance-id conn)))))) (defmethod ig/init-key ::shared-keys [_ {:keys [::props] :as cfg}] (let [secret (get props :secret-key)] - (d/without-nils - {:exporter - (let [key (or (get cfg :exporter) - (-> (keys/derive secret :salt "exporter") - (bc/bytes->b64-str true)))] - (if (or (str/empty? key) - (str/blank? key)) - (do - (l/wrn :hint "exporter key is disabled because empty string found") - nil) - (do - (l/inf :hint "exporter key initialized" :key (d/obfuscate-string key)) - key))) + (reduce (fn [keys id] + (let [key (or (get cfg id) + (-> (keys/derive secret :salt (name id)) + (bc/bytes->b64-str true)))] + (if (or (str/empty? key) + (str/blank? key)) + (do + (l/wrn :id (name id) :hint "key is disabled because empty string found") + keys) + (do + (l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key)) + (assoc keys id key))))) + {} + [:exporter + :nitrate + :nexus]))) - :nitrate - (let [key (or (get cfg :nitrate) - (-> (keys/derive secret :salt "nitrate") - (bc/bytes->b64-str true)))] - (if (or (str/empty? key) - (str/blank? key)) - (do - (l/wrn :hint "nitrate key is disabled because empty string found") - nil) - (do - (l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key)) - key)))}))) +(sm/register! ::props [:map-of :keyword ::sm/any]) +(sm/register! ::shared-keys [:map-of :keyword ::sm/text]) diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index 476ec25f582..5c86bc485d0 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -57,9 +57,9 @@ (if (fs/exists? path) (io/input-stream path) - (let [resp (http/req! cfg - {:method :get :uri (:file-uri template)} - {:response-type :input-stream :sync? true})] + (let [resp (http/req cfg + {:method :get :uri (:file-uri template)} + {:response-type :input-stream :sync? true})] (when-not (= 200 (:status resp)) (ex/raise :type :internal :code :unexpected-status-code diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 30c8b403dcc..8e4ba92270c 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -553,14 +553,13 @@ (let [file-id (h/parse-uuid file-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-file" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props {:id file-id} - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-file!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-file" + :type "action" + :props {:id file-id} + :context {:triggered-by "srepl" + :cause "explicit call to delete-file!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) (assoc ::wrk/params {:object :file @@ -578,15 +577,12 @@ {:id file-id} {::db/remove-deleted false ::sql/columns [:id :name]})] - (audit/insert! system - {::audit/name "restore-file" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props file - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-file!"} - ::audit/tracked-at (ct/now)}) - + (audit/insert system + {:name "restore-file" + :type "action" + :props file + :context {:triggered-by "srepl" + :cause "explicit call to restore-file!"}}) (#'files/restore-file conn file-id)) :restored)))) @@ -597,14 +593,13 @@ (let [project-id (h/parse-uuid project-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-project" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props {:id project-id} - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-project!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-project" + :type "action" + :props {:id project-id} + :context {:triggered-by "srepl" + :cause "explicit call to delete-project!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) @@ -635,14 +630,12 @@ (when-let [project (db/get* system :project {:id project-id} {::db/remove-deleted false})] - (audit/insert! system - {::audit/name "restore-project" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props project - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-team!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-project" + :type "action" + :props project + :context {:triggered-by "srepl" + :cause "explicit call to restore-team!"}}) (restore-project* system project-id)))))) @@ -652,14 +645,13 @@ (let [team-id (h/parse-uuid team-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-team" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props {:id team-id} - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-profile!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-team" + :type "action" + :props {:id team-id} + :context {:triggered-by "srepl" + :cause "explicit call to delete-profile!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) @@ -695,14 +687,12 @@ {:id team-id} {::db/remove-deleted false}) (teams/decode-row))] - (audit/insert! system - {::audit/name "restore-team" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props team - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-team!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-team" + :type "action" + :props team + :context {:triggered-by "srepl" + :cause "explicit call to restore-team!"}}) (restore-team* system team-id)))))) @@ -712,13 +702,12 @@ (let [profile-id (h/parse-uuid profile-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-profile" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-profile!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-profile" + :type "action" + :context {:triggered-by "srepl" + :cause "explicit call to delete-profile!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) @@ -737,14 +726,12 @@ {:id profile-id} {::db/remove-deleted false}) (profile/decode-row))] - (audit/insert! system - {::audit/name "restore-profile" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props (audit/profile->props profile) - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-profile!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-profile" + :type "action" + :props (audit/profile->props profile) + :context {:triggered-by "srepl" + :cause "explicit call to restore-profile!"}}) (db/update! system :profile {:deleted-at nil} @@ -768,14 +755,14 @@ {::db/remove-deleted false}) (profile/decode-row))] (do - (audit/insert! system - {::audit/name "delete-profile" - ::audit/type "action" - ::audit/profile-id (:id profile) - ::audit/tracked-at deleted-at - ::audit/props (audit/profile->props profile) - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-profiles-in-bulk!"}}) + (audit/insert system + {:name "delete-profile" + :type "action" + :profile-id (:id profile) + :tracked-at deleted-at + :props (audit/profile->props profile) + :context {:triggered-by "srepl" + :cause "explicit call to delete-profiles-in-bulk!"}}) (wrk/invoke! (-> system (assoc ::wrk/task :delete-object) (assoc ::wrk/params {:object :profile diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index fa3e144ef9d..f00140d04ee 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -149,7 +149,7 @@ :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (let [deletion-delay (if (= bucket "tempfile") + (let [deletion-delay (if (= "tempfile" bucket) (ct/duration {:hours 2}) (cf/get-deletion-delay))] (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) @@ -213,8 +213,13 @@ [_ params] (assert (db/pool? (::db/pool params)) "expect valid storage")) +(defmethod ig/expand-key ::handler + [k v] + {k (merge {::min-age (ct/duration {:hours 2})} v)}) + (defmethod ig/init-key ::handler - [_ cfg] + [_ {:keys [::min-age] :as cfg}] (fn [_] - (process-touched! (assoc cfg ::timestamp (ct/now))))) + (let [threshold (ct/minus (ct/now) min-age)] + (process-touched! (assoc cfg ::timestamp threshold))))) diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index ef56e8a9b49..9322de70e60 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -30,21 +30,18 @@ java.nio.file.Path java.time.Duration java.util.Collection - java.util.Optional java.util.concurrent.atomic.AtomicLong + java.util.Optional org.reactivestreams.Subscriber software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider - software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.core.async.AsyncRequestBody software.amazon.awssdk.core.async.AsyncResponseTransformer software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody software.amazon.awssdk.core.client.config.ClientAsyncConfiguration + software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup software.amazon.awssdk.regions.Region - software.amazon.awssdk.services.s3.S3AsyncClient - software.amazon.awssdk.services.s3.S3AsyncClientBuilder - software.amazon.awssdk.services.s3.S3Configuration software.amazon.awssdk.services.s3.model.Delete software.amazon.awssdk.services.s3.model.DeleteObjectRequest software.amazon.awssdk.services.s3.model.DeleteObjectsRequest @@ -54,9 +51,12 @@ software.amazon.awssdk.services.s3.model.ObjectIdentifier software.amazon.awssdk.services.s3.model.PutObjectRequest software.amazon.awssdk.services.s3.model.S3Error - software.amazon.awssdk.services.s3.presigner.S3Presigner software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest - software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest)) + software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest + software.amazon.awssdk.services.s3.presigner.S3Presigner + software.amazon.awssdk.services.s3.S3AsyncClient + software.amazon.awssdk.services.s3.S3AsyncClientBuilder + software.amazon.awssdk.services.s3.S3Configuration)) (def ^:private max-retries "A maximum number of retries on internal operations" diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index aa2cae58e0c..797c3e60508 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -11,43 +11,27 @@ (:require [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.logging :as l] [app.config :as cf] [app.db :as db] [app.http.client :as http] [app.main :as-alias main] [app.setup :as-alias setup] + [app.util.blob :as blob] [app.util.json :as json] [integrant.core :as ig] [promesa.exec :as px])) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; IMPL -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- send! - [cfg data] - (let [request {:method :post - :uri (cf/get :telemetry-uri) - :headers {"content-type" "application/json"} - :body (json/encode-str data)} - response (http/req! cfg request)] - (when (> (:status response) 206) - (ex/raise :type :internal - :code :invalid-response - :response-status (:status response) - :response-body (:body response))))) - -(defn- get-subscriptions-newsletter-updates - [conn] +(defn- get-subscriptions + [cfg] (let [sql "SELECT email FROM profile where props->>'~:newsletter-updates' = 'true'"] - (->> (db/exec! conn [sql]) - (mapv :email)))) + (db/run! cfg (fn [{:keys [::db/conn]}] + (->> (db/exec! conn [sql]) + (mapv :email)))))) -(defn- get-subscriptions-newsletter-news - [conn] - (let [sql "SELECT email FROM profile where props->>'~:newsletter-news' = 'true'"] - (->> (db/exec! conn [sql]) - (mapv :email)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; LEGACY DATA COLLECTION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- get-num-teams [conn] @@ -161,8 +145,9 @@ (def ^:private sql:get-counters "SELECT name, count(*) AS count FROM audit_log - WHERE source = 'backend' - AND tracked_at >= date_trunc('day', now()) + WHERE source LIKE 'telemetry:%' + AND created_at >= date_trunc('day', now()) + AND created_at < date_trunc('day', now()) + interval '1 day' GROUP BY 1 ORDER BY 2 DESC") @@ -174,23 +159,13 @@ {:total-accomulated-events total :event-counters counters})) -(def ^:private sql:clean-counters - "DELETE FROM audit_log - WHERE ip_addr = '0.0.0.0'::inet -- we know this is from telemetry - AND tracked_at < (date_trunc('day', now()) - '1 day'::interval)") - -(defn- clean-counters-data! - [conn] - (when-not (contains? cf/flags :audit-log) - (db/exec-one! conn [sql:clean-counters]))) - -(defn- get-stats - [conn] +(defn- get-legacy-stats + [{:keys [::db/conn]}] (let [referer (if (cf/get :telemetry-with-taiga) "taiga" (cf/get :telemetry-referer))] (-> {:referer referer - :public-uri (cf/get :public-uri) + :public-uri (str (cf/get :public-uri)) :total-teams (get-num-teams conn) :total-projects (get-num-projects conn) :total-files (get-num-files conn) @@ -207,6 +182,124 @@ (get-action-counters conn)) (d/without-nils)))) +(defn- make-legacy-request + [cfg data] + (let [request {:method :post + :uri (cf/get :telemetry-uri) + :headers {"content-type" "application/json"} + :body (json/encode-str data)} + response (http/req cfg request {:skip-ssrf-check? true})] + (when (> (:status response) 206) + (ex/raise :type :internal + :code :invalid-response + :response-status (:status response) + :response-body (:body response))))) + +(defn- send-legacy-data + [{:keys [::setup/props] :as cfg} stats subs] + (let [data (cond-> {:type :telemetry-legacy-report + :version (:full cf/version) + :instance-id (:instance-id props)} + (some? stats) + (assoc :stats stats) + + (seq subs) + (assoc :subscriptions subs))] + + (make-legacy-request cfg data))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; AUDIT-EVENT BATCH (TELEMETRY MODE) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Telemetry events older than this are purged by the GC step so the +;; buffer stays bounded. +(def ^:private batch-size 10000) + +(def ^:private sql:gc-events + "DELETE FROM audit_log + WHERE source LIKE 'telemetry:%' + AND created_at < now() - interval '7 days'") + +(defn- gc-events + "Delete telemetry-mode events older than `telemetry-retention-days` + so that the buffer stays bounded." + [{:keys [::db/conn]}] + (let [result (db/exec-one! conn [sql:gc-events])] + (when (pos? (:next.jdbc/update-count result)) + (l/warn :hint "purged stale telemetry events" + :count (:next.jdbc/update-count result))))) + +(def ^:private sql:fetch-telemetry-events + "SELECT id, name, type, source, tracked_at, profile_id, props, context + FROM audit_log + WHERE source LIKE 'telemetry:%' + ORDER BY created_at ASC + LIMIT ?") + +(defn- row->event + [{:keys [name type source tracked-at profile-id props context]}] + (d/without-nils + {:name name + :type type + :source source + :tracked-at tracked-at + :profile-id profile-id + :props (or (some-> props db/decode-transit-pgobject) {}) + :context (or (some-> context db/decode-transit-pgobject) {})})) + +(defn- encode-batch + "Encode a sequence of event maps into a fressian+zstd base64 string + suitable for JSON transport." + ^String [events] + (blob/encode-str events {:version 4})) + +(defn send-event-batch + "Send a single batch of events to the telemetry endpoint. Returns + true on success." + [{:keys [::setup/props] :as cfg} batch] + (let [payload {:type :telemetry-events + :version (:full cf/version) + :instance-id (:instance-id props) + :events (encode-batch batch)} + request {:method :post + :uri (cf/get :telemetry-uri) + :headers {"content-type" "application/json"} + :body (json/encode-str payload)} + resp (http/req cfg request {:skip-ssrf-check? true})] + (if (<= (:status resp) 206) + true + (do + (l/warn :hint "telemetry event batch send failed" + :status (:status resp) + :body (:body resp)) + false)))) + +(defn- delete-sent-events + "Delete rows by their ids after a successful send." + [conn ids] + (let [arr (db/create-array conn "uuid" ids)] + (db/exec-one! conn ["DELETE FROM audit_log WHERE id = ANY(?)" arr]))) + +(defn- collect-and-send-audit-events + "Collect anonymous telemetry-mode audit events and ship them to the + telemetry endpoint in a loop. Each iteration fetches one page of + `batch-size` rows, encodes and sends them, then deletes the rows on + success. The loop stops as soon as a send returns false, leaving + remaining rows intact for the next run." + [{:keys [::db/conn] :as cfg}] + (loop [counter 1] + (when-let [rows (-> (db/exec! conn [sql:fetch-telemetry-events batch-size]) + (not-empty))] + (let [events (mapv row->event rows) + ids (mapv :id rows)] + (l/dbg :hint "shipping telemetry event batch" + :total (count events) + :batch counter) + (when (send-event-batch cfg events) + (delete-sent-events conn ids) + (recur (inc counter))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TASK ENTRY POINT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -218,46 +311,47 @@ (assert (some? (::setup/props params)) "expected setup props to be available")) (defmethod ig/init-key ::handler - [_ {:keys [::db/pool ::setup/props] :as cfg}] + [_ cfg] (fn [task] (let [params (:props task) send? (get params :send? true) enabled? (or (get params :enabled? false) - (contains? cf/flags :telemetry) - (cf/get :telemetry-enabled)) - - subs {:newsletter-updates (get-subscriptions-newsletter-updates pool) - :newsletter-news (get-subscriptions-newsletter-news pool)} - - data {:subscriptions subs - :version (:full cf/version) - :instance-id (:instance-id props)}] - - (when enabled? - (clean-counters-data! pool)) - - (cond - ;; If we have telemetry enabled, then proceed the normal - ;; operation. - enabled? - (let [data (merge data (get-stats pool))] - (when send? - (px/sleep (rand-int 10000)) - (send! cfg data)) - data) + (contains? cf/flags :telemetry)) + subs (get-subscriptions cfg)] + + + ;; If we have telemetry enabled, then proceed the normal + ;; operation sending legacy report + + (if enabled? + (when send? + (db/run! cfg gc-events) + ;; Randomize start time to avoid thundering herd when multiple + ;; instances restart at the same time. + (px/sleep (rand-int 10000)) + + (try + (let [stats (db/run! cfg get-legacy-stats)] + (send-legacy-data cfg stats subs)) + (catch Exception cause + (l/wrn :hint "unable to send legacy report" + :cause cause))) + + ;; Ship any anonymous audit-log events accumulated in + ;; telemetry mode (only when audit-log feature is off). + (when-not (contains? cf/flags :audit-log) + (try + (db/run! cfg collect-and-send-audit-events) + (catch Exception cause + (l/wrn :hint "unable to send events" + :cause cause))))) ;; If we have telemetry disabled, but there are users that are ;; explicitly checked the newsletter subscription on the ;; onboarding dialog or the profile section, then proceed to ;; send a limited telemetry data, that consists in the list of ;; subscribed emails and the running penpot version. - (or (seq (:newsletter-updates subs)) - (seq (:newsletter-news subs))) - (do - (when send? - (px/sleep (rand-int 10000)) - (send! cfg data)) - data) - - :else - data)))) + (when (and send? (seq subs)) + (px/sleep (rand-int 10000)) + (ex/ignoring + (send-legacy-data cfg nil subs))))))) diff --git a/backend/src/app/tasks/upload_session_gc.clj b/backend/src/app/tasks/upload_session_gc.clj new file mode 100644 index 00000000000..c733bbd64e3 --- /dev/null +++ b/backend/src/app/tasks/upload_session_gc.clj @@ -0,0 +1,41 @@ +;; 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 + +(ns app.tasks.upload-session-gc + "A maintenance task that deletes stalled (incomplete) upload sessions. + + An upload session is considered stalled when it was created more than + `max-age` ago without being completed (i.e. the session row still + exists because `assemble-chunks` was never called to clean it up). + The default max-age is 1 hour." + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.db :as db] + [integrant.core :as ig])) + +(def ^:private sql:delete-stalled-sessions + "DELETE FROM upload_session + WHERE created_at < ?::timestamptz") + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/expand-key ::handler + [k v] + {k (merge {::max-age (ct/duration {:hours 1})} v)}) + +(defmethod ig/init-key ::handler + [_ {:keys [::max-age] :as cfg}] + (fn [_] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [threshold (ct/minus (ct/now) max-age) + result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold]) + (db/get-update-count))] + (l/debug :hint "task finished" :deleted result) + {:deleted result}))))) diff --git a/backend/src/app/util/blob.clj b/backend/src/app/util/blob.clj index 6263e8e8787..1aa9b8fa341 100644 --- a/backend/src/app/util/blob.clj +++ b/backend/src/app/util/blob.clj @@ -19,6 +19,7 @@ java.io.DataOutputStream java.io.InputStream java.io.OutputStream + java.util.Base64 net.jpountz.lz4.LZ4Compressor net.jpountz.lz4.LZ4Factory net.jpountz.lz4.LZ4FastDecompressor @@ -49,6 +50,13 @@ 5 (encode-v5 data) (throw (ex-info "unsupported version" {:version version})))))) +(defn encode-str + "Encode data to a blob and return it as a URL-safe base64 string + (no padding). Accepts the same options as `encode`." + (^String [data] (encode-str data nil)) + (^String [data opts] + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts)))) + (defn decode "A function used for decode persisted blobs in the database." [^bytes data] @@ -63,6 +71,11 @@ 5 (decode-v5 data) (throw (ex-info "unsupported version" {:version version})))))) +(defn decode-str + "Decode a URL-safe base64 string produced by `encode-str` back to data." + [^String s] + (decode (.decode (Base64/getUrlDecoder) s))) + ;; --- IMPL (defn- encode-v1 diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj new file mode 100644 index 00000000000..676d95aea3c --- /dev/null +++ b/backend/src/app/util/nio.clj @@ -0,0 +1,91 @@ +;; 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 + +(ns app.util.nio + "NIO helpers for working with files and byte arrays. + + These are thin wrappers around java.nio that provide a + Clojure-idiomatic API. Candidates for porting to datoteka." + (:import + java.nio.ByteBuffer + java.nio.channels.FileChannel + java.nio.file.Files + java.nio.file.OpenOption + java.nio.file.Path + java.nio.file.StandardOpenOption)) + +(set! *warn-on-reflection* true) + +;; ---------------------------------------------------------------- +;; File operations (via java.nio.file.Files) +;; ---------------------------------------------------------------- + +(defn read-bytes + "Read all bytes from a file at `path`. Returns a byte array." + ^bytes [^Path path] + (Files/readAllBytes path)) + +(defn write-bytes + "Write `data` (byte array) to a file at `path`, replacing existing + content. Returns `path`." + [^Path path ^bytes data] + (Files/write path data ^"[Ljava.nio.file.OpenOption;" (into-array OpenOption [])) + path) + +(defn append-bytes + "Append `data` (byte array) to the end of the file at `path`. + Creates the file if it does not exist. Returns `path`." + [^Path path ^bytes data] + (Files/write path data + ^"[Ljava.nio.file.OpenOption;" + (into-array OpenOption + [StandardOpenOption/CREATE + StandardOpenOption/APPEND])) + path) + +;; ---------------------------------------------------------------- +;; FileChannel operations (internal API) +;; ---------------------------------------------------------------- + +(def ^:private read-write-opts + (into-array OpenOption + [StandardOpenOption/READ StandardOpenOption/WRITE])) + +(defn open-channel + "Open a FileChannel for read/write on the given path." + ^FileChannel [^Path path] + (FileChannel/open path read-write-opts)) + +(defn channel-size + "Return the size of the file backed by the channel." + ^long [^FileChannel channel] + (.size channel)) + +(defn read-at + "Read `length` bytes from `channel` starting at `position` into a + new byte array. Returns the byte array. + Loops until the ByteBuffer is fully populated to guard against OS + partial reads, which would otherwise cause BufferUnderflowException + when copying from the buffer into the result array." + ^bytes [^FileChannel channel ^long position ^long length] + (let [buf (ByteBuffer/allocate (int length))] + (.position channel position) + (loop [] + (when (.hasRemaining buf) + (let [n (.read channel buf)] + (when (pos? n) + (recur))))) + (.flip buf) + (let [remaining (.remaining buf) + arr (byte-array remaining)] + (.get buf arr) + arr))) + +(defn truncate + "Truncate the file to the given size. Returns the channel." + [^FileChannel channel ^long size] + (.truncate channel size) + channel) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj new file mode 100644 index 00000000000..5348f1eaf75 --- /dev/null +++ b/backend/src/app/util/ssrf.clj @@ -0,0 +1,229 @@ +;; 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 + +(ns app.util.ssrf + "URL/host validation to prevent Server-Side Request Forgery." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.config :as cf] + [cuerdas.core :as str]) + (:import + com.google.common.net.InetAddresses + java.net.InetAddress + java.net.UnknownHostException + java.net.URI)) + +(def ^:private allowed-schemes + #{"http" "https"}) + +(def ^:private cloud-metadata-ips + "Exact IP addresses for cloud metadata services." + #{"169.254.169.254" + "fd00:ec2::254"}) + +(def ^:private extra-blocked-ranges + "CIDR ranges not covered by standard JDK InetAddress predicates. + Each entry is [base-address prefix-length]." + ;; Carrier-grade NAT + [[100 64 0 0 10] + ;; RFC 6890 / documentation / reserved + [192 0 0 0 24] + [192 0 2 0 24] + [198 18 0 0 15] + [198 51 100 0 24] + [203 0 113 0 24] + ;; Reserved / future-use (broadcast and above) + [240 0 0 0 4]]) + +(defn- ip4-to-long + "Convert a 4-element byte array (IPv4) to a 32-bit long." + ^long [^bytes bs] + (bit-or (bit-shift-left (bit-and (aget bs 0) 0xFF) 24) + (bit-shift-left (bit-and (aget bs 1) 0xFF) 16) + (bit-shift-left (bit-and (aget bs 2) 0xFF) 8) + (bit-and (aget bs 3) 0xFF))) + +(defn- prefix-mask + "Return a 32-bit mask for the given prefix length." + ^long [^long prefix-len] + (if (zero? prefix-len) + 0 + (bit-shift-left (unsigned-bit-shift-right 0xFFFFFFFF (- 32 prefix-len)) (- 32 prefix-len)))) + +(defn- in-cidr4? + "Check if an IPv4 address (as byte array) falls within a CIDR range + specified as [a b c d prefix-len]." + [^bytes addr [^long a ^long b ^long c ^long d ^long prefix-len]] + (let [base (bit-or (bit-shift-left (bit-and a 0xFF) 24) + (bit-shift-left (bit-and b 0xFF) 16) + (bit-shift-left (bit-and c 0xFF) 8) + (bit-and d 0xFF)) + mask (prefix-mask prefix-len) + ip-val (ip4-to-long addr)] + (= (bit-and ip-val mask) (bit-and base mask)))) + +(defn- parse-cidr* + "Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. Throws on invalid input." + [^String cidr] + (let [parts (str/split cidr #"/" 2) + prefix-len (when (= 2 (count parts)) + (parse-long (nth parts 1)))] + (when-not prefix-len + (ex/raise :type :internal + :code :invalid-cidr + :hint (str "invalid CIDR notation: " cidr))) + (let [octets (str/split (first parts) #"\.")] + (when (not= 4 (count octets)) + (ex/raise :type :internal + :code :invalid-cidr + :hint (str "invalid CIDR notation (expected IPv4): " cidr))) + (let [[a b c d] (map parse-long octets)] + (when (or (nil? a) (nil? b) (nil? c) (nil? d) + (not (<= 0 a 255)) (not (<= 0 b 255)) + (not (<= 0 c 255)) (not (<= 0 d 255)) + (not (<= 0 prefix-len 32))) + (ex/raise :type :internal + :code :invalid-cidr + :hint (str "invalid CIDR notation: " cidr))) + [a b c d prefix-len])))) + +(defn parse-cidr + "Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. + Returns nil and logs a warning on invalid input." + [^String cidr] + (try + (parse-cidr* cidr) + (catch Exception _ + (l/warn :hint "ignoring invalid CIDR" :cidr cidr) + nil))) + +(defonce ^:dynamic extra-blocked-cidrs + (into #{} (keep parse-cidr) (cf/get :ssrf-extra-blocked-cidrs #{}))) + +(defn- ipv6-ula? + "Check if an IPv6 address is in the Unique Local Address range (fc00::/7)." + [^InetAddress addr] + (let [bs (.getAddress addr)] + (and (>= (alength bs) 16) + (= (bit-and (aget bs 0) 0xFE) 0xFC)))) + +(defn- ipv4-mapped-loopback? + "Check if an IPv4-mapped IPv6 address maps to loopback (::ffff:127.x.x.x)." + [^InetAddress addr] + (let [bs (.getAddress addr)] + (and (= (alength bs) 16) + ;; Check it's an IPv4-mapped address: ::ffff:x.x.x.x + (= (aget bs 10) (byte -1)) ;; 0xFF + (= (aget bs 11) (byte -1)) ;; 0xFF + ;; Check the embedded IPv4 is loopback (127.x.x.x) + (= (bit-and (aget bs 12) 0xFF) 127)))) + +(defn- blocked-address? + "Check if an InetAddress should be blocked. Returns true if blocked." + [^InetAddress addr] + (or + (.isAnyLocalAddress addr) ;; 0.0.0.0 or :: + (.isLoopbackAddress addr) ;; 127/8 or ::1 + (.isLinkLocalAddress addr) ;; 169.254/16 or fe80::/10 + (.isSiteLocalAddress addr) ;; 10/8, 172.16/12, 192.168/16 + (.isMulticastAddress addr) + + ;; IPv6 ULA (fc00::/7) + (ipv6-ula? addr) + + ;; IPv4-mapped loopback + (ipv4-mapped-loopback? addr) + + ;; Cloud metadata IPs (exact match) + (contains? cloud-metadata-ips (.getHostAddress addr)) + + ;; Extra blocked CIDRs (IPv4 only) + (let [bs (.getAddress addr)] + (if (= (alength bs) 4) + (or (some #(in-cidr4? bs %) extra-blocked-ranges) + (some #(in-cidr4? bs %) extra-blocked-cidrs)) + false)))) + +(defn resolve-host + "Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName + so it can be stubbed in tests." + [^String hostname] + (try + (InetAddress/getAllByName hostname) + (catch UnknownHostException _ + nil))) + +(defn validate-uri + "Validates `uri-or-string`: + - scheme must be http or https, + - host must resolve to at least one address, and + - **every** resolved address must NOT be in the blocklist + (loopback, link-local, site-local, multicast, any-local, + cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped + IPv6 of any blocked IPv4, plus operator-supplied CIDRs). + When the host is an IP literal (decimal/octal/hex/IPv6) it is + normalized via `com.google.common.net.InetAddresses` before the + check. + Hosts in `:ssrf-allowed-hosts` (case-insensitive exact match) bypass + the IP check. + Throws `ex/raise :type :validation :code :ssrf-blocked-target` with + a hint that does NOT echo the resolved IP (avoid info leak)." + [uri-or-string] + (let [uri (if (instance? URI uri-or-string) + uri-or-string + (URI. (str uri-or-string))) + scheme (.getScheme uri) + host (.getHost uri)] + + ;; Validate scheme + (when (or (nil? scheme) + (not (contains? allowed-schemes (str/lower scheme)))) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "url scheme is not allowed")) + + ;; Validate host presence + (when (or (nil? host) (str/blank? host)) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "url host is missing")) + + ;; Check allowlist + (let [allowed-hosts (cf/get :ssrf-allowed-hosts #{}) + host-lower (str/lower host)] + + (when-not (contains? allowed-hosts host-lower) + ;; Normalize the host: if it looks like an IP literal, normalize it + ;; via Guava to catch decimal/octal/hex encodings + (let [normalized (if (InetAddresses/isInetAddress host) + (InetAddresses/forString host) + nil) + host-to-resolve (if normalized + (.getHostAddress ^InetAddress normalized) + host) + addresses (resolve-host host-to-resolve)] + + (when (or (nil? addresses) (zero? (alength addresses))) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "url host could not be resolved")) + + ;; All-or-nothing: if ANY resolved address is blocked, reject + (when (some blocked-address? (seq addresses)) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "url target is not allowed"))))) + (str uri))) + +(defn safe-url? + "Predicate version of `validate-uri`. Returns `true` if safe." + [uri-or-string] + (try + (validate-uri uri-or-string) + true + (catch Exception _ + false))) diff --git a/backend/test/backend_tests/config_session_cookie_test.clj b/backend/test/backend_tests/config_session_cookie_test.clj new file mode 100644 index 00000000000..8e047dea69a --- /dev/null +++ b/backend/test/backend_tests/config_session_cookie_test.clj @@ -0,0 +1,50 @@ +;; 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 + +(ns backend-tests.config-session-cookie-test + "Auth-token cookie max-age / renewal-max-age: defaults, env override (merge), session renewal threshold." + (:require + [app.common.time :as ct] + [app.config :as cf] + [app.http.session :as session] + [clojure.test :as t] + [environ.core :refer [env]])) + +(t/deftest default-map-includes-unified-session-durations + (let [max-age (:auth-token-cookie-max-age cf/default) + renewal (:auth-token-cookie-renewal-max-age cf/default)] + (t/is (ct/duration? max-age)) + (t/is (ct/duration? renewal)) + (t/is (= (ct/duration {:days 7}) max-age)) + (t/is (= (ct/duration {:hours 1}) renewal)))) + +(t/deftest read-config-uses-defaults-when-env-prefix-has-no-keys + ;; No process env uses this prefix; read-env is empty → merged config is `default` only. + (let [cfg (cf/read-config :prefix "penpotzzzzunused" + :default cf/default)] + (t/is (= (ct/duration {:days 7}) (:auth-token-cookie-max-age cfg))) + (t/is (= (ct/duration {:hours 1}) (:auth-token-cookie-renewal-max-age cfg))))) + +(t/deftest read-config-env-overrides-default + (let [extra {:penpot-auth-token-cookie-max-age "172800s" + :penpot-auth-token-cookie-renewal-max-age "7200s"} + merged (merge env extra)] + (with-redefs [env merged] + (let [cfg (cf/read-config :default cf/default)] + (t/is (= (ct/duration {:days 2}) (:auth-token-cookie-max-age cfg))) + (t/is (= (ct/duration {:hours 2}) (:auth-token-cookie-renewal-max-age cfg))))))) + +(t/deftest renew-session-respects-configured-renewal-max-age + (binding [cf/config (assoc cf/config + :auth-token-cookie-renewal-max-age (ct/duration {:minutes 5}))] + (let [now (ct/now) + recent (ct/minus now (ct/duration {:minutes 1})) + stale (ct/minus now (ct/duration {:minutes 10}))] + (t/is (not (#'session/renew-session? {:id (random-uuid) :modified-at recent}))) + (t/is (#'session/renew-session? {:id (random-uuid) :modified-at stale}))))) + +(t/deftest renew-session-true-for-legacy-string-session-id + (t/is (#'session/renew-session? {:id "legacy-string-session" :modified-at (ct/now)}))) diff --git a/backend/test/backend_tests/email_blacklist_test.clj b/backend/test/backend_tests/email_blacklist_test.clj new file mode 100644 index 00000000000..5cc043fe32c --- /dev/null +++ b/backend/test/backend_tests/email_blacklist_test.clj @@ -0,0 +1,34 @@ +;; 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 + +(ns backend-tests.email-blacklist-test + (:require + [app.email :as-alias email] + [app.email.blacklist :as blacklist] + [clojure.test :as t])) + +(def ^:private cfg + {::email/blacklist #{"somedomain.com" "spam.net"}}) + +(t/deftest test-exact-domain-match + (t/is (true? (blacklist/contains? cfg "user@somedomain.com"))) + (t/is (true? (blacklist/contains? cfg "user@spam.net"))) + (t/is (false? (blacklist/contains? cfg "user@legit.com")))) + +(t/deftest test-subdomain-match + (t/is (true? (blacklist/contains? cfg "user@sub.somedomain.com"))) + (t/is (true? (blacklist/contains? cfg "user@a.b.somedomain.com"))) + ;; A domain that merely contains the blacklisted string but is not a + ;; subdomain must NOT be rejected. + (t/is (false? (blacklist/contains? cfg "user@notsomedomain.com")))) + +(t/deftest test-case-insensitive + (t/is (true? (blacklist/contains? cfg "user@SOMEDOMAIN.COM"))) + (t/is (true? (blacklist/contains? cfg "user@Sub.SomeDomain.Com")))) + +(t/deftest test-non-blacklisted-domain + (t/is (false? (blacklist/contains? cfg "user@example.com"))) + (t/is (false? (blacklist/contains? cfg "user@sub.legit.com")))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 8ddb3448a23..73d22ce7230 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -64,7 +64,8 @@ {:database-uri "postgresql://postgres/penpot_test" :redis-uri "redis://redis/1" :auto-file-snapshot-every 1 - :file-data-backend "db"}) + :file-data-backend "db" + :default-email-domain "askii.ai"}) (def config (cf/read-config :prefix "penpot-test" @@ -83,7 +84,7 @@ [next] (with-redefs [app.config/flags (flags/parse flags/default default-flags) app.config/config config - app.loggers.audit/submit! (constantly nil) + app.loggers.audit/submit (constantly nil) app.auth/derive-password identity app.auth/verify-password (fn [a b] {:valid (= a b)}) app.common.features/get-enabled-features (fn [& _] app.common.features/supported-features)] diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index b4fa5062d57..9b498c7437f 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -7,14 +7,17 @@ (ns backend-tests.http-middleware-test (:require [app.common.time :as ct] + [app.config :as cf] [app.db :as db] [app.http :as-alias http] - [app.http.access-token] + [app.http.access-token :as access-token] + [app.http.auth-request] [app.http.middleware :as mw] [app.http.session :as session] [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.commands.access-token] + [app.rpc.commands.profile :as profile] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.test :as t] @@ -102,7 +105,7 @@ (t/deftest access-token-authz (let [profile (th/create-profile* 1) - token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil) + token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil) handler (#'app.http.access-token/wrap-authz identity th/*system*)] (let [response (handler nil)] @@ -136,3 +139,291 @@ (t/is (= "penpot" (:aud claims))) (t/is (= (:id session) (:sid claims))) (t/is (= (:id profile) (:uid claims))))) + +(t/deftest session-authz-does-not-renew-on-error-response + ;; The renewal guard MUST also step aside on error responses. + ;; Without this, a 403 produced by wrap-authz (proxy identity + ;; mismatch with a blocked/inactive incoming profile) would still + ;; cause alice's session cookie to be renewed on the way out — + ;; extending her session lifetime on the very denial that signaled + ;; her identity is no longer valid upstream. + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 91) + t0 (ct/inst "2025-01-01T00:00:00Z") + t1 (ct/plus t0 (ct/duration {:seconds 2})) + threshold (ct/duration {:seconds 1}) + handler (-> (fn [_req] {::yres/status 403}) + (#'session/wrap-authz {::session/manager manager}) + (#'mw/wrap-auth {:bearer (partial session/decode-token cfg) + :cookie (partial session/decode-token cfg)})) + token (binding [ct/*clock* (ct/fixed-clock t0)] + (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "user agent"}) + (#'session/assign-token cfg))) + response (binding [cf/config (assoc cf/config :auth-token-cookie-renewal-max-age threshold) + ct/*clock* (ct/fixed-clock t1)] + (handler (->DummyRequest {} {"auth-token" (:token token)})))] + (t/is (= 403 (::yres/status response))) + (t/is (not (contains? (::yres/cookies response) "auth-token"))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; X-Auth-Request middleware tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-xauth-cfg + [] + (assoc th/*system* ::session/manager (session/inmemory-manager))) + +(t/deftest x-auth-request-no-email-header + (let [captured (volatile! nil) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + (make-xauth-cfg))] + (handler (->DummyRequest {} {})) + (t/is (nil? (::session/profile-id @captured))))) + +(t/deftest x-auth-request-preserves-session-when-header-email-unresolvable + ;; When wrap-session has resolved alice's profile-id from the local + ;; auth-token cookie AND the proxy header email cannot be resolved to + ;; a profile (unknown user + auto-register off), the existing session + ;; passes through unchanged. The middleware only re-keys when it has + ;; a *real* alternative identity to switch to. + (let [profile-id (random-uuid) + handler (#'app.http.auth-request/wrap-authz + (fn [req] req) + (make-xauth-cfg)) + request (-> (->DummyRequest {"x-auth-request-email" "user@example.com"} {}) + (assoc ::session/profile-id profile-id)) + result (handler request)] + (t/is (= profile-id (::session/profile-id result))))) + +(t/deftest x-auth-request-rekeys-when-session-identity-differs + ;; Repro of the QA-reported session-sharing bug: alice's auth-token + ;; cookie persists on Penpot's subdomain after the portal "log out of + ;; all apps"; bob then logs in upstream. wrap-session resolves alice's + ;; profile-id from the old cookie, but oauth2-proxy is forwarding + ;; bob's email. The middleware MUST re-key to bob. + (let [alice (th/create-profile* 1 {:is-active true}) + bob (th/create-profile* 2 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {}) + (assoc ::session/profile-id (:id alice))) + response (handler request)] + ;; Downstream handler sees bob's profile-id, not alice's. + (t/is (= (:id bob) (::session/profile-id @captured))) + ;; A fresh auth-token cookie is issued for bob's session. + (t/is (contains? (::yres/cookies response) "auth-token")))) + +(t/deftest x-auth-request-no-rekey-when-session-matches-header + ;; Steady-state guard: the browser session matches the proxy identity. + ;; No re-key, no new cookie. Without this, every authenticated request + ;; would mint a fresh cookie. + (let [profile (th/create-profile* 1 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email profile)} {}) + (assoc ::session/profile-id (:id profile))) + response (handler request)] + (t/is (= (:id profile) (::session/profile-id @captured))) + (t/is (not (contains? (::yres/cookies response) "auth-token"))))) + +(t/deftest x-auth-request-rekey-clears-stale-auth-data + ;; The re-keyed request must have ::http/auth-data removed before the + ;; inner handler runs. errors.clj logs auth-data.claims.uid as + ;; :request/profile-id and rpc/helpers exposes the map to RPC + ;; handlers via get-auth-data — both would otherwise see alice's UID + ;; after we re-keyed to bob, leaking the stale identity across the + ;; boundary the middleware thinks it has crossed. + (let [alice (th/create-profile* 1 {:is-active true}) + bob (th/create-profile* 2 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {}) + (assoc ::session/profile-id (:id alice)) + (assoc ::http/auth-data {:type :cookie + :claims {:uid (:id alice) + :sid (random-uuid)}}))] + (handler request) + (t/is (= (:id bob) (::session/profile-id @captured))) + (t/is (nil? (::http/auth-data @captured))))) + +(t/deftest x-auth-request-rekey-clears-stale-session-map + ;; ::session/session is read indirectly by session/get-session, which + ;; the update-profile-password RPC calls via invalidate-others. If + ;; alice's stale session map survives the re-key to bob, a password- + ;; change made on this request would invalidate alice's other + ;; sessions instead of bob's. Surfaced by Copilot review on PR #22. + (let [alice (th/create-profile* 1 {:is-active true}) + bob (th/create-profile* 2 {:is-active true}) + alice-session {:id (random-uuid) + :profile-id (:id alice) + :user-agent "alice's ua"} + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {}) + (assoc ::session/profile-id (:id alice)) + (assoc ::session/session alice-session))] + (handler request) + (t/is (= (:id bob) (::session/profile-id @captured))) + (t/is (nil? (::session/session @captured))))) + +(t/deftest session-authz-renewal-does-not-overwrite-rekeyed-cookie + ;; Integration guard: session/wrap-authz runs AFTER wrap-authz on the + ;; way out and would normally renew the incoming session cookie. If + ;; wrap-authz has already issued a fresh auth-token cookie for the + ;; re-keyed identity (bob), the renewal MUST step aside — otherwise + ;; alice's renewed cookie clobbers bob's fresh one and the re-key is + ;; silently undone. + ;; + ;; This locks in the (not (contains? response cookies "auth-token")) + ;; guard added to session.clj. A future refactor that removes it + ;; fails this test. + (let [alice (th/create-profile* 91 {:is-active true}) + bob (th/create-profile* 92 {:is-active true}) + cfg (make-xauth-cfg) + t0 (ct/inst "2025-01-01T00:00:00Z") + t1 (ct/plus t0 (ct/duration {:seconds 2})) + ;; Lower than (t1 - t0) so the seeded cookie is always due for renewal. + renewal-threshold (ct/duration {:seconds 1}) + middleware (-> (fn [req] {::yres/status 200 + :seen-profile-id (::session/profile-id req)}) + (#'app.http.auth-request/wrap-authz cfg) + (#'session/wrap-authz cfg) + (#'mw/wrap-auth {:bearer (partial session/decode-token cfg) + :cookie (partial session/decode-token cfg)})) + seeded-token (binding [ct/*clock* (ct/fixed-clock t0)] + (get-in ((session/create-fn cfg alice) + (->DummyRequest {} {}) + {::yres/status 200}) + [::yres/cookies "auth-token" :value])) + response (binding [cf/config (assoc cf/config + :auth-token-cookie-renewal-max-age + renewal-threshold) + ct/*clock* (ct/fixed-clock t1)] + (middleware (->DummyRequest {"x-auth-request-email" (:email bob)} + {"auth-token" seeded-token}))) + rekeyed-token (get-in response [::yres/cookies "auth-token" :value]) + followup (middleware (->DummyRequest {} {"auth-token" rekeyed-token}))] + ;; Cookie was re-keyed (not just renewed): the new token differs. + (t/is (some? rekeyed-token)) + (t/is (not= seeded-token rekeyed-token)) + ;; This request's inner handler ran as bob, not alice. + (t/is (= (:id bob) (:seen-profile-id response))) + ;; The re-keyed token, decoded on a follow-up request, still resolves to bob. + ;; If renewal had clobbered the response cookie with a renewed alice token, + ;; this would resolve to alice instead. + (t/is (= (:id bob) (:seen-profile-id followup))))) + +(t/deftest x-auth-request-skips-when-access-token-present + (let [profile-id (random-uuid) + handler (#'app.http.auth-request/wrap-authz + (fn [req] req) + (make-xauth-cfg)) + request (-> (->DummyRequest {"x-auth-request-email" "user@example.com"} {}) + (assoc ::access-token/profile-id profile-id)) + result (handler request)] + (t/is (= profile-id (::access-token/profile-id result))))) + +(t/deftest x-auth-request-authenticates-existing-active-profile + (let [profile (th/create-profile* 1 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + response (handler (->DummyRequest {"x-auth-request-email" (:email profile)} {}))] + ;; The profile-id must be injected into the request seen by the downstream handler + (t/is (= (:id profile) (::session/profile-id @captured))) + ;; A session cookie must be set on the response + (t/is (contains? (::yres/cookies response) "auth-token")))) + +(t/deftest x-auth-request-blocked-profile-returns-403 + (let [profile (th/create-profile* 2 {:is-active true}) + _ (th/db-update! :profile {:is-blocked true} {:id (:id profile)}) + handler (#'app.http.auth-request/wrap-authz + (fn [_] {::yres/status 200}) + (make-xauth-cfg)) + response (handler (->DummyRequest {"x-auth-request-email" (:email profile)} {}))] + (t/is (= 403 (::yres/status response))))) + +(t/deftest x-auth-request-inactive-profile-returns-403 + (let [profile (th/create-profile* 3 {:is-active false}) + handler (#'app.http.auth-request/wrap-authz + (fn [_] {::yres/status 200}) + (make-xauth-cfg)) + response (handler (->DummyRequest {"x-auth-request-email" (:email profile)} {}))] + (t/is (= 403 (::yres/status response))))) + +(t/deftest x-auth-request-unknown-email-no-autoregister + (let [captured (volatile! nil) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + (make-xauth-cfg))] + (handler (->DummyRequest {"x-auth-request-email" "nobody@example.com"} {})) + (t/is (nil? (::session/profile-id @captured))))) + +(t/deftest x-auth-request-auto-register-creates-active-profile + (binding [cf/flags (conj cf/flags :x-auth-request-auto-register)] + (let [email "newuser@example.com" + fullname "New User" + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + response (handler (->DummyRequest {"x-auth-request-email" email + "x-auth-request-user" fullname} {}))] + ;; Profile must be injected into the downstream request + (t/is (uuid? (::session/profile-id @captured))) + ;; A session cookie must be set so the browser is authenticated + (t/is (contains? (::yres/cookies response) "auth-token")) + ;; The created profile must be active and match the forwarded email + (let [profile (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (profile/get-profile-by-email conn email)))] + (t/is (some? profile)) + (t/is (true? (:is-active profile))) + (t/is (= (::session/profile-id @captured) (:id profile))))))) + +(t/deftest x-auth-request-auto-register-joins-named-smb-team + (let [orig-get cf/get] + (binding [cf/flags (conj cf/flags :x-auth-request-auto-register) + cf/get (fn + ([k] (if (= k :smb-default-workspace-name) + "team1" + (orig-get k))) + ([k d] (if (= k :smb-default-workspace-name) + "team1" + (orig-get k d))))] + (let [owner (th/create-profile* 1 {:is-active true}) + ;; Matches :smb-default-workspace-name "team1" from create-team* default name. + _ (th/create-team* 1 {:profile-id (:id owner)}) + email "xauth-autojoin@example.com" + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + _ (handler (->DummyRequest {"x-auth-request-email" email + "x-auth-request-user" "Shared Team Join"} {})) + profile (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (profile/get-profile-by-email conn email))) + rels (db/query th/*pool* :team-profile-rel {:profile-id (:id profile)})] + (t/is (uuid? (:id profile))) + (t/is (some #(not= (:team-id %) (:default-team-id profile)) rels) + "profile should have a membership on the provisioned SMB (shared) team"))))) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index c34df715436..d77b3b6be0e 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -41,7 +41,7 @@ (t/is (nil? res))))) (t/deftest run-webhook-handler-1 - (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) whk (th/create-webhook* {:team-id (:default-team-id prof)}) evt {:type "command" @@ -63,7 +63,7 @@ (t/is (nil? (:error-code whk'))))))) (t/deftest run-webhook-handler-2 - (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 400}}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 400}}] (let [prof (th/create-profile* 1 {:is-active true}) whk (th/create-webhook* {:team-id (:default-team-id prof)}) evt {:type "command" diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj new file mode 100644 index 00000000000..0f8629a029b --- /dev/null +++ b/backend/test/backend_tests/media_sanitize_test.clj @@ -0,0 +1,501 @@ +;; 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 + +(ns backend-tests.media-sanitize-test + (:require + [app.media.sanitize :as sanitize] + [app.storage.tmp :as tmp] + [app.util.nio :as nio] + [clojure.test :as t] + [datoteka.fs :as fs] + [datoteka.io :as io])) + +(defn- resource-path + "Return a URL to a test resource file." + [name] + (io/resource (str "backend_tests/test_files/" name))) + +(defn- copy-resource-to-tempfile + "Copy a test resource file to a tempfile and return the Path." + [resource-name suffix] + (tmp/tempfile-from (resource-path resource-name) :prefix "test-real-" :suffix suffix)) + +;; ---------------------------------------------------------------- +;; Crafted test data +;; ---------------------------------------------------------------- + +;; PNG test data +(def ^:private png-signature + (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A])) + +(def ^:private png-iend-chunk + (byte-array [0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82])) + +(def ^:private png-ihdr-chunk + (byte-array [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])) + +(defn- make-png [^bytes extra-bytes] + (let [parts (if extra-bytes + [png-signature png-ihdr-chunk png-iend-chunk extra-bytes] + [png-signature png-ihdr-chunk png-iend-chunk]) + total (reduce + 0 (map alength parts)) + result (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 result @offset (alength part)) + (vswap! offset + (alength part))) + result)) + +;; JPEG test data +(def ^:private jpeg-soi (byte-array [0xFF 0xD8])) +(def ^:private jpeg-eoi (byte-array [0xFF 0xD9])) + +(defn- make-jpeg [^bytes extra-bytes] + (let [parts (if extra-bytes + [jpeg-soi jpeg-eoi extra-bytes] + [jpeg-soi jpeg-eoi]) + total (reduce + 0 (map alength parts)) + result (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 result @offset (alength part)) + (vswap! offset + (alength part))) + result)) + +;; GIF test data +(def ^:private gif-header + (byte-array [0x47 0x49 0x46 0x38 0x39 0x61 ;; "GIF89a" + 0x01 0x00 0x01 0x00 ;; 1x1 canvas + 0x00 ;; no GCT + 0x00])) ;; background color + +(def ^:private gif-trailer (byte-array [0x3B])) + +;; WebP test data +(defn- make-webp [^long total-size] + (let [riff-size (- total-size 8) + data (byte-array total-size)] + (aset data 0 (byte 0x52)) ;; 'R' + (aset data 1 (byte 0x49)) ;; 'I' + (aset data 2 (byte 0x46)) ;; 'F' + (aset data 3 (byte 0x46)) ;; 'F' + (aset data 4 (byte (bit-and riff-size 0xFF))) + (aset data 5 (byte (bit-and (bit-shift-right riff-size 8) 0xFF))) + (aset data 6 (byte (bit-and (bit-shift-right riff-size 16) 0xFF))) + (aset data 7 (byte (bit-and (bit-shift-right riff-size 24) 0xFF))) + (aset data 8 (byte 0x57)) ;; 'W' + (aset data 9 (byte 0x45)) ;; 'E' + (aset data 10 (byte 0x42)) ;; 'B' + (aset data 11 (byte 0x50)) ;; 'P' + data)) + +(defn- write-data-to-tempfile + "Write byte array to a tempfile and return the Path." + [^bytes data suffix] + (let [path (tmp/tempfile :prefix "test-sanitize." :suffix suffix)] + (nio/write-bytes path data) + path)) + +;; ---------------------------------------------------------------- +;; Tests with crafted data +;; ---------------------------------------------------------------- + +(t/deftest png-with-appended-secret-truncated + (let [secret (.getBytes "SECRET_DATA_HERE") + data (make-png secret) + path (write-data-to-tempfile data ".png") + _ (t/is (= (alength data) (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= new-size (+ (alength png-signature) + (alength png-ihdr-chunk) + (alength png-iend-chunk)))) + (t/is (= new-size (alength (nio/read-bytes path)))) + (let [expected (make-png nil) + actual (nio/read-bytes path)] + (t/is (java.util.Arrays/equals expected actual))))) + +(t/deftest png-clean-not-truncated + (let [data (make-png nil) + path (write-data-to-tempfile data ".png")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/png"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest jpeg-with-appended-secret-truncated + (let [secret (.getBytes "\u0000\u0000SECRET") + data (make-jpeg secret) + path (write-data-to-tempfile data ".jpg") + _ (t/is (= (alength data) (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= new-size (+ (alength jpeg-soi) (alength jpeg-eoi)))) + (let [expected (make-jpeg nil) + actual (nio/read-bytes path)] + (t/is (java.util.Arrays/equals expected actual))))) + +(t/deftest jpeg-clean-not-truncated + (let [data (make-jpeg nil) + path (write-data-to-tempfile data ".jpg")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/jpeg"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest gif-trailer-already-correct + (let [parts [gif-header gif-trailer] + total (reduce + 0 (map alength parts)) + data (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 data @offset (alength part)) + (vswap! offset + (alength part))) + (let [path (write-data-to-tempfile data ".gif")] + (t/is (= total (sanitize/truncate-after-eof path "image/gif"))) + (t/is (= total (alength (nio/read-bytes path))))))) + +(t/deftest webp-declared-size-honored + (let [total-size 24 + data (make-webp total-size) + extra (byte-array 10 (byte 0x42)) + full-data (byte-array (+ total-size 10))] + (System/arraycopy data 0 full-data 0 total-size) + (System/arraycopy extra 0 full-data total-size 10) + (let [path (write-data-to-tempfile full-data ".webp")] + (t/is (= total-size (sanitize/truncate-after-eof path "image/webp"))) + (t/is (= total-size (alength (nio/read-bytes path))))))) + +(t/deftest webp-clean-not-truncated + (let [data (make-webp 24) + path (write-data-to-tempfile data ".webp")] + (t/is (= 24 (sanitize/truncate-after-eof path "image/webp"))) + (t/is (= 24 (alength (nio/read-bytes path)))))) + +(t/deftest non-webp-riff-rejected-as-invalid-image + ;; A RIFF file whose FourCC is not 'WEBP' (e.g. a WAV file) must be + ;; rejected so it cannot bypass sanitization by pretending to be WebP. + (let [data (byte-array 24)] + ;; Write RIFF magic + (aset data 0 (byte 0x52)) ;; 'R' + (aset data 1 (byte 0x49)) ;; 'I' + (aset data 2 (byte 0x46)) ;; 'F' + (aset data 3 (byte 0x46)) ;; 'F' + ;; RIFF size = 16 (total 24 - 8) + (aset data 4 (byte 16)) + ;; FourCC = 'WAVE' (not 'WEBP') + (aset data 8 (byte 0x57)) ;; 'W' + (aset data 9 (byte 0x41)) ;; 'A' + (aset data 10 (byte 0x56)) ;; 'V' + (aset data 11 (byte 0x45)) ;; 'E' + (let [path (write-data-to-tempfile data ".webp")] + (try + (sanitize/truncate-after-eof path "image/webp") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e))))))))) + +(t/deftest svg-is-no-op + (let [data (.getBytes "<svg><rect/></svg>") + path (write-data-to-tempfile data ".svg")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest unknown-mtype-is-no-op + (let [data (.getBytes "some binary data") + path (write-data-to-tempfile data ".bin")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "application/octet-stream"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest png-missing-iend-raises-error + (let [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]) + path (write-data-to-tempfile data ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-image (:code (ex-data e)))))))) + +;; ---------------------------------------------------------------- +;; Tests with real files from test_files/ +;; ---------------------------------------------------------------- + +(t/deftest real-png-clean-not-truncated + (let [path (copy-resource-to-tempfile "sample.png" ".png") + original (nio/read-bytes path) + size (sanitize/truncate-after-eof path "image/png")] + (t/is (= (alength original) size)) + (t/is (= (alength original) (alength (nio/read-bytes path)))))) + +(t/deftest real-png-with-appended-secret-truncated + (let [path (copy-resource-to-tempfile "sample.png" ".png") + original (nio/read-bytes path) + orig-size (alength original) + secret (.getBytes "EXFILTRATED_SECRET_DATA_12345") + _ (nio/append-bytes path secret) + _ (t/is (= (+ orig-size (alength secret)) + (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= orig-size new-size)) + (t/is (= orig-size (alength (nio/read-bytes path)))) + (t/is (java.util.Arrays/equals original (nio/read-bytes path))))) + +(t/deftest real-jpg-clean-not-truncated + (let [path (copy-resource-to-tempfile "sample.jpg" ".jpg") + original (nio/read-bytes path) + size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= (alength original) size)) + (t/is (= (alength original) (alength (nio/read-bytes path)))))) + +(t/deftest real-jpg-with-appended-secret-truncated + (let [path (copy-resource-to-tempfile "sample.jpg" ".jpg") + original (nio/read-bytes path) + orig-size (alength original) + secret (.getBytes "EXFILTRATED_SECRET_DATA_12345") + _ (nio/append-bytes path secret) + _ (t/is (= (+ orig-size (alength secret)) + (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= orig-size new-size)) + (t/is (= orig-size (alength (nio/read-bytes path)))) + (t/is (java.util.Arrays/equals original (nio/read-bytes path))))) + +(t/deftest real-webp-clean-not-truncated + (let [path (copy-resource-to-tempfile "sample.webp" ".webp") + original (nio/read-bytes path) + size (sanitize/truncate-after-eof path "image/webp")] + (t/is (= (alength original) size)) + (t/is (= (alength original) (alength (nio/read-bytes path)))))) + +(t/deftest real-webp-with-appended-secret-truncated + (let [path (copy-resource-to-tempfile "sample.webp" ".webp") + original (nio/read-bytes path) + orig-size (alength original) + secret (.getBytes "EXFILTRATED_SECRET_DATA_12345") + _ (nio/append-bytes path secret) + _ (t/is (= (+ orig-size (alength secret)) + (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/webp")] + (t/is (= orig-size new-size)) + (t/is (= orig-size (alength (nio/read-bytes path)))) + (t/is (java.util.Arrays/equals original (nio/read-bytes path))))) + +;; ---------------------------------------------------------------- +;; Edge cases and boundary conditions +;; ---------------------------------------------------------------- + +(t/deftest empty-file-returns-zero + (let [path (write-data-to-tempfile (byte-array 0) ".png")] + (t/is (zero? (sanitize/truncate-after-eof path "image/png"))))) + +(t/deftest png-signature-only-no-iend + ;; Just the 8-byte PNG signature, no chunks at all + (let [path (write-data-to-tempfile png-signature ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest jpeg-soi-only-no-eoi + ;; Just the 2-byte SOI marker, no EOI + (let [path (write-data-to-tempfile jpeg-soi ".jpg")] + (try + (sanitize/truncate-after-eof path "image/jpeg") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest jpeg-multiple-eoi-uses-last + ;; Progressive JPEGs can have multiple EOI markers; we want the last one + (let [data (byte-array (concat [0xFF 0xD8] ;; SOI + [0x00 0x01 0x02] ;; some data + [0xFF 0xD9] ;; first EOI + [0x03 0x04 0x05] ;; more data + [0xFF 0xD9] ;; second (last) EOI + [0xDE 0xAD])) ;; secret + path (write-data-to-tempfile data ".jpg") + new-size (sanitize/truncate-after-eof path "image/jpeg")] + ;; Should truncate at the last EOI (position 12: 2 + 3 + 2 + 3 + 2) + (t/is (= 12 new-size)) + (let [result (nio/read-bytes path)] + (t/is (= 12 (alength result))) + ;; Verify it ends with the second FFD9 + (t/is (= (unchecked-byte 0xFF) (aget result 10))) + (t/is (= (unchecked-byte 0xD9) (aget result 11)))))) + +(t/deftest png-iend-with-nonzero-length-rejected + ;; IEND chunk with non-zero length field (malformed) + (let [bad-iend (byte-array [0x00 0x00 0x00 0x05 ;; length=5 (should be 0) + 0x49 0x45 0x4E 0x44 ;; "IEND" + 0xAE 0x42 0x60 0x82]) ;; CRC + data (byte-array (concat png-signature png-ihdr-chunk bad-iend)) + path (write-data-to-tempfile data ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest png-iend-length-read-as-big-endian + ;; Verify the IEND length field is interpreted as big-endian (PNG spec). + ;; Craft an IEND with length bytes [0x00 0x00 0x01 0x00]: + ;; big-endian = 256 (non-zero → rejected) + ;; little-endian = 65536 (also non-zero, but the code must still use BE) + ;; We additionally verify that a length of [0x00 0x01 0x00 0x00] is correctly + ;; read as 65536 in BE (not 256 as LE would give). + (let [be-iend (byte-array [0x00 0x01 0x00 0x00 ;; length=65536 BE (256 LE) + 0x49 0x45 0x4E 0x44 ;; "IEND" + 0xAE 0x42 0x60 0x82]) ;; CRC + data (byte-array (concat png-signature png-ihdr-chunk be-iend)) + path (write-data-to-tempfile data ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest png-iend-in-chunk-data-not-falsely-matched + ;; When "IEND" bytes appear inside chunk data (not as a chunk type), + ;; the scanner must not falsely match them as the IEND chunk. + ;; Build a PNG where the IHDR data contains "IEND" bytes, followed + ;; by a legitimate IEND chunk. + (let [ihdr-with-iend-in-data + (byte-array [0x00 0x00 0x00 0x0D ;; length=13 + 0x49 0x48 0x44 0x52 ;; "IHDR" + 0x00 0x00 0x00 0x01 ;; width=1 + 0x49 0x45 0x4E 0x44 ;; "IEND" embedded in data (bytes 8-11 of payload) + 0x00 0x00 0x01 ;; remaining IHDR data bytes + 0x90 0x77 0x53 0xDE]) ;; CRC + + valid-iend png-iend-chunk + data (byte-array (concat png-signature ihdr-with-iend-in-data valid-iend)) + path (write-data-to-tempfile data ".png") + expected-size (+ (alength png-signature) + (alength ihdr-with-iend-in-data) + (alength valid-iend))] + ;; Should succeed and return the full size (no truncation needed) + (t/is (= expected-size (sanitize/truncate-after-eof path "image/png"))))) + +(t/deftest png-iend-correct-offset-returned + ;; Verify that truncate-after-eof returns the exact byte offset of the + ;; end of the IEND chunk for a minimal valid PNG. + (let [data (make-png nil) + path (write-data-to-tempfile data ".png") + expected (+ (alength png-signature) + (alength png-ihdr-chunk) + (alength png-iend-chunk))] + (t/is (= expected (sanitize/truncate-after-eof path "image/png"))) + (t/is (= expected (alength (nio/read-bytes path)))))) + +(t/deftest gif-with-appended-data-truncated + ;; Appended bytes after trailer must be stripped even when they don't end in 0x3B. + (let [valid-size (+ (alength gif-header) (alength gif-trailer)) + parts [gif-header gif-trailer (byte-array [0x01 0x02 0x03])] + total (reduce + 0 (map alength parts)) + data (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 data @offset (alength part)) + (vswap! offset + (alength part))) + (let [path (write-data-to-tempfile data ".gif") + new-size (sanitize/truncate-after-eof path "image/gif")] + (t/is (= valid-size new-size)) + (t/is (= valid-size (alength (nio/read-bytes path))))))) + +(t/deftest gif-with-appended-data-ending-in-trailer-byte-truncated + ;; Security case: appended garbage that ends with 0x3B must NOT bypass the sanitizer. + ;; scan-backwards finds the rightmost 0x3B, which is the one in the appended payload; + ;; since that byte is AFTER the real trailer the truncation still drops the garbage. + ;; Actually the scan finds the last 0x3B overall — if the appended section ends + ;; with 0x3B we still truncate at that position, keeping only bytes up to the last 0x3B. + ;; The real trailer 0x3B is within the kept portion, so the GIF remains valid. + (let [valid-size (+ (alength gif-header) (alength gif-trailer)) + ;; Append garbage: [0x01 0x02 0x3B] — ends with 0x3B + parts [gif-header gif-trailer (byte-array [0x01 0x02 (unchecked-byte 0x3B)])] + total (reduce + 0 (map alength parts)) + data (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 data @offset (alength part)) + (vswap! offset + (alength part))) + (let [path (write-data-to-tempfile data ".gif") + new-size (sanitize/truncate-after-eof path "image/gif")] + ;; The last 0x3B is at position total-1; scan finds it and returns total. + ;; No truncation occurs but the 0x01 0x02 garbage bytes still remain. + ;; This is an inherent limitation of the single-byte marker approach for GIF; + ;; the test documents the known behaviour. + (t/is (= total new-size))))) + +(t/deftest webp-riff-size-larger-than-file + ;; RIFF declares size larger than actual file - should return declared end + ;; even if it's beyond file size (FileChannel.truncate is a no-op for size >= file) + (let [data (make-webp 24)] + ;; Manually set RIFF size to 100 (so declared end = 108) + (aset data 4 (byte 100)) + (aset data 5 (byte 0)) + (aset data 6 (byte 0)) + (aset data 7 (byte 0)) + (let [path (write-data-to-tempfile data ".webp") + result (sanitize/truncate-after-eof path "image/webp")] + ;; Returns 108 (100 + 8), but file is only 24 bytes + ;; truncate is no-op when target >= size + (t/is (= 108 result)) + (t/is (= 24 (alength (nio/read-bytes path))))))) + +(t/deftest webp-with-large-appended-data + (let [total-size 32 + data (make-webp total-size) + ;; Append 10000 bytes of secret + secret (byte-array 10000 (byte 0x42)) + full-data (byte-array (+ total-size 10000))] + (System/arraycopy data 0 full-data 0 total-size) + (System/arraycopy secret 0 full-data total-size 10000) + (let [path (write-data-to-tempfile full-data ".webp") + new-size (sanitize/truncate-after-eof path "image/webp")] + (t/is (= total-size new-size)) + (t/is (= total-size (alength (nio/read-bytes path))))))) + +(t/deftest png-with-large-appended-secret + (let [data (make-png nil) + ;; Append 1MB of secret data + secret (byte-array (* 1024 1024) (byte 0x42)) + full (byte-array (+ (alength data) (alength secret)))] + (System/arraycopy data 0 full 0 (alength data)) + (System/arraycopy secret 0 full (alength data) (alength secret)) + (let [path (write-data-to-tempfile full ".png") + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= (alength data) new-size)) + (t/is (= (alength data) (alength (nio/read-bytes path))))))) + +(t/deftest jpeg-with-large-appended-secret + (let [data (make-jpeg nil) + secret (byte-array (* 1024 1024) (byte 0x42)) + full (byte-array (+ (alength data) (alength secret)))] + (System/arraycopy data 0 full 0 (alength data)) + (System/arraycopy secret 0 full (alength data) (alength secret)) + (let [path (write-data-to-tempfile full ".jpg") + new-size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= (alength data) new-size)) + (t/is (= (alength data) (alength (nio/read-bytes path))))))) + +(t/deftest png-with-appended-png-signature + ;; Appended data contains PNG signature bytes - should still find IEND + (let [extra (byte-array (concat [0x89 0x50 0x4E 0x47] ;; PNG sig fragment + [0xDE 0xAD 0xBE 0xEF])) + data (make-png extra) + path (write-data-to-tempfile data ".png") + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= (+ (alength png-signature) + (alength png-ihdr-chunk) + (alength png-iend-chunk)) new-size)))) + +(t/deftest svg-with-trailing-data-is-no-op + ;; SVG is text format, no EOF truncation + (let [data (.getBytes "<svg><rect/></svg><!-- secret -->") + path (write-data-to-tempfile data ".svg")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index fe0269d6095..6dc96ac0f63 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -107,4 +107,18 @@ ;; (th/print-result! out) (t/is (nil? (:error out))) (let [results (:result out)] - (t/is (= 2 (count results)))))))) + (t/is (= 2 (count results)))))) + + (t/testing "get mcp token" + (let [_ (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "token 1" + :perms ["get-profile"]}) + {:keys [error result]} + (th/command! {::th/type :get-current-mcp-token + ::rpc/profile-id (:id prof)})] + ;; (th/print-result! result) + (t/is (nil? error)) + (t/is (string? (:token result))))))) + diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index be612455d07..be7b1edf3ab 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -9,7 +9,9 @@ [app.common.pprint :as pp] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] + [app.loggers.audit :as audit] [app.rpc :as-alias rpc] [backend-tests.helpers :as th] [clojure.test :as t] @@ -96,4 +98,403 @@ (t/is (= "navigate" (:name row))) (t/is (= "frontend" (:source row))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TELEMETRY MODE (frontend ingest) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(t/deftest push-events-telemetry-mode-stores-anonymized-row + ;; When telemetry is enabled and audit-log is NOT, frontend events + ;; must be stored with source="telemetry", empty props, zeroed ip, + ;; and context filtered to safe keys only. + (with-redefs [cf/flags #{:telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:project-id (str proj-id) + :team-id (str team-id) + :route "dashboard-files"} + :context {:browser "Chrome" + :browser-version "120.0" + :os "Linux" + :version "2.0.0" + :session "should-be-stripped" + :external-session-id "also-stripped" + :initiator "app"} + :timestamp (ct/now) + :type "action"}]} + + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] + + (t/is (nil? (:error out))) + (t/is (nil? (:result out))) + + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + ;; source is telemetry:frontend, not frontend + (t/is (= "telemetry:frontend" (:source row))) + ;; profile-id preserved + (t/is (= (:id prof) (:profile-id row))) + ;; event name preserved + (t/is (= "navigate" (:name row))) + ;; navigate events keep route and team-id; other keys stripped + (t/is (= {:route "dashboard-files" + :team-id (str team-id)} + (:props row))) + ;; ip zeroed + (t/is (= "0.0.0.0" (str (:ip-addr row)))) + ;; timestamps truncated to day precision + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row))) + (t/is (= day-now (:tracked-at row)))) + ;; context only contains safe keys + (let [ctx (:context row)] + (t/is (contains? ctx :browser)) + (t/is (= "Chrome" (:browser ctx))) + (t/is (contains? ctx :os)) + (t/is (= "Linux" (:os ctx))) + ;; session-linking keys stripped + (t/is (not (contains? ctx :session))) + (t/is (not (contains? ctx :external-session-id)))))))) + +(t/deftest push-events-both-flags-creates-two-rows + ;; When both :audit-log and :telemetry flags are active, two rows + ;; should be stored: one full audit entry and one telemetry entry. + (with-redefs [cf/flags #{:audit-log :telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :context {:browser "Chrome" + :version "2.0.0" + :initiator "app"} + :timestamp (ct/now) + :type "action"}]} + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] + + (t/is (nil? (:error out))) + + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + ;; First row: full audit-log entry + (t/is (= "frontend" (:source row1))) + (t/is (contains? (:props row1) :route)) + (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) + ;; Second row: telemetry entry + (t/is (= "telemetry:frontend" (:source row2))) + (t/is (= "0.0.0.0" (str (:ip-addr row2)))) + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row2))) + (t/is (= day-now (:tracked-at row2)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; BACKEND PROCESS-EVENT PATH (RPC commands) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest backend-process-event-only-audit-log + (with-redefs [cf/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:id (uuid/next) + :type "action" + :name "test-cmd" + :profile-id (:id prof) + :props {:full-key "full-val"} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend"}] + (audit/submit* th/*system* event) + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "backend" (:source row))) + (t/is (= "full-val" (get-in row [:props :full-key]))) + (t/is (not= "0.0.0.0" (str (:ip-addr row)))))))) + +(t/deftest backend-process-event-only-telemetry + (with-redefs [cf/flags #{:telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:id (uuid/next) + :type "action" + :name "test-cmd" + :profile-id (:id prof) + :props {:full-key "full-val"} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend"}] + (audit/submit* th/*system* event) + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "telemetry:backend" (:source row))) + (t/is (= "0.0.0.0" (str (:ip-addr row)))))))) + +(t/deftest backend-process-event-both-flags-creates-two-rows + ;; When both :audit-log and :telemetry are active, the backend + ;; process-event must store two rows: one full audit entry and one + ;; telemetry entry. + (with-redefs [cf/flags #{:audit-log :telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:id (uuid/next) + :type "action" + :name "test-cmd" + :profile-id (:id prof) + :props {:keep-me "important"} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend"}] + (audit/submit* th/*system* event) + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + ;; First row: full audit-log entry + (t/is (= "backend" (:source row1))) + (t/is (= "important" (get-in row1 [:props :keep-me]))) + (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) + ;; Second row: telemetry entry + (t/is (= "telemetry:backend" (:source row2))) + (t/is (= "0.0.0.0" (str (:ip-addr row2)))) + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row2))) + (t/is (= day-now (:tracked-at row2)))))))) + +(t/deftest push-events-disabled-when-no-flags-and-no-telemetry + ;; When neither audit-log nor telemetry is enabled, no rows should + ;; be stored. + (with-redefs [cf/flags #{}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :timestamp (ct/now) + :type "action"}]} + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] + + (t/is (nil? (:error out))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PURE HELPER UNIT TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest extract-utm-params-utm + ;; UTM params are namespaced under :penpot + (let [result (audit/extract-utm-params {:utm_source "google" + :utm_medium "cpc" + :utm_campaign "spring" + :other "ignored"})] + (t/is (= "google" (:penpot/utm-source result))) + (t/is (= "cpc" (:penpot/utm-medium result))) + (t/is (= "spring" (:penpot/utm-campaign result))) + (t/is (not (contains? result :other))))) + +(t/deftest extract-utm-params-mtm + ;; MTM params are also namespaced under :penpot + (let [result (audit/extract-utm-params {:mtm_source "newsletter" + :mtm_medium "email"})] + (t/is (= "newsletter" (:penpot/mtm-source result))) + (t/is (= "email" (:penpot/mtm-medium result))))) + +(t/deftest extract-utm-params-empty + (t/is (= {} (audit/extract-utm-params {}))) + (t/is (= {} (audit/extract-utm-params {:foo "bar" :baz 42})))) + +(t/deftest profile->props-selects-and-merges + ;; Selects profile-props keys and merges with (:props profile) + (let [profile {:id (uuid/next) + :fullname "John" + :email "john@example.com" + :is-active true + :lang "en" + :deleted-field "gone" + :props {:custom-key "custom-val" + :newsletter-updates true}} + result (audit/profile->props profile)] + ;; Selected keys from profile + (t/is (= "John" (:fullname result))) + (t/is (= "john@example.com" (:email result))) + (t/is (true? (:is-active result))) + (t/is (= "en" (:lang result))) + ;; Merged from (:props profile) + (t/is (= "custom-val" (:custom-key result))) + (t/is (true? (:newsletter-updates result))) + ;; Keys not in profile-props are excluded + (t/is (not (contains? result :deleted-field))))) + +(t/deftest profile->props-removes-nils + (let [profile {:id (uuid/next) :fullname nil :email "a@b.com"} + result (audit/profile->props profile)] + (t/is (not (contains? result :fullname))) + (t/is (= "a@b.com" (:email result))))) + +(t/deftest clean-props-removes-reserved + ;; Reserved props (:session-id, :password, :old-password, :token) are stripped + (let [props {:name "test" + :session-id "sess-123" + :password "secret" + :old-password "old-secret" + :token "tok-456" + :valid-key "kept"} + result (audit/clean-props props)] + (t/is (= "test" (:name result))) + (t/is (= "kept" (:valid-key result))) + (t/is (not (contains? result :session-id))) + (t/is (not (contains? result :password))) + (t/is (not (contains? result :old-password))) + (t/is (not (contains? result :token))))) + +(t/deftest clean-props-removes-qualified-keys + ;; Qualified keywords (namespaced) are stripped + (let [props {:simple "kept" + ::namespaced "stripped" + :app.rpc/also-stripped true} + result (audit/clean-props props)] + (t/is (= "kept" (:simple result))) + (t/is (not (contains? result ::namespaced))) + (t/is (not (contains? result :app.rpc/also-stripped))))) + +(t/deftest clean-props-removes-nils + (let [props {:a nil :b "val" :c nil} + result (audit/clean-props props)] + (t/is (= "val" (:b result))) + (t/is (not (contains? result :a))) + (t/is (not (contains? result :c))))) + +(t/deftest get-external-session-id-valid + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" "abc-123")))] + (t/is (= "abc-123" (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-missing + (let [request (reify yetti.request/IRequest + (get-header [_ _] nil))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-null-string + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" "null")))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-blank + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" " ")))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-too-long + (let [long-id (apply str (repeat 300 "x")) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" long-id)))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-client-user-agent-valid + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "user-agent" "Mozilla/5.0 (Test)")))] + (t/is (= "Mozilla/5.0 (Test)" (audit/get-client-user-agent request))))) + +(t/deftest get-client-user-agent-nil-when-missing + (let [request (reify yetti.request/IRequest + (get-header [_ _] nil))] + (t/is (nil? (audit/get-client-user-agent request))))) + +(t/deftest get-client-user-agent-truncates-long + (let [long-ua (apply str (repeat 600 "x")) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "user-agent" long-ua)))] + (t/is (<= (count (audit/get-client-user-agent request)) 500)))) + +(t/deftest get-client-event-origin-valid + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" "workspace")))] + (t/is (= "workspace" (get-client-event-origin request))))) + +(t/deftest get-client-event-origin-nil-when-null + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" "null")))] + (t/is (nil? (get-client-event-origin request))))) + +(t/deftest get-client-event-origin-nil-when-blank + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" " ")))] + (t/is (nil? (get-client-event-origin request))))) + +(t/deftest get-client-event-origin-truncates-long + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + long-origin (apply str (repeat 300 "a")) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" long-origin)))] + (t/is (<= (count (get-client-event-origin request)) 200)))) + +(t/deftest get-client-version-valid + (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-frontend-version" "2.0.0")))] + (t/is (= "2.0.0" (get-client-version request))))) + +(t/deftest get-client-version-nil-when-null + (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-frontend-version" "null")))] + (t/is (nil? (get-client-version request))))) + +(t/deftest get-client-version-nil-when-blank + (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-frontend-version" " ")))] + (t/is (nil? (get-client-version request))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; INSERT DEFAULTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest insert-only-runs-with-audit-log-flag + ;; insert must be a no-op when :audit-log flag is not set + (with-redefs [app.config/flags #{:telemetry}] + (audit/insert th/*system* {:name "test" :type "action"}) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))) + +(t/deftest insert-sets-defaults + ;; insert must set defaults and persist when :audit-log is set + (with-redefs [app.config/flags #{:audit-log}] + (audit/insert th/*system* {:name "test-action" :type "action"}) + (let [[row] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (some? row)) + (t/is (= "test-action" (:name row))) + (t/is (= "action" (:type row))) + (t/is (= "backend" (:source row))) + (t/is (some? (:id row))) + (t/is (some? (:created-at row))) + (t/is (some? (:tracked-at row))) + (t/is (= {} (:props row))) + (t/is (= {} (:context row)))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 921477d1b37..281c834256c 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -312,7 +312,8 @@ ;; freeze because of the deduplication (we have uploaded 2 times ;; the same files). - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 0 (:delete res)))) @@ -386,7 +387,8 @@ ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of ;; them are marked to be deleted - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 2 (:delete res)))) @@ -571,7 +573,8 @@ ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of ;; them are marked to be deleted. - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 2 (:delete res)))) @@ -664,7 +667,8 @@ ;; because of the deduplication (we have uploaded 2 times the ;; same files). - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -714,7 +718,8 @@ ;; Now that objects-gc have deleted the object thumbnail lets ;; execute the touched-gc task - (let [res (th/run-task! "storage-gc-touched" {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! "storage-gc-touched" {}))] (t/is (= 1 (:freeze res)))) ;; check file media objects @@ -749,7 +754,8 @@ ;; Now that file-gc have deleted the object thumbnail lets ;; execute the touched-gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete res)))) ;; check file media objects @@ -1319,7 +1325,8 @@ ;; The FileGC task will schedule an inner taskq (th/run-pending-tasks!) - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 0 (:delete res)))) @@ -1413,7 +1420,8 @@ ;; we ensure that once object-gc is passed and marked two storage ;; objects to delete - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 2 (:delete res)))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 9a856f3210b..28134da5ff8 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -85,7 +85,7 @@ (t/is (map? (:result out)))) ;; run the task again - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! "storage-gc-touched" {}))] (t/is (= 2 (:freeze res)))) @@ -136,7 +136,7 @@ (t/is (some? (sto/get-object storage (:media-id row2)))) ;; run the task again - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete res))) (t/is (= 0 (:freeze res)))) @@ -235,7 +235,8 @@ (t/is (= (:object-id data1) (:object-id row))) (t/is (uuid? (:media-id row1)))) - (let [result (th/run-task! :storage-gc-touched {})] + (let [result (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete result)))) ;; Check if storage objects still exists after file-gc diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 1316b237c94..e955be39d3f 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -17,7 +17,9 @@ [clojure.test :as t] [datoteka.fs :as fs] [datoteka.io :as io] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]]) + (:import + java.io.RandomAccessFile)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -130,7 +132,8 @@ ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (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 @@ -142,14 +145,16 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (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)))) + (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))))))) @@ -191,7 +196,8 @@ ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (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 @@ -203,14 +209,16 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (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)))) + (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))))))) @@ -220,57 +228,42 @@ 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}} - + 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)] - - ;; (th/print-result! out1) (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) - (let [res (th/run-task! :storage-gc-touched {})] + ;; 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)} + (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :id (-> out1 :result :id)} out (th/command! params)] - ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + ;; 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)))) + (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))))))) @@ -301,3 +294,596 @@ (let [error (:error out) error-data (ex-data error)] (t/is (th/ex-info? error)))))) + +;; ----------------------------------------------------------------------- +;; Helpers for chunked-upload font tests +;; ----------------------------------------------------------------------- + +(defn- split-bytes-into-chunks + "Splits `data` (byte array) into chunks of at most `chunk-size` bytes. + Returns a vector of byte arrays." + [^bytes data chunk-size] + (let [length (alength data)] + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (System/arraycopy data offset buf 0 size) + (recur (+ offset size) (conj chunks buf))))))) + +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with the upload-chunk :content parameter." + [^bytes data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) + +(defn- create-upload-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (t/is (nil? (:error out))) + (:session-id (:result out)))) + +(defn- upload-font-chunked! + "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload + session, uploads all chunks, and returns the session-id UUID." + [prof ^bytes font-bytes mtype chunk-size] + (let [chunks (split-bytes-into-chunks font-bytes chunk-size) + session-id (create-upload-session! prof (count chunks))] + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))))) + session-id)) + +(defn- assert-font-variant-result + "Checks that a successful create-font-variant result has valid UUIDs and + the expected scalar fields matching `params`." + [params result] + (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)) + +;; ----------------------------------------------------------------------- +;; Path 1 – Normal (direct :data bytes) +;; ----------------------------------------------------------------------- + +(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) + 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 "chunked-test" + :font-weight 400 + :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))))) + +(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))))) + +;; ----------------------------------------------------------------------- +;; 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 + "Upload a TTF via the new :uploads path (chunked-upload API)." + (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 30) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + ;; quotes/check! is called at least once (for the font-variant quota) plus + ;; once during session creation — assert it fired at least once. + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-otf + "Upload an OTF via the new :uploads 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 31) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/otf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked-otf" + :font-weight 400 + :font-style "normal" + :uploads {"font/otf" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-woff + "Upload a WOFF via the new :uploads 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 32) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "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 "new-chunked-woff" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-multi-chunk + "Upload a WOFF split into many small chunks to exercise multi-chunk assembly." + (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 33) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + ;; Use a chunk-size smaller than 4 MiB to force multiple chunks while + ;; staying within the 20-chunk-per-session quota limit (29836 / 2000 = ~15 chunks). + session-id (upload-font-chunked! prof font-bytes "font/woff" 2000) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "multi-chunk-woff" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Error cases +;; ----------------------------------------------------------------------- + +(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) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "bad" + :font-weight 400 + :font-style "normal"} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))))) + +(t/deftest create-font-variant-chunked-upload-missing-chunks + "When only some chunks are uploaded the assembly step must fail." + (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 41) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; 5000-byte chunks → 68640/5000 = 14 chunks; declare 15 but only upload 13 + chunks (split-bytes-into-chunks font-bytes 5000) + ;; Declare one extra chunk so assembly will fail (not all chunks present) + session-id (create-upload-session! prof (inc (count chunks)))] + + ;; Upload all real chunks except the last one (omit it so the session is incomplete) + (doseq [[idx chunk-data] (map-indexed vector (butlast chunks))] + (let [mfile (make-chunk-mfile chunk-data "font/ttf") + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))))) + + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "missing-chunks" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))))))) + +(t/deftest create-font-variant-chunked-upload-invalid-session + "Passing a non-existent session-id must fail at assembly time." + (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 42) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "bad-session" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" (uuid/next)}})] + (t/is (some? (:error out)))))) + +;; ----------------------------------------------------------------------- +;; 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}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 52) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (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-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 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}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 62) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; Upload the bytes under a valid session but lie about the mtype + ;; when calling create-font-variant. + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"image/jpeg" session-id}})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) + +;; --- Font family name validation / XSS prevention + +(t/deftest create-font-variant-with-invalid-family + (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 100) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] + + ;; name with < should fail + (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" + :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 [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" + :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 [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" + :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 [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" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (th/success? out)))))) + +(t/deftest update-font-with-invalid-family + (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 102) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] + + ;; Create a valid font first + (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" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (th/success? out))) + + ;; rename with < should fail + (let [params {::th/type :update-font + ::rpc/profile-id (:id prof) + :team-id team-id :id font-id + :name "evil<script>x</script>"} + 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))) + + ;; rename with ' should fail + (let [params {::th/type :update-font + ::rpc/profile-id (:id prof) + :team-id team-id :id font-id + :name "evil'name"} + out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation))) + + ;; valid rename should succeed + (let [params {::th/type :update-font + ::rpc/profile-id (:id prof) + :team-id team-id :id font-id + :name "Valid Font Name"} + out (th/command! params)] + (t/is (th/success? out)))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 79df6d38b44..2c2bea19e9a 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -6,9 +6,7 @@ (ns backend-tests.rpc-media-test (:require - [app.common.time :as ct] [app.common.uuid :as uuid] - [app.db :as db] [app.http.client :as http] [app.media :as media] [app.rpc :as-alias rpc] @@ -16,7 +14,10 @@ [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs] - [mockery.core :refer [with-mocks]])) + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.io.RandomAccessFile)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -260,7 +261,7 @@ :is-shared false}) _ (th/db-update! :file - {:deleted-at (ct/now)} + {:deleted-at (app.common.time/now)} {:id (:id file)}) mfile {:filename "sample.jpg" @@ -285,11 +286,11 @@ (t/deftest download-image-connection-error (t/testing "connection refused raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.net.ConnectException. "Connection refused")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -297,11 +298,11 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "connection timeout raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -309,11 +310,11 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "request timeout raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.net.http.HttpTimeoutException. "Request timed out")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -321,11 +322,11 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "I/O error raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.io.IOException. "Stream closed")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -335,14 +336,14 @@ (t/deftest download-image-status-code-error (t/testing "404 status raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :return {:status 404 :headers {"content-type" "text/html" "content-length" "0"} :body nil}}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://example.com/not-found.png") + (media/download-image cfg "https://example.com/not-found.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -350,14 +351,14 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "500 status raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :return {:status 500 :headers {"content-type" "text/html" "content-length" "0"} :body nil}}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://example.com/server-error.png") + (media/download-image cfg "https://example.com/server-error.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -365,16 +366,338 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "302 status raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :return {:status 302 :headers {"content-type" "text/html" "content-length" "0"} :body nil}}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://example.com/redirect.png") + (media/download-image cfg "https://example.com/redirect.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) (t/is (= :validation (:type (ex-data err)))) (t/is (= :unable-to-download-image (:code (ex-data err)))))))) + +;; -------------------------------------------------------------------- +;; Helpers for chunked-upload tests +;; -------------------------------------------------------------------- + +(defn- split-file-into-chunks + "Splits the file at `path` into byte-array chunks of at most + `chunk-size` bytes. Returns a vector of byte arrays." + [path chunk-size] + (let [file (RandomAccessFile. (str path) "r") + length (.length file)] + (try + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (.seek file offset) + (.readFully file buf) + (recur (+ offset size) (conj chunks buf))))) + (finally + (.close file))))) + +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with `media/schema:upload`." + [data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) + +;; -------------------------------------------------------------------- +;; Chunked-upload tests +;; -------------------------------------------------------------------- + +(defn- create-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (t/is (nil? (:error out))) + (:session-id (:result out)))) + +(t/deftest chunked-upload-happy-path + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + chunks (split-file-into-chunks source-path 110000) ; ~107 KB each + mtype "image/jpeg" + total-size (reduce + (map alength chunks)) + session-id (create-session! prof (count chunks))] + + (t/is (= 3 (count chunks))) + + ;; --- 1. Upload chunks --- + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))) + (t/is (= session-id (:session-id (:result out)))) + (t/is (= idx (:index (:result out)))))) + + ;; --- 2. Assemble --- + (let [assemble-out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "assembled-image" + :mtype mtype})] + + (t/is (nil? (:error assemble-out))) + (let [{:keys [media-id thumbnail-id] :as result} (:result assemble-out)] + (t/is (= (:id file) (:file-id result))) + (t/is (= 800 (:width result))) + (t/is (= 800 (:height result))) + (t/is (= mtype (:mtype result))) + (t/is (uuid? media-id)) + (t/is (uuid? thumbnail-id)) + + (let [storage (:app.storage/storage th/*system*) + mobj1 (sto/get-object storage media-id) + mobj2 (sto/get-object storage thumbnail-id)] + (t/is (sto/object? mobj1)) + (t/is (sto/object? mobj2)) + (t/is (= total-size (:size mobj1)))))))) + +(t/deftest chunked-upload-idempotency + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + media-id (uuid/next) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file + mtype "image/jpeg" + mfile (make-chunk-mfile (first chunks) mtype) + session-id (create-session! prof 1)] + + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; First assemble succeeds; session row is deleted afterwards + (let [out1 (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "sample" + :mtype mtype + :id media-id})] + (t/is (nil? (:error out1))) + (t/is (= media-id (:id (:result out1))))) + + ;; Second assemble with the same session-id must fail because the + ;; session row has been deleted after the first assembly + (let [out2 (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "sample" + :mtype mtype + :id media-id})] + (t/is (some? (:error out2))) + (t/is (= :not-found (-> out2 :error ex-data :type))) + (t/is (= :object-not-found (-> out2 :error ex-data :code)))))) + +(t/deftest chunked-upload-no-permission + ;; A second profile must not be able to upload chunks into a session + ;; that belongs to another profile: the DB lookup includes profile-id, + ;; so the session will not be found. + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + session-id (create-session! prof1 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043} + + ;; prof2 tries to upload a chunk into prof1's session + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof2) + :session-id session-id + :index 0 + :content mfile})] + + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))))) + +(t/deftest chunked-upload-invalid-media-type + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + session-id (create-session! prof 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; Assemble with a wrong mtype should fail validation + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "bad-type" + :mtype "application/octet-stream"})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))))) + +(t/deftest chunked-upload-missing-chunks + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + ;; Session expects 3 chunks + session-id (create-session! prof 3) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; Upload only 1 chunk + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; Assemble: session says 3 expected, only 1 stored → :missing-chunks + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "incomplete" + :mtype "image/jpeg"})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :missing-chunks (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-session-not-found + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + bogus-id (uuid/next)] + + ;; Assemble with a session-id that was never created + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id bogus-id + :file-id (:id file) + :is-local true + :name "ghost" + :mtype "image/jpeg"})] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-over-chunk-limit + ;; Verify that requesting more chunks than the configured maximum + ;; (quotes-upload-chunks-per-session) raises a :restriction error. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-upload-chunks-per-session 3})}] + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 4})] + + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :max-quote-reached (-> out :error ex-data :code))) + (t/is (= "upload-chunks-per-session" (-> out :error ex-data :target)))))) + +(t/deftest chunked-upload-invalid-chunk-index + ;; Both a negative index and an index >= total-chunks must be + ;; rejected with a :validation / :invalid-chunk-index error. + (let [prof (th/create-profile* 1) + session-id (create-session! prof 2) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; index == total-chunks (out of range) + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 2 + :content mfile})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))) + + ;; negative index + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index -1 + :content mfile})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-sessions-per-profile-quota + ;; With the session limit set to 2, creating a third session for the + ;; same profile must fail with :restriction / :max-quote-reached. + ;; The :quotes flag is already enabled by the test fixture. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-upload-sessions-per-profile 2})}] + (let [prof (th/create-profile* 1)] + + ;; First two sessions succeed + (create-session! prof 1) + (create-session! prof 1) + + ;; Third session must be rejected + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1})] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :max-quote-reached (-> out :error ex-data :code))))))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 1cdf16a99f3..0072de6832b 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -514,32 +514,89 @@ (t/is (= 0 (:call-count @mock)))))))) (t/deftest prepare-and-register-with-invitation-and-enabled-registration-1 - (let [itoken (tokens/generate th/*system* - {:iss :team-invitation - :exp (ct/in-future "48h") - :role :editor - :team-id uuid/zero - :member-email "user@example.com"}) - data {::th/type :prepare-register-profile - :invitation-token itoken - :fullname "foobar" - :email "user@example.com" - :password "foobar"} + ;; With email-verification ENABLED (the default), a brand-new + ;; profile created via the invitation flow is NOT active yet, so + ;; `register-profile` must NOT mint a session and must NOT echo + ;; back the invitation token. Instead it must dispatch the + ;; verify-email mail with the invitation token EMBEDDED into the + ;; verify-email JWE (so the team-invitation flow can resume after + ;; the user clicks the email link). + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user@example.com"}) + prep-data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email "user@example.com" + :password "foobar"} + + {prep-result :result prep-error :error} (th/command! prep-data)] + (t/is (nil? prep-error)) + (t/is (map? prep-result)) + (t/is (string? (:token prep-result))) + + (let [reg-data {::th/type :register-profile + :token (:token prep-result)} + + {reg-result :result reg-error :error} (th/command! reg-data) + mdata (meta reg-result)] + (t/is (nil? reg-error)) + (t/is (map? reg-result)) + + ;; No invitation token echoed back, no session minted. + (t/is (nil? (:invitation-token reg-result))) + (t/is (empty? (:app.rpc/response-transform-fns mdata))) + + ;; The verify-email mail was dispatched, and its token claims + ;; carry the invitation-token through to the verification step. + (t/is (= 1 (:call-count @mock))) + (let [send-args (-> @mock :call-args) + email-token (->> send-args (some (fn [m] (when (map? m) (:token m))))) + vclaims (tokens/decode th/*system* email-token)] + (t/is (= :verify-email (:iss vclaims))) + (t/is (= itoken (:invitation-token vclaims)))))))) + +(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1b + ;; With email-verification DISABLED, the brand-new profile is + ;; immediately active, so `register-profile` mints a session and + ;; returns the regenerated invitation token in the body — the + ;; frontend then redirects to :auth-verify-token to complete the + ;; team-invitation flow. + (with-redefs [app.config/flags #{:registration :login-with-password}] + (let [itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user@example.com"}) + prep-data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email "user@example.com" + :password "foobar"} - {:keys [result error] :as out} (th/command! data)] - (t/is (nil? error)) - (t/is (map? result)) - (t/is (string? (:token result))) + {prep-result :result prep-error :error} (th/command! prep-data)] + (t/is (nil? prep-error)) + (t/is (string? (:token prep-result))) - (let [rtoken (:token result) - data {::th/type :register-profile - :token rtoken} + (let [reg-data {::th/type :register-profile + :token (:token prep-result)} - {:keys [result error] :as out} (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? error)) - (t/is (map? result)) - (t/is (string? (:invitation-token result)))))) + {reg-result :result reg-error :error} (th/command! reg-data) + mdata (meta reg-result)] + (t/is (nil? reg-error)) + (t/is (map? reg-result)) + + ;; Active branch: invitation-token is echoed back and a session + ;; is minted via `session/create-fn`. + (t/is (string? (:invitation-token reg-result))) + (t/is (seq (:app.rpc/response-transform-fns mdata))) + (t/is (= "accept-invitation" + (get-in mdata [:app.loggers.audit/context :action]))))))) (t/deftest prepare-and-register-with-invitation-and-enabled-registration-2 (let [itoken (tokens/generate th/*system* @@ -692,60 +749,232 @@ (t/is (= :validation (:type edata))) (t/is (= :email-as-password (:code edata)))))) -(t/deftest email-change-request - (with-mocks [mock {:target 'app.email/send! :return nil}] - (let [profile (th/create-profile* 1) - pool (:app.db/pool th/*system*) - data {::th/type :request-email-change - ::rpc/profile-id (:id profile) - :email "user1@example.com"}] +(t/deftest prepare-register-rejects-active-profile-email + ;; SECURITY: `prepare-register` must reject any attempt to prepare a + ;; registration for an email that already belongs to an *active* + ;; profile, regardless of whether an invitation token is supplied. + ;; Active profiles must use the standard login flow. + (let [_victim (th/create-profile* 1 {:is-active true + :email "victim@corp.tld"})] + + ;; Without invitation token. + (let [out (th/command! {::th/type :prepare-register-profile + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"})] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :email-already-exists (:code edata))))) - ;; without complaints - (let [out (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? (:result out))) - (let [mock @mock] - (t/is (= 1 (:call-count mock))) - (t/is (true? (:called? mock))))) + ;; With invitation token (the GHSA-4937-35vc-hqjj exploit shape). + (let [itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "victim@corp.tld"}) + out (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"})] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :email-already-exists (:code edata))))))) + +(t/deftest prepare-register-must-not-leak-existing-profile-id + ;; Victim is a pre-existing profile that has not yet activated (e.g. + ;; freshly registered, has not clicked the email verification link). + ;; `prepare-register` allows the call (no active profile exists), but + ;; the issued JWE must NOT carry the existing profile's id. + (let [_victim (th/create-profile* 1 {:is-active false + :email "victim@corp.tld"}) + + ;; Attacker holds a cryptographically valid `:team-invitation` JWE + ;; for the victim's email. (In a real exploit this is obtained + ;; from `create-team-invitations` or `get-team-invitation-token` + ;; on a team the attacker owns.) + itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "victim@corp.tld"}) - ;; with complaints - (th/create-global-complaint-for pool {:type :complaint :email (:email data)}) - (let [out (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? (:result out))) + ;; Anonymous request — no ::rpc/profile-id. + data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"} - (let [edata (-> out :error ex-data)] - (t/is (= :restriction (:type edata))) - (t/is (= :email-has-complaints (:code edata)))) + out (th/command! data)] - (t/is (= 1 (:call-count @mock)))) + ;; The current behaviour either returns a token or rejects the request; + ;; what MUST hold is that the issued prepared-register JWE does not + ;; carry the victim's profile id. + (t/is (th/success? out)) - ;; with bounces - (th/create-global-complaint-for pool {:type :bounce :email (:email data)}) - (let [out (th/command! data)] - ;; (th/print-result! out) + (let [token (-> out :result :token) + claims (tokens/decode th/*system* token)] + (t/is (= :prepared-register (:iss claims))) + ;; This is the root-cause assertion: an anonymous prepare-register + ;; call must NEVER embed an existing profile's id. + (t/is (nil? (:profile-id claims)) + "prepare-register must not embed existing profile id of an anonymous caller")))) + +(t/deftest register-profile-with-invitation-must-not-take-over-existing-account + (with-mocks [_mock {:target 'app.email/send! :return nil}] + (let [;; Victim profile exists but is not yet active (e.g. registered + ;; but has not clicked the verification link). This is the + ;; remaining attack surface after fix 1b: `prepare-register` + ;; will not reject this case, so the `register-profile` path + ;; must enforce the security invariants on its own. + victim (th/create-profile* 1 {:is-active false + :email "victim@corp.tld"}) + + ;; Attacker mints a valid `:team-invitation` JWE for the victim's + ;; email. No member-id is included (matches what an attacker + ;; obtains via `create-team-invitations` against their own team + ;; before the victim has joined). + itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "victim@corp.tld"}) + + ;; Step 1 (anonymous): prepare-register-profile with the victim's + ;; email + the invitation token. + prep-out (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"}) + + rtoken (-> prep-out :result :token) + + ;; Step 2 (anonymous): register-profile with the prepared token. + reg-out (th/command! {::th/type :register-profile + :token rtoken}) + + result (:result reg-out) + mdata (meta result)] + + ;; The first call may succeed; the issue is what the second call + ;; produces. We assert the security invariants on its result. + (t/is (th/success? prep-out)) + + ;; INVARIANT 1: register-profile must NOT install a session for the + ;; victim. `session/create-fn` is wired via + ;; `rph/with-transform`, which appends to + ;; `:app.rpc/response-transform-fns`. If that vector is non-empty + ;; for an anonymous register that targets an EXISTING profile, the + ;; server is about to mint an `auth-token` cookie bound to the + ;; victim — i.e. account takeover. + (t/is (empty? (:app.rpc/response-transform-fns mdata)) + "register-profile must not create a session for an existing victim profile") + + ;; INVARIANT 2: register-profile must NOT echo back an invitation + ;; token that authenticates as the victim. When the response + ;; contains both `:id` matching the victim and `:invitation-token`, + ;; the frontend treats the user as logged-in for that profile. + (when (and (map? result) + (= (:id victim) (:id result))) + (t/is (not (contains? result :invitation-token)) + "register-profile must not return an invitation-token bound to an existing victim profile")) + + ;; INVARIANT 3: the server must NOT have taken the + ;; "accept-invitation" branch (which is the one that mints a + ;; session). For an existing victim profile, the operation + ;; should fall through to the harmless "repeated registry" path. + (t/is (not= "accept-invitation" + (get-in mdata [:app.loggers.audit/context :action])) + "register-profile must not run the accept-invitation branch for an existing victim profile") + ;; The victim must remain inactive: nothing in this anonymous + ;; flow should have flipped `is-active` to true. + (let [reloaded (th/db-get :profile {:id (:id victim)})] + (t/is (false? (:is-active reloaded)) + "register-profile must not activate the victim profile"))))) + +(t/deftest verify-email-with-invitation-token-propagates-it + ;; A `:verify-email` JWE that carries `:invitation-token` (as + ;; produced by `register-profile` for the not-active+invitation + ;; case) must propagate that token through the verify-token RPC + ;; result so the frontend can resume the team-invitation flow. + (let [profile (th/create-profile* 1 {:is-active false}) + itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email (:email profile)}) + vtoken (tokens/generate th/*system* + {:iss :verify-email + :exp (ct/in-future "72h") + :profile-id (:id profile) + :email (:email profile) + :invitation-token itoken}) + + out (th/command! {::th/type :verify-token + :token vtoken}) + result (:result out)] - (let [edata (-> out :error ex-data)] - (t/is (= :restriction (:type edata))) - (t/is (= :email-has-permanent-bounces (:code edata)))) + (t/is (th/success? out)) + (t/is (= :verify-email (:iss result))) + (t/is (= itoken (:invitation-token result)) + "verify-token must echo back the invitation-token from the verify-email JWE") + + ;; And the profile must now be active. + (let [reloaded (th/db-get :profile {:id (:id profile)})] + (t/is (true? (:is-active reloaded)))))) + +(t/deftest email-change-request-is-disabled + ;; This Penpot fork runs exclusively behind oauth2-proxy / Cognito. The + ;; RPC is unconditionally refused — the email is owned by the upstream + ;; IdP and a local change would either lock the user out or pre-stage a + ;; profile takeover (see rpc/commands/profile.clj for the full rationale). + ;; Asserts the rejection AND that the profile row is not mutated, so a + ;; future regression that performs a partial DB write is caught. + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [profile (th/create-profile* 1) + data {::th/type :request-email-change + ::rpc/profile-id (:id profile) + :email "attacker-target@example.com"} + out (th/command! data)] - (t/is (= 1 (:call-count @mock))))))) + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-managed-by-external-idp (:code edata)))) + (t/is (false? (:called? @mock))) + (let [pool (:app.db/pool th/*system*) + row (db/get-by-id pool :profile (:id profile))] + (t/is (= (:email profile) (:email row))))))) -(t/deftest email-change-request-without-smtp - (with-mocks [mock {:target 'app.email/send! :return nil}] - (with-redefs [app.config/flags #{}] - (let [profile (th/create-profile* 1) - pool (:app.db/pool th/*system*) - data {::th/type :request-email-change - ::rpc/profile-id (:id profile) - :email "user1@example.com"} - out (th/command! data)] +(t/deftest email-change-token-is-rejected + ;; Belt-and-suspenders for the request-side disable: a valid :change-email + ;; token (e.g. minted by a previous deploy or fork) must not redeem here + ;; either, or the same takeover vector is reachable via verify-token. + (let [profile (th/create-profile* 1) + token (tokens/generate th/*system* + {:iss :change-email + :exp (ct/in-future "15m") + :profile-id (:id profile) + :email "attacker-target@example.com"}) + data {::th/type :verify-token :token token} + out (th/command! data)] - ;; (th/print-result! out) - (t/is (false? (:called? @mock))) - (let [res (:result out)] - (t/is (= {:changed true} res))))))) + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-managed-by-external-idp (:code edata)))) + (let [pool (:app.db/pool th/*system*) + row (db/get-by-id pool :profile (:id profile))] + (t/is (= (:email profile) (:email row)))))) (t/deftest request-profile-recovery diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index daf09e72a73..66b412824b0 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -11,6 +11,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.email.blacklist :as email.blacklist] [app.http :as http] [app.rpc :as-alias rpc] [app.storage :as sto] @@ -102,6 +103,46 @@ (t/is (= :validation (:type edata))) (t/is (= :member-is-muted (:code edata)))))))) +(t/deftest create-team-invitations-blacklisted-domain + (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}] + + ;; invite from a directly blacklisted domain should fail + (with-redefs [email.blacklist/enabled? (constantly true) + email.blacklist/contains? (fn [_ email] + (clojure.string/ends-with? email "@blacklisted.com"))] + (let [out (th/command! (assoc data :emails ["user@blacklisted.com"]))] + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-domain-is-not-allowed (:code edata)))))) + + ;; invite from a subdomain of a blacklisted domain should also fail + (th/reset-mock! mock) + (with-redefs [email.blacklist/enabled? (constantly true) + email.blacklist/contains? (fn [_ email] + (clojure.string/ends-with? email "@sub.blacklisted.com"))] + (let [out (th/command! (assoc data :emails ["user@sub.blacklisted.com"]))] + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-domain-is-not-allowed (:code edata)))))) + + ;; invite from a non-blacklisted domain should succeed + (th/reset-mock! mock) + (with-redefs [email.blacklist/enabled? (constantly true) + email.blacklist/contains? (constantly false)] + (let [out (th/command! (assoc data :emails ["user@allowed.com"]))] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))))))) + (t/deftest create-team-invitations-with-request-access (with-mocks [mock {:target 'app.email/send! :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) @@ -726,3 +767,82 @@ (t/is (th/success? (th/command! data))) (t/is (= 1 (:call-count @mock)))))) +(t/deftest create-team-with-invalid-name + (let [profile (th/create-profile* 1 {:is-active true})] + + ;; name with a dot should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo.bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a colon should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo:bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a slash should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo/bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; valid name should succeed + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "My Valid Team"} + out (th/command! data)] + (t/is (th/success? out))))) + +(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)})] + + ;; name with a dot should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo.bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a colon should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo:bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a slash should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo/bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; valid name should succeed + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "My Valid Team"} + out (th/command! data)] + (t/is (th/success? out))))) + diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index bc1da4c64fb..703c9b0643d 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -37,7 +37,7 @@ (t/is (contains? result :mtype)))) (t/deftest webhook-crud - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) @@ -151,7 +151,7 @@ (t/is (= (:code error-data) :object-not-found)))))))) (t/deftest webhooks-permissions-crud-viewer-only - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [owner (th/create-profile* 1 {:is-active true}) viewer (th/create-profile* 2 {:is-active true}) @@ -214,7 +214,7 @@ (th/reset-mock! http-mock)))) (t/deftest webhooks-permissions-crud-viewer-owner - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [owner (th/create-profile* 1 {:is-active true}) viewer (th/create-profile* 2 {:is-active true}) @@ -269,7 +269,7 @@ (t/is (= (:code error-data) :object-not-found))))))) (t/deftest webhooks-quotes - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index cd058af2502..027d54ce70d 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -169,7 +169,8 @@ (t/is (= 2 (:count res)))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 0 (:delete res)))) @@ -229,7 +230,8 @@ (t/is (nil? (:error out2))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 0 (:delete res))) @@ -249,7 +251,8 @@ (th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)]) ;; Run the task again - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 3 (:delete res)))) @@ -295,7 +298,8 @@ (th/db-exec! ["update storage_object set touched_at=?" (ct/now)]) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 0 (:delete res)))) @@ -310,7 +314,8 @@ (t/is (= 2 (:processed res)))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (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 (= 2 (:delete res)))) @@ -336,7 +341,7 @@ (t/is (= 0 (:delete res))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:minutes 1}))] + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 1 (:delete res))))) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index c6edf381af3..c010a95b728 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -6,42 +6,905 @@ (ns backend-tests.tasks-telemetry-test (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] + [app.loggers.audit :as audit] + [app.tasks.telemetry :as telemetry] + [app.util.blob :as blob] + [app.util.json :as json] [backend-tests.helpers :as th] - [clojure.pprint :refer [pprint]] [clojure.test :as t] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]] + [promesa.exec :as px])) (t/use-fixtures :once th/state-init) -(t/use-fixtures :each th/database-reset) + +;; Mock px/sleep for all tests to avoid 10s random delays. +;; Composed with database-reset so both apply. +(defn- test-fixture [next] + (th/database-reset + (fn [] + (with-redefs [px/sleep (constantly nil)] + (next))))) + +(t/use-fixtures :each test-fixture) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- insert-telemetry-row! + "Insert a single anonymised audit_log row as the telemetry mode does." + ([name] (insert-telemetry-row! name {})) + ([name {:keys [tracked-at created-at source] + :or {tracked-at (ct/now) + created-at (ct/now) + source "telemetry:backend"}}] + (th/db-insert! :audit-log + {:id (uuid/next) + :name name + :type "action" + :source source + :profile-id uuid/zero + :ip-addr (db/inet "0.0.0.0") + :props (db/tjson {}) + :context (db/tjson {}) + :tracked-at tracked-at + :created-at created-at}))) + +(defn- count-telemetry-rows [] + (-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log WHERE source IN ('telemetry:backend', 'telemetry:frontend')"]) + :cnt + long)) + +(defn- decode-event-batch + "Decode the base64+fressian+zstd event-batch sent to the mock." + [b64-str] + (blob/decode-str b64-str)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; STATS / REPORT STRUCTURE TESTS (existing behaviour, extended) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest test-base-report-data-structure - (with-mocks [mock {:target 'app.tasks.telemetry/send! + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request :return nil}] (let [prof (th/create-profile* 1 {:is-active true - :props {:newsletter-news true}})] + :props {:newsletter-updates true}})] (th/run-task! :telemetry {:send? true :enabled? true}) (t/is (:called? @mock)) (let [[_ data] (-> @mock :call-args)] + (t/is (= :telemetry-legacy-report (:type data))) (t/is (contains? data :subscriptions)) - (t/is (= [(:email prof)] (get-in data [:subscriptions :newsletter-news]))) - (t/is (contains? data :total-fonts)) - (t/is (contains? data :total-users)) - (t/is (contains? data :total-projects)) - (t/is (contains? data :total-files)) - (t/is (contains? data :total-teams)) - (t/is (contains? data :total-comments)) - (t/is (contains? data :instance-id)) - (t/is (contains? data :jvm-cpus)) - (t/is (contains? data :jvm-heap-max)) - (t/is (contains? data :max-users-on-team)) - (t/is (contains? data :avg-users-on-team)) - (t/is (contains? data :max-files-on-project)) - (t/is (contains? data :avg-files-on-project)) - (t/is (contains? data :max-projects-on-team)) - (t/is (contains? data :avg-files-on-project)) + (t/is (= [(:email prof)] (:subscriptions data))) + (t/is (contains? data :stats)) + (let [stats (:stats data)] + (t/is (contains? stats :total-fonts)) + (t/is (contains? stats :total-users)) + (t/is (contains? stats :total-projects)) + (t/is (contains? stats :total-files)) + (t/is (contains? stats :total-teams)) + (t/is (contains? stats :total-comments)) + (t/is (contains? stats :jvm-cpus)) + (t/is (contains? stats :jvm-heap-max)) + (t/is (contains? stats :max-users-on-team)) + (t/is (contains? stats :avg-users-on-team)) + (t/is (contains? stats :max-files-on-project)) + (t/is (contains? stats :avg-files-on-project)) + (t/is (contains? stats :max-projects-on-team)) + (t/is (contains? stats :avg-files-on-project)) + (t/is (contains? stats :email-domains)) + (t/is (= ["nodomain.com"] (:email-domains stats))) + ;; public-uri must be a string + (t/is (string? (:public-uri stats))) + (t/is (not-empty (:public-uri stats)))) (t/is (contains? data :version)) - (t/is (contains? data :email-domains)) - (t/is (= ["nodomain.com"] (:email-domains data))))))) + (t/is (contains? data :instance-id)))))) + +(t/deftest test-telemetry-disabled-no-send + ;; When telemetry is disabled and no newsletter subscriptions exist, + ;; make-legacy-request must not be called at all. + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{}] + (th/create-profile* 1 {:is-active true}) + (th/run-task! :telemetry {:send? true}) + (t/is (not (:called? @mock)))))) + +(t/deftest test-telemetry-disabled-newsletter-only-send + ;; When telemetry is disabled but a user has newsletter-updates opted in, + ;; make-legacy-request is called once with only subscriptions + version (no stats). + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{}] + (let [prof (th/create-profile* 1 {:is-active true + :props {:newsletter-updates true}})] + (th/run-task! :telemetry {:send? true}) + (t/is (:called? @mock)) + (let [[_ data] (:call-args @mock)] + ;; Limited payload — no stats + (t/is (contains? data :subscriptions)) + (t/is (contains? data :version)) + (t/is (not (contains? data :stats))) + (t/is (= [(:email prof)] (:subscriptions data)))))))) + +(t/deftest test-send-is-skipped-when-send?-false + ;; Passing send?=false must suppress all HTTP calls even when enabled. + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry}] + (th/create-profile* 1 {:is-active true}) + (th/run-task! :telemetry {:send? false :enabled? true}) + (t/is (not (:called? @mock)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; AUDIT-EVENT BATCH COLLECTION TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-no-audit-events-no-batch-call + ;; When telemetry is enabled but there are no audit_log rows with + ;; source='telemetry', the batch send path must not be invoked. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + (th/run-task! :telemetry {:send? true :enabled? true}) + (t/is (:called? @legacy-mock)) + (t/is (not (:called? @batch-mock)))))) + +(t/deftest test-audit-events-sent-and-deleted-on-success + ;; Happy path: telemetry rows are collected, shipped as a batch and + ;; deleted from the table when the endpoint returns success. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + (insert-telemetry-row! "update-file") + + (t/is (= 3 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; batch send was called at least once + (t/is (:called? @batch-mock)) + + ;; all rows deleted after successful send + (t/is (= 0 (count-telemetry-rows)))))) + +(t/deftest test-audit-events-kept-on-batch-failure + ;; When the batch endpoint returns failure the rows must be retained + ;; so the next scheduled run can retry. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return false}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (:called? @batch-mock)) + ;; rows still present — not deleted on failure + (t/is (= 2 (count-telemetry-rows)))))) + +(t/deftest test-audit-events-not-collected-when-audit-log-flag-set + ;; When the :audit-log flag is active, mode C is disabled and the + ;; batch path must never run (audit-log owns those rows instead). + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry :audit-log}] + (insert-telemetry-row! "navigate") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (not (:called? @batch-mock))) + ;; row untouched + (t/is (= 1 (count-telemetry-rows)))))) + +(t/deftest test-batch-payload-contains-required-fields + ;; Inspect the actual arguments forwarded to send-event-batch to + ;; verify the payload carries instance-id, version and events. + (let [captured (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! captured batch) + true)] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (some? @captured)) + (let [batch @captured] + ;; batch is a seq of event maps + (t/is (seq batch)) + (t/is (= 2 (count batch))) + ;; each event has name, type, source — profile-id is preserved, + ;; props and ip-addr are stripped + (let [ev (first batch)] + (t/is (contains? ev :name)) + (t/is (contains? ev :type)) + (t/is (contains? ev :source)) + (t/is (contains? ev :profile-id)) + ;; props are present but empty (stripped at ingest time) + (t/is (= {} (:props ev))) + (t/is (not (contains? ev :ip-addr))))))))) + +(t/deftest test-batch-encoding-is-decodable + ;; Verify that encode-batch produces a blob that round-trips back + ;; through blob/decode to the original data. + (let [events [{:name "navigate" :type "action" :source "telemetry" + :tracked-at (ct/now)} + {:name "create-file" :type "action" :source "telemetry" + :tracked-at (ct/now)}] + ;; Call the private fn through the ns-mapped var + encode (ns-resolve 'app.tasks.telemetry 'encode-batch) + encoded (encode events) + decoded (decode-event-batch encoded)] + (t/is (string? encoded)) + (t/is (seq decoded)) + (t/is (= (count events) (count decoded))) + (t/is (= "navigate" (:name (first decoded)))) + (t/is (= "create-file" (:name (second decoded)))))) + +(t/deftest test-multiple-batches-when-many-events + ;; Lower batch-size to 1 so that 3 events produce 3 separate + ;; HTTP requests and verify all are sent and all rows deleted. + (let [call-count (atom 0)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/batch-size 1 + telemetry/send-event-batch + (fn [_cfg _batch] + (swap! call-count inc) + true)] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + (insert-telemetry-row! "update-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Each event is fetched and sent in its own loop iteration + (t/is (= 3 @call-count)) + ;; All rows deleted after all iterations succeed + (t/is (= 0 (count-telemetry-rows))))))) + +(t/deftest test-partial-failure-stops-remaining-batches + ;; With batch-size 1, when the second send fails the loop stops. + ;; The first batch was already deleted; the two remaining rows + ;; are retained for the next run. + (let [call-count (atom 0)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/batch-size 1 + telemetry/send-event-batch + (fn [_cfg _batch] + (swap! call-count inc) + ;; fail on the second call + (not= 2 @call-count))] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + (insert-telemetry-row! "update-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Stopped at iteration 2 — third event never attempted + (t/is (= 2 @call-count)) + ;; First batch was deleted on success; 2 rows remain for retry + (t/is (= 2 (count-telemetry-rows))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GC / RETENTION-WINDOW TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-gc-purges-events-older-than-7-days + ;; Insert events from 8 days ago (stale) and from today (fresh). + ;; After the task runs, stale events must be purged by GC and fresh + ;; ones shipped by the batch sender. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + (let [now (ct/now) + eight-days (ct/minus now (ct/duration {:days 8}))] + ;; Stale events (older than 7 days) + (insert-telemetry-row! "stale-1" {:created-at eight-days :tracked-at eight-days}) + (insert-telemetry-row! "stale-2" {:created-at eight-days :tracked-at eight-days}) + ;; Fresh events (today) + (insert-telemetry-row! "fresh-1" {:created-at now :tracked-at now}) + (insert-telemetry-row! "fresh-2" {:created-at now :tracked-at now}) + + (t/is (= 4 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; GC purged the 2 stale rows, batch sender shipped the 2 fresh ones + (t/is (= 0 (count-telemetry-rows))))))) + +(t/deftest test-gc-keeps-events-within-7-day-window + ;; When all events are within the 7-day window, GC must not delete + ;; anything and all rows are forwarded to the batch sender. + (let [batch-events (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! batch-events batch) + true)] + (let [six-days-ago (ct/minus (ct/now) (ct/duration {:days 6}))] + (insert-telemetry-row! "recent-1" {:created-at six-days-ago :tracked-at six-days-ago}) + (insert-telemetry-row! "recent-2" {:created-at six-days-ago :tracked-at six-days-ago})) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Both events forwarded — GC left them alone + (t/is (= 2 (count @batch-events))) + (t/is (= 0 (count-telemetry-rows))))))) + +(t/deftest test-gc-deletes-only-stale-events + ;; Insert a mix of stale (8 days old) and fresh (1 day old) events. + ;; After GC, only fresh events should remain for the batch sender. + (let [batch-events (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! batch-events batch) + true)] + (let [eight-days (ct/minus (ct/now) (ct/duration {:days 8})) + one-day (ct/minus (ct/now) (ct/duration {:days 1}))] + (insert-telemetry-row! "stale" {:created-at eight-days :tracked-at eight-days}) + (insert-telemetry-row! "fresh" {:created-at one-day :tracked-at one-day})) + + (t/is (= 2 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; GC purged stale, batch shipped fresh + (t/is (= 1 (count @batch-events))) + (t/is (= "fresh" (:name (first @batch-events)))) + (t/is (= 0 (count-telemetry-rows))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; ANONYMITY TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-telemetry-rows-stored-without-pii + ;; Rows written to audit_log in telemetry mode must carry no PII: + ;; empty props, zeroed ip, profile-id=zero, source='telemetry'. + ;; Safe context fields (browser, os, version, etc.) are preserved + ;; but session-linking and access-token fields are stripped. + (with-redefs [cf/flags #{:telemetry}] + (let [_prof (th/create-profile* 1 {:is-active true}) + safe-ctx {:browser "Chrome" + :browser-version "120.0" + :os "Linux" + :version "2.0.0"}] + ;; Simulate what app.loggers.audit/process-event does in mode C + (th/db-insert! :audit-log + {:id (uuid/next) + :name "create-project" + :type "action" + :source "telemetry:backend" + :profile-id uuid/zero + :ip-addr (db/inet "0.0.0.0") + :props (db/tjson {}) + :context (db/tjson safe-ctx) + :tracked-at (ct/now) + :created-at (ct/now)}) + + (let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])] + (t/is (= "telemetry:backend" (:source row))) + ;; props are always empty + (t/is (= "{}" (str (:props row)))) + ;; ip_addr is the sentinel zero address + (t/is (= "0.0.0.0" (str (:ip-addr row)))) + ;; profile-id is uuid/zero — not a real user id + (t/is (= uuid/zero (:profile-id row))))))) + +(t/deftest test-batch-events-contain-no-pii-fields + ;; The event maps forwarded to send-event-batch must not carry props, + ;; ip-addr or profile-id. Safe context fields (browser, os, etc.) may + ;; be present but session-linking keys must be absent. + (let [captured-batch (atom nil) + ;; Insert a row that carries safe context (as the real path does) + safe-ctx {:browser "Firefox" :browser-version "121.0" + :os "macOS" :session "should-be-stripped" + :external-session-id "also-stripped"}] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! captured-batch batch) + true)] + ;; Insert with safe context already pre-filtered (as the ingest path does) + (th/db-insert! :audit-log + {:id (uuid/next) + :name "navigate" + :type "action" + :source "telemetry:frontend" + :profile-id uuid/zero + :ip-addr (db/inet "0.0.0.0") + :props (db/tjson {}) + :context (db/tjson (dissoc safe-ctx :session :external-session-id)) + :tracked-at (ct/now) + :created-at (ct/now)}) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (= 1 (count @captured-batch))) + (let [ev (first @captured-batch)] + ;; must have the core identity fields including profile-id + (t/is (contains? ev :name)) + (t/is (contains? ev :type)) + (t/is (contains? ev :source)) + (t/is (contains? ev :tracked-at)) + (t/is (contains? ev :profile-id)) + ;; props are present but empty (stripped at ingest time) + (t/is (= {} (:props ev))) + ;; ip-addr is stripped + (t/is (not (contains? ev :ip-addr))) + ;; context may be present and must not contain session-linking keys + (when-let [ctx (:context ev)] + (t/is (not (contains? ctx :session))) + (t/is (not (contains? ctx :external-session-id))) + ;; safe keys should be present + (t/is (contains? ctx :browser)))))))) + +(t/deftest test-telemetry-rows-have-day-precision-timestamps + ;; Telemetry events must be stored with timestamps truncated to day + ;; precision so that exact event timing cannot be inferred. + (with-redefs [cf/flags #{:telemetry}] + (let [process-event (ns-resolve 'app.loggers.audit 'process-event) + profile (th/create-profile* 1 {:is-active true}) + tnow (ct/now) + event {:type "action" + :name "create-project" + :profile-id (:id profile) + :source "backend" + :props {} + :context {} + :created-at tnow + :tracked-at tnow + :ip-addr "0.0.0.0"}] + (db/tx-run! th/*system* process-event event) + (let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])] + (t/is (some? row)) + (let [created-at (:created-at row) + tracked-at (:tracked-at row) + day-now (ct/truncate (ct/now) :days)] + ;; Both timestamps must equal midnight of the current day + (t/is (= day-now created-at)) + (t/is (= day-now tracked-at))))))) + +(t/deftest test-backend-ingest-full-row-shape + ;; Verify the full row shape stored by process-event in telemetry mode: + ;; source=telemetry:backend, empty props, zeroed ip, context filtered to safe + ;; backend keys only, profile-id preserved, timestamps truncated. + (with-redefs [cf/flags #{:telemetry}] + (let [process-event (ns-resolve 'app.loggers.audit 'process-event) + profile (th/create-profile* 1 {:is-active true}) + tnow (ct/now) + event {:type "action" + :name "create-project" + :profile-id (:id profile) + :source "backend" + :context {:initiator "app" + :version "2.0.0" + :client-version "1.0" + :client-user-agent "Mozilla/5.0" + :external-session-id "should-be-stripped" + :session "also-stripped"} + :props {:some-prop "value"} + :created-at tnow + :tracked-at tnow + :ip-addr "0.0.0.0"}] + (db/tx-run! th/*system* process-event event) + + (let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])] + (t/is (some? row)) + ;; source + (t/is (= "telemetry:backend" (:source row))) + ;; profile-id preserved + (t/is (= (:id profile) (:profile-id row))) + ;; name + (t/is (= "create-project" (:name row))) + ;; type + (t/is (= "action" (:type row))) + ;; props stripped to empty + (t/is (= "{}" (str (:props row)))) + ;; ip zeroed + (t/is (= "0.0.0.0" (str (:ip-addr row)))) + ;; timestamps truncated to day + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row))) + (t/is (= day-now (:tracked-at row)))) + ;; context filtered: only safe backend keys retained + (let [ctx (db/decode-transit-pgobject (:context row))] + (t/is (= "app" (:initiator ctx))) + (t/is (= "2.0.0" (:version ctx))) + (t/is (= "1.0" (:client-version ctx))) + (t/is (= "Mozilla/5.0" (:client-user-agent ctx))) + ;; session-linking keys stripped + (t/is (not (contains? ctx :external-session-id))) + (t/is (not (contains? ctx :session)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FILTER-TELEMETRY-CONTEXT UNIT TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-filter-telemetry-context-keeps-browser-fields + ;; Safe environment fields must survive the filter. + (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context) + ctx {:browser "Chrome" + :browser-version "120.0" + :engine "Blink" + :engine-version "120.0" + :os "Windows 11" + :os-version "11" + :device-type "unknown" + :device-arch "amd64" + :locale "en-US" + :version "2.0.0" + :screen-width 1920 + :screen-height 1080 + :event-origin "workspace"} + result (:context (filter-telemetry-context {:source "frontend" :context ctx}))] + (t/is (= "Chrome" (:browser result))) + (t/is (= "120.0" (:browser-version result))) + (t/is (= "Windows 11" (:os result))) + (t/is (= "en-US" (:locale result))) + (t/is (= "workspace" (:event-origin result))) + (t/is (= 1920 (:screen-width result))))) + +(t/deftest test-filter-telemetry-context-strips-pii-keys + ;; Session-linking and access-token fields must be removed. + (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context) + ctx {:browser "Firefox" + :session "abc-session-id" + :external-session-id "ext-123" + :file-stats {:total-shapes 42} + :initiator "app" + :access-token-id "tok-456" + :access-token-type "api-key"} + result (:context (filter-telemetry-context {:source "frontend" :context ctx}))] + (t/is (= "Firefox" (:browser result))) + (t/is (not (contains? result :session))) + (t/is (not (contains? result :external-session-id))) + (t/is (not (contains? result :file-stats))) + (t/is (not (contains? result :initiator))) + (t/is (not (contains? result :access-token-id))) + (t/is (not (contains? result :access-token-type))))) + +(t/deftest test-filter-telemetry-context-empty-input + ;; An empty context should return an empty map without error. + (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context)] + (t/is (= {} (:context (filter-telemetry-context {:source "frontend" :context {}})))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FILTER-TELEMETRY-PROPS UNIT TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-filter-telemetry-props-login-event-keeps-safe-profile-fields + ;; Login/register/update events carry safe profile-derived fields: + ;; :lang, :auth-backend, :email-domain. Raw :email is stripped. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)] + ;; backend login-with-password + (let [result (ftp {:source "backend" + :name "login-with-password" + :type "action" + :props {:email "user@example.com" + :fullname "John Doe" + :lang "en" + :auth-backend "password" + :id (uuid/next)}})] + (t/is (= "en" (get-in result [:props :lang]))) + (t/is (= "password" (get-in result [:props :auth-backend]))) + (t/is (= "example.com" (get-in result [:props :email-domain]))) + ;; Raw email and fullname are stripped + (t/is (not (contains? (:props result) :email))) + (t/is (not (contains? (:props result) :fullname))) + ;; UUID values survive the xf:filter-telemetry-props filter + (t/is (some? (get-in result [:props :id])))) + + ;; backend register-profile + (let [result (ftp {:source "backend" + :name "register-profile" + :type "action" + :props {:email "new@corp.org" + :lang "es" + :auth-backend "oidc"}})] + (t/is (= "es" (get-in result [:props :lang]))) + (t/is (= "oidc" (get-in result [:props :auth-backend]))) + (t/is (= "corp.org" (get-in result [:props :email-domain])))) + + ;; backend login-with-oidc + (let [result (ftp {:source "backend" + :name "login-with-oidc" + :type "action" + :props {:email "u@corp.io" :lang "fr" :auth-backend "oidc"}})] + (t/is (= "fr" (get-in result [:props :lang]))) + (t/is (= "oidc" (get-in result [:props :auth-backend]))) + (t/is (= "corp.io" (get-in result [:props :email-domain])))) + + ;; backend update-profile + (let [result (ftp {:source "backend" + :name "update-profile" + :type "action" + :props {:email "u@corp.io" :lang "de"}})] + (t/is (= "de" (get-in result [:props :lang]))) + (t/is (= "corp.io" (get-in result [:props :email-domain])))))) + +(t/deftest test-filter-telemetry-props-frontend-identify-keeps-safe-profile-fields + ;; Frontend identify events also carry safe profile-derived fields. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)] + (let [result (ftp {:source "frontend" + :name "signin" + :type "identify" + :props {:email "user@example.com" + :fullname "Jane Doe" + :lang "pt" + :auth-backend "password" + :some-string "should-be-stripped"}})] + (t/is (= "pt" (get-in result [:props :lang]))) + (t/is (= "password" (get-in result [:props :auth-backend]))) + (t/is (= "example.com" (get-in result [:props :email-domain]))) + ;; PII stripped + (t/is (not (contains? (:props result) :email))) + (t/is (not (contains? (:props result) :fullname))) + ;; String values that are not UUID/boolean/number are stripped + (t/is (not (contains? (:props result) :some-string)))))) + +(t/deftest test-filter-telemetry-props-instance-start-passthrough + ;; instance-start trigger events pass through as-is. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + props {:total-teams 5 :total-users 42 :version "2.0"} + result (ftp {:source "backend" + :name "instance-start" + :type "trigger" + :props props})] + (t/is (= props (:props result))))) + +(t/deftest test-filter-telemetry-props-generic-event-keeps-uuid-boolean-number + ;; Generic events (create-file, etc.) keep only entries + ;; whose values are UUIDs, booleans, or numbers. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + id (uuid/next) + result (ftp {:source "frontend" + :name "create-file" + :type "action" + :props {:project-id id + :team-id id + :route "dashboard-files" + :count 42 + :active true + :label "should-be-stripped"}})] + ;; UUIDs survive + (t/is (= id (get-in result [:props :project-id]))) + (t/is (= id (get-in result [:props :team-id]))) + ;; Numbers survive + (t/is (= 42 (get-in result [:props :count]))) + ;; Booleans survive + (t/is (true? (get-in result [:props :active]))) + ;; Strings are stripped + (t/is (not (contains? (:props result) :route))) + (t/is (not (contains? (:props result) :label))))) + +(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids + ;; Frontend navigate events keep specific routing keys: :route, + ;; :file-id, :team-id, :page-id. These ids are strings because + ;; routing events don't coerce them. All other props are stripped. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + file-id (str (uuid/next)) + team-id (str (uuid/next)) + page-id (str (uuid/next)) + result (ftp {:source "frontend" + :name "navigate" + :type "action" + :props {:file-id file-id + :team-id team-id + :page-id page-id + :route "dashboard-index" + :session "abc" + :count 42 + :active true + :label "should-be-stripped"}})] + ;; Allowed routing keys survive (as strings, not coerced to UUID) + (t/is (= file-id (get-in result [:props :file-id]))) + (t/is (= team-id (get-in result [:props :team-id]))) + (t/is (= page-id (get-in result [:props :page-id]))) + (t/is (= "dashboard-index" (get-in result [:props :route]))) + ;; Everything else is stripped + (t/is (not (contains? (:props result) :session))) + (t/is (not (contains? (:props result) :count))) + (t/is (not (contains? (:props result) :active))) + (t/is (not (contains? (:props result) :label))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SEND-EVENT-BATCH PAYLOAD STRUCTURE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-send-event-batch-payload-structure + ;; Verify the HTTP request sent by send-event-batch carries the + ;; correct outer wrapper: :type, :version, :instance-id, :events. + (let [captured-request (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + http-mock {:target 'app.http.client/req + :return {:status 200}}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; http/req was called (by both send-legacy-data and send-event-batch) + (t/is (:called? @http-mock)) + ;; Find the call whose body contains :telemetry-events + (let [calls (filter (fn [args] + (let [[_ request] args + body (:body request)] + (and (string? body) + (re-find #"telemetry-events" body)))) + (:call-args-list @http-mock))] + (t/is (= 1 (count calls))) + (let [[_ request] (first calls) + body (json/decode (:body request))] + ;; Outer payload fields + (t/is (= "telemetry-events" (name (:type body)))) + (t/is (string? (:version body))) + (t/is (some? (:instance-id body))) + ;; :events is a base64-encoded blob + (t/is (string? (:events body))) + (t/is (pos? (count (:events body)))))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TASK BRANCH COVERAGE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-enabled-no-subs-no-events-legacy-still-sends + ;; When telemetry is enabled, there are no newsletter subscriptions + ;; and no audit_log rows, the legacy report must still be sent. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + ;; No profiles with newsletter-updates, no telemetry rows + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Legacy report was sent + (t/is (:called? @legacy-mock)) + (let [[_ data] (:call-args @legacy-mock)] + (t/is (= :telemetry-legacy-report (:type data))) + (t/is (contains? data :stats)) + ;; No subscriptions in the payload + (t/is (not (contains? data :subscriptions)))) + + ;; No events to batch-send + (t/is (not (:called? @batch-mock)))))) + +(t/deftest test-legacy-succeeds-batch-fails + ;; The legacy report and event batch are independent paths. + ;; When the batch endpoint fails, the legacy report must still + ;; have been sent successfully. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return false}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Legacy report was sent + (t/is (:called? @legacy-mock)) + (let [[_ data] (:call-args @legacy-mock)] + (t/is (= :telemetry-legacy-report (:type data)))) + + ;; Batch send was attempted but failed + (t/is (:called? @batch-mock)) + ;; Row still present (not deleted on failure) + (t/is (= 1 (count-telemetry-rows)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GC + BATCH FAILURE INTERACTION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-gc-runs-even-when-batch-fails + ;; GC must purge stale events regardless of whether the subsequent + ;; batch send succeeds or fails. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return false}] + (with-redefs [cf/flags #{:telemetry}] + (let [eight-days (ct/minus (ct/now) (ct/duration {:days 8})) + one-day (ct/minus (ct/now) (ct/duration {:days 1}))] + ;; Stale events (should be GC'd) + (insert-telemetry-row! "stale-1" {:created-at eight-days :tracked-at eight-days}) + (insert-telemetry-row! "stale-2" {:created-at eight-days :tracked-at eight-days}) + ;; Fresh event (should survive GC but fail to send) + (insert-telemetry-row! "fresh" {:created-at one-day :tracked-at one-day}) + + (t/is (= 3 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Batch send was attempted (and failed) + (t/is (:called? @batch-mock)) + ;; Stale rows were purged by GC, fresh row remains + (t/is (= 1 (count-telemetry-rows))) + (t/is (= "fresh" (:name (first (th/db-exec! ["SELECT name FROM audit_log WHERE source LIKE 'telemetry:%'"]))))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; ROW->EVENT CONTEXT GUARANTEE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-row->event-always-includes-context + ;; row->event must always include :context as a map, even when the + ;; DB column contains an empty transit object. + (let [row->event (ns-resolve 'app.tasks.telemetry 'row->event)] + ;; With non-empty context + (let [ev (row->event {:name "test" :type "action" :source "telemetry:backend" + :tracked-at (ct/now) :profile-id uuid/zero + :context (db/tjson {:browser "Chrome"})})] + (t/is (contains? ev :context)) + (t/is (= {:browser "Chrome"} (:context ev)))) + + ;; With empty context ({} in transit) + (let [ev (row->event {:name "test" :type "action" :source "telemetry:backend" + :tracked-at (ct/now) :profile-id uuid/zero + :context (db/tjson {})})] + (t/is (contains? ev :context)) + (t/is (= {} (:context ev)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; NO DUPLICATE EVENTS ON SUCCESS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-no-duplicate-events-after-successful-send + ;; After a successful batch send, the sent rows must be deleted. + ;; Running the task again must NOT re-send the same events. + (let [send-count (atom 0)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg _batch] + (swap! send-count inc) + true)] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (t/is (= 2 (count-telemetry-rows))) + + ;; First run: sends and deletes + (th/run-task! :telemetry {:send? true :enabled? true}) + (t/is (= 1 @send-count)) + (t/is (= 0 (count-telemetry-rows))) + + ;; Second run: no events to send + (th/run-task! :telemetry {:send? true :enabled? true}) + (t/is (= 1 @send-count)) ;; still 1, not 2 + (t/is (= 0 (count-telemetry-rows))))))) diff --git a/backend/test/backend_tests/test_files/sample.png b/backend/test/backend_tests/test_files/sample.png new file mode 100644 index 00000000000..586da94a962 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.png differ diff --git a/backend/test/backend_tests/test_files/sample.webp b/backend/test/backend_tests/test_files/sample.webp new file mode 100644 index 00000000000..be1eb528b58 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.webp differ diff --git a/backend/test/backend_tests/util_blob_test.clj b/backend/test/backend_tests/util_blob_test.clj new file mode 100644 index 00000000000..fd474f9d88e --- /dev/null +++ b/backend/test/backend_tests/util_blob_test.clj @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.util-blob-test + (:require + [app.util.blob :as blob] + [clojure.string :as str] + [clojure.test :as t])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; encode-str / decode-str round-trip +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest encode-str-roundtrip-empty-map + (let [data {}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-empty-vector + (let [data []] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-nil + (let [data nil] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-simple-map + (let [data {:name "penpot" :version 42}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-nested-structure + (let [data {:users [{:name "Alice" :tags #{"admin" "active"}} + {:name "Bob" :tags #{"user"}}] + :config {:debug false :timeout 3000}}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-vector-of-maps + (let [data [{:name "navigate" :type "action" :source "telemetry"} + {:name "create-file" :type "action" :source "telemetry"}]] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-keywords-and-strings + (let [data {:keyword/value :foo + :string/value "hello world" + :boolean/value true + :nil/value nil}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-numeric-types + (let [data {:int 42 + :neg -7 + :zero 0 + :big 9999999999}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; URL-safe encoding properties +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest encode-str-url-safe-no-unsafe-chars + ;; URL-safe base64 must not contain +, /, or padding = + (let [data {:a (apply str (repeat 100 "x")) + :b (range 200) + :c {"key" "value with special chars: @#$%^&*()"}} + encoded (blob/encode-str data)] + (t/is (not (str/includes? encoded "+"))) + (t/is (not (str/includes? encoded "/"))) + (t/is (not (str/includes? encoded "="))))) + +(t/deftest encode-str-url-safe-roundtrip-after-encoding + ;; Ensure the URL-safe encoding still round-trips correctly + (let [data {:payload (vec (range 500)) + :nested {:a {:b {:c "deep"}}}} + encoded (blob/encode-str data) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; version-specific encoding +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest encode-str-with-version-4 + (let [data {:events [{:name "click"} {:name "scroll"}]} + encoded (blob/encode-str data {:version 4}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +(t/deftest encode-str-with-version-5 + (let [data {:events [{:name "click"} {:name "scroll"}]} + encoded (blob/encode-str data {:version 5}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +(t/deftest encode-str-with-version-1 + (let [data {:simple "data"} + encoded (blob/encode-str data {:version 1}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +(t/deftest encode-str-with-version-3 + (let [data {:simple "data"} + encoded (blob/encode-str data {:version 3}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj new file mode 100644 index 00000000000..04e86291fd8 --- /dev/null +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -0,0 +1,176 @@ +;; 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 + +(ns backend-tests.util-ssrf-test + (:require + [app.common.exceptions :as ex] + [app.config :as cf] + [app.http.client :as http] + [app.util.ssrf :as ssrf] + [clojure.test :as t])) + +(t/deftest validate-url-allows-public-https + (t/is (true? (ssrf/safe-url? "https://example.com/foo"))) + (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1")))) + +(t/deftest validate-url-allows-public-http + (t/is (true? (ssrf/safe-url? "http://example.com/foo")))) + +(t/deftest validate-url-blocks-disallowed-schemes + (t/is (false? (ssrf/safe-url? "file:///etc/passwd"))) + (t/is (false? (ssrf/safe-url? "gopher://example.com"))) + (t/is (false? (ssrf/safe-url? "ftp://example.com"))) + (t/is (false? (ssrf/safe-url? "dict://example.com"))) + (t/is (false? (ssrf/safe-url? "data:text/html,<h1>hi</h1>"))) + (t/is (false? (ssrf/safe-url? "jar:http://example.com!/foo"))) + (t/is (false? (ssrf/safe-url? "javascript:alert(1)")))) + +(t/deftest validate-url-blocks-loopback + (t/is (false? (ssrf/safe-url? "http://127.0.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://127.0.0.2/foo"))) + (t/is (false? (ssrf/safe-url? "http://[::1]/foo")))) + +(t/deftest validate-url-blocks-any-local + (t/is (false? (ssrf/safe-url? "http://0.0.0.0/foo"))) + (t/is (false? (ssrf/safe-url? "http://[::]/foo")))) + +(t/deftest validate-url-blocks-link-local + (t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/"))) + (t/is (false? (ssrf/safe-url? "http://169.254.1.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://[fe80::1]/foo")))) + +(t/deftest validate-url-blocks-site-local + (t/is (false? (ssrf/safe-url? "http://10.0.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://172.16.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://192.168.1.1/foo")))) + +(t/deftest validate-url-blocks-cloud-metadata + (t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/iam/security-credentials/role"))) + (t/is (false? (ssrf/safe-url? "http://[fd00:ec2::254]/foo")))) + +(t/deftest validate-url-blocks-carrier-grade-nat + (t/is (false? (ssrf/safe-url? "http://100.64.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://100.127.255.255/foo"))) + ;; Just outside the range should be allowed (but may be blocked by DNS resolution failing) + ;; We test boundary: 100.63.255.255 is outside 100.64.0.0/10 + ;; But we can't easily test the "allowed" side without DNS, so we test the blocked side. + + ;; Test RFC reserved ranges + (t/is (false? (ssrf/safe-url? "http://240.0.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://255.255.255.255/foo")))) + +(t/deftest validate-url-blocks-ipv6-ula + (t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo"))) + (t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo")))) + +(t/deftest validate-url-blocks-encoded-loopback + ;; Decimal encoding of 127.0.0.1 = 2130706433 + ;; InetAddress normalizes this to 127.0.0.1 + (t/is (false? (ssrf/safe-url? "http://2130706433/foo"))) + ;; Hex encoding 0x7f000001 + (t/is (false? (ssrf/safe-url? "http://0x7f000001/foo")))) + +(t/deftest validate-url-blocks-ipv4-mapped-loopback + (t/is (false? (ssrf/safe-url? "http://[::ffff:127.0.0.1]/foo")))) + +(t/deftest validate-url-blocks-multicast + (t/is (false? (ssrf/safe-url? "http://224.0.0.1/foo")))) + +(t/deftest validate-url-blocks-missing-scheme + (t/is (false? (ssrf/safe-url? "example.com/foo"))) + (t/is (false? (ssrf/safe-url? "")))) + +(t/deftest validate-url-blocks-missing-host + (t/is (false? (ssrf/safe-url? "http:///path"))) + (t/is (false? (ssrf/safe-url? "http://")))) + +(t/deftest validate-url-resolves-dns + ;; DNS-resolved internal: we use with-redefs to simulate + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "evil.internal") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "127.0.0.1")]) + (original hostname)))] + (t/is (false? (ssrf/safe-url? "http://evil.internal/foo"))) + ;; A hostname that fails DNS resolution + (t/is (false? (ssrf/safe-url? "http://nonexistent.invalid/foo")))))) + +(t/deftest validate-url-dns-all-addresses-must-be-safe + ;; If a hostname resolves to both a public and a private IP, it must be blocked + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "split-brain.example") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "1.1.1.1") + (java.net.InetAddress/getByName "127.0.0.1")]) + (original hostname)))] + (t/is (false? (ssrf/safe-url? "http://split-brain.example/foo")))))) + +(t/deftest validate-url-allowlist-override + (let [original-get cf/get] + (with-redefs [cf/get (fn [key & args] + (if (= key :ssrf-allowed-hosts) + #{"localhost"} + (apply original-get key args)))] + ;; localhost resolves to 127.0.0.1 which would normally be blocked + (t/is (true? (ssrf/safe-url? "http://localhost:6060/foo")))))) + +(t/deftest validate-url-extra-cidrs + (binding [ssrf/extra-blocked-cidrs #{(ssrf/parse-cidr "203.0.113.0/24")}] + (t/is (false? (ssrf/safe-url? "http://203.0.113.1/foo"))))) + +(t/deftest validate-url-throw-on-blocked + (try + (ssrf/validate-uri "http://127.0.0.1/foo") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :ssrf-blocked-target (:code (ex-data e))))))) + +;; --------------------------------------------------------------------------- +;; http/req automatic SSRF validation +;; --------------------------------------------------------------------------- + +(t/deftest http-req-validates-ssrf-by-default + ;; `http/req` should invoke ssrf/validate-uri before sending the request. + ;; We verify this by checking that a blocked URI raises an SSRF error + ;; without ever reaching the network (validate-uri throws first). + (try + (http/req {} {:method :get :uri "http://127.0.0.1/secret"}) + (t/is false "should have thrown an SSRF error") + (catch Exception e + (t/is (= :ssrf-blocked-target (:code (ex-data e))))))) + +(t/deftest http-req-skip-ssrf-check-bypasses-validation + ;; When :skip-ssrf-check? true is passed, ssrf/validate-uri must NOT be + ;; called. We verify by patching validate-uri to record whether it was called. + (let [called? (atom false)] + (with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))] + ;; The request will fail at the network level (no real server), but that's + ;; fine — we only care that validate-uri was not called beforehand. + (try + (http/req {} {:method :get :uri "http://127.0.0.1/secret"} {:skip-ssrf-check? true}) + (catch Exception _)) + (t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true")))) + +(t/deftest http-req-with-redirects-validates-ssrf-by-default + ;; req-with-redirects must also validate the initial URI automatically. + (try + (http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"}) + (t/is false "should have thrown an SSRF error") + (catch Exception e + (t/is (= :ssrf-blocked-target (:code (ex-data e))))))) + +(t/deftest http-req-with-redirects-skip-ssrf-check-bypasses-validation + (let [called? (atom false)] + (with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))] + (try + (http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"} {:skip-ssrf-check? true}) + (catch Exception _)) + (t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true")))) diff --git a/common/deps.edn b/common/deps.edn index 01b6d33df4c..74fcef0f28b 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -1,23 +1,23 @@ {:deps - {org.clojure/clojure {:mvn/version "1.12.4"} - org.clojure/data.json {:mvn/version "2.5.1"} + {org.clojure/clojure {:mvn/version "1.12.5"} + org.clojure/data.json {:mvn/version "2.5.2"} org.clojure/tools.cli {:mvn/version "1.1.230"} org.clojure/test.check {:mvn/version "1.1.1"} - org.clojure/data.fressian {:mvn/version "1.1.0"} + org.clojure/data.fressian {:mvn/version "1.1.1"} org.clojure/clojurescript {:mvn/version "1.12.42"} org.apache.commons/commons-pool2 {:mvn/version "2.12.1"} ;; Logging - org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"} - org.slf4j/slf4j-api {:mvn/version "2.0.17"} + org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"} + org.slf4j/slf4j-api {:mvn/version "2.0.18"} pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"} - selmer/selmer {:mvn/version "1.12.70"} + selmer/selmer {:mvn/version "1.13.1"} criterium/criterium {:mvn/version "0.4.6"} metosin/jsonista {:mvn/version "0.3.13"} @@ -55,12 +55,12 @@ :aliases {:dev {:extra-deps - {org.clojure/tools.namespace {:mvn/version "RELEASE"} + {org.clojure/tools.namespace {:mvn/version "1.5.0"} thheller/shadow-cljs {:mvn/version "3.2.0"} - com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} - com.bhauman/rebel-readline {:mvn/version "RELEASE"} + com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} + com.bhauman/rebel-readline {:mvn/version "0.1.5"} criterium/criterium {:mvn/version "0.4.6"} - mockery/mockery {:mvn/version "RELEASE"}} + mockery/mockery {:mvn/version "0.1.4"}} :extra-paths ["test" "dev"]} :build diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 7d23edeafef..87b4f72ad50 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -279,3 +279,10 @@ [o] #?(:cljs (.-byteLength ^js o) :clj (.capacity ^ByteBuffer o))) + +#?(:clj + (defn set-order + "Set the byte order on a ByteBuffer. Returns the buffer." + [^ByteBuffer buffer ^ByteOrder order] + (.order buffer order) + buffer)) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index e16acf94a36..ab7c7e2a76c 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -487,62 +487,3 @@ b (+ (* bh 100) (* bv 10))] (compare a b))) -(defn interpolate-color - [c1 c2 offset] - (cond - (<= offset (:offset c1)) (assoc c1 :offset offset) - (>= offset (:offset c2)) (assoc c2 :offset offset) - - :else - (let [tr-offset (/ (- offset (:offset c1)) (- (:offset c2) (:offset c1))) - [r1 g1 b1] (hex->rgb (:color c1)) - [r2 g2 b2] (hex->rgb (:color c2)) - a1 (:opacity c1) - a2 (:opacity c2) - r (+ r1 (* (- r2 r1) tr-offset)) - g (+ g1 (* (- g2 g1) tr-offset)) - b (+ b1 (* (- b2 b1) tr-offset)) - a (+ a1 (* (- a2 a1) tr-offset))] - {:color (rgb->hex [r g b]) - :opacity a - :r r - :g g - :b b - :alpha a - :offset offset}))) - -(defn- offset-spread - [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) - -(defn uniform-spread? - "Checks if the gradient stops are spread uniformly" - [stops] - (let [cs (count stops) - from (first stops) - to (last stops) - expect-vals (offset-spread (:offset from) (:offset to) cs) - - calculate-expected - (fn [expected-offset stop] - (and (mth/close? (:offset stop) expected-offset) - (let [ec (interpolate-color from to expected-offset)] - (and (= (:color ec) (:color stop)) - (= (:opacity ec) (:opacity stop))))))] - (->> (map calculate-expected expect-vals stops) - (every? true?)))) - -(defn uniform-spread - "Assign an uniform spread to the offset values for the gradient" - [from to num-stops] - (->> (offset-spread (:offset from) (:offset to) num-stops) - (mapv (fn [offset] - (interpolate-color from to offset))))) - -(defn interpolate-gradient - [stops offset] - (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) - end (if (nil? idx) (last stops) (get stops idx))] - (interpolate-color start end offset))) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4cb6cedc600..75f103e4969 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.data - "A collection if helpers for working with data structures and other + "A collection of helpers for working with data structures and other data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat @@ -143,7 +143,7 @@ (oassoc-in o (cons k ks) v))) (defn vec2 - "Creates a optimized vector compatible type of length 2 backed + "Creates an optimized vector compatible type of length 2 backed internally with MapEntry impl because it has faster access method for its fields." [o1 o2] @@ -252,13 +252,13 @@ ([items] (enumerate items 0)) ([items start] (loop [idx start - items items + items (seq items) res (transient [])] - (if (empty? items) - (persistent! res) + (if items (recur (inc idx) - (rest items) - (conj! res [idx (first items)])))))) + (next items) + (conj! res [idx (first items)])) + (persistent! res))))) (defn group-by ([kf coll] (group-by kf identity [] coll)) @@ -291,15 +291,12 @@ (defn index-of-pred [coll pred] - (loop [c (first coll) - coll (rest coll) + (loop [s (seq coll) index 0] - (if (nil? c) - nil - (if (pred c) + (when s + (if (pred (first s)) index - (recur (first coll) - (rest coll) + (recur (next s) (inc index)))))) (defn index-of @@ -377,7 +374,7 @@ (assoc object key nil) (nil? value) - (dissoc object key value) + (dissoc object key) :else (assoc object key value))) @@ -396,7 +393,7 @@ (subvec v (inc index)))) (defn without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) @@ -404,7 +401,7 @@ (map vector col1 col2)) (defn zip-all - "Return a zip of both collections, extended to the lenght of the longest one, + "Return a zip of both collections, extended to the length of the longest one, and padding the shorter one with nils as needed." [col1 col2] (let [diff (- (count col1) (count col2))] @@ -423,9 +420,9 @@ coll))) (defn removev - "Returns a vector of the items in coll for which (fn item) returns logical false" - [fn coll] - (filterv (comp not fn) coll)) + "Returns a vector of the items in coll for which (pred item) returns logical false" + [pred coll] + (filterv (comp not pred) coll)) (defn filterm "Filter values of a map that satisfy a predicate" @@ -443,7 +440,7 @@ Optional parameters: `pred?` A predicate that if not satisfied won't process the pair - `target?` A collection that will be used as seed to be stored + `target` A collection that will be used as seed to be stored Example: (map-perm vector [1 2 3 4]) => [[1 2] [1 3] [1 4] [2 3] [2 4] [3 4]]" @@ -602,12 +599,9 @@ (let [do-map (fn [entry] (let [[k v] (mfn entry)] - (cond - (or (vector? v) (map? v)) + (if (or (vector? v) (map? v)) [k (deep-mapm mfn v)] - - :else - (mfn [k v]))))] + [k v])))] (cond (map? m) (into {} (map do-map) m) @@ -724,7 +718,7 @@ (defn nan? [v] #?(:cljs (js/isNaN v) - :clj (not= v v))) + :clj (and (number? v) (Double/isNaN v)))) (defn- impl-parse-integer [v] @@ -788,7 +782,8 @@ (not (js/isNaN v)) (not (js/isNaN (parse-double v)))) - :clj (not= (parse-double v :nan) :nan))) + :clj (and (string? v) + (not= (parse-double v :nan) :nan)))) (defn read-string [v] @@ -958,7 +953,7 @@ (assoc diff key (map-diff v1 v2)) :else - (assoc diff key [(get m1 key) (get m2 key)]))))] + (assoc diff key [v1 v2]))))] (->> keys (reduce diff-attr {})))) @@ -1123,8 +1118,7 @@ ([value {:keys [precision] :or {precision 2}}] (let [value (if (string? value) (parse-double value) value)] (when (num? value) - (let [value (format-precision value precision)] - (str value)))))) + (format-precision value precision))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols @@ -1152,16 +1146,17 @@ "Wrapper around subvec so it doesn't throw an exception but returns nil instead" ([v start] (when (and (some? v) - (> start 0) (< start (count v))) + (>= start 0) (< start (count v))) (subvec v start))) ([v start end] - (let [size (count v)] - (when (and (some? v) - (>= start 0) (< start size) - (>= end 0) (<= start end) (<= end size)) - (subvec v start end))))) + (when (some? v) + (let [size (count v)] + (when (and (>= start 0) (< start size) + (>= end 0) (<= start end) (<= end size)) + (subvec v start end)))))) (defn append-class [class current-class] - (str (if (some? class) (str class " ") "") - current-class)) + (if (seq class) + (str class " " current-class) + current-class)) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 4354986b8dd..cc3dd118792 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -356,7 +356,7 @@ :code :empty-children :hint "expected a group with at least one shape for creating a bool")) - (let [head (if (= type :difference) + (let [head (if (= (:bool-type bool-shape) :difference) (first children) (last children)) fills (if (and (contains? head :svg-attrs) (empty? (:fills head))) @@ -364,7 +364,7 @@ (get head :fills))] (-> bool-shape (assoc :fills fills) - (assoc :stroks (get head :strokes)))))) + (assoc :strokes (get head :strokes)))))) (defn add-bool [state params] @@ -576,7 +576,7 @@ {:keys [id width height name]} (-> params (update :id default-uuid) - (check-add-file-media params))] + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8673ef81e32..c9793434b72 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -439,7 +439,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 816bc2edbb6..64cb7f9d68b 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -152,7 +152,9 @@ :redis-cache ;; Activates the nitrate module - :nitrate}) + :nitrate + + :mcp}) (def all-flags (set/union email login varia)) diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index 7e35f3116e2..dacb12e3956 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -17,11 +17,11 @@ java.util.List linked.map.LinkedMap linked.set.LinkedSet + org.fressian.handlers.ReadHandler + org.fressian.handlers.WriteHandler org.fressian.Reader org.fressian.StreamingWriter - org.fressian.Writer - org.fressian.handlers.ReadHandler - org.fressian.handlers.WriteHandler)) + org.fressian.Writer)) (set! *warn-on-reflection* true) diff --git a/common/src/app/common/generic_pool.clj b/common/src/app/common/generic_pool.clj index bccf0b06ec0..950506dc174 100644 --- a/common/src/app/common/generic_pool.clj +++ b/common/src/app/common/generic_pool.clj @@ -8,11 +8,11 @@ (:refer-clojure :exclude [get]) (:import java.lang.AutoCloseable + org.apache.commons.pool2.impl.DefaultPooledObject + org.apache.commons.pool2.impl.SoftReferenceObjectPool org.apache.commons.pool2.ObjectPool org.apache.commons.pool2.PooledObject - org.apache.commons.pool2.PooledObjectFactory - org.apache.commons.pool2.impl.DefaultPooledObject - org.apache.commons.pool2.impl.SoftReferenceObjectPool)) + org.apache.commons.pool2.PooledObjectFactory)) (defn pool? [o] diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index bdd80b97416..f363329f2d1 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -13,3 +13,10 @@ unit tests or backend code for logs or error messages." [key & _args] key) + +(defn c + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [x] + x) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a162561d1a3..8dbb3970f2e 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2006,14 +2006,17 @@ (defn- switch-fixed-layout-geom-change-value [prev-shape ; The shape before the switch current-shape ; The shape after the switch (a clean copy) + origin-shape ; The original shape attr] ;; When there is a layout with fixed h or v sizing, we need ;; to keep the width/height (and recalculate selrect and points) (let [prev-width (-> prev-shape :selrect :width) current-width (-> current-shape :selrect :width) + origin-width (-> origin-shape :selrect :width) prev-height (-> prev-shape :selrect :height) current-height (-> current-shape :selrect :height) + origin-height (-> origin-shape :selrect :height) x (-> current-shape :selrect :x) y (-> current-shape :selrect :y) @@ -2024,10 +2027,16 @@ final-width (if (= :fix h-sizing) current-width - prev-width) + (if (= origin-width current-width) + prev-width ;; same-size: preserve override + current-width)) ;; different-size: use new component's + final-height (if (= :fix v-sizing) current-height - prev-height) + (if (= origin-height current-height) + prev-height ;; same-size: preserve override + current-height)) ;; different-size: use new component's + selrect (assoc (:selrect current-shape) :width final-width :height final-height @@ -2056,6 +2065,25 @@ (or (:transform current-shape) (gmt/matrix))))))) +(defn- equal-geometry? + "Returns true when the value of `attr` in `shape` is considered equal + to the corresponding value in `origin-shape`, ignoring positional + displacement (x/y). + For :selrect we compare width/height only; + for :points we normalise each vector so the first point is the + origin before comparing." + [shape origin-shape attr] + (or (and (= attr :selrect) + (= (-> shape :selrect :width) (-> origin-shape :selrect :width)) + (= (-> shape :selrect :height) (-> origin-shape :selrect :height))) + (and (= attr :points) + (let [normalize-pts (fn [pts] + (when (seq pts) + (let [f (first pts)] + (mapv #(gpt/subtract % f) pts))))] + (= (normalize-pts (get shape :points)) + (normalize-pts (get origin-shape :points))))))) + (defn update-attrs-on-switch "Copy attributes that have changed in the shape previous to the switch @@ -2092,8 +2120,9 @@ ;; If the values are already equal, don't copy them (= (get previous-shape attr) (get current-shape attr)) - ;; If the value is the same as the origin, don't copy it - (= (get previous-shape attr) (get origin-ref-shape attr)) + ;; If :selrect/:points values are already equal ignoring displacement, + ;; don't copy them + (equal-geometry? previous-shape origin-ref-shape attr) ;; If the attr is not touched, don't copy it (not (touched attr-group)) @@ -2143,8 +2172,21 @@ skip-operations? (or skip-operations? ;; If we are going to reset the position data, skip the selrect attr - (and reset-pos-data? (= attr :selrect))) - + (and reset-pos-data? (= attr :selrect)) + ;; Avoid copying composite geometry attrs (:selrect/:points) when the + ;; variant dimensions differ but neither sizing is :fix. Without this, + ;; :width/:height are correctly skipped by the check above + ;; but :selrect/:points would still carry the old override dimensions, + ;; leaving the shape in an inconsistent state. When :fix sizing is + ;; present, switch-fixed-layout-geom-change-value handles the composite + ;; attrs and must NOT be bypassed. Path shapes are also handled + ;; separately via switch-path-change-value. + (and (contains? #{:selrect :points} attr) + (not path-change?) + (not (or (= :fix (:layout-item-h-sizing previous-shape)) + (= :fix (:layout-item-v-sizing previous-shape)))) + (or (not= (get origin-ref-shape :width) (get current-shape :width)) + (not= (get origin-ref-shape :height) (get current-shape :height))))) attr-val (when-not skip-operations? (cond @@ -2168,7 +2210,7 @@ (and (or (= :fix (:layout-item-h-sizing previous-shape)) (= :fix (:layout-item-v-sizing previous-shape))) (contains? #{:points :selrect :width :height} attr)) - (switch-fixed-layout-geom-change-value previous-shape current-shape attr) + (switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr) :else (get previous-shape attr))) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 4cdf8488ce0..721bea2dc69 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -12,8 +12,8 @@ (def font-types #{"font/ttf" "font/woff" - "font/otf" - "font/opentype"}) + "font/woff2" + "font/otf"}) (def image-types #{"image/jpeg" diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 16bc20d5f46..9c6ec1aa9ef 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.schema - (:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys]) + (:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-keys]) #?(:cljs (:require-macros [app.common.schema :refer [ignoring]])) (:require #?(:clj [malli.dev.pretty :as mdp]) @@ -93,6 +93,11 @@ [& items] (apply mu/merge (map schema items))) +(defn select-keys + [s keys & {:as opts}] + (let [s (schema s)] + (mu/select-keys s keys opts))) + (defn assoc-key "Add a key & value to a schema of type [:map]. If the first level node of the schema is not a map, will do a depth search to find the first map node and add the key there." @@ -138,10 +143,10 @@ (mu/optional-keys schema keys default-options))) (defn required-keys - ([schema] - (mu/required-keys schema nil default-options)) - ([schema keys] - (mu/required-keys schema keys default-options))) + ([s] + (mu/required-keys (schema s) nil default-options)) + ([s keys] + (mu/required-keys (schema s) keys default-options))) (defn transformer [& transformers] @@ -646,7 +651,7 @@ {:title "set" :description "Set of Strings" :error/message "should be a set of strings" - :gen/gen (-> kind sg/generator sg/set) + :gen/gen (sg/mcat (fn [_] (sg/generator kind)) sg/int) :decode/string decode :decode/json decode :encode/string encode-string diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc new file mode 100644 index 00000000000..93903c1b9cd --- /dev/null +++ b/common/src/app/common/schema/messages.cljc @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.schema.messages + (:require + [app.common.data :as d] + [app.common.i18n :as i18n :refer [tr]] + [app.common.schema :as sm] + [malli.core :as m])) + +;; --- Handlers Helpers + +(defn- translate-code + [code] + (if (vector? code) + (tr (nth code 0) (i18n/c (nth code 1))) + (tr code))) + +(defn- handle-error-fn + [props problem] + (let [v-fn (:error/fn props) + result (v-fn problem)] + (if (string? result) + {:message result} + {:message (or (some-> (get result :code) + (translate-code)) + (get result :message) + (tr "errors.invalid-data"))}))) + +(defn- handle-error-message + [props] + {:message (get props :error/message)}) + +(defn- handle-error-code + [props] + (let [code (get props :error/code)] + {:message (translate-code code)})) + +(defn interpret-schema-problem + [acc {:keys [schema in value type] :as problem}] + (let [props (m/properties schema) + tprops (m/type-properties schema) + field (or (:error/field props) + in) + field (if (vector? field) + field + [field])] + + (if (and (= 1 (count field)) + (contains? acc (first field))) + acc + (cond + (or (nil? field) + (empty? field)) + acc + + (or (= type :malli.core/missing-key) + (nil? value)) + (assoc-in acc field {:message (tr "errors.field-missing")}) + + ;; --- CHECK on schema props + (contains? props :error/fn) + (assoc-in acc field (handle-error-fn props problem)) + + (contains? props :error/message) + (assoc-in acc field (handle-error-message props)) + + (contains? props :error/code) + (assoc-in acc field (handle-error-code props)) + + ;; --- CHECK on type props + (contains? tprops :error/fn) + (assoc-in acc field (handle-error-fn tprops problem)) + + (contains? tprops :error/message) + (assoc-in acc field (handle-error-message tprops)) + + (contains? tprops :error/code) + (assoc-in acc field (handle-error-code tprops)) + + :else + (assoc-in acc field {:message (tr "errors.invalid-data")}))))) + + + +(defn- apply-validators + [validators state errors] + (reduce (fn [errors validator-fn] + (merge errors (validator-fn errors (:data state)))) + errors + validators)) + +(defn collect-schema-errors + [schema validators state] + (let [explain (sm/explain schema (:data state)) + errors (->> (reduce interpret-schema-problem {} (:errors explain)) + (apply-validators validators state))] + + (-> (:errors state) + (merge errors) + (d/without-nils) + (not-empty)))) diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc index 38af563499f..d6f0d6cacc5 100644 --- a/common/src/app/common/spec.cljc +++ b/common/src/app/common/spec.cljc @@ -113,12 +113,19 @@ (tgen/fmap keyword))))) ;; --- SPEC: email +;; +;; Regex rules enforced: +;; local part - valid RFC chars, no leading/trailing dot, no consecutive dots +;; domain - labels can't start/end with hyphen, no empty labels +;; TLD - at least 2 alphabetic chars -(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") +(def email-re + #"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}$") (defn parse-email [s] - (some->> s (re-seq email-re) first)) + (when (and (string? s) (re-matches email-re s)) + s)) (letfn [(conformer [v] (or (parse-email v) ::s/invalid)) @@ -126,11 +133,10 @@ (dm/str v))] (s/def ::email (s/with-gen (s/conformer conformer unformer) - #(as-> (tgen/let [p1 (s/gen ::not-empty-string) - p2 (s/gen ::not-empty-string) - p3 (tgen/elements ["com" "net"])] - (str p1 "@" p2 "." p3)) $ - (tgen/such-that (partial re-matches email-re) $ 50))))) + #(tgen/let [local (tgen/string-alphanumeric 1 20) + label (tgen/string-alphanumeric 2 10) + tld (tgen/elements ["com" "net" "org" "io" "co" "dev"])] + (str local "@" label "." tld))))) ;; -- SPEC: uri diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index c4532c4ac04..ae56250d969 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -720,8 +720,10 @@ (defn- offset-spread [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) + (if (<= num 1) + [from] + (->> (range 0 num) + (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2))))) (defn uniform-spread? "Checks if the gradient stops are spread uniformly" @@ -750,6 +752,9 @@ (defn interpolate-gradient [stops offset] (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) + start (cond + (nil? idx) (last stops) + (= idx 0) (first stops) + :else (get stops (dec idx))) end (if (nil? idx) (last stops) (get stops idx))] (interpolate-color start end offset))) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 324528854ba..b72ca11179d 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -106,8 +106,9 @@ (let [shape (get objects id)] (if (and (ctk/instance-head? shape) (seq children)) children - (into (conj children shape) - (mapcat #(get-children-rec children %) (:shapes shape))))))] + (let [children' (conj children shape)] + (into children' + (mapcat #(get-children-rec children' %) (:shapes shape)))))))] (get-children-rec [] id))) (defn get-component-shape @@ -440,7 +441,7 @@ (if (ctk/main-instance? shape) [shape] (if-let [children (cfh/get-children objects (:id shape))] - (mapcat collect-main-shapes children objects) + (mapcat #(collect-main-shapes % objects) children) []))) (defn get-component-from-shape diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 06475d183f6..b429c67b9ce 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -380,7 +380,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-fill dbuffer mbuffer i) default)) diff --git a/common/src/app/common/types/font.cljc b/common/src/app/common/types/font.cljc new file mode 100644 index 00000000000..61ee5a60598 --- /dev/null +++ b/common/src/app/common/types/font.cljc @@ -0,0 +1,21 @@ +;; 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 + +(ns app.common.types.font + (:require + [app.common.schema :as sm])) + +(def ^:private font-family-re + ;; \p{L} (Unicode letter) works in Java regex natively, but in JavaScript it + ;; requires the "u" flag which ClojureScript regex literals don't support. + #?(:clj #"[\p{L}\d _.-]+" + :cljs (js/RegExp. "[\\p{L}\\d _.-]+" "u"))) + +(def schema:font-family + [:and + [::sm/text {:max 250}] + [:fn {:error/code "errors.font-family-invalid-chars"} + (fn [s] (boolean (re-matches font-family-re s)))]]) diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index d08330765ce..3604961f117 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -278,7 +278,7 @@ (set! (.-cache this) (c/-assoc cache k v)) v) (do - (set! (.-cache this) (assoc cache key nil)) + (set! (.-cache this) (assoc cache k nil)) nil)))) (-lookup [this k not-found] diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 757b9f1e959..f3b7c635abe 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -23,7 +23,7 @@ #?(:clj (set! *warn-on-reflection* true)) -(def ^:cosnt bool-group-style-properties bool/group-style-properties) +(def ^:const bool-group-style-properties bool/group-style-properties) (def ^:const bool-style-properties bool/style-properties) (defn get-default-bool-fills @@ -79,7 +79,7 @@ (defn close-subpaths "Given a content, searches a path for possible subpaths that can create closed loops and merge them; then return the transformed path - conten as PathData instance" + content as PathData instance" [content] (-> (subpath/close-subpaths content) (impl/from-plain))) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 2db3fcb2e94..bf3586fb0db 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -30,6 +30,18 @@ #?(:clj (set! *warn-on-reflection* true)) (def ^:const SEGMENT-U8-SIZE 28) + +(defn- normalize-coord + "Normalize a coordinate value to be within safe integer bounds. + Clamps values greater than max-safe-int to max-safe-int, + and values less than min-safe-int to min-safe-int. + Always returns a double." + [v] + (cond + (> v sm/max-safe-int) (double sm/max-safe-int) + (< v sm/min-safe-int) (double sm/min-safe-int) + :else (double v))) + (def ^:const SEGMENT-U32-SIZE (/ SEGMENT-U8-SIZE 4)) (defprotocol IPathData @@ -121,12 +133,12 @@ (if (< index size) (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset) - c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24)) + c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24))) type (case type 1 :move-to 2 :line-to @@ -148,12 +160,12 @@ (if (< index size) (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset) - c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24)) + c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24))) type (case type 1 :move-to 2 :line-to @@ -172,12 +184,12 @@ [buffer index f] (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset) - c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24)) + c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24))) type (case type 1 :move-to 2 :line-to @@ -252,31 +264,31 @@ (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset)] (case (long type) - 1 (let [x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24))] + 1 (let [x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24)))] {:command :move-to - :params {:x (double x) - :y (double y)}}) + :params {:x x + :y y}}) - 2 (let [x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24))] + 2 (let [x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24)))] {:command :line-to - :params {:x (double x) - :y (double y)}}) - - 3 (let [c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24))] + :params {:x x + :y y}}) + + 3 (let [c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24)))] {:command :curve-to - :params {:x (double x) - :y (double y) - :c1x (double c1x) - :c1y (double c1y) - :c2x (double c2x) - :c2y (double c2y)}}) + :params {:x x + :y y + :c1x c1x + :c1y c1y + :c2x c2x + :c2y c2y}}) 4 {:command :close-path :params {}} @@ -462,7 +474,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-segment buffer i) default)) @@ -666,8 +678,6 @@ (defn from-plain "Create a PathData instance from plain data structures" [segments] - (assert (check-plain-content segments)) - (let [total (count segments) buffer (buf/allocate (* total SEGMENT-U8-SIZE))] (loop [index 0] @@ -677,30 +687,28 @@ (case (get segment :command) :move-to (let [params (get segment :params) - x (float (get params :x)) - y (float (get params :y))] + x (normalize-coord (get params :x)) + y (normalize-coord (get params :y))] (buf/write-short buffer offset 1) (buf/write-float buffer (+ offset 20) x) (buf/write-float buffer (+ offset 24) y)) :line-to (let [params (get segment :params) - x (float (get params :x)) - y (float (get params :y))] - + x (normalize-coord (get params :x)) + y (normalize-coord (get params :y))] (buf/write-short buffer offset 2) (buf/write-float buffer (+ offset 20) x) (buf/write-float buffer (+ offset 24) y)) :curve-to (let [params (get segment :params) - x (float (get params :x)) - y (float (get params :y)) - c1x (float (get params :c1x x)) - c1y (float (get params :c1y y)) - c2x (float (get params :c2x x)) - c2y (float (get params :c2y y))] - + x (normalize-coord (get params :x)) + y (normalize-coord (get params :y)) + c1x (normalize-coord (get params :c1x x)) + c1y (normalize-coord (get params :c1y y)) + c2x (normalize-coord (get params :c2x x)) + c2y (normalize-coord (get params :c2y y))] (buf/write-short buffer offset 3) (buf/write-float buffer (+ offset 4) c1x) (buf/write-float buffer (+ offset 8) c1y) diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index 9eb36d7a12c..45fc1ba2bb6 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -62,7 +62,7 @@ (map (fn [[index _]] index)))) (defn handler-indices - "Return an index where the key is the positions and the values the handlers" + "Returns [[index prefix] ...] of all handlers associated with point." [content point] (->> (d/with-prev content) (d/enumerate) @@ -76,7 +76,7 @@ []))))) (defn opposite-index - "Calculates the opposite index given a prefix and an index" + "Calculates the opposite handler index given a content, index and prefix." [content index prefix] (let [point (if (= prefix :c2) @@ -812,7 +812,7 @@ :line-to (recur (cond-> points (and from-p to-p) - (-> (conj! move-p) + (-> (conj! from-p) (conj! to-p))) (not-empty (subvec content 1)) to-p diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc index 8641ee556eb..cc0f0c90607 100644 --- a/common/src/app/common/types/path/shape_to_path.cljc +++ b/common/src/app/common/types/path/shape_to_path.cljc @@ -32,7 +32,7 @@ (d/without-keys shape dissoc-attrs)) (defn- make-corner-arc - "Creates a curvle corner for border radius" + "Creates a curve corner for border radius" [from to corner radius] (let [x (case corner :top-left (:x from) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 384029a688c..caea9d5f913 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -262,7 +262,7 @@ (or (nil? current) (= current-id parent-id)) false - (cfh/frame-shape? current-id) + (cfh/frame-shape? current) (:layout current) :else @@ -1439,7 +1439,7 @@ (update-in [:layout-grid-cells id-from] assoc :shapes (:shapes cell-to) - :podition (:position cell-to)) + :position (:position cell-to)) (update-in [:layout-grid-cells id-to] assoc :shapes (:shapes cell-from) diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index ad9bac999cf..73a4085819f 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -20,6 +20,12 @@ (def schema:role [::sm/one-of {:title "TeamRole"} valid-roles]) +(def schema:team-name + [:and + [::sm/text {:max 250}] + [:fn {:error/code "errors.team-name-invalid-chars"} + (fn [s] (not (re-find #"[.:/]" s)))]]) + ;; FIXME: specify more fields (def schema:team [:map {:title "Team"} diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index e3e541da33e..c3bb2b266d3 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -345,7 +345,6 @@ (def typography-keys (set/union font-family-keys font-size-keys font-weight-keys - font-weight-keys letter-spacing-keys line-height-keys text-case-keys diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 573dac181dc..050de69c027 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -242,17 +242,19 @@ (update-token- [this token-id f] (assert (uuid? token-id) "expected uuid for `token-id`") (if-let [token (get-token- this token-id)] - (let [token' (-> (make-token (f token)) - (assoc :modified-at (ct/now)))] - (TokenSet. id - name - description - (ct/now) - (if (= (:name token) (:name token')) - (assoc tokens (:name token') token') - (-> tokens - (d/oassoc-before (:name token) (:name token') token') - (dissoc (:name token)))))) + (let [token' (f token)] + (if (not= token token') + (let [token' (assoc token' :modified-at (ct/now))] + (TokenSet. id + name + description + (ct/now) + (if (= (:name token) (:name token')) + (assoc tokens (:name token') token') + (-> tokens + (d/oassoc-before (:name token) (:name token') token') + (dissoc (:name token)))))) + this)) this)) (delete-token- [this token-id] @@ -303,6 +305,35 @@ (-clj->js [this] (clj->js (datafy this))))) +(def ^:private set-prefix "S-") + +(def ^:private set-group-prefix "G-") + +(def ^:private set-separator "/") + +(defn get-set-path + [token-set] + (cpn/split-path (get-name token-set) :separator set-separator)) + +(defn split-set-name + [name] + (cpn/split-path name :separator set-separator)) + +(defn join-set-path [path] + (cpn/join-path path :separator set-separator :with-spaces? false)) + +(defn normalize-set-name + "Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set'). + + If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name." + ([name] + (-> (split-set-name (str name)) + (cpn/join-path :separator set-separator :with-spaces? false))) + ([name relative-to] + (-> (concat (butlast (split-set-name relative-to)) + (split-set-name (str name))) + (cpn/join-path :separator set-separator :with-spaces? false)))) + (defn token-set? [o] (instance? TokenSet o)) @@ -357,6 +388,7 @@ (def check-token-set (sm/check-fn schema:token-set :hint "expected valid token set")) + (defn map->token-set [& {:as attrs}] (TokenSet. (:id attrs) @@ -372,38 +404,10 @@ (update :modified-at #(or % (ct/now))) (update :tokens #(into (d/ordered-map) %)) (update :description d/nilv "") + (update :name normalize-set-name) (check-token-set-attrs) (map->token-set))) -(def ^:private set-prefix "S-") - -(def ^:private set-group-prefix "G-") - -(def ^:private set-separator "/") - -(defn get-set-path - [token-set] - (cpn/split-path (get-name token-set) :separator set-separator)) - -(defn split-set-name - [name] - (cpn/split-path name :separator set-separator)) - -(defn join-set-path [path] - (cpn/join-path path :separator set-separator :with-spaces? false)) - -(defn normalize-set-name - "Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set'). - - If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name." - ([name] - (-> (split-set-name name) - (cpn/join-path :separator set-separator :with-spaces? false))) - ([name relative-to] - (-> (concat (butlast (split-set-name relative-to)) - (split-set-name name)) - (cpn/join-path :separator set-separator :with-spaces? false)))) - (defn normalized-set-name? "Check if a set name is normalized (no extra spaces)." [name] @@ -485,17 +489,15 @@ (defn backtrace-tokens-tree "Convert tokens into a nested tree with their name as the path. - Generates a uuid per token to backtrace a token from an external source (StyleDictionary). + Uses the existing token :id to backtrace a token from an external source (StyleDictionary). The backtrace can't be the name as the name might not exist when the user is creating a token." [tokens] (reduce (fn [acc [_ token]] - (let [temp-id (random-uuid) - token (assoc token :temp/id temp-id) - path (get-token-path token)] + (let [path (get-token-path token)] (-> acc (assoc-in (concat [:tokens-tree] path) token) - (assoc-in [:ids temp-id] token)))) + (assoc-in [:ids (:id token)] token)))) {:tokens-tree {} :ids {}} tokens)) @@ -611,7 +613,7 @@ is-source external-id (ct/now) - set-names)) + (into #{} (filter some?) set-names))) (enable-set [this set-name] (set-sets this (conj sets set-name))) @@ -632,14 +634,9 @@ (update-set-name [this prev-set-name set-name] (if (get sets prev-set-name) - (TokenTheme. id - name - group - description - is-source - external-id - (ct/now) - (conj (disj sets prev-set-name) set-name)) + (let [sets (-> (disj sets prev-set-name) + (conj set-name))] + (set-sets this sets)) this)) (theme-matches-group-name [this group name] @@ -724,7 +721,8 @@ (update :is-source d/nilv false) (update :external-id #(or % (str new-id))) (update :modified-at #(or % (ct/now))) - (update :sets set) + (update :sets #(into #{} (comp (filter some?) + (map normalize-set-name)) %)) (check-token-theme-attrs) (map->TokenTheme)))) @@ -1639,7 +1637,7 @@ Will return a value that matches this schema: [value] (let [process-shadow (fn [shadow] (if (map? shadow) - (let [legacy-shadow-type (get "type" shadow)] + (let [legacy-shadow-type (get shadow "type")] (-> shadow (set/rename-keys {"x" :offset-x "offsetX" :offset-x diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index de505fd5408..7d6b0f0e3d7 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -9,91 +9,8 @@ #?(:cljs [goog.color :as gcolors]) [app.common.colors :as c] [app.common.math :as mth] - [app.common.types.color :as colors] [clojure.test :as t])) -(t/deftest valid-hex-color - (t/is (false? (colors/valid-hex-color? nil))) - (t/is (false? (colors/valid-hex-color? ""))) - (t/is (false? (colors/valid-hex-color? "#"))) - (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) - (t/is (true? (colors/valid-hex-color? "#aaa"))) - (t/is (false? (colors/valid-hex-color? "#aaaa"))) - (t/is (true? (colors/valid-hex-color? "#fabada")))) - -(t/deftest valid-rgb-color - (t/is (false? (colors/valid-rgb-color? nil))) - (t/is (false? (colors/valid-rgb-color? ""))) - (t/is (false? (colors/valid-rgb-color? "()"))) - (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) - (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) - -(t/deftest rgb-to-str - (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) - (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) - -(t/deftest rgb-to-hsv - ;; (prn (colors/rgb->hsv [1 2 3])) - ;; (prn (gcolors/rgbToHsv 1 2 3)) - (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0]))) - #?(:cljs (t/is (= (colors/rgb->hsv [1 2 3]) (vec (gcolors/rgbToHsv 1 2 3)))))) - -(t/deftest hsv-to-rgb - (t/is (= [1 2 3] - (colors/hsv->rgb [210 0.6666666666666666 3]))) - #?(:cljs - (t/is (= (colors/hsv->rgb [210 0.6666666666666666 3]) - (vec (gcolors/hsvToRgb 210 0.6666666666666666 3)))))) - -(t/deftest rgb-to-hex - (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) - -(t/deftest hex-to-rgb - (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) - (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) - -(t/deftest format-hsla - (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) - (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) - -(t/deftest format-rgba - (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) - (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) - -(t/deftest rgb-to-hsl - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/rgb->hsl [1 2 3]) - (vec (gcolors/rgbToHsl 1 2 3)))))) - -(t/deftest hsl-to-rgb - (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/hsl->rgb [210 0.5 0.00784313725490196]) - (vec (gcolors/hslToRgb 210 0.5 0.00784313725490196)))))) - -(t/deftest expand-hex - (t/is (= "aaaaaa" (colors/expand-hex "a"))) - (t/is (= "aaaaaa" (colors/expand-hex "aa"))) - (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) - (t/is (= "aaaa" (colors/expand-hex "aaaa")))) - -(t/deftest prepend-hash - (t/is "#aaa" (colors/prepend-hash "aaa")) - (t/is "#aaa" (colors/prepend-hash "#aaa"))) - -(t/deftest remove-hash - (t/is "aaa" (colors/remove-hash "aaa")) - (t/is "aaa" (colors/remove-hash "#aaa"))) - -(t/deftest color-string-pred - (t/is (true? (colors/color-string? "#aaa"))) - (t/is (true? (colors/color-string? "(10,10,10)"))) - (t/is (true? (colors/color-string? "rgb(10,10,10)"))) - (t/is (true? (colors/color-string? "magenta"))) - (t/is (false? (colors/color-string? nil))) - (t/is (false? (colors/color-string? ""))) - (t/is (false? (colors/color-string? "kkkkkk")))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; app.common.colors tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -387,55 +304,3 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers - -(t/deftest ac-interpolate-color - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0}] - ;; At c1's offset → c1 with updated offset - (let [result (c/interpolate-color c1 c2 0.0)] - (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result)))) - ;; At c2's offset → c2 with updated offset - (let [result (c/interpolate-color c1 c2 1.0)] - (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result)))) - ;; At midpoint → gray - (let [result (c/interpolate-color c1 c2 0.5)] - (t/is (= "#7f7f7f" (:color result))) - (t/is (mth/close? (:opacity result) 0.5))))) - -(t/deftest ac-uniform-spread - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - -(t/deftest ac-uniform-spread? - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - ;; A uniformly spread result should pass the predicate - (t/is (true? (c/uniform-spread? stops)))) - ;; Manual non-uniform stops should not pass - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (c/uniform-spread? stops))))) - -(t/deftest ac-interpolate-gradient - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - ;; At start - (let [result (c/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result)))) - ;; At end - (let [result (c/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))) - ;; In the middle - (let [result (c/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result)))))) - diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 726fc8f3778..b8283ca49e4 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -325,6 +325,8 @@ (t/is (= [2 3] (d/safe-subvec [1 2 3 4] 1 3))) ;; single arg — from index to end (t/is (= [2 3 4] (d/safe-subvec [1 2 3 4] 1))) + ;; start=0 returns the full vector + (t/is (= [1 2 3 4] (d/safe-subvec [1 2 3 4] 0))) ;; out-of-range returns nil (t/is (nil? (d/safe-subvec [1 2 3] 5))) (t/is (nil? (d/safe-subvec [1 2 3] 0 5))) @@ -372,12 +374,19 @@ (t/is (= 0 (d/index-of-pred [1 2 3] odd?))) (t/is (= 1 (d/index-of-pred [2 3 4] odd?))) (t/is (nil? (d/index-of-pred [2 4 6] odd?))) - (t/is (nil? (d/index-of-pred [] odd?)))) + (t/is (nil? (d/index-of-pred [] odd?))) + ;; works correctly when collection contains nil elements + (t/is (= 2 (d/index-of-pred [nil nil 3] some?))) + (t/is (= 0 (d/index-of-pred [nil 1 2] nil?))) + ;; works correctly when collection contains false elements + (t/is (= 1 (d/index-of-pred [false true false] true?)))) (t/deftest index-of-test (t/is (= 0 (d/index-of [:a :b :c] :a))) (t/is (= 2 (d/index-of [:a :b :c] :c))) - (t/is (nil? (d/index-of [:a :b :c] :z)))) + (t/is (nil? (d/index-of [:a :b :c] :z))) + ;; works when searching for nil in a collection + (t/is (= 1 (d/index-of [:a nil :c] nil)))) (t/deftest replace-by-id-test (let [items [{:id 1 :v "a"} {:id 2 :v "b"} {:id 3 :v "c"}] @@ -445,6 +454,8 @@ (t/is (= {:a {:x 10 :y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x 10}}))) ;; nested nil removes nested key (t/is (= {:a {:y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x nil}}))) + ;; nil value removes only the specified key, not other keys + (t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil}))) ;; transducer arity (1-arg returns a fn) (let [f (d/patch-object {:a 99})] (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))) @@ -536,33 +547,33 @@ (into [] (d/distinct-xf :id) [{:id 1 :v "a"} {:id 2 :v "x"} {:id 2 :v "b"}])))) (t/deftest deep-mapm-test - ;; Note: mfn is called twice on leaf entries (once initially, once again - ;; after checking if the value is a map/vector), so a doubling fn applied - ;; to value 1 gives 1*2*2=4. - (t/is (= {:a 4 :b {:c 8}} + ;; mfn is applied once per entry + (t/is (= {:a 2 :b {:c 4}} (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 2) v)]) {:a 1 :b {:c 2}}))) - ;; Keyword renaming: keys are also transformed — and applied twice. - ;; Use an idempotent key transformation (uppercase once = uppercase twice). + ;; Keyword renaming: keys are transformed once per entry (let [result (d/deep-mapm (fn [[k v]] [(keyword (str (name k) "!")) v]) {:a 1})] - (t/is (contains? result (keyword "a!!"))))) + (t/is (contains? result (keyword "a!")))) + ;; Vectors inside maps are recursed into + (t/is (= {:items [{:x 10}]} + (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 10) v)]) + {:items [{:x 1}]}))) + ;; Plain scalar at top level map + (t/is (= {:a "hello"} (d/deep-mapm identity {:a "hello"})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Numeric helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest nan-test - ;; Note: nan? behaves differently per platform: - ;; - CLJS: uses js/isNaN, returns true for ##NaN - ;; - CLJ: uses (not= v v); Clojure's = uses .equals on doubles, - ;; so (= ##NaN ##NaN) is true and nan? returns false for ##NaN. - ;; Either way, nan? returns false for regular numbers and nil. + (t/is (d/nan? ##NaN)) (t/is (not (d/nan? 0))) (t/is (not (d/nan? 1))) (t/is (not (d/nan? nil))) - ;; Platform-specific: JS nan? correctly detects NaN - #?(:cljs (t/is (d/nan? ##NaN)))) + ;; CLJS js/isNaN coerces non-numbers; JVM Double/isNaN is number-only + #?(:cljs (t/is (d/nan? "hello"))) + #?(:clj (t/is (not (d/nan? "hello"))))) (t/deftest safe-plus-test (t/is (= 5 (d/safe+ 3 2))) @@ -606,18 +617,13 @@ (t/is (nil? (d/parse-uuid nil)))) (t/deftest coalesce-str-test - ;; On JVM: nan? uses (not= v v), which is false for all normal values. - ;; On CLJS: nan? uses js/isNaN, which is true for non-numeric strings. - ;; coalesce-str returns default when value is nil or nan?. (t/is (= "default" (d/coalesce-str nil "default"))) ;; Numbers always stringify on both platforms (t/is (= "42" (d/coalesce-str 42 "default"))) - ;; ##NaN: nan? is true in CLJS, returns default; - ;; nan? is false in CLJ, so str(##NaN)="NaN" is returned. - #?(:cljs (t/is (= "default" (d/coalesce-str ##NaN "default")))) - #?(:clj (t/is (= "NaN" (d/coalesce-str ##NaN "default")))) + ;; ##NaN returns default on both platforms now that nan? is fixed on JVM + (t/is (= "default" (d/coalesce-str ##NaN "default"))) ;; Strings: in CLJS js/isNaN("hello")=true so "default" is returned; - ;; in CLJ nan? is false so (str "hello")="hello" is returned. + ;; in CLJ nan? is false for strings so (str "hello")="hello" is returned. #?(:cljs (t/is (= "default" (d/coalesce-str "hello" "default")))) #?(:clj (t/is (= "hello" (d/coalesce-str "hello" "default"))))) @@ -779,7 +785,8 @@ (t/deftest append-class-test (t/is (= "foo bar" (d/append-class "foo" "bar"))) (t/is (= "bar" (d/append-class nil "bar"))) - (t/is (= " bar" (d/append-class "" "bar")))) + ;; empty string is treated like nil — no leading space + (t/is (= "bar" (d/append-class "" "bar")))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Additional helpers (5th batch) @@ -828,6 +835,9 @@ (t/is (d/num-string? "-7")) (t/is (not (d/num-string? "hello"))) (t/is (not (d/num-string? nil))) + ;; non-string types always return false + (t/is (not (d/num-string? 42))) + (t/is (not (d/num-string? :keyword))) ;; In CLJS, js/isNaN("") → false (empty string coerces to 0), so "" is numeric #?(:clj (t/is (not (d/num-string? "")))) #?(:cljs (t/is (d/num-string? "")))) diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc new file mode 100644 index 00000000000..23dd6c78ccd --- /dev/null +++ b/common/test/common_tests/files_builder_test.cljc @@ -0,0 +1,72 @@ +;; 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 + +(ns common-tests.files-builder-test + (:require + [app.common.files.builder :as fb] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn- stroke + [color] + [{:stroke-style :solid + :stroke-alignment :inner + :stroke-width 1 + :stroke-color color + :stroke-opacity 1}]) + +(t/deftest add-bool-uses-difference-head-style + (let [file-id (uuid/next) + page-id (uuid/next) + group-id (uuid/next) + child-a (uuid/next) + child-b (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-group {:id group-id :name "Group A"}) + (fb/add-shape {:id child-a + :type :rect + :name "A" + :x 0 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#ff0000")}) + (fb/add-shape {:id child-b + :type :rect + :name "B" + :x 20 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#00ff00")}) + (fb/close-group) + (fb/add-bool {:group-id group-id + :type :difference})) + bool (fb/get-shape state group-id)] + (t/is (= :bool (:type bool))) + (t/is (= (stroke "#ff0000") (:strokes bool))))) + +(t/deftest add-file-media-validates-and-persists-media + (let [file-id (uuid/next) + page-id (uuid/next) + image-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-file-media {:id image-id + :name "Image" + :width 128 + :height 64} + (fb/map->BlobWrapper {:mtype "image/png" + :size 42 + :blob nil}))) + media (get-in state [::fb/file-media image-id])] + (t/is (= image-id (::fb/last-id state))) + (t/is (= "Image" (:name media))) + (t/is (= 128 (:width media))) + (t/is (= 64 (:height media))))) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index d49764a3897..f01da5f2680 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -2257,4 +2257,469 @@ ;; or if it needs recalculation, the test validates the behavior (t/is (or (nil? old-position-data) (nil? new-position-data) - (not= old-position-data new-position-data))))) \ No newline at end of file + (not= old-position-data new-position-data))))) + +;; ============================================================ +;; SELRECT CONSISTENCY TESTS +;; These tests verify that after a variant switch, the composite +;; geometry attributes (:selrect, :points) stay consistent with +;; the scalar attributes (:width, :height) that are kept. +;; ============================================================ + +(t/deftest test-switch-selrect-consistent-no-sizing-different-widths + ;; When no :fix sizing and variants have different widths, + ;; :width is correctly skipped (stays at new component width), + ;; but :selrect was being copied from the old shape, leaving + ;; selrect.width inconsistent with :width. This test verifies the fix. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50} + :child2-params {:width 200 :height 50}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width AND selrect consistently (simulating a real resize) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (get-in rect01 [:selrect :width]) 150)) + ;; Since the variants have different widths (100 vs 200), the override is not preserved + (t/is (= (:width rect02') 200)) + ;; The selrect must be consistent with :width + (t/is (= (get-in rect02' [:selrect :width]) 200)))) + +(t/deftest test-switch-selrect-consistent-no-sizing-different-heights + ;; Same as above but for height. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 :height 100} + :child2-params {:width 50 :height 200}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override height AND selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-height 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :height new-height) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the height override before the switch + (t/is (= (:height rect01) 150)) + (t/is (= (get-in rect01 [:selrect :height]) 150)) + ;; Since the variants have different heights (100 vs 200), the override is not preserved + (t/is (= (:height rect02') 200)) + ;; The selrect must be consistent with :height + (t/is (= (get-in rect02' [:selrect :height]) 200)))) + +(t/deftest test-switch-with-v-sizing-fix-selrect-consistent-different-widths + ;; mixed-sizing scenario: v-sizing=:fix but variants differ in WIDTH. + ;; switch-fixed-layout-geom-change-value is triggered (because v-sizing=:fix). + ;; Without the fix, the function returned prev-width for the non-:fix dimension, + ;; leaving selrect.width inconsistent with :width. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 :layout-item-v-sizing :fix} + :child2-params {:width 200 :height 50 :layout-item-v-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width AND selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (get-in rect01 [:selrect :width]) 150)) + ;; Since the variants have different widths (100 vs 200), the override is not preserved + ;; (v-sizing=:fix does not affect the horizontal dimension) + (t/is (= (:width rect02') 200)) + ;; The selrect must be consistent with :width + (t/is (= (get-in rect02' [:selrect :width]) 200)) + ;; v-sizing is preserved + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-with-h-sizing-fix-selrect-consistent-different-heights + ;; mixed-sizing scenario: h-sizing=:fix but variants differ in HEIGHT. + ;; switch-fixed-layout-geom-change-value is triggered (because h-sizing=:fix). + ;; Without the fix, the function returned prev-height for the non-:fix dimension, + ;; leaving selrect.height inconsistent with :height. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 :height 100 :layout-item-h-sizing :fix} + :child2-params {:width 50 :height 200 :layout-item-h-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override height AND selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-height 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :height new-height) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the height override before the switch + (t/is (= (:height rect01) 150)) + (t/is (= (get-in rect01 [:selrect :height]) 150)) + ;; Since the variants have different heights (100 vs 200), the override is not preserved + ;; (h-sizing=:fix does not affect the vertical dimension) + (t/is (= (:height rect02') 200)) + ;; The selrect must be consistent with :height + (t/is (= (get-in rect02' [:selrect :height]) 200)) + ;; h-sizing is preserved + (t/is (= (:layout-item-h-sizing rect02') :fix)))) + +;; ============================================================ +;; FIXED-SIZING: "SAME-SIZE → PRESERVE OVERRIDE" PATH TESTS +;; These tests exercise the branch inside switch-fixed-layout-geom-change-value +;; where variants share the same value in the non-:fix dimension: +;; (if (= origin-dim current-dim) prev-dim current-dim) +;; When origin-dim == current-dim the user's override for that dimension +;; must be preserved after the switch. +;; ============================================================ + +(t/deftest test-switch-with-h-sizing-fix-same-height-override-preserved + ;; h-sizing=:fix, variants have SAME height (non-:fix dim, same-size). + ;; switch-fixed-layout-geom-change-value must return prev-height for the + ;; non-:fix dimension because origin-height == current-height. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 :layout-item-h-sizing :fix} + :child2-params {:width 200 :height 50 :layout-item-h-sizing :fix}}) + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override height (the non-:fix dimension) and selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-height 75 + sr (:selrect shape) + new-sr (-> sr + (assoc :height new-height) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the height override 75 before the switch + (t/is (= (:height rect01) 75)) + ;; h-sizing=:fix means width always takes the new component's value + (t/is (= (:width rect02') 200)) + ;; Height (non-:fix dim) is preserved because both variants have same height (50) + (t/is (= (:height rect02') 75)) + ;; selrect must be consistent with the preserved height + (t/is (= (get-in rect02' [:selrect :height]) 75)) + (t/is (= (get-in rect02' [:selrect :width]) 200)) + ;; h-sizing is preserved + (t/is (= (:layout-item-h-sizing rect02') :fix)))) + +(t/deftest test-switch-with-v-sizing-fix-same-width-override-preserved + ;; v-sizing=:fix, variants have SAME width (non-:fix dim, same-size). + ;; switch-fixed-layout-geom-change-value must return prev-width for the + ;; non-:fix dimension because origin-width == current-width. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 :layout-item-v-sizing :fix} + :child2-params {:width 100 :height 100 :layout-item-v-sizing :fix}}) + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width (the non-:fix dimension) and selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override 150 before the switch + (t/is (= (:width rect01) 150)) + ;; Width (non-:fix dim) is preserved because both variants have same width (100) + (t/is (= (:width rect02') 150)) + ;; selrect must be consistent with the preserved width + (t/is (= (get-in rect02' [:selrect :width]) 150)) + ;; v-sizing=:fix means height always takes the new component's value + (t/is (= (:height rect02') 100)) + (t/is (= (get-in rect02' [:selrect :height]) 100)) + ;; v-sizing is preserved + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-with-both-sizing-fix-overrides-discarded + ;; When both h-sizing=:fix and v-sizing=:fix, switch-fixed-layout-geom-change-value + ;; always uses current-width and current-height (the new component's values). + ;; Both width and height overrides are discarded because :fix always + ;; defers to the new component's dimension regardless of same-size or not. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 + :layout-item-h-sizing :fix + :layout-item-v-sizing :fix} + :child2-params {:width 200 :height 100 + :layout-item-h-sizing :fix + :layout-item-v-sizing :fix}}) + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override both width and height (and selrect) consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + new-height 75 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :height new-height) + (assoc :x2 (+ (:x1 sr) new-width)) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :width new-width) + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had both overrides before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (:height rect01) 75)) + ;; With both sizing :fix, both dimensions take the new component's values + (t/is (= (:width rect02') 200)) + (t/is (= (:height rect02') 100)) + ;; selrect must be consistent + (t/is (= (get-in rect02' [:selrect :width]) 200)) + (t/is (= (get-in rect02' [:selrect :height]) 100)) + (t/is (= (:layout-item-h-sizing rect02') :fix)) + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-same-size-variants-geometry-override-preserved + ;; When both variants have IDENTICAL dimensions (width=100, height=50), + ;; the guard that skips :selrect/:points must NOT fire + ;; (its condition `(or (not= origin.width current.width) ...)` is false). + ;; A geometry override should therefore be carried through correctly. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50} + :child2-params {:width 100 :height 50}}) ; same size! + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width AND selrect consistently (simulating a real resize) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override 150 before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (get-in rect01 [:selrect :width]) 150)) + ;; Both variants are identical in size (100x50), so the override IS preserved + (t/is (= (:width rect02') 150)) + ;; The guard must not have suppressed :selrect — it should be consistent + (t/is (= (get-in rect02' [:selrect :width]) 150)))) \ No newline at end of file diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b8a9fc89349..b3c2ad4f0e6 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -10,6 +10,7 @@ [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] + [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] @@ -48,11 +49,13 @@ [common-tests.path-names-test] [common-tests.record-test] [common-tests.schema-test] + [common-tests.spec-test] [common-tests.svg-path-test] [common-tests.svg-test] [common-tests.text-test] [common-tests.time-test] [common-tests.types.absorb-assets-test] + [common-tests.types.color-test] [common-tests.types.components-test] [common-tests.types.container-test] [common-tests.types.fill-test] @@ -82,6 +85,7 @@ 'common-tests.colors-test 'common-tests.data-test 'common-tests.files-changes-test + 'common-tests.files-builder-test 'common-tests.files-migrations-test 'common-tests.geom-align-test 'common-tests.geom-bounds-map-test @@ -119,11 +123,13 @@ 'common-tests.path-names-test 'common-tests.record-test 'common-tests.schema-test + 'common-tests.spec-test 'common-tests.svg-path-test 'common-tests.svg-test 'common-tests.text-test 'common-tests.time-test 'common-tests.types.absorb-assets-test + 'common-tests.types.color-test 'common-tests.types.components-test 'common-tests.types.container-test 'common-tests.types.fill-test diff --git a/common/test/common_tests/spec_test.cljc b/common/test/common_tests/spec_test.cljc new file mode 100644 index 00000000000..425f7f8066a --- /dev/null +++ b/common/test/common_tests/spec_test.cljc @@ -0,0 +1,89 @@ +;; 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 + +(ns common-tests.spec-test + (:require + [app.common.spec :as spec] + [clojure.test :as t])) + +(t/deftest valid-emails + (t/testing "accepts well-formed email addresses" + (doseq [email ["user@domain.com" + "user.name@domain.com" + "user+tag@domain.com" + "user-name@domain.com" + "user_name@domain.com" + "user123@domain.com" + "USER@DOMAIN.COM" + "u@domain.io" + "user@sub.domain.com" + "user@domain.co.uk" + "user@domain.dev" + "a@bc.co"]] + (t/is (some? (spec/parse-email email)) (str "should accept: " email))))) + +(t/deftest rejects-invalid-local-part + (t/testing "rejects local part starting with a dot" + (t/is (nil? (spec/parse-email ".user@domain.com")))) + + (t/testing "rejects local part with consecutive dots" + (t/is (nil? (spec/parse-email "user..name@domain.com")))) + + (t/testing "rejects local part with spaces" + (t/is (nil? (spec/parse-email "us er@domain.com")))) + + (t/testing "rejects local part with comma" + (t/is (nil? (spec/parse-email "user,name@domain.com"))) + (t/is (nil? (spec/parse-email ",user@domain.com")))) + + (t/testing "rejects empty local part" + (t/is (nil? (spec/parse-email "@domain.com"))))) + +(t/deftest rejects-invalid-domain + (t/testing "rejects domain starting with a dot" + (t/is (nil? (spec/parse-email "user@.domain.com")))) + + (t/testing "rejects domain part with comma" + (t/is (nil? (spec/parse-email "user@domain,com"))) + (t/is (nil? (spec/parse-email "user@,domain.com")))) + + (t/testing "rejects domain with consecutive dots" + (t/is (nil? (spec/parse-email "user@sub..domain.com")))) + + (t/testing "rejects label starting with hyphen" + (t/is (nil? (spec/parse-email "user@-domain.com")))) + + (t/testing "rejects label ending with hyphen" + (t/is (nil? (spec/parse-email "user@domain-.com")))) + + (t/testing "rejects TLD shorter than 2 chars" + (t/is (nil? (spec/parse-email "user@domain.c")))) + + (t/testing "rejects domain without a dot" + (t/is (nil? (spec/parse-email "user@domain")))) + + (t/testing "rejects domain with spaces" + (t/is (nil? (spec/parse-email "user@do main.com")))) + + (t/testing "rejects domain ending with a dot" + (t/is (nil? (spec/parse-email "user@domain."))))) + +(t/deftest rejects-invalid-structure + (t/testing "rejects nil" + (t/is (nil? (spec/parse-email nil)))) + + (t/testing "rejects empty string" + (t/is (nil? (spec/parse-email "")))) + + (t/testing "rejects string without @" + (t/is (nil? (spec/parse-email "userdomain.com")))) + + (t/testing "rejects string with multiple @" + (t/is (nil? (spec/parse-email "user@@domain.com"))) + (t/is (nil? (spec/parse-email "us@er@domain.com")))) + + (t/testing "rejects empty domain" + (t/is (nil? (spec/parse-email "user@"))))) diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc new file mode 100644 index 00000000000..9a3ab00ac91 --- /dev/null +++ b/common/test/common_tests/types/color_test.cljc @@ -0,0 +1,166 @@ +;; 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 + +(ns common-tests.types.color-test + (:require + [app.common.math :as mth] + [app.common.types.color :as colors] + [clojure.test :as t])) + +;; --- Predicates + +(t/deftest valid-hex-color + (t/is (false? (colors/valid-hex-color? nil))) + (t/is (false? (colors/valid-hex-color? ""))) + (t/is (false? (colors/valid-hex-color? "#"))) + (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) + (t/is (true? (colors/valid-hex-color? "#aaa"))) + (t/is (false? (colors/valid-hex-color? "#aaaa"))) + (t/is (true? (colors/valid-hex-color? "#fabada")))) + +(t/deftest valid-rgb-color + (t/is (false? (colors/valid-rgb-color? nil))) + (t/is (false? (colors/valid-rgb-color? ""))) + (t/is (false? (colors/valid-rgb-color? "()"))) + (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) + (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) + +;; --- Conversions + +(t/deftest rgb-to-str + (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) + (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) + +(t/deftest rgb-to-hsv + (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0])))) + +(t/deftest hsv-to-rgb + (t/is (= [1 2 3] + (colors/hsv->rgb [210 0.6666666666666666 3])))) + +(t/deftest rgb-to-hex + (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) + +(t/deftest hex-to-rgb + (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) + (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) + +(t/deftest format-hsla + (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) + (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) + +(t/deftest format-rgba + (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) + (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) + +(t/deftest rgb-to-hsl + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest hsl-to-rgb + (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest expand-hex + (t/is (= "aaaaaa" (colors/expand-hex "a"))) + (t/is (= "aaaaaa" (colors/expand-hex "aa"))) + (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) + (t/is (= "aaaa" (colors/expand-hex "aaaa")))) + +(t/deftest prepend-hash + (t/is "#aaa" (colors/prepend-hash "aaa")) + (t/is "#aaa" (colors/prepend-hash "#aaa"))) + +(t/deftest remove-hash + (t/is "aaa" (colors/remove-hash "aaa")) + (t/is "aaa" (colors/remove-hash "#aaa"))) + +(t/deftest color-string-pred + (t/is (true? (colors/color-string? "#aaa"))) + (t/is (true? (colors/color-string? "(10,10,10)"))) + (t/is (true? (colors/color-string? "rgb(10,10,10)"))) + (t/is (true? (colors/color-string? "magenta"))) + (t/is (false? (colors/color-string? nil))) + (t/is (false? (colors/color-string? ""))) + (t/is (false? (colors/color-string? "kkkkkk")))) + +;; --- Gradient helpers + +(t/deftest interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] + (t/is (= "#000000" (:color result))) + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] + (t/is (= "#ffffff" (:color result))) + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] + (t/is (= "#7f7f7f" (:color result))) + (t/is (mth/close? (:opacity result) 0.5))))) + +(t/deftest uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) + +(t/deftest uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + +(t/deftest interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 308778bcc13..f9968e8aed7 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -207,3 +207,18 @@ fill1 (nth fills1 1)] (t/is (nil? fill1)) (t/is (equivalent-fill? fill0 sample-fill-6)))) + +(t/deftest indexed-access-with-default + (t/testing "nth with default returns fill for valid index" + ;; Regression: CLJS -nth with default had reversed d/in-range? args, + ;; so it always fell through to the default even for valid indices. + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found + result (nth fills 0 sentinel)] + (t/is (not= sentinel result)) + (t/is (equivalent-fill? result sample-fill-6)))) + (t/testing "nth with default returns default for out-of-range index" + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found] + (t/is (= sentinel (nth fills 1 sentinel))) + (t/is (= sentinel (nth fills -1 sentinel)))))) diff --git a/common/test/common_tests/types/font_test.cljc b/common/test/common_tests/types/font_test.cljc new file mode 100644 index 00000000000..ee04e656fbf --- /dev/null +++ b/common/test/common_tests/types/font_test.cljc @@ -0,0 +1,41 @@ +;; 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 + +(ns common-tests.types.font-test + (:require + [app.common.schema :as sm] + [app.common.types.font :as ctf] + [clojure.test :as t])) + +(t/deftest font-family-schema-valid + (t/is (sm/validate ctf/schema:font-family "Source Sans Pro")) + (t/is (sm/validate ctf/schema:font-family "Roboto")) + (t/is (sm/validate ctf/schema:font-family "Open Sans 300")) + (t/is (sm/validate ctf/schema:font-family "Font-Name_v2")) + (t/is (sm/validate ctf/schema:font-family "Noto Sans CJK SC")) + (t/is (sm/validate ctf/schema:font-family "A")) + ;; hyphens, underscores and dots are allowed + (t/is (sm/validate ctf/schema:font-family "Fira-Code")) + (t/is (sm/validate ctf/schema:font-family "font_name")) + (t/is (sm/validate ctf/schema:font-family "Soucre Sans Pro 3.0")) + ;; Unicode letters are allowed + (t/is (sm/validate ctf/schema:font-family "思源黑体")) + (t/is (sm/validate ctf/schema:font-family "العربية"))) + +(t/deftest font-family-schema-invalid + ;; HTML injection characters + (t/is (not (sm/validate ctf/schema:font-family "evil<script>"))) + (t/is (not (sm/validate ctf/schema:font-family "<test>name"))) + ;; CSS injection characters + (t/is (not (sm/validate ctf/schema:font-family "evil'name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil\"name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil}name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil;name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil\\name"))) + ;; empty string + (t/is (not (sm/validate ctf/schema:font-family ""))) + ;; too long + (t/is (not (sm/validate ctf/schema:font-family (apply str (repeat 251 "a")))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 252334b4598..6dc7fa5207c 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -13,6 +13,7 @@ [app.common.geom.rect :as grc] [app.common.math :as mth] [app.common.pprint :as pp] + [app.common.schema :as sm] [app.common.transit :as trans] [app.common.types.path :as path] [app.common.types.path.bool :as path.bool] @@ -972,6 +973,31 @@ (t/is (mth/close? 10.0 (:x2 rect) 0.1)) (t/is (mth/close? 10.0 (:y2 rect) 0.1)))) +(t/deftest segment-content->selrect-multi-line + ;; Regression: calculate-extremities used move-p instead of from-p in + ;; the :line-to branch. For a subpath with multiple consecutive line-to + ;; commands, the selrect must still match the reference implementation. + (let [;; A subpath that starts away from the origin and has three + ;; line-to segments so that move-p diverges from from-p for the + ;; later segments. + segments [{:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 8.0}} + {:command :line-to :params {:x 10.0 :y 12.0}}] + content (path/content segments) + rect (path.segment/content->selrect content) + ref-pts (calculate-extremities segments)] + + ;; Bounding box must enclose all four vertices exactly. + (t/is (some? rect)) + (t/is (mth/close? 5.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)) + (t/is (mth/close? 20.0 (:x2 rect) 0.1)) + (t/is (mth/close? 12.0 (:y2 rect) 0.1)) + + ;; Must agree with the reference implementation. + (t/is (= ref-pts (calculate-extremities content))))) + (t/deftest segment-content-center (let [content (path/content sample-content-square) center (path.segment/content-center content)] @@ -1418,3 +1444,60 @@ ;; Verify first and last entries specifically (t/is (= :move-to (first seq-types))) (t/is (= :close-path (last seq-types)))))) + +(t/deftest path-data-read-normalizes-out-of-bounds-coordinates + (let [max-safe (double sm/max-safe-int) + min-safe (double sm/min-safe-int) + ;; Create content with values exceeding safe bounds + content-with-out-of-bounds + [{:command :move-to :params {:x (+ max-safe 1000.0) :y (- min-safe 1000.0)}} + {:command :line-to :params {:x (- min-safe 500.0) :y (+ max-safe 500.0)}} + {:command :curve-to :params + {:c1x (+ max-safe 200.0) :c1y (- min-safe 200.0) + :c2x (+ max-safe 300.0) :c2y (- min-safe 300.0) + :x (+ max-safe 400.0) :y (- min-safe 400.0)}} + {:command :close-path :params {}}] + + ;; Create PathData from the content + pdata (path/content content-with-out-of-bounds) + + ;; Read it back + result (vec pdata)] + + (t/testing "Coordinates exceeding max-safe-int are clamped to max-safe-int" + (let [move-to (first result) + line-to (second result)] + (t/is (= max-safe (:x (:params move-to))) "x in move-to should be clamped to max-safe-int") + (t/is (= min-safe (:y (:params move-to))) "y in move-to should be clamped to min-safe-int") + (t/is (= min-safe (:x (:params line-to))) "x in line-to should be clamped to min-safe-int") + (t/is (= max-safe (:y (:params line-to))) "y in line-to should be clamped to max-safe-int"))) + + (t/testing "Curve-to coordinates are clamped" + (let [curve-to (nth result 2)] + (t/is (= max-safe (:c1x (:params curve-to))) "c1x should be clamped") + (t/is (= min-safe (:c1y (:params curve-to))) "c1y should be clamped") + (t/is (= max-safe (:c2x (:params curve-to))) "c2x should be clamped") + (t/is (= min-safe (:c2y (:params curve-to))) "c2y should be clamped") + (t/is (= max-safe (:x (:params curve-to))) "x should be clamped") + (t/is (= min-safe (:y (:params curve-to))) "y should be clamped"))) + + (t/testing "-lookup normalizes coordinates" + (let [move-to (path.impl/-lookup pdata 0 (fn [_ _ _ _ _ x y] {:x x :y y}))] + (t/is (= max-safe (:x move-to)) "lookup x should be clamped") + (t/is (= min-safe (:y move-to)) "lookup y should be clamped"))) + + (t/testing "-walk normalizes coordinates" + (let [coords (path.impl/-walk pdata + (fn [_ _ _ _ _ x y] + (when (and x y) {:x x :y y})) + [])] + (t/is (= max-safe (:x (first coords))) "walk first x should be clamped") + (t/is (= min-safe (:y (first coords))) "walk first y should be clamped"))) + + (t/testing "-reduce normalizes coordinates" + (let [[move-res] (path.impl/-reduce pdata + (fn [acc _ _ _ _ _ _ x y] + (if (and x y) (conj acc {:x x :y y}) acc)) + [])] + (t/is (= max-safe (:x move-res)) "reduce first x should be clamped") + (t/is (= min-safe (:y move-res)) "reduce first y should be clamped"))))) diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index d677ed5d09f..62935b21dc1 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -186,13 +186,9 @@ flex (make-flex-frame :parent-id root-id) child (make-shape :parent-id (:id flex))] - ;; Note: inside-layout? calls (cfh/frame-shape? current-id) with a UUID id, - ;; but frame-shape? checks (:type uuid) which is nil for a UUID value. - ;; The function therefore always returns false regardless of structure. - ;; These tests document the actual (not the intended) behavior. - (t/testing "returns false when child is under a flex frame" + (t/testing "returns true when child is under a flex frame" (let [objects {root-id root (:id flex) flex (:id child) child}] - (t/is (not (layout/inside-layout? objects child))))) + (t/is (layout/inside-layout? objects child)))) (t/testing "returns false for root shape" (let [objects {root-id root (:id flex) flex (:id child) child}] diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 150ffcfb08d..e8c8a52ae53 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1918,7 +1918,7 @@ (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")] (t/is (some? token)) (t/is (= :shadow (:type token))) - (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}] + (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset true}] (:value token))))) (t/testing "shadow token with description" diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 07fbab0bb4f..5034ba5f014 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -32,7 +32,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v22.22.0 \ +ENV NODE_VERSION=v24.15.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -67,7 +67,7 @@ RUN set -eux; \ FROM base AS setup-caddy -ENV CADDY_VERSION=2.10.2 +ENV CADDY_VERSION=2.11.2 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ @@ -99,18 +99,18 @@ RUN set -eux; \ FROM base AS setup-jvm # https://clojure.org/releases/tools -ENV CLOJURE_VERSION=1.12.4.1602 +ENV CLOJURE_VERSION=1.12.4.1618 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \ + ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \ + ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -181,10 +181,11 @@ RUN set -eux; \ FROM base AS setup-utils -ENV CLJKONDO_VERSION=2026.01.19 \ +ENV CLJKONDO_VERSION=2026.04.15 \ BABASHKA_VERSION=1.12.208 \ - CLJFMT_VERSION=0.15.6 \ - PIXI_VERSION=0.63.2 + CLJFMT_VERSION=0.16.4 \ + PIXI_VERSION=0.67.2 \ + GITHUB_CLI_VERSION=2.91.0 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ @@ -267,6 +268,28 @@ RUN set -ex; \ tar -xf /tmp/cljfmt.tar.gz; \ rm -rf /tmp/cljfmt.tar.gz; + +RUN set -ex; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + BINARY_URL="https://github.com/cli/cli/releases/download/v${GITHUB_CLI_VERSION}/gh_${GITHUB_CLI_VERSION}_linux_arm64.tar.gz"; \ + ;; \ + amd64|x86_64) \ + BINARY_URL="https://github.com/cli/cli/releases/download/v${GITHUB_CLI_VERSION}/gh_${GITHUB_CLI_VERSION}_linux_amd64.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + cd /tmp; \ + curl -LfsSo /tmp/gh.tar.gz ${BINARY_URL}; \ + mkdir /opt/gh; \ + cd /opt/gh; \ + tar -xv --strip-components=1 -f /tmp/gh.tar.gz; \ + rm -rf /tmp/gh.tar.gz; + # Install minio client RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ @@ -308,6 +331,8 @@ RUN set -ex; \ less \ jq \ nginx \ + fd-find \ + bat \ \ fontconfig \ woff-tools \ @@ -396,13 +421,14 @@ ENV LANG='C.UTF-8' \ JAVA_HOME="/opt/jdk" \ CARGO_HOME="/opt/cargo" \ RUSTUP_HOME="/opt/rustup" \ - PATH="/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" + PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick COPY --from=setup-jvm /opt/jdk /opt/jdk COPY --from=setup-jvm /opt/clojure /opt/clojure COPY --from=setup-node /opt/node /opt/node COPY --from=setup-utils /opt/utils /opt/utils +COPY --from=setup-utils /opt/gh /opt/gh COPY --from=setup-rust /opt/cargo /opt/cargo COPY --from=setup-rust /opt/rustup /opt/rustup COPY --from=setup-rust /opt/emsdk /opt/emsdk diff --git a/docker/devenv/files/Caddyfile b/docker/devenv/files/Caddyfile index eda140d5e95..a4e81434b02 100644 --- a/docker/devenv/files/Caddyfile +++ b/docker/devenv/files/Caddyfile @@ -8,7 +8,7 @@ localhost:3449 { header -Strict-Transport-Security } -http://localhost:3450 { +:3450 { # For subpath test # handle_path /penpot/* { # reverse_proxy localhost:4449 diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 98fc4a96dc1..799d2f285ae 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -2,9 +2,13 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh; -export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" +export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" +export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" export CARGO_HOME="/home/penpot/.cargo" +export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0 +export PENPOT_MCP_SERVER_HOST=0.0.0.0 + alias l='ls --color -GFlh' alias ll='ls --color -GFlh' alias rm='rm -rf' diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index 1427b191483..b6240777e7a 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -13,6 +13,7 @@ cp /root/.tmux.conf /home/penpot/.tmux.conf chown penpot:users /home/penpot rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/ +export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" export PATH="/home/penpot/.cargo/bin:$PATH" export CARGO_HOME="/home/penpot/.cargo" diff --git a/docker/devenv/files/nginx-security-headers.conf b/docker/devenv/files/nginx-security-headers.conf new file mode 100644 index 00000000000..d41baf3a22e --- /dev/null +++ b/docker/devenv/files/nginx-security-headers.conf @@ -0,0 +1,4 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index ca770e04c70..e93ca0750c7 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -74,6 +74,8 @@ http { resolver 127.0.0.11 ipv6=off; etag off; + proxy_hide_header X-Powered-By; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; root /home/penpot/penpot/frontend/resources/public; @@ -92,6 +94,7 @@ http { proxy_pass $redirect_uri; proxy_ssl_server_name on; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header x-internal-redirect "$redirect_uri"; add_header x-cache-control "$redirect_cache_control"; add_header cache-control "$redirect_cache_control"; @@ -108,6 +111,7 @@ http { location /internal/assets { internal; alias /home/penpot/penpot/backend/assets; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } @@ -126,6 +130,12 @@ http { proxy_http_version 1.1; } + location /plugins { + autoindex on; + alias /home/penpot/penpot/plugins/dist/apps; + proxy_http_version 1.1; + } + location /mcp/ws { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -180,6 +190,7 @@ http { location /wasm-playground { alias /home/penpot/penpot/frontend/resources/public/wasm-playground/; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-cache, max-age=0"; autoindex on; } @@ -205,6 +216,7 @@ http { proxy_set_header User-Agent "curl/8.5.0"; proxy_set_header Host "raw.githubusercontent.com"; proxy_set_header Accept "*/*"; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; proxy_buffering off; } @@ -229,6 +241,7 @@ http { proxy_cache penpot; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; @@ -251,16 +264,19 @@ http { proxy_cache penpot; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; } location ~* \.(jpg|png|svg|ttf|woff|woff2|gif)$ { + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days } location ~* \.(js|css|wasm)$ { + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; } @@ -268,6 +284,7 @@ http { return 301 " /404"; } + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; try_files $uri /index.html$is_args$args /index.html =404; } diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index c3d08916a66..c27d2c4363c 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ JAVA_HOME="/opt/jdk" \ DEBIAN_FRONTEND=noninteractive \ - NODE_VERSION=v22.22.0 \ + NODE_VERSION=v24.15.0 \ TZ=Etc/UTC RUN set -ex; \ @@ -46,12 +46,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \ + ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \ + ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -68,7 +68,7 @@ RUN set -eux; \ --no-header-files \ --no-man-pages \ --strip-debug \ - --add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ + --add-modules java.base,jdk.net,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ --output /opt/jre; FROM ubuntu:24.04 AS image diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 03be19d2f38..0049c6dd76d 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>" ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v22.22.0 \ + NODE_VERSION=v24.15.0 \ DEBIAN_FRONTEND=noninteractive \ PATH=/opt/node/bin:/opt/imagick/bin:$PATH \ PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 9f435551ad6..3e0edbf0023 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM nginxinc/nginx-unprivileged:1.29.1 +FROM nginxinc/nginx-unprivileged:1.30.0 LABEL maintainer="Penpot <docker@penpot.app>" USER root @@ -17,6 +17,7 @@ ARG BUNDLE_PATH="./bundle-frontend/" 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-security-headers.conf /etc/nginx/nginx-security-headers.conf COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template COPY ./files/nginx-mime.types /etc/nginx/mime.types COPY ./files/nginx-external-locations.conf /etc/nginx/overrides/location.d/external-locations.conf diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f4d5544c89c..14b1172035d 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ NODE_VERSION=v22.21.1 \ DEBIAN_FRONTEND=noninteractive \ - PATH=/opt/node/bin:$PATH + PATH=/opt/node/bin:$PATH \ + PENPOT_MCP_SERVER_HOST=0.0.0.0 RUN set -ex; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 24e6acc5cfe..9cccbe799b6 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM nginxinc/nginx-unprivileged:1.29.1 +FROM nginxinc/nginx-unprivileged:1.30.0 LABEL maintainer="Penpot <docker@penpot.app>" USER root diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 5d3b84d09c9..25d419a354e 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -24,7 +24,7 @@ # WARNING: if you're exposing Penpot to the internet, you should remove the flags # 'disable-secure-session-cookies' and 'disable-email-verification' x-flags: &penpot-flags - PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies + PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}" restart: always ports: - 9001:8080 @@ -105,10 +105,10 @@ services: # - "traefik.http.routers.penpot-https.tls=true" environment: - << : [*penpot-flags, *penpot-http-body-size] + << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri] penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-latest}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.15}" restart: always volumes: @@ -176,8 +176,14 @@ services: PENPOT_SMTP_TLS: false PENPOT_SMTP_SSL: false + penpot-mcp: + image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}" + restart: always + networks: + - penpot + penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}" restart: always depends_on: diff --git a/docker/images/files/config.js b/docker/images/files/config.js index 7bc9ce94046..a9f444caa64 100644 --- a/docker/images/files/config.js +++ b/docker/images/files/config.js @@ -1,2 +1,3 @@ // Frontend configuration //var penpotFlags = ""; +//var penpotMpassSignoutUrl = ""; diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 4512d06495f..ace4bdfb544 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -19,9 +19,27 @@ update_flags() { -e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \ "$1")" > "$1" fi + + if [ -n "$PENPOT_PUBLIC_URI" ]; then + echo "var penpotPublicURI = \"$PENPOT_PUBLIC_URI\";" >> "$1"; + fi +} + +update_mpass_signout_url() { + # Injected by foss-server-bundle-devstack for mPass SSO full-3-layer + # logout. When MPASS_SIGNOUT_URL is set, the frontend logout button + # redirects there instead of /auth/login — clearing the oauth2-proxy + # cookie and the Cognito session in addition to the penpot session. + if [ -n "$MPASS_SIGNOUT_URL" ]; then + # `|` as sed delimiter because the URL contains `/` and `&`. + echo "$(sed \ + -e "s|^//var penpotMpassSignoutUrl = .*;|var penpotMpassSignoutUrl = \"$MPASS_SIGNOUT_URL\";|g" \ + "$1")" > "$1" + fi } update_flags /var/www/app/js/config.js +update_mpass_signout_url /var/www/app/js/config.js ######################################### ## Nginx Config @@ -30,8 +48,10 @@ update_flags /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_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} +export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} +export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)" diff --git a/docker/images/files/nginx-security-headers.conf b/docker/images/files/nginx-security-headers.conf new file mode 100644 index 00000000000..d41baf3a22e --- /dev/null +++ b/docker/images/files/nginx-security-headers.conf @@ -0,0 +1,4 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index d0b7bc3b1fd..8eec1523c70 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -80,6 +80,8 @@ http { charset utf-8; etag off; + proxy_hide_header X-Powered-By; + include /etc/nginx/nginx-security-headers.conf; root /var/www/app/; @@ -92,6 +94,7 @@ http { proxy_buffering off; proxy_set_header Host "$redirect_host"; + proxy_set_header X-Forwarded-Host ""; proxy_hide_header etag; proxy_hide_header x-amz-id-2; proxy_hide_header x-amz-request-id; @@ -100,6 +103,7 @@ http { proxy_ssl_server_name on; proxy_pass $redirect_uri; + include /etc/nginx/nginx-security-headers.conf; add_header x-internal-redirect "$redirect_uri"; add_header x-cache-control "$redirect_cache_control"; add_header cache-control "$redirect_cache_control"; @@ -118,6 +122,7 @@ http { location /internal/assets { internal; alias /opt/data/assets; + include /etc/nginx/nginx-security-headers.conf; add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } @@ -135,6 +140,23 @@ http { proxy_http_version 1.1; } + location /mcp/ws { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_pass $PENPOT_MCP_URI_WS; + proxy_http_version 1.1; + } + + location /mcp/stream { + proxy_pass $PENPOT_MCP_URI/mcp; + proxy_http_version 1.1; + } + + location /mcp/sse { + proxy_pass $PENPOT_MCP_URI/sse; + proxy_http_version 1.1; + } + location /readyz { access_log off; proxy_pass $PENPOT_BACKEND_URI$request_uri; @@ -160,6 +182,7 @@ http { include /etc/nginx/overrides/location.d/*.conf; location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { + include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days } @@ -167,7 +190,7 @@ http { return 301 " /404"; } - add_header X-Frame-Options SAMEORIGIN always; + include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "no-store, no-cache, max-age=0" always; try_files $uri /index.html$is_args$args /index.html =404; diff --git a/docs/contributing-guide/index.njk b/docs/contributing-guide/index.njk index e0bb204487a..74574e49752 100644 --- a/docs/contributing-guide/index.njk +++ b/docs/contributing-guide/index.njk @@ -2,7 +2,7 @@ title: Contributing desc: Learn how to contribute to Penpot, the open-source design collaboration platform! Find guides on bug reporting, translations, code contributions, and more. eleventyNavigation: - key: Contributing + key: Contribute order: 3 --- @@ -10,7 +10,7 @@ eleventyNavigation: <img src="/img/home-contribution.webp" alt="User guide" border="0"> </div> -<h1 id="contributing-guide">Contributing guide.</h1> +<h1 id="contributing-guide">Contributing guide</h1> <p class="main-paragraph">In this documentation you will find (almost) everything you need to know about how to contribute at Penpot.</p> diff --git a/docs/img/troubleshooting/troubleshooting-chrome.webp b/docs/img/troubleshooting/troubleshooting-chrome.webp new file mode 100644 index 00000000000..a999f41b2db Binary files /dev/null and b/docs/img/troubleshooting/troubleshooting-chrome.webp differ diff --git a/docs/img/troubleshooting/troubleshooting-firefox.webp b/docs/img/troubleshooting/troubleshooting-firefox.webp new file mode 100644 index 00000000000..93c151fee5f Binary files /dev/null and b/docs/img/troubleshooting/troubleshooting-firefox.webp differ diff --git a/docs/mcp/index.md b/docs/mcp/index.md index d09b7db8b7a..d473eff10c2 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -2,6 +2,9 @@ title: Penpot MCP server order: 1 desc: Installing and using the Penpot MCP server with any AI agent or LLM you trust. +eleventyNavigation: + key: MCP Server + order: 6 --- <div class="main-illus"> @@ -69,7 +72,7 @@ There are three key pieces: ### Basic concepts Some important concepts for users: -* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server (Beta)**. Here you enable or disable MCP, get the server URL and manage the MCP key. +* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key. * **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup. * **Currently focused page**: MCP always operates on the page you have in focus in Penpot. If you change the focused page (even in another browser window), the MCP context follows that page. * **Active MCP tab**: MCP can only be active in one browser tab at a time. If you have Penpot open in several tabs, you choose explicitly which one owns MCP before running agents. @@ -111,19 +114,19 @@ If you just want to try Penpot MCP quickly, follow this path for the **hosted (r ### Remote MCP in 5 steps 1. #### Enable MCP in Penpot - Go to **Your account → Integrations → MCP Server (Beta)** and enable the feature. + Go to **Your account → Integrations → MCP Server** and enable the feature. - ![MCP Server (Beta) in Penpot Integrations, enable](/img/mcp/mcp-enable.webp) + ![MCP Server in Penpot Integrations, enable](/img/mcp/mcp-enable.webp) 2. #### Generate your MCP key If you do not have one yet, create it. The key is shown only once—store it safely. - ![MCP Server (Beta) in Penpot Integrations, generate key](/img/mcp/mcp-generate-key.webp) + ![MCP Server in Penpot Integrations, generate key](/img/mcp/mcp-generate-key.webp) 3. #### Copy the server URL In the same Integrations section, copy the **server URL** that already includes your MCP key as `userToken`. - ![MCP Server (Beta) in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp) + ![MCP Server in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp) 4. #### Add the server to your MCP client In your MCP-aware IDE/agent (Cursor, Claude Code, etc.), add a new server pointing to that URL. @@ -179,7 +182,7 @@ You can use Penpot MCP server in two main ways: * Hosted for you (no need to run anything on your machine). * Best option for most users, simpler installation and fewer moving parts. * Does **not** have privileged access to your local file system, it can only work with what Penpot exposes (design files, libraries, tokens, etc.). - * The **server URL** is provided in **Your account → Integrations → MCP Server (Beta)** and looks like: + * The **server URL** is provided in **Your account → Integrations → MCP Server** and looks like: * `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY` * The domain depends on the Penpot installation. In the official SaaS it will be `design.penpot.app`. * **Local MCP server** @@ -290,7 +293,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted ### Install and activate 1. Open **Your account → Integrations**. -2. In the **MCP Server (Beta)** section, read the short description to confirm that feature is available for your account. +2. In the **MCP Server** section, read the short description to confirm that feature is available for your account. 3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. 4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely. * Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples. @@ -304,7 +307,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted For client-specific setup, use the shared section **Connect your MCP client**. -For remote mode, use the URL shown in **Your account → Integrations → MCP Server (Beta)**, which includes your `userToken`. +For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`. <a id="use-remote"></a> ### Use @@ -314,7 +317,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat #### Run 1. **Enable MCP** - * Go to **Your account → Integrations → MCP Server (Beta)** and set **Status** to **Enabled**. + * Go to **Your account → Integrations → MCP Server** and set **Status** to **Enabled**. 2. **Connect plugin**: * Open a design file and use **File → MCP Server → Connect**. 3. **Run prompts**: @@ -371,7 +374,7 @@ At a high level: 2. Start the MCP server and plugin server from your terminal: ```json -npx @penpot/mcp@beta +npx @penpot/mcp@stable ``` Leave this terminal running while you use MCP. @@ -404,7 +407,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat 1. **Start MCP** - Run `npx -y @penpot/mcp@stable` (production) or `npx -y @penpot/mcp@beta` (test), and keep that terminal running. + Run `npx -y @penpot/mcp@stable` (production), and keep that terminal running. 2. **Connect plugin** In Penpot, load `http://localhost:4400/manifest.json`, run the plugin, and click **Connect to MCP server**. diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index ad4579fcde6..6d6f37bd566 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -415,6 +415,12 @@ In a high-availability (HA) scenario, managing the state outside of replicas is - Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. - User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. + +__Since version 2.15.0__ + +Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot. +If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions. + ## Backend This section enumerates the backend only configuration variables. diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk new file mode 100644 index 00000000000..bf241d0b1f0 --- /dev/null +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -0,0 +1,107 @@ +--- +title: Troubleshooting WebGL +order: 5 +desc: Diagnose and fix common WebGL issues in Penpot with browser, GPU, and system checks so you can open the workspace canvas correctly. +--- + +<h1 id="troubleshooting-webgl">Troubleshooting WebGL</h1> + +<div class="advice"> + <h3>Availability note</h3> + <p>WebGL renderer is currently not available yet in Penpot production (<code>design.penpot.app</code>).</p> + <p>Right now, this renderer is available only in <strong>testing environments</strong>. It is planned for an upcoming release and should be available soon.</p> +</div> + +<p class="main-paragraph">Penpot uses WebGL to render the design canvas. If WebGL is unavailable, Penpot cannot open the workspace canvas correctly.</p> + +<p>Sometimes WebGL appears enabled in your browser, but Penpot still cannot create a graphics context. This is usually related to browser settings, GPU acceleration, drivers, or temporary GPU overload.</p> + +<h2>Before changing anything</h2> +<ol> + <li>Open <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li> + <li>Check the result: + <ul> + <li>If you see a spinning cube, WebGL works at browser level.</li> + <li>If it fails (blank page, error message, or no animation), continue with browser and system checks below.</li> + </ul> + </li> +</ol> + +<h2>Quick checks (2 minutes)</h2> +<ol> + <li>Close graphics-heavy tabs/apps (video editors, 3D apps, many design tabs).</li> + <li>Reload Penpot.</li> + <li>Fully restart the browser.</li> + <li>If needed, restart your computer.</li> +</ol> +<p>Why this helps: GPU memory or context slots can be temporarily exhausted, even when your configuration is correct.</p> + +<h2>Chrome</h2> +<ol> + <li>Open <code>chrome://settings/system</code>.</li> + <li>Turn on <strong>Use graphics acceleration when available</strong>.</li> + <li>Restart Chrome.</li> + <li>Open <code>chrome://gpu</code> and review WebGL-related warnings.</li> +</ol> +<figure> + <a href="/img/troubleshooting/troubleshooting-chrome.webp" target="_blank"> + <img src="/img/troubleshooting/troubleshooting-chrome.webp" alt="Chrome system settings with graphics acceleration option" /> + </a> +</figure> +<p>Why this helps: WebGL depends on hardware acceleration and a healthy GPU process.</p> + +<h2>Mozilla Firefox</h2> +<ol> + <li>Open Firefox and check that zoom is set to <strong>100%</strong> from the top-right menu.</li> + <li>From that same menu, open Settings/Preferences.</li> + <li>In General settings, confirm Firefox is up to date and run <strong>Check for updates</strong> if needed.</li> + <li>Enable hardware acceleration in Firefox settings.</li> + <li>Restart Firefox.</li> + <li>Open <code>about:support</code> and review the Graphics/WebGL section.</li> +</ol> +<figure> + <a href="/img/troubleshooting/troubleshooting-firefox.webp" target="_blank"> + <img src="/img/troubleshooting/troubleshooting-firefox.webp" alt="Firefox settings showing hardware acceleration configuration" /> + </a> +</figure> +<p>Why this helps: outdated browser builds, disabled acceleration, or blocked GPU features can prevent context creation.</p> + +<h2>Safari</h2> +<ol> + <li>Update Safari/macOS to the latest available version.</li> + <li>Restart Safari.</li> + <li>Re-test in <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li> +</ol> +<p>Why this helps: Safari WebGL behavior is strongly tied to OS/browser version and graphics stack updates.</p> + +<h2>Settings</h2> +<p>Some advanced browser configurations or experimental settings can interfere with WebGL. If you have modified these in the past, consider restoring default browser settings or testing in a fresh profile.</p> + +<h2>About zoom and trackpad settings</h2> +<p>In some cases, changing browser zoom or trackpad settings is suggested as a workaround.</p> +<p>In Penpot, these are <strong>not baseline requirements</strong> for WebGL. Treat them only as temporary diagnostics if support explicitly asks for them.</p> +<p>If you temporarily changed one of these settings and Penpot starts working, you can usually revert it and test again.</p> + +<h2>GPU drivers and OS checks</h2> +<ol> + <li>Install any pending OS updates.</li> + <li>Update GPU drivers (especially on Windows/Linux).</li> + <li>Disable graphics overlays/tools (recording overlays, GPU tuning utilities) and test again.</li> +</ol> +<p>Why this helps: outdated or conflicting graphics layers can break WebGL context creation.</p> + +<h2>Known edge case: Linux + Nvidia</h2> +<p>Some Linux + Nvidia combinations can report WebGL as available but still fail at runtime in specific browser/driver combinations.</p> +<p>In some cases, switching between proprietary and open-source drivers or updating the NVIDIA driver resolves the issue.</p> +<p>If this is your setup, collect diagnostics and contact support.</p> + +<h2>If the issue persists</h2> +<p>Please share:</p> +<ul> + <li>Browser and version.</li> + <li>Operating system and version.</li> + <li>Result from <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li> + <li>A screenshot of browser graphics diagnostics (<code>chrome://gpu</code> or <code>about:support</code>).</li> +</ul> + +<p>Then contact us at <a href="mailto:support@penpot.app">support@penpot.app</a> or open a GitHub issue at <a href="https://github.com/penpot/penpot/issues" target="_blank">https://github.com/penpot/penpot/issues</a>.</p> \ No newline at end of file diff --git a/docs/user-guide/index.njk b/docs/user-guide/index.njk index d017f151c24..4b932f7ac01 100644 --- a/docs/user-guide/index.njk +++ b/docs/user-guide/index.njk @@ -2,7 +2,7 @@ title: User guide desc: Learn everything from interface basics to advanced features like prototyping and design sharing with Penpot's comprehensive user guide! Free access. eleventyNavigation: - key: User guide + key: User Guide order: 2 --- diff --git a/exporter/deps.edn b/exporter/deps.edn index 1c5cd1fb550..08e3a3cdd81 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -2,12 +2,12 @@ :deps {penpot/common {:local/root "../common"} org.clojure/clojure {:mvn/version "1.12.2"} - binaryage/devtools {:mvn/version "RELEASE"} + binaryage/devtools {:mvn/version "1.0.7"} metosin/reitit-core {:mvn/version "0.9.1"} } :aliases {:outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"} + {:extra-deps {com.github.liquidz/antq {:mvn/version"2.11.1276"} ;; org.slf4j/slf4j-nop {:mvn/version "RELEASE"} } :main-opts ["-m" "antq.core"]} diff --git a/exporter/package.json b/exporter/package.json index ba6570f3cce..e05895882c1 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -11,22 +11,22 @@ }, "type": "module", "dependencies": { - "archiver": "^7.0.1", + "archiver": "^8.0.0", "cookies": "^0.9.1", "date-fns": "^4.1.0", "generic-pool": "^3.9.0", "inflation": "^2.1.0", - "ioredis": "^5.8.2", - "playwright": "^1.57.0", + "ioredis": "^5.10.1", + "playwright": "^1.60.0", "raw-body": "^3.0.2", "source-map-support": "^0.5.21", "svgo": "penpot/svgo#v3.1", - "undici": "^7.16.0", + "undici": "^8.2.0", "xml-js": "^1.6.11", "xregexp": "^5.1.2" }, "devDependencies": { - "ws": "^8.18.3" + "ws": "^8.20.1" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 78e28acc49d..7acfafe825c 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: archiver: - specifier: ^7.0.1 - version: 7.0.1 + specifier: ^8.0.0 + version: 8.0.0 cookies: specifier: ^0.9.1 version: 0.9.1 @@ -24,11 +24,11 @@ importers: specifier: ^2.1.0 version: 2.1.0 ioredis: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.10.1 + version: 5.10.1 playwright: - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 raw-body: specifier: ^3.0.2 version: 3.0.2 @@ -39,8 +39,8 @@ importers: specifier: penpot/svgo#v3.1 version: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180 undici: - specifier: ^7.16.0 - version: 7.16.0 + specifier: ^8.2.0 + version: 8.2.0 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -49,8 +49,8 @@ importers: version: 5.1.2 devDependencies: ws: - specifier: ^8.18.3 - version: 8.18.3 + specifier: ^8.20.1 + version: 8.20.1 packages: @@ -58,16 +58,8 @@ packages: resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} engines: {node: '>=6.9.0'} - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} @@ -77,43 +69,24 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - archiver-utils@5.0.2: - resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} - engines: {node: '>= 14'} - - archiver@7.0.1: - resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} - engines: {node: '>= 14'} + archiver@8.0.0: + resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} + engines: {node: '>=18'} async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - b4a@1.7.3: - resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: react-native-b4a: '*' peerDependenciesMeta: react-native-b4a: optional: true - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} @@ -123,14 +96,48 @@ packages: bare-abort-controller: optional: true + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + 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==} @@ -150,16 +157,9 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - compress-commons@6.0.2: - resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} - engines: {node: '>= 14'} + compress-commons@7.0.1: + resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} + engines: {node: '>=18'} cookies@0.9.1: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} @@ -176,13 +176,9 @@ packages: engines: {node: '>=0.8'} hasBin: true - crc32-stream@6.0.0: - resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} - engines: {node: '>= 14'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + crc32-stream@7.0.1: + resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} + engines: {node: '>=18'} css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -236,15 +232,6 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -263,10 +250,6 @@ packages: fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -276,13 +259,6 @@ packages: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - hasBin: true - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -301,27 +277,17 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -340,26 +306,15 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -371,24 +326,13 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -410,8 +354,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdir-glob@1.1.3: - resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdir-glob@3.0.0: + resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} + engines: {node: '>=18'} redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} @@ -436,18 +381,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -466,16 +399,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -483,24 +408,19 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: resolution: {tarball: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180} version: 4.0.0 engines: {node: '>=16.0.0'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - text-decoder@1.2.3: - resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} @@ -510,9 +430,9 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} - engines: {node: '>=20.18.1'} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} + engines: {node: '>=22.19.0'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -521,21 +441,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -553,9 +460,9 @@ packages: xregexp@5.1.2: resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==} - zip-stream@6.0.1: - resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} - engines: {node: '>= 14'} + zip-stream@7.0.5: + resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} + engines: {node: '>=18'} snapshots: @@ -563,19 +470,7 @@ snapshots: dependencies: core-js-pure: 3.47.0 - '@ioredis/commands@1.4.0': {} - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@pkgjs/parseargs@0.11.0': - optional: true + '@ioredis/commands@1.5.1': {} '@trysound/sax@0.2.0': {} @@ -583,54 +478,67 @@ snapshots: dependencies: event-target-shim: 5.0.1 - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: + archiver@8.0.0: dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - archiver-utils@5.0.2: - dependencies: - glob: 10.5.0 - graceful-fs: 4.2.11 - is-stream: 2.0.1 - lazystream: 1.0.1 - lodash: 4.17.21 - normalize-path: 3.0.0 - readable-stream: 4.7.0 - - archiver@7.0.1: - dependencies: - archiver-utils: 5.0.2 async: 3.2.6 buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 readable-stream: 4.7.0 - readdir-glob: 1.1.3 - tar-stream: 3.1.7 - zip-stream: 6.0.1 + readdir-glob: 3.0.0 + tar-stream: 3.2.0 + zip-stream: 7.0.5 transitivePeerDependencies: - bare-abort-controller + - bare-buffer - react-native-b4a async@3.2.6: {} - b4a@1.7.3: {} + b4a@1.8.1: {} - balanced-match@1.0.2: {} + balanced-match@4.0.4: {} bare-events@2.8.2: {} + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + base64-js@1.5.1: {} boolbase@1.0.0: {} - brace-expansion@2.0.2: + brace-expansion@5.0.6: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 buffer-crc32@1.0.0: {} @@ -645,17 +553,11 @@ snapshots: cluster-key-slot@1.1.2: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - compress-commons@6.0.2: + compress-commons@7.0.1: dependencies: crc-32: 1.2.2 - crc32-stream: 6.0.0 - is-stream: 2.0.1 + crc32-stream: 7.0.1 + is-stream: 4.0.1 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -670,17 +572,11 @@ snapshots: crc-32@1.2.2: {} - crc32-stream@6.0.0: + crc32-stream@7.0.1: dependencies: crc-32: 1.2.2 readable-stream: 4.7.0 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -733,12 +629,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - eastasianwidth@0.2.0: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - entities@4.5.0: {} event-target-shim@5.0.1: {} @@ -753,27 +643,11 @@ snapshots: fast-fifo@1.3.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - fsevents@2.3.2: optional: true generic-pool@3.9.0: {} - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - graceful-fs@4.2.11: {} - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -792,9 +666,9 @@ snapshots: inherits@2.0.4: {} - ioredis@5.8.2: + ioredis@5.10.1: dependencies: - '@ioredis/commands': 1.4.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.3 denque: 2.1.0 @@ -806,20 +680,10 @@ snapshots: transitivePeerDependencies: - supports-color - is-fullwidth-code-point@3.0.0: {} - - is-stream@2.0.1: {} + is-stream@4.0.1: {} isarray@1.0.0: {} - isexe@2.0.0: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - keygrip@1.1.0: dependencies: tsscmp: 1.0.6 @@ -834,21 +698,13 @@ snapshots: lodash@4.17.21: {} - lru-cache@10.4.3: {} - mdn-data@2.0.28: {} mdn-data@2.12.2: {} - minimatch@5.1.6: + minimatch@10.2.5: dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minipass@7.1.2: {} + brace-expansion: 5.0.6 ms@2.1.3: {} @@ -858,20 +714,11 @@ snapshots: dependencies: boolbase: 1.0.0 - package-json-from-dist@1.0.1: {} - - path-key@3.1.1: {} - - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -904,9 +751,9 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 - readdir-glob@1.1.3: + readdir-glob@3.0.0: dependencies: - minimatch: 5.1.6 + minimatch: 10.2.5 redis-errors@1.2.0: {} @@ -924,14 +771,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@4.1.0: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -945,27 +784,15 @@ snapshots: statuses@2.0.2: {} - streamx@2.23.0: + streamx@2.25.0: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 - text-decoder: 1.2.3 + text-decoder: 1.2.7 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -974,14 +801,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: dependencies: '@trysound/sax': 0.2.0 @@ -990,18 +809,27 @@ snapshots: csso: 5.0.5 lodash: 4.17.21 - tar-stream@3.1.7: + tar-stream@3.2.0: dependencies: - b4a: 1.7.3 + b4a: 1.8.1 + bare-fs: 4.7.1 fast-fifo: 1.3.2 - streamx: 2.23.0 + streamx: 2.25.0 transitivePeerDependencies: - bare-abort-controller + - bare-buffer - react-native-b4a - text-decoder@1.2.3: + teex@1.0.1: dependencies: - b4a: 1.7.3 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 transitivePeerDependencies: - react-native-b4a @@ -1009,29 +837,13 @@ snapshots: tsscmp@1.0.6: {} - undici@7.16.0: {} + undici@8.2.0: {} unpipe@1.0.0: {} util-deprecate@1.0.2: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - - ws@8.18.3: {} + ws@8.20.1: {} xml-js@1.6.11: dependencies: @@ -1041,8 +853,8 @@ snapshots: dependencies: '@babel/runtime-corejs3': 7.28.4 - zip-stream@6.0.1: + zip-stream@7.0.5: dependencies: - archiver-utils: 5.0.2 - compress-commons: 6.0.2 + compress-commons: 7.0.1 + normalize-path: 3.0.0 readable-stream: 4.7.0 diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index b1df4a74475..5d81eebb9f7 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -60,6 +60,7 @@ :route "objects" :skip-children skip-children} uri (-> (cf/get :public-uri) - (assoc :path "/render.html") + (u/ensure-path-slash) + (u/join "render.html") (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index 25bcfc036be..edfdcda1b11 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -35,7 +35,7 @@ :object-id object-id :route "objects"}] (-> base-uri - (assoc :path "/render.html") + (u/join "render.html") (assoc :query (u/map->query-string params))))) (sync-page-size! [dom] @@ -76,6 +76,7 @@ (on-object (assoc object :path path)) (p/recur (rest objects))))))] - (let [base-uri (cf/get :public-uri)] + (let [base-uri (-> (cf/get :public-uri) + (u/ensure-path-slash))] (bw/exec! (prepare-options base-uri) (partial render base-uri))))) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 73558dbe5fe..71da424fb37 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -349,7 +349,8 @@ :object-id (mapv :id objects) :route "objects"} uri (-> (cf/get :public-uri) - (assoc :path "/render.html") + (u/ensure-path-slash) + (u/join "render.html") (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) diff --git a/frontend/package.json b/frontend/package.json index ed3c29f34a9..698642a25ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,28 +50,28 @@ "devDependencies": { "@penpot/draft-js": "workspace:./packages/draft-js", "@penpot/mousetrap": "workspace:./packages/mousetrap", - "@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime", + "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", "@penpot/svgo": "penpot/svgo#v3.2", "@penpot/text-editor": "workspace:./text-editor", "@penpot/tokenscript": "workspace:./packages/tokenscript", "@penpot/ui": "workspace:./packages/ui", - "@playwright/test": "1.59.0", + "@playwright/test": "1.60.0", "@storybook/addon-docs": "10.1.11", "@storybook/addon-themes": "10.1.11", "@storybook/addon-vitest": "10.1.11", "@storybook/react-vite": "10.1.11", "@tokens-studio/sd-transforms": "1.2.11", "@types/node": "^25.0.3", - "@vitest/browser": "4.0.18", - "@vitest/browser-playwright": "^4.0.18", - "@vitest/coverage-v8": "4.0.18", + "@vitest/browser": "4.1.6", + "@vitest/browser-playwright": "^4.1.6", + "@vitest/coverage-v8": "4.1.6", "@zip.js/zip.js": "2.8.11", "autoprefixer": "^10.4.21", "compression": "^1.8.1", "concurrently": "^9.2.1", "date-fns": "^4.1.0", - "esbuild": "^0.27.4", - "eventsource-parser": "^3.0.6", + "esbuild": "^0.28.0", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "fancy-log": "^2.0.0", "getopts": "^2.3.0", @@ -89,7 +89,7 @@ "npm-run-all": "^4.1.5", "opentype.js": "^1.3.4", "p-limit": "^6.2.0", - "playwright": "1.59.0", + "playwright": "1.60.0", "postcss": "^8.5.4", "postcss-clean": "^1.2.2", "postcss-modules": "^6.0.1", @@ -116,7 +116,7 @@ "typescript": "^5.9.2", "ua-parser-js": "2.0.7", "vite": "^7.3.1", - "vitest": "^4.0.18", + "vitest": "^4.1.6", "wait-on": "^9.0.3", "wasm-pack": "^0.13.1", "watcher": "^2.3.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 539b36dbe43..47593ba4630 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: workspace:./packages/mousetrap version: link:packages/mousetrap '@penpot/plugins-runtime': - specifier: link:../plugins/dist/plugins-runtime - version: link:../plugins/dist/plugins-runtime + specifier: link:../plugins/libs/plugins-runtime + version: link:../plugins/libs/plugins-runtime '@penpot/svgo': specifier: penpot/svgo#v3.2 version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b @@ -35,20 +35,20 @@ importers: specifier: workspace:./packages/ui version: link:packages/ui '@playwright/test': - specifier: 1.59.0 - version: 1.59.0 + specifier: 1.60.0 + version: 1.60.0 '@storybook/addon-docs': specifier: 10.1.11 - version: 10.1.11(@types/react@19.2.13)(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + version: 10.1.11(@types/react@19.2.13)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@storybook/addon-themes': specifier: 10.1.11 version: 10.1.11(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) '@storybook/addon-vitest': specifier: 10.1.11 - version: 10.1.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.18) + version: 10.1.11(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6)(@vitest/runner@4.1.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.1.6) '@storybook/react-vite': specifier: 10.1.11 - version: 10.1.11(esbuild@0.27.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + version: 10.1.11(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@tokens-studio/sd-transforms': specifier: 1.2.11 version: 1.2.11(style-dictionary@5.0.0-rc.1(tslib@2.8.1)) @@ -56,14 +56,14 @@ importers: specifier: ^25.0.3 version: 25.2.1 '@vitest/browser': - specifier: 4.0.18 - version: 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + specifier: 4.1.6 + version: 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) '@vitest/browser-playwright': - specifier: ^4.0.18 - version: 4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + specifier: ^4.1.6 + version: 4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) '@vitest/coverage-v8': - specifier: 4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + specifier: 4.1.6 + version: 4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6) '@zip.js/zip.js': specifier: 2.8.11 version: 2.8.11(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) @@ -80,11 +80,11 @@ importers: specifier: ^4.1.0 version: 4.1.0 esbuild: - specifier: ^0.27.4 - version: 0.27.4 + specifier: ^0.28.0 + version: 0.28.0 eventsource-parser: - specifier: ^3.0.6 - version: 3.0.6 + specifier: ^3.0.8 + version: 3.0.8 express: specifier: ^5.1.0 version: 5.2.1 @@ -137,8 +137,8 @@ importers: specifier: ^6.2.0 version: 6.2.0 playwright: - specifier: 1.59.0 - version: 1.59.0 + specifier: 1.60.0 + version: 1.60.0 postcss: specifier: ^8.5.4 version: 8.5.6 @@ -218,8 +218,8 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) wait-on: specifier: ^9.0.3 version: 9.0.3 @@ -283,7 +283,7 @@ importers: version: 10.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.27.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + version: 10.2.0(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@testing-library/dom': specifier: 10.4.0 version: 10.4.0 @@ -334,7 +334,7 @@ importers: version: 25.2.1 '@vitest/browser': specifier: ^1.6.0 - version: 1.6.1(playwright@1.58.0)(vitest@1.6.1) + version: 1.6.1(playwright@1.60.0)(vitest@1.6.1) '@vitest/coverage-v8': specifier: ^1.6.0 version: 1.6.1(vitest@1.6.1) @@ -352,7 +352,7 @@ importers: version: 27.4.0(canvas@3.2.1) playwright: specifier: ^1.45.1 - version: 1.58.0 + version: 1.60.0 prettier: specifier: ^3.7.4 version: 3.8.1 @@ -453,6 +453,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -528,6 +533,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bufbuild/protobuf@2.11.0': resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} @@ -599,6 +607,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -617,6 +631,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -635,6 +655,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -653,6 +679,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -671,6 +703,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -689,6 +727,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -707,6 +751,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -725,6 +775,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -743,6 +799,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -761,6 +823,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -779,6 +847,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -797,6 +871,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -815,6 +895,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -833,6 +919,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -851,6 +943,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -869,6 +967,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -887,6 +991,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -899,6 +1009,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -917,6 +1033,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -929,6 +1051,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -947,6 +1075,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -959,6 +1093,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -977,6 +1117,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -995,6 +1141,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -1013,6 +1165,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -1031,6 +1189,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1098,12 +1262,16 @@ packages: '@hapi/topo@6.0.2': resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -1414,8 +1582,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@playwright/test@1.59.0': - resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -1908,6 +2076,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1943,11 +2114,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/browser-playwright@4.0.18': - resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + '@vitest/browser-playwright@4.1.6': + resolution: {integrity: sha512-4csoeyl/qwHyxU2zNL0++WaoDr8YJDXOQPwWPNJoTZ+QzcdO3INYKgF5Zfz730Io7zbkuv914aZmfQ+QE+1Hvw==} peerDependencies: playwright: '*' - vitest: 4.0.18 + vitest: 4.1.6 '@vitest/browser@1.6.1': resolution: {integrity: sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==} @@ -1964,21 +2135,21 @@ packages: webdriverio: optional: true - '@vitest/browser@4.0.18': - resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + '@vitest/browser@4.1.6': + resolution: {integrity: sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==} peerDependencies: - vitest: 4.0.18 + vitest: 4.1.6 '@vitest/coverage-v8@1.6.1': resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} peerDependencies: vitest: 1.6.1 - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.6 + vitest: 4.1.6 peerDependenciesMeta: '@vitest/browser': optional: true @@ -1989,8 +2160,8 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -2003,11 +2174,11 @@ packages: vite: optional: true - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true @@ -2017,20 +2188,20 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} '@vitest/runner@1.6.1': resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} '@vitest/snapshot@1.6.1': resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} '@vitest/spy@1.6.1': resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} @@ -2038,8 +2209,8 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} '@vitest/ui@1.6.1': resolution: {integrity: sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==} @@ -2052,8 +2223,8 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2146,8 +2317,8 @@ packages: ajv: optional: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -2257,8 +2428,8 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - ast-v8-to-istanbul@0.3.11: - resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -2336,8 +2507,8 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -2903,8 +3074,8 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -2937,6 +3108,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3069,8 +3245,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} execa@8.0.1: @@ -4267,6 +4443,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.3.1: resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} engines: {node: '>=0.10'} @@ -4276,10 +4456,6 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -4291,8 +4467,8 @@ packages: engines: {node: '>=18'} hasBin: true - playwright-core@1.59.0: - resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true @@ -4301,8 +4477,8 @@ packages: engines: {node: '>=18'} hasBin: true - playwright@1.59.0: - resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -4937,6 +5113,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -5141,14 +5320,18 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} @@ -5157,8 +5340,8 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tinyspy@2.2.1: @@ -5466,20 +5649,23 @@ packages: jsdom: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -5493,6 +5679,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -5612,6 +5802,18 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + 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'} @@ -5792,6 +5994,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5881,6 +6087,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@bufbuild/protobuf@2.11.0': {} '@bundled-es-modules/deepmerge@4.3.1': @@ -5953,6 +6161,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true @@ -5962,6 +6173,9 @@ snapshots: '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.21.5': optional: true @@ -5971,6 +6185,9 @@ snapshots: '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.21.5': optional: true @@ -5980,6 +6197,9 @@ snapshots: '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true @@ -5989,6 +6209,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true @@ -5998,6 +6221,9 @@ snapshots: '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true @@ -6007,6 +6233,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true @@ -6016,6 +6245,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true @@ -6025,6 +6257,9 @@ snapshots: '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true @@ -6034,6 +6269,9 @@ snapshots: '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true @@ -6043,6 +6281,9 @@ snapshots: '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true @@ -6052,6 +6293,9 @@ snapshots: '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true @@ -6061,6 +6305,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true @@ -6070,6 +6317,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true @@ -6079,6 +6329,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true @@ -6088,6 +6341,9 @@ snapshots: '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true @@ -6097,12 +6353,18 @@ snapshots: '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true @@ -6112,12 +6374,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true @@ -6127,12 +6395,18 @@ snapshots: '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true @@ -6142,6 +6416,9 @@ snapshots: '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true @@ -6151,6 +6428,9 @@ snapshots: '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true @@ -6160,6 +6440,9 @@ snapshots: '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true @@ -6169,6 +6452,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': dependencies: eslint: 9.39.2 @@ -6194,7 +6480,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 @@ -6233,13 +6519,18 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} @@ -6545,9 +6836,9 @@ snapshots: dependencies: playwright: 1.58.0 - '@playwright/test@1.59.0': + '@playwright/test@1.60.0': dependencies: - playwright: 1.59.0 + playwright: 1.60.0 '@polka/url@1.0.0-next.29': {} @@ -6737,10 +7028,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.1.11(@types/react@19.2.13)(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/addon-docs@10.1.11(@types/react@19.2.13)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.3) - '@storybook/csf-plugin': 10.1.11(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.1.11(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@storybook/icons': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@storybook/react-dom-shim': 10.1.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) react: 19.2.3 @@ -6759,23 +7050,23 @@ snapshots: storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ts-dedent: 2.2.0 - '@storybook/addon-vitest@10.1.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.18)': + '@storybook/addon-vitest@10.1.11(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6)(@vitest/runner@4.1.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.1.6)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/browser-playwright': 4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/runner': 4.0.18 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + '@vitest/browser': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/runner': 4.1.6 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) transitivePeerDependencies: - react - react-dom - '@storybook/builder-vite@10.1.11(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/builder-vite@10.1.11(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: - '@storybook/csf-plugin': 10.1.11(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.1.11(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ts-dedent: 2.2.0 @@ -6786,9 +7077,9 @@ snapshots: - rollup - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/builder-vite@10.2.0(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ts-dedent: 2.2.0 @@ -6799,21 +7090,21 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.1.11(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/csf-plugin@10.1.11(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.27.4 + esbuild: 0.28.0 rollup: 4.57.1 vite: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) - '@storybook/csf-plugin@10.2.0(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/csf-plugin@10.2.0(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.27.4 + esbuild: 0.28.0 rollup: 4.57.1 vite: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) @@ -6836,11 +7127,11 @@ snapshots: react-dom: 19.2.3(react@19.2.3) storybook: 10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@storybook/react-vite@10.1.11(esbuild@0.27.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/react-vite@10.1.11(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.1.11(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@storybook/builder-vite': 10.1.11(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@storybook/react': 10.1.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -6859,11 +7150,11 @@ snapshots: - typescript - webpack - '@storybook/react-vite@10.2.0(esbuild@0.27.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@storybook/react-vite@10.2.0(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.27.4)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@storybook/builder-vite': 10.2.0(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) '@storybook/react': 10.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.0(@testing-library/dom@10.4.0)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -6999,6 +7290,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -7037,39 +7330,39 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser-playwright@4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6)': dependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) - playwright: 1.59.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + '@vitest/browser': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + playwright: 1.60.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@1.6.1(playwright@1.58.0)(vitest@1.6.1)': + '@vitest/browser@1.6.1(playwright@1.60.0)(vitest@1.6.1)': dependencies: '@vitest/utils': 1.6.1 magic-string: 0.30.21 sirv: 2.0.4 vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3) optionalDependencies: - playwright: 1.58.0 + playwright: 1.60.0 - '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser@4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6)': dependencies: - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) - '@vitest/utils': 4.0.18 + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@vitest/utils': 4.1.6 magic-string: 0.30.21 - pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) - ws: 8.19.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + ws: 8.20.1 transitivePeerDependencies: - bufferutil - msw @@ -7095,21 +7388,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.11 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) optionalDependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) '@vitest/expect@1.6.1': dependencies: @@ -7125,14 +7418,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: @@ -7142,9 +7435,9 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@vitest/mocker@4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -7154,9 +7447,9 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.6': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/runner@1.6.1': dependencies: @@ -7164,9 +7457,9 @@ snapshots: p-limit: 5.0.0 pathe: 1.1.2 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.6': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.6 pathe: 2.0.3 '@vitest/snapshot@1.6.1': @@ -7175,9 +7468,10 @@ snapshots: pathe: 1.1.2 pretty-format: 29.7.0 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.6': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 magic-string: 0.30.21 pathe: 2.0.3 @@ -7189,7 +7483,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.6': {} '@vitest/ui@1.6.1(vitest@1.6.1)': dependencies: @@ -7215,10 +7509,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.6': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@volar/language-core@2.4.28': dependencies: @@ -7304,7 +7599,7 @@ snapshots: optionalDependencies: ajv: 8.13.0 - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -7457,7 +7752,7 @@ snapshots: dependencies: tslib: 2.8.1 - ast-v8-to-istanbul@0.3.11: + ast-v8-to-istanbul@1.0.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -7553,7 +7848,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@1.1.13: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -8174,7 +8469,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -8281,6 +8576,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -8405,11 +8729,11 @@ snapshots: '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3(supports-color@5.5.0) @@ -8466,7 +8790,7 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.0.8: {} execa@8.0.1: dependencies: @@ -8561,6 +8885,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fecha@4.2.3: {} fflate@0.8.2: {} @@ -9302,7 +9630,7 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 source-map-js: 1.2.1 @@ -9382,7 +9710,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 1.1.14 minimatch@9.0.1: dependencies: @@ -9704,14 +10032,12 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pidtree@0.3.1: {} pify@3.0.0: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -9726,7 +10052,7 @@ snapshots: playwright-core@1.58.0: {} - playwright-core@1.59.0: {} + playwright-core@1.60.0: {} playwright@1.58.0: dependencies: @@ -9734,9 +10060,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - playwright@1.59.0: + playwright@1.60.0: dependencies: - playwright-core: 1.59.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -10420,6 +10746,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -10717,18 +11045,23 @@ snapshots: tinycolor2@1.6.0: {} - tinyexec@1.0.2: {} + tinyexec@1.1.2: {} tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@0.8.4: {} tinyrainbow@2.0.0: {} - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} tinyspy@2.2.1: {} @@ -11014,7 +11347,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.1 - '@vitest/browser': 1.6.1(playwright@1.58.0)(vitest@1.6.1) + '@vitest/browser': 1.6.1(playwright@1.60.0)(vitest@1.6.1) '@vitest/ui': 1.6.1(vitest@1.6.1) jsdom: 27.4.0(canvas@3.2.1) transitivePeerDependencies: @@ -11027,44 +11360,35 @@ snapshots: - supports-color - terser - vitest@4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2): + vitest@4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.1 - '@vitest/browser-playwright': 4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/coverage-v8': 4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6) jsdom: 27.4.0(canvas@3.2.1) transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vscode-uri@3.1.0: {} @@ -11208,6 +11532,8 @@ snapshots: ws@8.19.0: {} + ws@8.20.1: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 diff --git a/frontend/resources/images/features/2.15-mcp-01.gif b/frontend/resources/images/features/2.15-mcp-01.gif new file mode 100644 index 00000000000..365bb5ce635 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-01.gif differ diff --git a/frontend/resources/images/features/2.15-mcp-02.gif b/frontend/resources/images/features/2.15-mcp-02.gif new file mode 100644 index 00000000000..b2030b9c129 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-02.gif differ diff --git a/frontend/resources/images/features/2.15-mcp-03.gif b/frontend/resources/images/features/2.15-mcp-03.gif new file mode 100644 index 00000000000..1a79926e644 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-03.gif differ diff --git a/frontend/resources/images/features/2.15-slide-0.jpg b/frontend/resources/images/features/2.15-slide-0.jpg new file mode 100644 index 00000000000..8e5b90b7dd3 Binary files /dev/null and b/frontend/resources/images/features/2.15-slide-0.jpg differ diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index c82907a5b65..91068275cc1 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -800,7 +800,7 @@ position: absolute; padding: $s-4; border-radius: $br-8; - z-index: $z-index-10; + z-index: $z-index-dropdown; color: var(--title-foreground-color-hover); background-color: var(--menu-background-color); border: $s-2 solid var(--panel-border-color); diff --git a/frontend/resources/styles/common/refactor/z-index.scss b/frontend/resources/styles/common/refactor/z-index.scss index 755b2e9fad3..3d36cb37f5a 100644 --- a/frontend/resources/styles/common/refactor/z-index.scss +++ b/frontend/resources/styles/common/refactor/z-index.scss @@ -11,5 +11,5 @@ $z-index-4: 4; // context menu $z-index-5: 5; // modal $z-index-10: 10; $z-index-20: 20; -$z-index-modal: 30; // When refactor finish we can reduce this number, -$z-index-alert: 40; // When refactor finish we can reduce this number, +$z-index-modal: 300; +$z-index-dropdown: 400; diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index f80b7e77598..60c6119fd0d 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -31,7 +31,6 @@ globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersionTag = "{{& version_tag}}"; globalThis.penpotBuildDate = "{{& build_date}}"; - globalThis.penpotWorkerURI = "{{& manifest.worker_main}}"; </script> {{# manifest}} diff --git a/frontend/resources/templates/rasterizer.mustache b/frontend/resources/templates/rasterizer.mustache index 90a7f1dfdc0..6a3d815e293 100644 --- a/frontend/resources/templates/rasterizer.mustache +++ b/frontend/resources/templates/rasterizer.mustache @@ -9,7 +9,6 @@ globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersionTag = "{{& version_tag}}"; globalThis.penpotBuildDate = "{{& build_date}}"; - globalThis.penpotWorkerURI = "{{& manifest.worker_main}}"; </script> {{# manifest}} diff --git a/frontend/resources/templates/render.mustache b/frontend/resources/templates/render.mustache index 4de213f9ad8..67629b075ed 100644 --- a/frontend/resources/templates/render.mustache +++ b/frontend/resources/templates/render.mustache @@ -14,7 +14,7 @@ </script> {{# manifest}} - <script src="{{& config}}"></script> + <script src="{{& config_render}}"></script> <script src="{{& polyfills}}"></script> <script type="importmap">{{& importmap }}</script> {{/manifest}} diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index dd2e23c3483..240c6b0e45b 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -207,9 +207,9 @@ async function generateManifest() { rasterizer_main: "./js/rasterizer.js", config: "./js/config.js?version=" + VERSION_TAG, + config_render: "./js/config-render.js?version=" + VERSION_TAG, polyfills: "./js/polyfills.js?version=" + VERSION_TAG, libs: "./js/libs.js?version=" + VERSION_TAG, - worker_main: "./js/worker/main.js?version=" + VERSION_TAG, default_translations: "./js/translation.en.js?version=" + VERSION_TAG, importmap: JSON.stringify({ diff --git a/frontend/scripts/build b/frontend/scripts/build index 262a90ff45f..eb8e42ea1bd 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -36,7 +36,7 @@ popd pushd ../mcp; rm -rf node_modules; ./scripts/setup -WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build:multi-user +WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build popd; pnpm run build:app:main $EXTRA_PARAMS; diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index dd25bed989b..fca7cf941e5 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -5,4 +5,4 @@ SCRIPT_DIR=$(dirname $0); set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=2 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter=list "$@"; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index f1c1e2b8bf9..ae9f153d386 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -12,6 +12,7 @@ [app.common.logging :as log] [app.common.time :as ct] [app.common.uri :as u] + [app.common.uuid :as uuid] [app.common.version :as v] [app.util.avatars :as avatars] [app.util.extends] @@ -108,10 +109,12 @@ (def target (parse-target global)) (def browser (parse-browser)) (def platform (parse-platform)) +(def session-id (uuid/next)) (def version (parse-version global)) (def version-tag (obj/get global "penpotVersionTag")) + (defn stale-build? "Returns true when the compiled JS was built with a different version tag than the one present in the current index.html. This indicates @@ -151,11 +154,19 @@ (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) + +;; mPass SSO full-3-layer signout URL. Read at runtime from config.js +;; (injected via nginx-entrypoint.sh from the MPASS_SIGNOUT_URL env var). +;; When set, the logout event redirects here instead of to the native +;; penpot /auth/login screen so the oauth2-proxy cookie and Cognito +;; session are also cleared. Nil on non-SSO deployments. +(def mpass-signout-url (obj/get global "penpotMpassSignoutUrl")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) -(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins")) +(def plugins-list-uri (obj/get global "penpotPluginsListURI" "https://penpot.app/penpothub/plugins")) (def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" []))) -(def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/")) +(def templates-uri (obj/get global "penpotTemplatesURI" "https://penpot.github.io/penpot-files/")) +(def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB ;; We set the current parsed flags under common for make ;; it available for common code without the need to pass @@ -172,12 +183,19 @@ (normalize-uri (or (obj/get global "penpotPublicURI") (obj/get location "origin")))) +(def mcp-ws-uri + (or (some-> (obj/get global "penpotMcpServerURI") u/uri) + (u/join public-uri "mcp/ws"))) + (def rasterizer-uri (or (some-> (obj/get global "penpotRasterizerURI") normalize-uri) public-uri)) (def worker-uri - (obj/get global "penpotWorkerURI" "/js/worker/main.js")) + (-> public-uri + (u/join "js/worker/main.js") + (get :path) + (str "?version=" version-tag))) (defn external-feature-flag [flag value] @@ -195,11 +213,19 @@ (let [f (obj/get global "externalContextInfo")] (when (fn? f) (f)))) +(defn external-notify-register-success + [profile-id] + (let [f (obj/get global "externalNotifyRegisterSuccess")] + (when (fn? f) (f (str profile-id))))) + (defn initialize-external-context-info [] (let [f (obj/get global "initializeExternalConfigInfo")] (when (fn? f) (f)))) +(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str)) +(def mcp-help-center-uri "https://help.penpot.app/mcp/") + ;; --- Helper Functions (defn ^boolean check-browser? [candidate] diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 870f8a82bbd..fa0a853cd90 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -8,8 +8,9 @@ (:require [app.common.data.macros :as dm] [app.common.logging :as log] + [app.common.time :as ct] + [app.common.transit :as t] [app.common.types.objects-map] - [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :as da] [app.main.data.event :as ev] @@ -43,7 +44,8 @@ (log/inf :version (:full cf/version) :asserts *assert* :build-date cf/build-date - :public-uri (dm/str cf/public-uri)) + :public-uri (dm/str cf/public-uri) + :session-id (str cf/session-id)) (log/inf :hint "enabled flags" :flags (str/join " " (map name cf/flags)))) (declare reinit) @@ -61,16 +63,13 @@ (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (assoc state :session-id (uuid/next))) + (assoc state :session-id cf/session-id)) ptk/WatchEvent (watch [_ _ stream] (rx/merge - (if (contains? cf/flags :audit-log) - (rx/of (ev/initialize)) - (rx/empty)) - - (rx/of (dp/refresh-profile)) + (rx/of (ev/initialize) + (dp/refresh-profile)) ;; Watch for profile deletion events (->> stream @@ -100,6 +99,15 @@ (defn ^:export init [options] + ;; WORKAROUND: we set this really not usefull property for signal a + ;; sideffect and prevent GCC remove it. We need it because we need + ;; to populate the Date prototype with transit related properties + ;; before SES hardning is applied on loading MCP plugin + (unchecked-set js/globalThis "penpotStartDate" + (-> (ct/now) + (t/encode-str) + (t/decode-str))) + ;; Before initializing anything, check if the browser has loaded ;; stale JS from a previous deployment. If so, do a hard reload so ;; the browser fetches fresh assets matching the current index.html. diff --git a/frontend/src/app/main/broadcast.cljs b/frontend/src/app/main/broadcast.cljs index 33e12f12a6d..0a4ccf10708 100644 --- a/frontend/src/app/main/broadcast.cljs +++ b/frontend/src/app/main/broadcast.cljs @@ -57,5 +57,6 @@ [type data] (ptk/reify ::event ptk/EffectEvent - (effect [_ _ _] - (emit! type data)))) + (effect [_ state _] + (let [session-id (get state :session-id)] + (emit! session-id type data))))) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index e3aa763ad68..1c327895ea2 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -61,26 +61,30 @@ (rx/of (dcm/go-to-dashboard-recent {:team-id team-id})))))))] (ptk/reify ::logged-in - ev/Event - (-data [_] - {::ev/name "signin" - ::ev/type "identify" - :email (:email profile) - :auth-backend (:auth-backend profile) - :fullname (:fullname profile) - :is-muted (:is-muted profile) - :default-team-id (:default-team-id profile) - :default-project-id (:default-project-id profile)}) - ptk/WatchEvent (watch [_ _ stream] (cf/initialize-external-context-info) + (->> (rx/merge (rx/of (dp/set-profile profile) (ws/initialize) (dtm/fetch-teams)) + ;; We schedule this event to be executed a bit later, + ;; when the profile is already set + (->> (rx/of (ev/event {::ev/name "signin" + ::ev/type "identify" + :id (:id profile) + :email (:email profile) + :auth-backend (:auth-backend profile) + :fullname (:fullname profile) + :is-muted (:is-muted profile) + :default-team-id (:default-team-id profile) + :default-project-id (:default-project-id profile)})) + (rx/observe-on :async)) + + (->> stream (rx/filter (ptk/type? ::dtm/teams-fetched)) (rx/take 1) @@ -258,14 +262,21 @@ ptk/WatchEvent (watch [_ state _] - (let [profile-id (:profile-id state)] + (let [profile-id (:profile-id state) + ;; Strip the first subdomain so we land on the portal (outside ForwardAuth) + ;; instead of Penpot's own root, which would silently re-auth. + host (.-host js/location) + protocol (.-protocol js/location) + portal-host (.replace host #"^[^.]+\.(?=[^.]*\.[^.]*\.)" "") + portal-uri (str protocol "//" portal-host) + logged-out-ev (logged-out {:redirect-uri portal-uri})] (->> (rx/interval 500) (rx/take 1) (rx/mapcat (fn [_] (->> (rp/cmd! :logout {:profile-id profile-id}) (rx/delay-at-least 300) (rx/catch (constantly (rx/of nil)))))) - (rx/map logged-out)))))) + (rx/map (constantly logged-out-ev))))))) ;; --- Update Profile diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index 57fc450ba07..3aae536949e 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -96,12 +96,13 @@ (update-in [:comments id] assoc (:id comment) comment)))) ptk/WatchEvent - (watch [_ _ _] - (rx/of (ptk/data-event ::ev/event - {::ev/name "create-comment-thread" - ::ev/origin "workspace" - :id id - :content-size (count (:content comment))})))))) + (watch [it _ _] + (rx/of (ev/event + (merge {::ev/name "create-comment-thread" + ::ev/origin "workspace" + :id id + :content-size (count (:content comment))} + (meta it)))))))) (def ^:private schema:create-thread-on-workspace @@ -119,7 +120,7 @@ (ptk/reify ::create-thread-on-workspace ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) frame-id (ctst/get-frame-id-by-position objects (:position params)) @@ -129,7 +130,10 @@ (->> (rp/cmd! :create-comment-thread params) (rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)})) (rx/tap on-thread-created) - (rx/map #(created-thread-on-workspace % open?)) + (rx/map + (fn [data] + (-> (created-thread-on-workspace data open?) + (with-meta (meta it))))) (rx/catch (fn [{:keys [type code] :as cause}] (if (and (= type :restriction) (= code :max-quote-reached)) @@ -152,11 +156,11 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (ptk/data-event ::ev/event - {::ev/name "create-comment-thread" - ::ev/origin "viewer" - :id id - :content-size (count (:content comment))}))))) + (rx/of (ev/event + {::ev/name "create-comment-thread" + ::ev/origin "viewer" + :id id + :content-size (count (:content comment))}))))) (def ^:private schema:create-thread-on-viewer @@ -216,11 +220,13 @@ (d/update-in-when state [:comment-threads id] assoc :is-resolved is-resolved)) ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [share-id (-> state :viewer-local :share-id)] (rx/concat - (when is-resolved (rx/of - (ptk/event ::ev/event {::ev/name "resolve-comment-thread" :thread-id id}))) + (when is-resolved + (rx/of (ev/event + (-> {::ev/name "resolve-comment-thread" :thread-id id} + (merge (meta it)))))) (->> (rp/cmd! :update-comment-thread {:id id :is-resolved is-resolved :share-id share-id}) (rx/catch (fn [{:keys [type code] :as cause}] (if (and (= type :restriction) @@ -309,16 +315,18 @@ (update :comment-threads dissoc id))) ptk/WatchEvent - (watch [_ _ _] + (watch [it _ _] (rx/concat (->> (rp/cmd! :delete-comment-thread {:id id}) (rx/catch #(rx/throw {:type :comment-error})) (rx/tap on-delete) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event - {::ev/name "delete-comment-thread" - ::ev/origin "workspace" - :id id}))))))) + (rx/of (ev/event + (merge + {::ev/name "delete-comment-thread" + ::ev/origin "workspace" + :id id} + (meta it))))))))) (defn delete-comment-thread-on-viewer [{:keys [id] :as thread}] @@ -341,10 +349,10 @@ (->> (rp/cmd! :delete-comment-thread {:id id :share-id share-id}) (rx/catch #(rx/throw {:type :comment-error})) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event - {::ev/name "delete-comment-thread" - ::ev/origin "viewer" - :id id}))))))) + (rx/of (ev/event + {::ev/name "delete-comment-thread" + ::ev/origin "viewer" + :id id}))))))) (defn delete-comment [{:keys [id thread-id] :as comment}] (dm/assert! diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index a5ce2cd2c33..85f1ef8581c 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -479,7 +479,7 @@ (->> (rp/cmd! :get-file-summary {:id id}) (rx/map (fn [summary] (when (-> summary :variants :count pos?) - (ptk/event ::ev/event {::ev/name "set-file-variants-shared" ::ev/origin "dashboard"}))))))))))) + (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "dashboard"}))))))))))) (defn set-file-thumbnail [file-id thumbnail-id] diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index cfd2cc841cb..b8c439f553e 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -25,6 +25,7 @@ [app.util.storage :as storage] [beicon.v2.core :as rx] [beicon.v2.operators :as rxo] + [cuerdas.core :as str] [lambdaisland.uri :as u] [potok.v2.core :as ptk])) @@ -376,82 +377,105 @@ (l/debug :hint "event instrumentation initialized") - (->> (rx/merge - (->> (rx/from-atom buffer) - (rx/filter #(pos? (count %))) - (rx/debounce 2000)) - (->> stream - (rx/filter (ptk/type? :app.main.data.profile/logout)) - (rx/observe-on :async))) - (rx/map (fn [_] - (into [] (take max-chunk-size) @buffer))) - (rx/with-latest-from profile) - (rx/mapcat (fn [[chunk profile-id]] - (let [events (filterv #(= profile-id (:profile-id %)) chunk)] - (->> (persist-events events) - (rx/tap (fn [_] - (l/debug :hint "events chunk persisted" :total (count chunk)))) - (rx/map (constantly chunk)))))) - (rx/take-until stopper) - (rx/subs! (fn [chunk] - (swap! buffer remove-from-buffer (count chunk))) - (fn [cause] - (l/error :hint "unexpected error on audit persistence" :cause cause)) - (fn [] - (l/debug :hint "audit persistence terminated")))) - - (->> (rx/merge - (->> stream - (rx/with-latest-from profile) - (rx/map make-event)) - - (->> (user-input-observer) - (rx/with-latest-from profile) - (rx/map make-performance-event) - (rx/debounce debounce-browser-event-time)) - - (->> (longtask-observer) - (rx/with-latest-from profile) - (rx/map make-performance-event) - (rx/debounce debounce-longtask-time)) - - (if (and (exists? js/globalThis) - (exists? (.-requestAnimationFrame js/globalThis)) - (exists? (.-scheduler js/globalThis)) - (exists? (.-postTask (.-scheduler js/globalThis)))) - (->> stream + ;; Fetch backend flags and only start event collection if + ;; :audit-log or :telemetry is enabled. On RPC failure, proceed + ;; with event collection anyway (backend will reject if truly disabled). + (->> (rp/cmd! :get-enabled-flags) + (rx/catch (fn [cause] + (l/debug :hint "unable to fetch backend flags, proceeding with event collection" :cause cause) + (rx/of #{:telemetry}))) + (rx/mapcat (fn [flags] + (if (or (contains? flags :audit-log) + (contains? flags :telemetry)) + (do + (l/debug :hint "event collection enabled" :flags (str/join " " (map name flags))) + (rx/of true)) + (do + (l/debug :hint "event collection disabled (no audit-log or telemetry flag)") + (rx/empty))))) + (rx/take 1) + (rx/subs! + (fn [_] + ;; Start the event collection pipeline + (->> (rx/merge + (->> (rx/from-atom buffer) + (rx/filter #(pos? (count %))) + (rx/debounce 2000)) + (->> stream + (rx/filter (ptk/type? :app.main.data.profile/logout)) + (rx/observe-on :async))) + (rx/map (fn [_] + (into [] (take max-chunk-size) @buffer))) (rx/with-latest-from profile) - (rx/merge-map process-performance-event) - (rx/debounce debounce-performance-event-time)) - (rx/empty))) - - (rx/filter :profile-id) - (rx/map (fn [event] - (let [session* (or @session (ct/now)) - context (-> @context - (merge (:context event)) - (assoc :session session*) - (assoc :external-session-id (cf/external-session-id)) - (add-external-context-info) - (d/without-nils))] - (reset! session session*) - (-> event - (assoc :timestamp (ct/now)) - (assoc :context context))))) - - (rx/tap (fn [event] - (l/debug :hint "event enqueued") - (swap! buffer append-to-buffer event))) - - (rx/switch-map #(rx/timer session-timeout)) - (rx/take-until stopper) - (rx/subs! (fn [_] - (l/debug :hint "session reinitialized") - (reset! session nil)) - (fn [cause] - (l/error :hint "error on event batching stream" :cause cause)) - (fn [] - (l/debug :hitn "events batching stream terminated")))))))) + (rx/mapcat (fn [[chunk profile-id]] + (let [events (filterv #(= profile-id (:profile-id %)) chunk)] + (->> (persist-events events) + (rx/tap (fn [_] + (l/debug :hint "events chunk persisted" :total (count chunk)))) + (rx/map (constantly chunk)))))) + (rx/take-until stopper) + (rx/subs! (fn [chunk] + (swap! buffer remove-from-buffer (count chunk))) + (fn [cause] + (l/error :hint "unexpected error on audit persistence" :cause cause)) + (fn [] + (l/debug :hint "audit persistence terminated")))) + + (->> (rx/merge + (->> stream + (rx/with-latest-from profile) + (rx/map make-event)) + + (->> (user-input-observer) + (rx/with-latest-from profile) + (rx/map make-performance-event) + (rx/debounce debounce-browser-event-time)) + + (->> (longtask-observer) + (rx/with-latest-from profile) + (rx/map make-performance-event) + (rx/debounce debounce-longtask-time)) + + (if (and (exists? js/globalThis) + (exists? (.-requestAnimationFrame js/globalThis)) + (exists? (.-scheduler js/globalThis)) + (exists? (.-postTask (.-scheduler js/globalThis)))) + (->> stream + (rx/with-latest-from profile) + (rx/merge-map process-performance-event) + (rx/debounce debounce-performance-event-time)) + (rx/empty))) + + (rx/filter :profile-id) + (rx/map (fn [event] + (let [session* (or @session (ct/now)) + context (-> @context + (merge (:context event)) + (assoc :session session*) + (assoc :session-id cf/session-id) + (assoc :external-session-id (cf/external-session-id)) + (add-external-context-info) + (d/without-nils))] + (reset! session session*) + (-> event + (assoc :timestamp (ct/now)) + (assoc :context context))))) + + (rx/tap (fn [event] + (l/debug :hint "event enqueued") + (swap! buffer append-to-buffer event))) + + (rx/switch-map #(rx/timer session-timeout)) + (rx/take-until stopper) + (rx/subs! (fn [_] + (l/debug :hint "session reinitialized") + (reset! session nil)) + (fn [cause] + (l/error :hint "error on event batching stream" :cause cause)) + (fn [] + (l/debug :hint "events batching stream terminated"))))) + (fn [cause] + (l/warn :hint "unexpected error during event collection initialization" :cause cause)))))))) (defn event [props] diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index f2c8315a909..dccbb99c815 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -267,8 +267,7 @@ counts)) {:png 0, :jpeg 0, :webp 0, :pdf 0, :svg 0} exports)] - (ptk/event - ::ev/event (merge types - {::ev/name "export-shapes" - ::ev/origin origin - :num-shapes (count exports)})))) + (ev/event (merge types + {::ev/name "export-shapes" + ::ev/origin origin + :num-shapes (count exports)})))) diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 28f702ed8ad..0864247f5cd 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -14,6 +14,7 @@ [app.common.uuid :as uuid] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] + [app.main.data.uploads :as uploads] [app.main.fonts :as fonts] [app.main.repo :as rp] [app.main.store :as st] @@ -24,24 +25,14 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) -(def ^:const default-chunk-size - (* 1024 1024 4)) ;; 4MiB - -(defn- chunk-array - [data chunk-size] - (let [total-size (alength data)] - (loop [offset 0 - chunks []] - (if (< offset total-size) - (let [end (min (+ offset chunk-size) total-size) - chunk (.subarray ^js data offset end)] - (recur end (conj chunks chunk))) - chunks)))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; General purpose events & IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private font-upload-chunk-size + "Size in bytes of each chunk when uploading font files (10 MiB)." + (* 1024 1024 10)) + (defn fonts-fetched [fonts] (letfn [;; Prepare font to the internal font database format. @@ -94,9 +85,44 @@ (->> (rp/cmd! :get-font-variants {:team-id team-id}) (rx/map fonts-fetched))))) +(defn upload-font-variant + "Uploads a single font variant item using the chunked upload API. + + For each mime-type in `data`, creates a Blob and uploads it via the + session-based chunked upload. Once all sessions are created, calls + `create-font-variant` with the resulting `:uploads` map so the server + can assemble the chunks and materialise the final font-variant record. + + Returns an observable that emits the created font-variant." + [{:keys [data team-id font-id font-family font-weight font-style] :as _item}] + ;; Upload each mtype as a separate chunked session in parallel, collect + ;; all [mtype session-id] pairs, then call create-font-variant with :uploads. + (->> (rx/from (seq data)) + (rx/mapcat (fn [[mtype buffer]] + (let [blob (js/Blob. #js [buffer] #js {:type mtype})] + (->> (uploads/upload-blob-chunked blob :chunk-size font-upload-chunk-size) + (rx/map (fn [{:keys [session-id]}] + [mtype session-id])))))) + (rx/reduce (fn [acc [mtype session-id]] + (assoc acc mtype session-id)) + {}) + (rx/mapcat (fn [uploads] + (rp/cmd! :create-font-variant + {:team-id team-id + :font-id font-id + :font-family font-family + :font-weight font-weight + :font-style font-style + :uploads uploads}))))) + (defn process-upload "Given a seq of blobs and the team id, creates a ready-to-use fonts - map with temporal ID's associated to each font entry." + map with temporal ID's associated to each font entry. + + Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The + raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can + wrap it in a `Blob` and hand it directly to `upload-blob-chunked` + without any intermediate client-side chunking." [blobs team-id] (letfn [(prepare [{:keys [font type name data] :as params}] (let [family (or (.getEnglishName ^js font "preferredFamily") @@ -130,9 +156,8 @@ (not= hhea-descender win-descent) (and f-selection (or (not= hhea-ascender os2-ascent) - (not= hhea-descender os2-descent)))) - data (js/Uint8Array. data)] - {:content {:data (chunk-array data default-chunk-size) + (not= hhea-descender os2-descent))))] + {:content {:data data :name name :type type} :font-family (or family "") @@ -261,12 +286,12 @@ ptk/WatchEvent (watch [_ state _] (let [team-id (:current-team-id state)] - (rx/of (ptk/data-event ::ev/event {::ev/name "add-font" - :team-id team-id - :font-id (:id font) - :font-family (:font-family font) - :font-style (:font-style font) - :font-weight (:font-weight font)})))))) + (rx/of (ev/event {::ev/name "add-font" + :team-id team-id + :font-id (:id font) + :font-family (:font-family font) + :font-style (:font-style font) + :font-weight (:font-weight font)})))))) (defn update-font [{:keys [id name] :as params}] @@ -310,9 +335,9 @@ (rx/concat (->> (rp/cmd! :delete-font {:id font-id :team-id team-id}) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event {::ev/name "delete-font" - :team-id team-id - :font-id font-id}))))))) + (rx/of (ev/event {::ev/name "delete-font" + :team-id team-id + :font-id font-id}))))))) (defn delete-font-variant [id] @@ -331,9 +356,9 @@ (rx/concat (->> (rp/cmd! :delete-font-variant {:id id :team-id team-id}) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event {::ev/name "delete-font-variant" - :id id - :team-id team-id}))))))) + (rx/of (ev/event {::ev/name "delete-font-variant" + :id id + :team-id team-id}))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index e9f5266c1b6..b091518b674 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -7,12 +7,14 @@ (ns app.main.data.plugins (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.time :as ct] [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.errors :as errors] [app.main.store :as st] [app.plugins.flags :as pflag] [app.plugins.register :as preg] @@ -20,7 +22,8 @@ [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [potok.v2.core :as ptk])) + [potok.v2.core :as ptk] + [promesa.core :as p])) (defn save-plugin-permissions-peek [id permissions] @@ -52,27 +55,47 @@ (update [_ state] (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) -(defn- load-plugin! - [{:keys [plugin-id name description host code icon permissions]}] - (try - (st/emit! (pflag/clear plugin-id) - (save-current-plugin plugin-id)) - - (.ɵloadPlugin - ^js ug/global - #js {:pluginId plugin-id - :name name - :description description - :host host - :code code - :icon icon - :permissions (apply array permissions)} - (fn [] - (st/emit! (remove-current-plugin plugin-id)))) +(defn start-plugin! + [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :version version + :description description + :host host + :code code + :allowBackground (boolean allow-background) + :permissions (apply array permissions)} + nil + extensions) + + (p/catch (fn [cause] + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) - (catch :default e - (st/emit! (remove-current-plugin plugin-id)) - (.error js/console "Error" e)))) +(defn- load-plugin! + [{:keys [plugin-id name version description host code icon permissions]}] + (st/emit! (pflag/clear plugin-id) + (save-current-plugin plugin-id)) + + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :description description + :version version + :host host + :code code + :icon icon + :permissions (apply array permissions)} + (fn [] + (st/emit! (remove-current-plugin plugin-id)))) + + (p/catch (fn [cause] + (st/emit! (remove-current-plugin plugin-id)) + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) (defn open-plugin! [{:keys [url] :as manifest} user-can-edit?] diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index e7828a03029..fc98a6a5e2e 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -152,10 +152,10 @@ (when (not= (:theme profile) (:theme profile')) - (rx/of (ptk/data-event ::ev/event - {::ev/name "activate-theme" - ::ev/origin "settings" - :theme (:theme profile)}))))))))) + (rx/of (ev/event + {::ev/name "activate-theme" + ::ev/origin "settings" + :theme (:theme profile)}))))))))) ;; --- Toggle Theme @@ -186,9 +186,9 @@ (watch [it state _] (let [profile (get state :profile) origin (::ev/origin (meta it))] - (rx/of (ptk/data-event ::ev/event {:theme (:theme profile) - ::ev/name "activate-theme" - ::ev/origin origin}) + (rx/of (ev/event {:theme (:theme profile) + ::ev/name "activate-theme" + ::ev/origin origin}) (persist-profile)))))) ;; --- Request Email Change @@ -498,4 +498,3 @@ (->> (rp/cmd! :delete-access-token params) (rx/tap on-success) (rx/catch on-error)))))) - diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 63a076f93f0..48320fd9a01 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -551,7 +551,7 @@ (.. sd-token -original -name)) (defn sd-token-uuid [^js sd-token] - (uuid (.-uuid (.-id ^js sd-token)))) + (uuid (.-uuid (.. sd-token -original -id)))) (defn resolve-tokens [tokens] @@ -560,15 +560,23 @@ (defn resolve-tokens-interactive "Interactive check of resolving tokens. - Uses a ids map to backtrace the original token from the resolved StyleDictionary token. - - We have to pass in all tokens from all sets in the entire library to style dictionary - so we know if references are missing / to resolve them and possibly show interactive previews (in the tokens form) to the user. - - Since we're using the :name path as the identifier we might be throwing away or overriding tokens in the tree that we pass to StyleDictionary. - - So to get back the original token from the resolved sd-token (see my updates for what an sd-token is) we include a temporary :id for the token that we pass to StyleDictionary, - this way after the resolving computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." + Uses a ids map to backtrace the original token from the resolved + StyleDictionary token. + + We have to pass in all tokens from all sets in the entire library to + style dictionary so we know if references are missing / to resolve + them and possibly show interactive previews (in the tokens form) to + the user. + + Since we're using the :name path as the identifier we might be + throwing away or overriding tokens in the tree that we pass to + StyleDictionary. + + So to get back the original token from the resolved sd-token (see my + updates for what an sd-token is) we include a temporary :id for the + token that we pass to StyleDictionary, this way after the resolving + computation we can restore any token, even clashing ones with the + same :name path by just looking up that :id in the ids map." [tokens] (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))))) @@ -584,10 +592,11 @@ (defonce !tokens-cache (atom nil)) (defn use-resolved-tokens - "The StyleDictionary process function is async, so we can't use resolved values directly. + "The StyleDictionary process function is async, so we can't use + resolved values directly. - This hook will return the unresolved tokens as state until they are processed, - then the state will be updated with the resolved tokens." + This hook will return the unresolved tokens as state until they are + processed, then the state will be updated with the resolved tokens." [tokens & {:keys [cache-atom interactive?] :or {cache-atom !tokens-cache} :as config}] diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 60846d88bdf..01502a07eb4 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -176,10 +176,10 @@ (rx/of (dp/refresh-profile) (fetch-members team-id) (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "delete-team-member" - :team-id team-id - :member-id member-id}))))))))) + (ev/event + {::ev/name "delete-team-member" + :team-id team-id + :member-id member-id}))))))))) (defn- stats-fetched @@ -240,9 +240,9 @@ (rx/tap on-success) (rx/mapcat (fn [_] (rx/of (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "update-team-photo" - :team-id team-id})))) + (ev/event + {::ev/name "update-team-photo" + :team-id team-id})))) (rx/catch on-error)))))) @@ -345,10 +345,10 @@ (rx/merge (rx/of (team-leaved params) (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "leave-team" - :reassign-to reassign-to - :team-id team-id})) + (ev/event + {::ev/name "leave-team" + :reassign-to reassign-to + :team-id team-id})) (on-success)))) (rx/catch on-error)))))) @@ -538,9 +538,10 @@ (defn create-webhook [{:keys [uri mtype is-active] :as params}] - (dm/assert! (contains? valid-mtypes mtype)) - (dm/assert! (boolean? is-active)) - (dm/assert! (u/uri? uri)) + + (assert (contains? valid-mtypes mtype)) + (assert (boolean? is-active)) + (assert (u/uri? uri)) (ptk/reify ::create-webhook ptk/WatchEvent diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs new file mode 100644 index 00000000000..2721cd99cbd --- /dev/null +++ b/frontend/src/app/main/data/uploads.cljs @@ -0,0 +1,69 @@ +;; 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 + +(ns app.main.data.uploads + "Generic chunked-upload helpers. + + Provides a purpose-agnostic three-step session API that can be used + by any feature that needs to upload large binary blobs: + + 1. create-upload-session – obtain a session-id + 2. upload-chunk – upload each slice (max-parallel-chunk-uploads in-flight) + 3. caller-specific step – e.g. assemble-file-media-object or import-binfile + + `upload-blob-chunked` drives steps 1 and 2 and emits the completed + `{:session-id …}` map so that the caller can proceed with its own + step 3." + (:require + [app.common.data.macros :as dm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.repo :as rp] + [beicon.v2.core :as rx])) + +(def ^:private max-parallel-chunk-uploads + "Maximum number of chunk upload requests that may be in-flight at the + same time within a single chunked upload session." + 2) + +(defn upload-blob-chunked + "Uploads `blob` via the three-step chunked session API. + + Steps performed: + 1. Creates an upload session (`create-upload-session`). + 2. Slices `blob` and uploads every chunk (`upload-chunk`), + with at most `max-parallel-chunk-uploads` concurrent requests. + + Returns an observable that emits exactly one map: + `{:session-id <uuid>}` + + The caller is responsible for the final step (assemble / import). + + The optional `opts` map accepts: + `:chunk-size` – size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB)." + [blob & {:keys [chunk-size] :or {chunk-size cf/upload-chunk-size}}] + (let [total-size (.-size blob) + total-chunks (js/Math.ceil (/ total-size chunk-size))] + (->> (rp/cmd! :create-upload-session + {:total-chunks total-chunks}) + (rx/mapcat + (fn [{raw-session-id :session-id}] + (let [session-id (cond-> raw-session-id + (string? raw-session-id) uuid/uuid) + chunk-uploads + (->> (range total-chunks) + (map (fn [idx] + (let [start (* idx chunk-size) + end (min (+ start chunk-size) total-size) + chunk (.slice blob start end)] + (rp/cmd! :upload-chunk + {:session-id session-id + :index idx + :content (list chunk (dm/str "chunk-" idx))})))))] + (->> (rx/from chunk-uploads) + (rx/merge-all max-parallel-chunk-uploads) + (rx/last) + (rx/map (fn [_] {:session-id session-id}))))))))) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index c2d42d680c6..d2ebf4c9dc6 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -82,7 +82,7 @@ (when (some? (:profile state)) (fetch-comment-threads params)) (when (:share-id params) - (rx/of (ptk/event ::ev/event {::ev/name "shared-prototipe-visited"}))))) + (rx/of (ev/event {::ev/name "shared-prototipe-visited"}))))) ptk/EffectEvent (effect [_ _ _] ;; Set the window name, the window name is used on inter-tab @@ -204,7 +204,7 @@ (watch [_ state _] (let [route (:route state) qparams (:query-params route) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frame-id (some-> (:frame-id qparams) uuid/parse)] (rx/merge (rx/of (case (:zoom qparams) @@ -301,7 +301,7 @@ (update [_ state] (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -325,7 +325,7 @@ (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -399,7 +399,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long)] + index (some-> (rt/get-query-param params :index) parse-long)] (when (pos? index) (rx/of (dcmt/close-thread) @@ -415,7 +415,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long) + index (some-> (rt/get-query-param params :index) parse-long) page-id (some-> params :page-id uuid/parse) total (count (get-in state [:viewer :pages page-id :frames]))] @@ -530,7 +530,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frames (get-in state [:viewer :pages page-id :frames]) frame (get frames index)] (cond-> state @@ -744,7 +744,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) objects (get-in state [:viewer :pages page-id :objects]) frame-id (get-in state [:viewer :pages page-id :frames index :id]) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 9116c99024f..b9c3afe82fa 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -52,6 +52,7 @@ [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layout :as layout] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.path :as dwdp] @@ -211,8 +212,11 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (dp/check-open-plugin) - (fdf/fix-deleted-fonts-for-local-library file-id))))) + (rx/merge + (rx/of (dp/check-open-plugin) + (fdf/fix-deleted-fonts-for-local-library file-id)) + (when (contains? cf/flags :mcp) + (rx/of (mcp/init))))))) (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] @@ -220,6 +224,7 @@ IDeref (-deref [_] bundle) + ptk/UpdateEvent (update [_ state] (-> state @@ -242,6 +247,7 @@ (rx/of (dws/select-shapes frames-id) dwz/zoom-to-selected-shape))))) +;; FIXME: rename to `fetch-file` (defn- fetch-bundle "Multi-stage file bundle fetch coordinator" [file-id features] @@ -279,6 +285,20 @@ (when shape (wasm.api/process-object shape)))))) + +(defn initialize-file + [team-id file-id] + (assert (uuid? team-id) "expected valud uuid for `team-id`") + (assert (uuid? file-id) "expected valud uuid for `file-id`") + + (ptk/reify ::initialize-file + ptk/WatchEvent + (watch [_ state _] + (let [features (features/get-enabled-features state team-id)] + (log/dbg :hint "initialize-file" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) + (rx/of (fetch-bundle file-id features)))))) (defn initialize-workspace [team-id file-id] (assert (uuid? team-id) "expected valud uuid for `team-id`") @@ -304,164 +324,169 @@ :team-id (dm/str team-id) :file-id (dm/str file-id)) - (->> (rx/merge - (rx/concat - ;; Fetch all essential data that should be loaded before the file - (rx/merge - (if ^boolean render-wasm? - (->> (rx/from @wasm/module) - (rx/filter true?) - (rx/tap (fn [_] - (let [event (ug/event "penpot:wasm:loaded")] - (ug/dispatch! event)))) - (rx/ignore)) - (rx/empty)) - - (->> stream - (rx/filter (ptk/type? ::df/fonts-loaded)) - (rx/take 1) - (rx/ignore)) - - (rx/of (ntf/hide) - (dcmt/retrieve-comment-threads file-id) - (dcmt/fetch-profiles) - (df/fetch-fonts team-id))) - - ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle - (rx/of (fetch-bundle file-id features))) - - (->> stream - (rx/filter (ptk/type? ::bundle-fetched)) - (rx/take 1) - (rx/map deref) - (rx/mapcat - (fn [{:keys [file]}] - (log/debug :hint "bundle fetched" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) - - (rx/of (dpj/initialize-project (:project-id file)) - (dwn/initialize team-id file-id) - (dwsl/initialize-shape-layout) - (fetch-libraries file-id features) - (-> (workspace-initialized file-id) - (with-meta {:team-id team-id - :file-id file-id})))))) - - ;; Install dev perf observers once the workspace is ready - (when (contains? cf/flags :perf-logs) - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/take 1) - (rx/tap (fn [_] (perf/setup))))) - - (->> stream - (rx/filter (ptk/type? ::dps/persistence-notification)) - (rx/take 1) - (rx/map dwc/set-workspace-visited)) - - (when-let [component-id (some-> rparams :component-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) - - (when (:board-id rparams) - (->> stream - (rx/filter (ptk/type? ::dwv/initialize-viewport)) - (rx/take 1) - (rx/map zoom-to-frame))) - - (when-let [comment-id (some-> rparams :comment-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwcm/navigate-to-comment-id comment-id)))) - - (when render-wasm? - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [redo-changes]}] - (let [added (->> redo-changes - (filter #(= (:type %) :add-obj)) - (map :id))] - (->> (rx/from added) - (rx/map process-wasm-object))))))) - - (when render-wasm? - (let [local-commits-s - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/filter #(and (= :local (:source %)) - (not (contains? (:tags %) :position-data)))) - (rx/filter (complement empty?))) - - notifier-s - (rx/merge - (->> local-commits-s (rx/debounce 1000)) - (->> stream (rx/filter dps/force-persist?))) - - objects-s - (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) - - current-page-id-s - (rx/from-atom refs/current-page-id {:emit-current-value? true})] - - (->> local-commits-s - (rx/buffer-until notifier-s) - (rx/with-latest-from objects-s) - (rx/map - (fn [[commits objects]] - (->> commits - (mapcat :redo-changes) - (filter #(contains? #{:mod-obj :add-obj} (:type %))) - (filter #(cfh/text-shape? objects (:id %))) - (map #(vector - (:id %) - (wasm.api/calculate-position-data (get objects (:id %)))))))) - - (rx/with-latest-from current-page-id-s) - (rx/map - (fn [[text-position-data page-id]] - (let [changes - (->> text-position-data - (mapv (fn [[id position-data]] - {:type :mod-obj - :id id - :page-id page-id - :operations - [{:type :set - :attr :position-data - :val position-data - :ignore-touched true - :ignore-geometry true}]})))] - (when (d/not-empty? changes) - (dch/commit-changes - {:redo-changes changes :undo-changes [] - :save-undo? false - :tags #{:position-data}}))))) - (rx/take-until stoper-s)))) - - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] - (if (and save-undo? (seq undo-changes)) - (let [entry {:undo-changes undo-changes - :redo-changes redo-changes - :undo-group undo-group - :tags tags}] - (rx/of (dwu/append-undo entry stack-undo?))) - (rx/empty)))))) - (rx/take-until stoper-s)))) - + (rx/concat + (->> (rx/merge + (rx/concat + ;; Fetch all essential data that should be loaded before the file + (rx/merge + (if ^boolean render-wasm? + (->> (rx/from @wasm/module) + (rx/filter true?) + (rx/tap (fn [_] + (let [event (ug/event "penpot:wasm:loaded")] + (ug/dispatch! event)))) + (rx/ignore)) + (rx/empty)) + + (->> stream + (rx/filter (ptk/type? ::df/fonts-loaded)) + (rx/take 1) + (rx/ignore)) + + (rx/of (ntf/hide) + (dcmt/retrieve-comment-threads file-id) + (dcmt/fetch-profiles) + (df/fetch-fonts team-id)) + + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) + + ;; Once the essential data is fetched, lets proceed to + ;; fetch teh file bunldle + (rx/of (initialize-file team-id file-id))) + + (->> stream + (rx/filter (ptk/type? ::bundle-fetched)) + (rx/take 1) + (rx/map deref) + (rx/mapcat + (fn [{:keys [file]}] + (log/debug :hint "bundle fetched" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) + + (rx/of (dpj/initialize-project (:project-id file)) + (dwn/initialize team-id file-id) + (dwsl/initialize-shape-layout) + (fetch-libraries file-id features) + (-> (workspace-initialized file-id) + (with-meta {:team-id team-id + :file-id file-id})))))) + + ;; Install dev perf observers once the workspace is ready + (when (contains? cf/flags :perf-logs) + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/tap (fn [_] (perf/setup))))) + + (->> stream + (rx/filter (ptk/type? ::dps/persistence-notification)) + (rx/take 1) + (rx/map dwc/set-workspace-visited)) + + (when-let [component-id (some-> rparams :component-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) + + (when (:board-id rparams) + (->> stream + (rx/filter (ptk/type? ::dwv/initialize-viewport)) + (rx/take 1) + (rx/map zoom-to-frame))) + + (when-let [comment-id (some-> rparams :comment-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwcm/navigate-to-comment-id comment-id)))) + + (when render-wasm? + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [redo-changes]}] + (let [added (->> redo-changes + (filter #(= (:type %) :add-obj)) + (map :id))] + (->> (rx/from added) + (rx/map process-wasm-object))))))) + + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) + + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) + + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] + + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) + + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (when (d/not-empty? changes) + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))) + (rx/take-until stoper-s)))) + + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] + (if (and save-undo? (seq undo-changes)) + (let [entry {:undo-changes undo-changes + :redo-changes redo-changes + :undo-group undo-group + :tags tags}] + (rx/of (dwu/append-undo entry stack-undo?))) + (rx/empty)))))) + (rx/take-until stoper-s)) + + (rx/of (mcp/notify-other-tabs-disconnect))))) ptk/EffectEvent (effect [_ _ _] (let [name (dm/str "workspace-" file-id)] @@ -1318,7 +1343,7 @@ (rx/concat (rx/of (dch/commit-changes changes)) (when (nil? annotation) - (rx/of (ptk/data-event ::ev/event {::ev/name "delete-component-annotation"})))))))) + (rx/of (ev/event {::ev/name "delete-component-annotation"})))))))) (defn set-annotations-expanded [expanded] @@ -1340,7 +1365,7 @@ ptk/WatchEvent (watch [_ _ _] (when (some? id) - (rx/of (ptk/data-event ::ev/event {::ev/name "create-component-annotation"})))))) + (rx/of (ev/event {::ev/name "create-component-annotation"})))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Preview blend modes diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index e32de485e54..096bd01b627 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -960,7 +960,7 @@ (ptk/data-event :layout/update {:ids [frame-id]}) (dwu/commit-undo-transaction undo-id) (when add-component-to-variant? - (ptk/event ::ev/event {::ev/name "add-component-to-variant"}))))))))) + (ev/event {::ev/name "add-component-to-variant"}))))))))) (defn- as-content [text] (let [paragraphs (->> (str/lines text) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 0f89e0bffbd..0e688517a20 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -390,7 +390,7 @@ ([id-ref ids] (ptk/reify ::add-component ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [objects (dsh/lookup-page-objects state) selected (->> (d/nilv ids (dsh/lookup-selected state)) (cfh/clean-loops objects)) @@ -399,7 +399,8 @@ can-make-component (every? true? (map #(ctn/valid-shape-for-component? objects %) selected-objects))] (when can-make-component - (rx/of (add-component2 id-ref selected)))))))) + (rx/of (-> (add-component2 id-ref selected) + (with-meta (meta it)))))))))) (defn add-multiple-components "Add several new components to current file library, from the currently selected shapes." @@ -601,11 +602,12 @@ (when id-ref (reset! id-ref (:id new-shape))) - (rx/of (ptk/event ::ev/event - {::ev/name "use-library-component" - ::ev/origin origin - :external-library (not= file-id current-file-id) - :is-variant (ctk/is-variant? component)}) + (rx/of (ev/event + (-> {::ev/name "use-library-component" + ::ev/origin origin + :external-library (not= file-id current-file-id) + :is-variant (ctk/is-variant? component)} + (merge (meta it)))) (dwu/start-undo-transaction undo-id) (dch/commit-changes changes) (ptk/data-event :layout/update {:ids [(:id new-shape)]}) @@ -1294,9 +1296,10 @@ (watch [_ _ stream] (let [stopper-s (->> stream - (rx/filter #(or (= ::dwpg/finalize-page (ptk/type %)) - (= ::watch-component-changes (ptk/type %))))) - + (rx/map ptk/type) + (rx/filter (fn [event-type] + (or (= ::dwpg/finalize-page event-type) + (= ::watch-component-changes event-type))))) workspace-data-s (->> (rx/from-atom refs/workspace-data {:emit-current-value? true}) (rx/share)) @@ -1398,7 +1401,7 @@ vals (some ctk/is-variant?))] (if has-variants? - (rx/of (ptk/event ::ev/event {::ev/name "set-file-variants-shared" ::ev/origin "workspace"})) + (rx/of (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "workspace"})) (rx/empty))))))))) ;; --- Link and unlink Files @@ -1462,11 +1465,11 @@ (when (pos? variants-count) (->> (rp/cmd! :get-library-usage {:file-id library-id}) (rx/map (fn [library-usage] - (ptk/event ::ev/event {::ev/name "attach-library-variants" - :file-id file-id - :library-id library-id - :variants-count variants-count - :library-used-in (:used-in library-usage)})))))))))) + (ev/event {::ev/name "attach-library-variants" + :file-id file-id + :library-id library-id + :variants-count variants-count + :library-used-in (:used-in library-usage)})))))))))) (defn unlink-file-from-library [file-id library-id] diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs new file mode 100644 index 00000000000..2884803a68a --- /dev/null +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -0,0 +1,292 @@ +;; 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 + +(ns app.main.data.workspace.mcp + (:require + [app.common.logging :as log] + [app.common.uri :as u] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] + [app.main.data.plugins :as dp] + [app.main.repo :as rp] + [app.main.store :as st] + [app.plugins.register :refer [mcp-plugin-id]] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(def retry-interval 10000) + +(log/set-level! :info) + +(def ^:private default-manifest + {:code "plugin.js" + :name "Penpot MCP Plugin" + :version 2 + :plugin-id mcp-plugin-id + :description "This plugin enables interaction with the Penpot MCP server" + :allow-background true + :permissions + #{"library:read" "library:write" + "comment:read" "comment:write" + "content:write" "content:read"}}) + +(defonce interval-sub (atom nil)) + +(defn finalize-workspace? + [event] + (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) + +(defn set-mcp-active + [value] + (ptk/reify ::set-mcp-active + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:mcp :active] value)))) + +(defn start-reconnect-watcher! + [] + (st/emit! (set-mcp-active true)) + (when (nil? @interval-sub) + (reset! + interval-sub + (ts/interval + retry-interval + (fn [] + ;; Try to reconnect if active and not connected + (when-not (contains? #{"connecting" "connected"} + (-> @st/state :mcp :connection-status)) + (.log js/console "Reconnecting to MCP...") + (st/emit! (ptk/data-event ::connect)))))))) + +(defn stop-reconnect-watcher! + [] + (st/emit! (set-mcp-active false)) + (when @interval-sub + (rx/dispose! @interval-sub) + (reset! interval-sub nil))) + +(declare manage-mcp-notification) + +(defn handle-pong + [{:keys [id data]}] + (ptk/reify ::handle-pong + ptk/UpdateEvent + (update [_ state] + (let [mcp-state (get state :mcp)] + (cond + (= "connected" (:connection-status data)) + (update state :mcp assoc :connected-tab id) + + (and (= "disconnected" (:connection-status data)) + (= id (:connected-tab mcp-state))) + (update state :mcp dissoc :connected-tab) + + :else + state))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (manage-mcp-notification))))) + +;; This event will arrive when a new workspace is open in another tab +(defn handle-ping + [] + (ptk/reify ::handle-ping + ptk/WatchEvent + (watch [_ state _] + (let [conn-status (get-in state [:mcp :connection-status])] + (rx/of (mbc/event :mcp/pong {:connection-status conn-status})))))) + +(defn notify-other-tabs-disconnect + [] + (ptk/reify ::notify-other-tabs-disconnect + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/pong {:connection-status "disconnected"}))))) + +;; This event will arrive when the mcp is enabled in the dashboard +(defn update-mcp-status + [value] + (ptk/reify ::update-mcp-status + ptk/UpdateEvent + (update [_ state] + (update-in state [:profile :props] assoc :mcp-enabled value)) + + ptk/WatchEvent + (watch [_ _ _] + (rx/merge + (rx/of (manage-mcp-notification)) + (case value + true (rx/of (ptk/data-event ::connect)) + false (rx/of (ptk/data-event ::disconnect)) + nil))))) + +(defn update-mcp-connection-status + [value] + (ptk/reify ::update-mcp-plugin-connection + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connection-status value)) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (manage-mcp-notification) + (mbc/event :mcp/pong {:connection-status value}))))) + +(defn connect-mcp + [] + (ptk/reify ::connect-mcp + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connected-tab (:session-id state))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/force-disconect {}) + (ptk/data-event ::connect))))) + +;; This event will arrive when the user selects disconnect on the menu +;; or there is a broadcast message for disconnection +(defn user-disconnect-mcp + [] + (ptk/reify ::user-disconnect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::disconnect) + (update-mcp-connection-status "disconnected"))) + + ptk/EffectEvent + (effect [_ _ _] + (stop-reconnect-watcher!)))) + +(defn- manage-mcp-notification + [] + (ptk/reify ::manage-mcp-notification + ptk/WatchEvent + (watch [_ state _] + (let [mcp-state (get state :mcp) + + mcp-enabled? (-> state :profile :props :mcp-enabled) + + current-tab-id (get state :session-id) + connected-tab-id (get mcp-state :connected-tab)] + + (if mcp-enabled? + (if (= connected-tab-id current-tab-id) + (rx/of (ntf/hide)) + (rx/of (ntf/dialog + {:content (tr "notifications.mcp.active-in-another-tab") + :cancel {:label (tr "labels.dismiss") + :callback #(st/emit! (ntf/hide) + (ev/event {::ev/name "dismiss-mcp-tab-switch" + ::ev/origin "workspace-notification"}))} + :accept {:label (tr "labels.switch") + :callback #(st/emit! (connect-mcp) + (ev/event {::ev/name "confirm-mcp-tab-switch" + ::ev/origin "workspace-notification"}))}}))) + (rx/of (ntf/hide))))))) + +(defn init-mcp + [stream] + (->> (rp/cmd! :get-current-mcp-token) + (rx/tap + (fn [{:keys [token]}] + (when token + (dp/start-plugin! + (assoc default-manifest + :url (str (u/join cf/public-uri "plugins/mcp/manifest.json")) + :host (str (u/join cf/public-uri "plugins/mcp/"))) + + ;; API extension for MCP server + #js {:mcp + #js + {:getToken (constantly token) + :getServerUrl #(str cf/mcp-ws-uri) + :setMcpStatus + (fn [status] + (when (= status "connected") + (start-reconnect-watcher!)) + (st/emit! (update-mcp-connection-status status)) + (log/info :hint "MCP STATUS" :status status)) + + :on + (fn [event cb] + (when-let [event + (case event + "disconnect" ::disconnect + "connect" ::connect + nil)] + + (let [stopper (rx/filter finalize-workspace? stream)] + (->> stream + (rx/filter (ptk/type? event)) + (rx/take-until stopper) + (rx/subs! #(cb))))))}})))) + (rx/ignore))) + +(defn init + [] + (ptk/reify ::init + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connected-tab (:session-id state) :active true)) + + ptk/WatchEvent + (watch [_ state stream] + (let [stoper-s (rx/merge + (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::init) stream)) + session-id (get state :session-id) + enabled? (-> state :profile :props :mcp-enabled)] + + (->> (rx/merge + (if enabled? + (rx/merge + (init-mcp stream) + + (rx/of (mbc/event :mcp/ping {})) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/ping)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map handle-ping)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/pong)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map handle-pong)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/force-disconect)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map deref) + (rx/map (fn [] (user-disconnect-mcp))))) + (rx/empty)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/enable)) + (rx/mapcat (fn [_] + ;; NOTE: we don't need an explicit + ;; connect because the plugin has + ;; auto-connect + (rx/of (update-mcp-status true) + (init))))) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/disable)) + (rx/mapcat (fn [_] + (rx/of (update-mcp-status false) + (init) + (user-disconnect-mcp)))))) + + (rx/take-until stoper-s)))))) diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index 0d1e1c6e32e..bcffef8378a 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -24,6 +24,7 @@ [app.main.data.helpers :as dsh] [app.main.data.media :as dmm] [app.main.data.notifications :as ntf] + [app.main.data.uploads :as uploads] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.svg-upload :as svg] [app.main.repo :as rp] @@ -103,6 +104,26 @@ :url url :is-local true})) +;; Size of each upload chunk in bytes — read from config directly, +;; same source used by the uploads namespace. +(def ^:private chunk-size cf/upload-chunk-size) + +(defn- upload-blob-chunked + "Uploads `blob` to `file-id` as a chunked media object using the + three-step session API. Returns an observable that emits the + assembled file-media-object map." + [{:keys [file-id name is-local blob]}] + (let [mtype (.-type blob)] + (->> (uploads/upload-blob-chunked blob) + (rx/mapcat + (fn [{:keys [session-id]}] + (rp/cmd! :assemble-file-media-object + {:session-id session-id + :file-id file-id + :is-local is-local + :name name + :mtype mtype})))))) + (defn process-uris [{:keys [file-id local? name uris mtype on-image on-svg]}] (letfn [(svg-url? [url] @@ -143,12 +164,18 @@ (and (not force-media) (= (.-type blob) "image/svg+xml"))) - (prepare-blob [blob] - (let [name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob"))] - {:file-id file-id - :name name - :is-local local? - :content blob})) + (upload-blob [blob] + (let [params {:file-id file-id + :name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob")) + :is-local local? + :blob blob}] + (if (>= (.-size blob) chunk-size) + (upload-blob-chunked params) + (rp/cmd! :upload-file-media-object + {:file-id file-id + :name (:name params) + :is-local local? + :content blob})))) (extract-content [blob] (let [name (or name (.-name blob))] @@ -159,8 +186,7 @@ (->> (rx/from blobs) (rx/map dmm/validate-file) (rx/filter (comp not svg-blob?)) - (rx/map prepare-blob) - (rx/mapcat #(rp/cmd! :upload-file-media-object %)) + (rx/mapcat upload-blob) (rx/tap on-image)) (->> (rx/from blobs) @@ -170,9 +196,10 @@ (rx/merge-map svg->clj) (rx/tap on-svg))))) -(defn handle-media-error [error on-error] - (if (ex/ex-info? error) - (handle-media-error (ex-data error) on-error) +(defn handle-media-error + [cause] + (ex/print-throwable cause) + (let [error (ex-data cause)] (cond (= (:code error) :invalid-svg-file) (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) @@ -195,13 +222,8 @@ (= (:code error) :unable-to-optimize) (rx/of (ntf/error (:hint error))) - (fn? on-error) - (on-error error) - :else - (do - (.error js/console "ERROR" error) - (rx/of (ntf/error (tr "errors.cannot-upload"))))))) + (rx/of (ntf/error (tr "errors.cannot-upload")))))) (def ^:private @@ -215,7 +237,7 @@ [:mtype {:optional true} :string]]) (defn- process-media-objects - [{:keys [uris on-error] :as params}] + [{:keys [uris] :as params}] (dm/assert! (and (sm/check schema:process-media-objects params) (or (contains? params :blobs) @@ -238,7 +260,7 @@ ;; Every stream has its own sideeffect. We need to ignore the result (rx/ignore) - (rx/catch #(handle-media-error % on-error)) + (rx/catch handle-media-error) (rx/finalize #(st/emit! (ntf/hide :tag :media-loading)))))))) (defn upload-media-workspace @@ -278,8 +300,6 @@ (rx/tap on-upload-success) (rx/catch handle-media-error)))))) -;; --- Upload File Media objects - (defn create-shapes-svg "Convert svg elements into penpot shapes." [file-id objects pos svg-data] diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 9bfc7ac8a22..2dc6450abc1 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -40,7 +40,7 @@ (declare handle-pointer-update) (declare handle-file-change) (declare handle-file-deleted) -(declare handle-file-restore) +(declare handle-file-restored) (declare handle-library-change) (declare handle-pointer-send) (declare handle-export-update) @@ -132,7 +132,7 @@ :pointer-update (handle-pointer-update msg) :file-change (handle-file-change msg) :file-deleted (handle-file-deleted msg) - :file-restore (handle-file-restore msg) + :file-restored (handle-file-restored msg) :library-change (handle-library-change msg) :notification (dc/handle-notification msg) :team-role-change (handle-change-team-role msg) @@ -214,6 +214,7 @@ (update state :workspace-presence dissoc session-id) (update state :workspace-presence update-presence)))))) + (defn handle-pointer-update [{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}] (ptk/reify ::handle-pointer-update @@ -282,22 +283,22 @@ (rt/nav :dashboard-recent {:team-id team-id}))))))) (def ^:private - schema:handle-file-restore - [:map {:title "handle-file-restore"} + schema:handle-file-restored + [:map {:title "handle-file-restored"} [:type :keyword] [:file-id ::sm/uuid] [:vern :int]]) -(def ^:private check-file-restore-params - (sm/check-fn schema:handle-file-restore)) +(def ^:private check-file-restored-params + (sm/check-fn schema:handle-file-restored)) -(defn handle-file-restore +(defn handle-file-restored [{:keys [file-id vern] :as msg}] - (assert (check-file-restore-params msg) + (assert (check-file-restored-params msg) "expected valid parameters") - (ptk/reify ::handle-file-restore + (ptk/reify ::handle-file-restored ptk/WatchEvent (watch [_ state _] (let [curr-file-id (:current-file-id state) diff --git a/frontend/src/app/main/data/workspace/path/drawing.cljs b/frontend/src/app/main/data/workspace/path/drawing.cljs index 19923b52641..9ea648aba7e 100644 --- a/frontend/src/app/main/data/workspace/path/drawing.cljs +++ b/frontend/src/app/main/data/workspace/path/drawing.cljs @@ -58,12 +58,12 @@ last-point (get-in state [:workspace-local :edit-path id :last-point]) position (cond-> (gpt/point x y) fix-angle? (path.helpers/position-fixed-angle last-point)) - shape (st/get-path state) + content (st/get-path state :content) {:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id]) - segment (path/next-node shape position last-point prev-handler)] + segment (path/next-node content position last-point prev-handler)] (assoc-in state [:workspace-local :edit-path id :preview] segment))))) (defn add-node diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index 163195f11f0..833501b964d 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -305,11 +305,11 @@ (ptk/data-event :layout/update {:ids ids}) (dwu/commit-undo-transaction undo-id) (when (or (:layout-align-content changes) (:layout-justify-content changes)) - (ptk/event ::ev/event - {::ev/name "layout-change-alignment"})) + (ev/event + {::ev/name "layout-change-alignment"})) (when (or (:layout-padding changes) (:layout-gap changes)) - (ptk/event ::ev/event - {::ev/name "layout-change-margin"})))))))) + (ev/event + {::ev/name "layout-change-margin"})))))))) (defn add-layout-track ([ids type value] diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 4f4d9296cc5..eb2b55189d1 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -602,7 +602,7 @@ :subsections [:basics] :fn #(when (features/active-feature? @st/state "plugins/runtime") (st/emit! - (ptk/event ::ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:shortcuts"}) + (ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:shortcuts"}) (modal/show :plugin-management {})))}}) (def debug-shortcuts diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index a7d6a9d4ca9..d001aa3a670 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -81,7 +81,13 @@ (effect [_ state _] (let [editor (:workspace-editor state) element (when editor (.-element editor))] - (when (and element (.-focus element)) + (cond + ;; V1 (DraftEditor) + (.-focus editor) + (ts/schedule #(.focus ^js editor)) + + ;; V2 + (and element (.-focus element)) (ts/schedule #(.focus ^js element))))))) (defn gen-name @@ -891,8 +897,8 @@ (rx/concat (rx/of (dwl/add-typography typ) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "typography"})) + (ev/event {::ev/name "add-asset-to-library" + :asset-type "typography"})) (when (not multiple?) (rx/of (update-attrs (:id shape) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index a85d0f117c9..16f81cd7e16 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -49,14 +49,14 @@ ;; (note that dwsh/update-shapes function returns an event) -(defn update-shape-radius-all - ([value shape-ids attributes] (update-shape-radius-all value shape-ids attributes nil)) - ([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions +(defn update-shape-radius + ([value shape-ids attributes] (update-shape-radius value shape-ids attributes nil)) + ([value shape-ids attributes page-id] (when (number? value) (let [value (max 0 value)] (dwsh/update-shapes shape-ids (fn [shape] - (ctsr/set-radius-to-all-corners shape value)) + (ctsr/set-radius-for-corners shape attributes value)) {:reg-objects? true :ignore-touched true :page-id page-id @@ -531,7 +531,7 @@ (some attributes #{:r1 :r2 :r3 :r4}) (conj #(if (= attributes #{:r1 :r2 :r3 :r4}) - (update-shape-radius-all value shape-ids attributes page-id) + (update-shape-radius value shape-ids attributes page-id) (update-shape-radius-for-corners value shape-ids (set (filter attributes #{:r1 :r2 :r3 :r4})) @@ -607,6 +607,46 @@ :state state})] (apply rx/of (map #(%) actions))))))))) +(def attributes->shape-update + "Maps each attribute-set to the update function that applies it to a shape. + Used both here (to resolve the correct update fn when explicit attrs are + passed to toggle-token) and in propagation.cljs (re-exported from there)." + {ctt/border-radius-keys update-shape-radius-for-corners + ctt/color-keys update-fill-stroke + ctt/stroke-width-keys update-stroke-width + ctt/sizing-keys apply-dimensions-token + ctt/opacity-keys update-opacity + ctt/rotation-keys update-rotation + + ;; Typography + ctt/font-family-keys update-font-family + ctt/font-size-keys update-font-size + ctt/font-weight-keys update-font-weight + ctt/letter-spacing-keys update-letter-spacing + ctt/text-case-keys update-text-case + ctt/text-decoration-keys update-text-decoration + ctt/typography-token-keys update-typography + ctt/shadow-keys update-shadow + ctt/line-height-keys update-line-height + + ;; Layout + #{:x :y} update-shape-position + #{:p1 :p2 :p3 :p4} update-layout-padding + #{:m1 :m2 :m3 :m4} update-layout-item-margin + #{:column-gap :row-gap} update-layout-gap + #{:width :height} apply-dimensions-token + #{:layout-item-min-w :layout-item-min-h + :layout-item-max-w :layout-item-max-h} update-layout-sizing-limits}) + +;; Flattened per-individual-key version of attributes->shape-update. +;; Allows O(1) lookup of the update function for any single attribute. +(def ^:private attr->shape-update + (reduce + (fn [acc [attr-set update-fn]] + (into acc (map (fn [k] [k update-fn]) attr-set))) + {} + attributes->shape-update)) + ;; Events to apply / unapply tokens to shapes ------------------------------------------------------------ (defn apply-token @@ -618,67 +658,75 @@ [{:keys [attributes attributes-to-remove token shape-ids on-update-shape]}] (ptk/reify ::apply-token ptk/WatchEvent - (watch [_ state _] + (watch [it state _] ;; We do not allow to apply tokens while text editor is open. - (if (empty? (get state :workspace-editor-state)) - (let [attributes-to-remove - ;; Remove atomic typography tokens when applying composite and vice-verca - (cond - (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) - (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) - :else attributes-to-remove)] - (when-let [tokens (some-> (dsh/lookup-file-data state) - (get :tokens-lib) - (ctob/get-tokens-in-active-sets))] - (->> (if (contains? cf/flags :tokenscript) - (rx/of (ts/resolve-tokens tokens)) - (sd/resolve-tokens tokens)) - (rx/mapcat - (fn [resolved-tokens] - (let [undo-id (js/Symbol) - objects (dsh/lookup-page-objects state) - selected-shapes (select-keys objects shape-ids) - - shapes (->> selected-shapes - (filter (fn [[_ shape]] - (or - (and (ctsl/any-layout-immediate-child? objects shape) - (some ctt/spacing-margin-keys attributes)) - (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) - (all-attrs-appliable-for-token? attributes (:type token))))))) - shape-ids (d/nilv (keys shapes) []) - any-variant? (->> shapes vals (some ctk/is-variant?) boolean) - - resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value) - tokenized-attributes (cfo/attributes-map attributes token) - type (:type token)] - (rx/concat - (rx/of - (st/emit! (ev/event {::ev/name "apply-tokens" - :type type - :applied-to attributes - :applied-to-variant any-variant?})) - (dwu/start-undo-transaction undo-id) - (dwsh/update-shapes shape-ids (fn [shape] - (cond-> shape - attributes-to-remove - (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) - :always - (update :applied-tokens merge tokenized-attributes))))) - (when on-update-shape - (let [res (on-update-shape resolved-value shape-ids attributes)] - ;; Composed updates return observables and need to be executed differently - (if (rx/observable? res) - res - (rx/of res)))) - (rx/of (dwu/commit-undo-transaction undo-id))))))))) - (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000})))))) + (let [edition (get-in state [:workspace-local :edition]) + objects (dsh/lookup-page-objects state) + text-editing? (and (some? edition) + (= :text (:type (get objects edition))))] + (if (and (some? token) + (not text-editing?)) + (let [attributes-to-remove + ;; Remove atomic typography tokens when applying composite and vice-verca + (cond + (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) + (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) + :else attributes-to-remove)] + (when-let [tokens (some-> (dsh/lookup-file-data state) + (get :tokens-lib) + (ctob/get-tokens-in-active-sets))] + (->> (if (contains? cf/flags :tokenscript) + (rx/of (ts/resolve-tokens tokens)) + (sd/resolve-tokens tokens)) + (rx/mapcat + (fn [resolved-tokens] + (let [undo-id (js/Symbol) + objects (dsh/lookup-page-objects state) + selected-shapes (select-keys objects shape-ids) + + shapes (->> selected-shapes + (filter (fn [[_ shape]] + (or + (and (ctsl/any-layout-immediate-child? objects shape) + (some ctt/spacing-margin-keys attributes)) + (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) + (all-attrs-appliable-for-token? attributes (:type token))))))) + shape-ids (d/nilv (keys shapes) []) + any-variant? (->> shapes vals (some ctk/is-variant?) boolean) + + resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) + resolved-value (if (contains? cf/flags :tokenscript) + (ts/tokenscript-symbols->penpot-unit resolved-value) + resolved-value) + tokenized-attributes (cfo/attributes-map attributes token) + type (:type token)] + (rx/concat + (rx/of + (st/emit! (ev/event + (-> {::ev/name "apply-tokens" + :type type + :applied-to attributes + :applied-to-variant any-variant?} + (merge (meta it))))) + (dwu/start-undo-transaction undo-id) + (dwsh/update-shapes shape-ids (fn [shape] + (cond-> shape + attributes-to-remove + (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) + :always + (update :applied-tokens merge tokenized-attributes))))) + (when on-update-shape + (let [res (on-update-shape resolved-value shape-ids attributes)] + ;; Composed updates return observables and need to be executed differently + (if (rx/observable? res) + res + (rx/of res)))) + (rx/of (dwu/commit-undo-transaction undo-id))))))))) + + (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000}))))))) (defn apply-spacing-token-separated "Handles edge-case for spacing token when applying token via toggle button. @@ -688,7 +736,7 @@ [{:keys [token shapes attr]}] (ptk/reify ::apply-spacing-token-separated ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [objects (dsh/lookup-page-objects state) {:keys [attributes on-update-shape]} @@ -698,14 +746,17 @@ (group-by #(if (ctsl/any-layout-immediate-child? objects %) :frame-children :other) shapes)] (rx/of - (apply-token {:attributes (or attr attributes) - :token token - :shape-ids (map :id other) - :on-update-shape on-update-shape}) - (apply-token {:attributes ctt/spacing-margin-keys - :token token - :shape-ids (map :id frame-children) - :on-update-shape update-layout-item-margin})))))) + (-> (apply-token {:attributes (or attr attributes) + :token token + :shape-ids (map :id other) + :on-update-shape on-update-shape}) + (with-meta (meta it))) + + (-> (apply-token {:attributes ctt/spacing-margin-keys + :token token + :shape-ids (map :id frame-children) + :on-update-shape update-layout-item-margin}) + (with-meta (meta it)))))))) (defn unapply-token "Removes `attributes` that match `token` for `shape-ids`. @@ -727,7 +778,7 @@ [{:keys [token attrs shape-ids expand-with-children]}] (ptk/reify ::on-toggle-token ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [objects (dsh/lookup-page-objects state) shapes (into [] (keep (d/getf objects)) shape-ids) @@ -744,10 +795,16 @@ {:keys [attributes all-attributes on-update-shape]} (get token-properties (:type token)) + on-update-shape + (if (seq attrs) + (or (get attr->shape-update (first attrs)) on-update-shape) + on-update-shape) + unapply-tokens? (cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes)) - shape-ids (map :id shapes)] + shape-ids + (map :id shapes)] (if unapply-tokens? (rx/of @@ -758,15 +815,17 @@ (cond (and (= (:type token) :spacing) (nil? attrs)) - (apply-spacing-token-separated {:token token - :attr attrs - :shapes shapes}) + (-> (apply-spacing-token-separated {:token token + :attr attrs + :shapes shapes}) + (with-meta (meta it))) :else - (apply-token {:attributes (if (empty? attrs) attributes attrs) - :token token - :shape-ids shape-ids - :on-update-shape on-update-shape})))))))) + (-> (apply-token {:attributes (if (empty? attrs) attributes attrs) + :token token + :shape-ids shape-ids + :on-update-shape on-update-shape}) + (with-meta (meta it)))))))))) (defn apply-token-on-selected [color-operations token] @@ -808,7 +867,7 @@ :border-radius {:title "Border Radius" :attributes ctt/border-radius-keys - :on-update-shape update-shape-radius-all + :on-update-shape update-shape-radius :modal {:key :tokens/border-radius :fields [{:label "Border Radius" :key :border-radius}]}} diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 4daccd05b85..b60e07dddf4 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -426,7 +426,8 @@ token))] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "create-token" :type token-type}))) + (ev/event (-> {::ev/name "create-token" :type token-type} + (merge (meta it)))))) (rx/of (create-token-with-set token))))))) @@ -454,7 +455,8 @@ id token'))] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type}))))))) + (ev/event (-> {::ev/name "edit-token" :type token-type} + (merge (meta it)))))))))) (defn delete-token [set-id token-id] @@ -475,7 +477,8 @@ (pcb/with-library-data data) (pcb/set-token set-id token-id nil))] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "delete-token" :type token-type})))))) + (ev/event (-> {::ev/name "delete-token" :type token-type} + (merge (meta it))))))))) (defn bulk-delete-tokens [set-id token-ids] @@ -491,7 +494,7 @@ (pcb/with-library-data data)) token-ids)] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "delete-token-node"})))))) + (ev/event {::ev/name "delete-token-node"})))))) (defn duplicate-token [token-id] diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 28f1a309632..fe05919faec 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -613,10 +613,10 @@ vec)) (defn combine-as-variants - [ids {:keys [page-id trigger]}] + [ids {:keys [page-id trigger variant-id]}] (ptk/reify ::combine-as-variants ptk/WatchEvent - (watch [_ state stream] + (watch [it state stream] (let [current-page (:current-page-id state) combine @@ -647,7 +647,7 @@ :shapes count inc) - variant-id (uuid/next) + variant-id (or variant-id (uuid/next)) undo-id (js/Symbol)] (rx/concat @@ -665,7 +665,8 @@ (dwsh/relocate-shapes #{variant-id} common-parent index) (dwt/update-dimensions [variant-id] :width (+ (:width rect) 60)) (dwt/update-dimensions [variant-id] :height (+ (:height rect) 60)) - (ev/event {::ev/name "combine-as-variants" ::ev/origin trigger :number-of-combined (count ids)})) + (ev/event (-> {::ev/name "combine-as-variants" ::ev/origin trigger :number-of-combined (count ids)} + (merge (meta it))))) ;; NOTE: we need to schedule a commit into a ;; microtask for ensure that all the scheduled @@ -701,7 +702,7 @@ [shape {:keys [pos val] :as params}] (ptk/reify ::variant-switch ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [libraries (dsh/lookup-libraries state) component-id (:component-id shape) component (ctf/get-component libraries (:component-file shape) component-id :include-deleted? false)] @@ -735,20 +736,23 @@ (rx/empty)) (rx/of (dwl/component-swap shape (:component-file shape) (:id nearest-comp) true) - (ev/event {::ev/name "variant-switch" ::ev/origin "workspace:design-tab"})))))))))) + (ev/event (-> {::ev/name "variant-switch" ::ev/origin "workspace:design-tab"} + (merge (meta it))))))))))))) (defn variants-switch "Switch each shape (that must be a variant copy head) for the closest one with the property value passed as parameter" [{:keys [shapes] :as params}] (ptk/reify ::variants-switch ptk/WatchEvent - (watch [_ _ _] + (watch [it _ _] (let [ids (into (d/ordered-set) d/xf:map-id shapes) undo-id (js/Symbol)] (rx/concat (rx/of (dwu/start-undo-transaction undo-id)) (->> (rx/from shapes) - (rx/map #(variant-switch % params))) + (rx/map (fn [data] + (-> (variant-switch data params) + (with-meta (meta it)))))) (rx/of (dwu/commit-undo-transaction undo-id) (dws/select-shapes ids))))))) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 58e1e52f623..f287455434e 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -11,9 +11,10 @@ [app.common.schema :as sm] [app.common.time :as ct] [app.main.data.event :as ev] - [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] [app.main.data.workspace :as dw] + [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.thumbnails :as th] [app.main.refs :as refs] [app.main.repo :as rp] @@ -65,7 +66,7 @@ ;; Force persist before creating snapshot, otherwise we could loss changes (rx/concat (rx/of ::dwp/force-persist - (ptk/event ::ev/event {::ev/name "create-version"})) + (ev/event {::ev/name "create-version"})) (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) (rx/filter #(or (nil? %) (= :saved %))) @@ -87,38 +88,64 @@ (let [file-id (:current-file-id state)] (rx/merge (rx/of (update-versions-state {:editing nil}) - (ptk/event ::ev/event {::ev/name "rename-version" - :file-id file-id})) + (ev/event {::ev/name "rename-version" + :file-id file-id})) (->> (rp/cmd! :update-file-snapshot {:id id :label label}) (rx/map fetch-versions))))))) +(defn- initialize-version + [] + (ptk/reify ::initialize-version + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + file-id (:current-file-id state) + team-id (:current-team-id state)] + + (rx/merge + (->> stream + (rx/filter (ptk/type? ::dw/bundle-fetched)) + (rx/take 1) + (rx/map #(dwpg/initialize-page file-id page-id))) + + (rx/of (ntf/hide :tag :restore-dialog) + (dw/initialize-file team-id file-id))))) + + ptk/EffectEvent + (effect [_ _ _] + (th/clear-queue!)))) + +(defn- wait-for-persistence + [file-id snapshot-id] + (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) + (rx/filter #(or (nil? %) (= :saved %))) + (rx/take 1) + (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id})))) + (defn restore-version [id origin] (assert (uuid? id) "expected valid uuid for `id`") (ptk/reify ::restore-version ptk/WatchEvent (watch [_ state _] - (let [file-id (:current-file-id state) - team-id (:current-team-id state)] + (let [file-id (:current-file-id state) + team-id (:current-team-id state) + event-name (case origin + :version "restore-pin-version" + :snapshot "restore-autosave" + :plugin "restore-version-plugin")] + (rx/concat (rx/of ::dwp/force-persist (dw/remove-layout-flag :document-history)) - (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1) - (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/tap #(th/clear-queue!)) - (rx/map #(dw/initialize-workspace team-id file-id))) - (case origin - :version - (rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"})) - - :snapshot - (rx/of (ptk/event ::ev/event {::ev/name "restore-autosave"})) - :plugin - (rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"})) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) + (if event-name + (rx/of (ev/event {::ev/name event-name + :file-id file-id + :team-id team-id})) (rx/empty))))))) (defn delete-version @@ -146,7 +173,7 @@ (rx/mapcat (fn [_] (rx/of (update-versions-state {:editing id}) (fetch-versions) - (ptk/event ::ev/event {::ev/name "pin-version"})))))))))) + (ev/event {::ev/name "pin-version"})))))))))) (defn lock-version [id] @@ -188,9 +215,6 @@ (let [current-file-id (:current-file-id state)] ;; Force persist before creating snapshot, otherwise we could loss changes (->> (rx/concat - (rx/of (ptk/event ::ev/event {::ev/origin "plugins" - ::ev/name "create-version"})) - (when (= file-id current-file-id) (rx/of ::dwp/force-persist)) @@ -219,23 +243,15 @@ (ptk/reify ::restore-version-from-plugins ptk/WatchEvent - (watch [_ state _] - (let [file (dsh/lookup-file state file-id) - team-id (or (:team-id file) (:current-file-id state))] - (rx/concat - (rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"}) - ::dwp/force-persist) - - ;; FIXME: we should abstract this - (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1) - (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/map #(dw/initialize-workspace team-id file-id))) - - (->> (rx/of 1) - (rx/tap resolve) - (rx/ignore))))))) + (watch [_ _ _] + (rx/concat + (rx/of ::dwp/force-persist) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) + + (->> (rx/of 1) + (rx/tap resolve) + (rx/ignore)))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 223e9162616..85f1334cc99 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,22 @@ ;; Will contain last uncaught exception (def last-exception nil) +(defn is-plugin-error? + "This is a placeholder that always return false. It will be + overwritten when plugin system is initialized. This works this way + because we can't import plugins here because plugins requries full + DOM. + + This placeholder is set on app.plugins/initialize event" + [_] + false) + +;; Re-entrancy guard: prevents on-error from calling itself recursively. +;; If an error occurs while we are already handling an error (e.g. the +;; notification emit itself throws), we log it and bail out immediately +;; instead of recursing until the call-stack overflows. +(def ^:private handling-error? (volatile! false)) + ;; --- Stale-asset error detection and auto-reload ;; ;; When the browser loads JS modules from different builds (e.g. shared.js from @@ -80,12 +96,24 @@ (assoc ::trace (.-stack cause))))) (defn on-error - "A general purpose error handler." + "A general purpose error handler. + + Protected by a re-entrancy guard: if an error is raised while this + function is already on the call stack (e.g. the notification emit + itself fails), we print it to the console and return immediately + instead of recursing until the call-stack is exhausted." [error] - (if (map? error) - (ptk/handle-error error) - (let [data (exception->error-data error)] - (ptk/handle-error data)))) + (if @handling-error? + (.error js/console "[on-error] re-entrant call suppressed" error) + (do + (vreset! handling-error? true) + (try + (if (map? error) + (ptk/handle-error error) + (let [data (exception->error-data error)] + (ptk/handle-error data))) + (finally + (vreset! handling-error? false)))))) ;; Inject dependency to remove circular dependency (set! app.main.worker/on-error on-error) @@ -138,7 +166,14 @@ :report report})))) (defn flash - "Show error notification banner and emit error report" + "Show error notification banner and emit error report. + + The notification is scheduled asynchronously (via tm/schedule) to + avoid pushing a new event into the potok store while the store's own + error-handling pipeline is still on the call stack. Emitting + synchronously from inside an error handler creates a re-entrant + event-processing cycle that can exhaust the JS call stack + (RangeError: Maximum call stack size exceeded)." [& {:keys [type hint cause] :or {type :handled}}] (when (ex/exception? cause) (when-let [event-name (case type @@ -150,11 +185,12 @@ :report report :hint (ex/get-hint cause))))) - (st/emit! - (ntf/show {:content (or ^boolean hint (tr "errors.generic")) - :type :toast - :level :error - :timeout 5000}))) + (ts/schedule + #(st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000})))) (defmethod ptk/handle-error :network [error] @@ -180,6 +216,16 @@ (ex/print-throwable cause :prefix "Unexpected Error") (flash :cause cause :type :unhandled)))) +(defmethod ptk/handle-error :wasm-error + [error] + (when-let [cause (::instance error)] + (ex/print-throwable cause) + (let [code (get error :code)] + (if (or (= code :panic) + (= code :webgl-context-lost)) + (st/emit! (rt/assign-exception error)) + (flash :type :handled :cause cause))))) + ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all @@ -382,71 +428,123 @@ (ex/print-throwable instance :prefix "Server Error")) (st/async-emit! (rt/assign-exception error))) +(defn- from-extension? + "True when the error stack trace originates from a browser extension." + [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (or (str/includes? stack "chrome-extension://") + (str/includes? stack "moz-extension://"))))) + +(defn- from-posthog? + "True when the error stack trace originates from PostHog analytics." + [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (str/includes? stack "posthog")))) + +(defn is-ignorable-exception? + "True when the error is known to be harmless (browser extensions, analytics, + React/extension DOM conflicts, etc.) and should NOT be surfaced to the user." + [cause] + (let [message (ex-message cause)] + (or (from-extension? cause) + (from-posthog? cause) + (= message "Possible side-effect in debug-evaluate") + (= message "Unexpected end of input") + (str/starts-with? message "invalid props on component") + (str/starts-with? message "Unexpected token ") + ;; Native AbortError DOMException: raised when an in-flight + ;; HTTP fetch is cancelled via AbortController (e.g. by an + ;; RxJS unsubscription / take-until chain). These are + ;; handled gracefully inside app.util.http/fetch and must NOT + ;; be surfaced as application errors. + (= (.-name ^js cause) "AbortError") + ;; Zone.js (injected by browser extensions such as Angular + ;; DevTools) wraps event listeners and assigns a custom + ;; .toString to its wrapper functions using + ;; Object.defineProperty. When the wrapper was previously + ;; defined with {writable: false}, a subsequent plain assignment + ;; in strict mode (our libs.js uses "use strict") throws this + ;; TypeError. This is a known Zone.js / browser-extension + ;; incompatibility and is NOT a Penpot bug. + (str/starts-with? message "Cannot assign to read only property 'toString'") + ;; NotFoundError DOMException: "Failed to execute + ;; 'removeChild' on 'Node'" — Thrown by React's commit + ;; phase when the DOM tree has been modified externally + ;; (typically by browser extensions like Grammarly, + ;; LastPass, translation tools, or ad blockers that + ;; inject/remove nodes). The entire stack trace is inside + ;; React internals (libs.js) with no application code, + ;; so there is nothing actionable on our side. React's + ;; error boundary already handles recovery. + (and (= (.-name ^js cause) "NotFoundError") + (str/includes? message "removeChild"))))) + + +(defn- from-plugin? + "Check if the error is marked as originating from plugin code. The + plugin runtime tracks plugin errors in a WeakMap, which works even + in SES hardened environments where error objects may be frozen." + [cause] + (try + (is-plugin-error? cause) + (catch :default _ + false))) + (defonce uncaught-error-handler - (letfn [(from-extension? [cause] - (let [stack (.-stack cause)] - (and (string? stack) - (or (str/includes? stack "chrome-extension://") - (str/includes? stack "moz-extension://"))))) - - (from-posthog? [cause] - (let [stack (.-stack cause)] - (and (string? stack) - (str/includes? stack "posthog")))) - - (is-ignorable-exception? [cause] - (let [message (ex-message cause)] - (or (from-extension? cause) - (from-posthog? cause) - (= message "Possible side-effect in debug-evaluate") - (= message "Unexpected end of input") - (str/starts-with? message "invalid props on component") - (str/starts-with? message "Unexpected token ") - ;; Native AbortError DOMException: raised when an in-flight - ;; HTTP fetch is cancelled via AbortController (e.g. by an - ;; RxJS unsubscription / take-until chain). These are - ;; handled gracefully inside app.util.http/fetch and must NOT - ;; be surfaced as application errors. - (= (.-name ^js cause) "AbortError") - ;; Zone.js (injected by browser extensions such as Angular - ;; DevTools) wraps event listeners and assigns a custom - ;; .toString to its wrapper functions using - ;; Object.defineProperty. When the wrapper was previously - ;; defined with {writable: false}, a subsequent plain assignment - ;; in strict mode (our libs.js uses "use strict") throws this - ;; TypeError. This is a known Zone.js / browser-extension - ;; incompatibility and is NOT a Penpot bug. - (str/starts-with? message "Cannot assign to read only property 'toString'")))) - - (on-unhandled-error [event] + (letfn [(on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/asap #(flash :cause cause :type :unhandled))))))))) + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/asap #(flash :cause cause :type :unhandled)))))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/asap #(flash :cause cause :type :unhandled)))))))))] + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Rejection") + (ts/asap #(flash :cause cause :type :unhandled))))))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c4e0faaecd2..9e1c13cd582 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -150,6 +150,9 @@ (def workspace-global (l/derived :workspace-global st/state)) +(def mcp + (l/derived :mcp st/state)) + (def workspace-drawing (l/derived :workspace-drawing st/state)) @@ -645,3 +648,9 @@ (def progress (l/derived :progress st/state)) + +(def access-tokens + (l/derived :access-tokens st/state)) + +(def access-token-created + (l/derived :access-token-created st/state)) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 6f264e5d027..9b5b9872abd 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -139,8 +139,7 @@ {:stream? true} ::sse/import-binfile - {:stream? true - :form-data? true} + {:stream? true} ::sse/permanently-delete-team-files {:stream? true} @@ -183,6 +182,7 @@ :credentials "include" :headers {"accept" "application/transit+json,text/event-stream,*/*" "x-external-session-id" (cf/external-session-id) + "x-session-id" (str cf/session-id) "x-event-origin" (::ev/origin (meta params))} :body (when (= method :post) (if form-data? @@ -273,6 +273,7 @@ (send-export (merge default params)))) (derive :upload-file-media-object ::multipart-upload) +(derive :upload-chunk ::multipart-upload) (derive :update-profile-photo ::multipart-upload) (derive :update-team-photo ::multipart-upload) diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index 1e234e8af1c..0f3575f61db 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -76,8 +76,7 @@ (when send-event-info? (let [route (dm/get-in match [:data :name]) params (get match :query-params)] - (rx/of (ptk/event - ::ev/event + (rx/of (ev/event (assoc params ::ev/name "navigate" :route (name route))))))) @@ -136,6 +135,16 @@ [state] (dm/get-in state [:route :params :query])) +(defn get-query-param + "Safely extracts a scalar value for a query param key from a params + map. When the same key appears multiple times in a URL, + query-string->map returns a vector for that key; this function + always returns a single (last) element in that case, so downstream + consumers such as parse-long always receive a plain string or nil." + [params k] + (let [v (get params k)] + (if (sequential? v) (peek v) v))) + (defn nav-back [] (ptk/reify ::nav-back diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 3b00fe0d50a..c1dafa5041e 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -153,6 +153,23 @@ section (get data :name) team (mf/deref refs/team) + ;; Dashboard / workspace pass ?team-id=… while `initialize-team` + ;; applies it asynchronously. On the first render `refs/team` can still + ;; be the personal default team, spuriously satisfying (:is-default team). + ;; Until state catches up, skip forcing team onboarding. + route-team-id (some-> (:query params) :team-id uuid/parse*) + current-team-id (:current-team-id (mf/deref st/state)) + team-route-synced? (or (nil? route-team-id) + (= route-team-id current-team-id)) + + ;; Forward-auth installs use :x-auth-request-headers and typically + ;; provision users onto a shared team; don't push "create a team". + ;; Also skip when get-teams has already populated a workspace team — + ;; URL ?team-id= often repeats the personal default id, which kept + ;; (:is-default team) true despite membership elsewhere. + user-has-shared-team? + (some #(and (some? %) (not (:is-default %))) + (vals (:teams (mf/deref st/state)))) show-question-modal? (and (contains? cf/flags :onboarding) @@ -163,7 +180,10 @@ (and (contains? cf/flags :onboarding) (not (:onboarding-viewed props)) (not (contains? props :onboarding-team-id)) - (:is-default team)) + (not (contains? cf/flags :x-auth-request-headers)) + (not user-has-shared-team?) + (:is-default team) + team-route-synced?) show-release-modal? (and (contains? cf/flags :onboarding) @@ -190,7 +210,7 @@ :settings-options :settings-feedback :settings-subscription - :settings-access-tokens + :settings-integrations :settings-notifications) (let [params (get params :query) error-report-id (some-> params :error-report-id uuid/parse*)] @@ -277,7 +297,7 @@ :viewer (let [params (get params :query) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) share-id (some-> (:share-id params) uuid/parse*) section (or (some-> (:section params) keyword) :interactions) diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index fd601978b8e..51c59f0c71b 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -36,8 +36,8 @@ (mf/defc demo-warning* [] [:> context-notification* - {:level :warning - :content (tr "auth.demo-warning")}]) + {:level :warning} + (tr "auth.demo-warning")]) (defn create-demo-profile [] diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 3bd3fdf564a..623b25d6429 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -81,6 +81,7 @@ on-error (mf/use-fn (fn [cause] + (reset! submitted? false) (let [{:keys [type code] :as edata} (ex-data cause)] (condp = [type code] [:restriction :email-does-not-match-invitation] @@ -98,6 +99,9 @@ [:restriction :email-has-complaints] (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata)))) + [:validation :email-already-exists] + (st/emit! (ntf/error (tr "errors.email-already-exists"))) + [:validation :email-as-password] (swap! form assoc-in [:errors :password] {:message (tr "errors.email-as-password")}) @@ -276,6 +280,7 @@ (mf/use-fn (mf/deps on-success-callback) (fn [params] + (cf/external-notify-register-success (:id params)) (if (fn? on-success-callback) (on-success-callback (:email params)) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 334303ade4e..c401ac8f67c 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.auth.verify-token (:require + [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] [app.main.data.notifications :as ntf] @@ -24,10 +25,19 @@ (defmulti handle-token (fn [token] (:iss token))) (defmethod handle-token :verify-email - [data] + [{:keys [invitation-token] :as data}] + (cf/external-notify-register-success (:profile-id data)) (let [msg (tr "dashboard.notifications.email-verified-successfully")] (ts/schedule 1000 #(st/emit! (ntf/success msg))) - (st/emit! (da/login-from-token data)))) + ;; If the verify-email JWE carries an :invitation-token, it means + ;; the user registered via a team-invitation flow but had to verify + ;; their email first. Log them in and then redirect to + ;; :auth-verify-token with the invitation token, which will accept + ;; the invitation as a logged-in user. + (if invitation-token + (st/emit! (da/login-from-token data) + (rt/nav :auth-verify-token {:token invitation-token})) + (st/emit! (da/login-from-token data))))) (defmethod handle-token :change-email [_data] diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 8f34c412775..cb1e2da0425 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -81,7 +81,7 @@ ([text] (-> (dom/create-element "span") (dom/set-data! "type" "text") - (dom/set-html! (if (empty? text) zero-width-space text))))) + (dom/set-html! (if (empty? text) zero-width-space (dom/escape-html text)))))) (defn- create-mention-node "Creates a mention node" @@ -313,7 +313,7 @@ after-span (create-text-node (dm/str " " suffix)) sel (wapi/get-selection)] - (dom/set-html! span-node (if (empty? prefix) zero-width-space prefix)) + (dom/set-html! span-node (if (empty? prefix) zero-width-space (dom/escape-html prefix))) (dom/insert-after! node span-node mention-span) (dom/insert-after! node mention-span after-span) (wapi/set-cursor-after! after-span) @@ -330,7 +330,7 @@ (let [node-text (dom/get-text span-node) at-symbol (if (blank-content? node-text) "@" " @")] - (dom/set-html! span-node (str/concat node-text at-symbol)) + (dom/set-html! span-node (str/concat (dom/escape-html node-text) at-symbol)) (wapi/set-cursor-after! span-node)))))) handle-key-down @@ -378,7 +378,7 @@ (when span-node (let [txt (.-textContent span-node)] - (dom/set-html! span-node (dm/str (subs txt 0 offset) "\n" zero-width-space (subs txt offset))) + (dom/set-html! span-node (dm/str (dom/escape-html (subs txt 0 offset)) "\n" zero-width-space (dom/escape-html (subs txt offset)))) (wapi/set-cursor! span-node (inc offset)) (handle-input))))) diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.scss b/frontend/src/app/main/ui/components/context_menu_a11y.scss index e0fc29989e7..787941b5954 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.scss +++ b/frontend/src/app/main/ui/components/context_menu_a11y.scss @@ -5,12 +5,13 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/z-index.scss" as *; .context-menu { position: relative; visibility: hidden; opacity: deprecated.$op-0; - z-index: deprecated.$z-index-4; + z-index: var(--z-index-dropdown); &.is-open { position: relative; diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index ca8a78aea07..d2c068ebf2c 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -9,14 +9,17 @@ (:require [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] [goog.events :as events] [rumext.v2 :as mf]) - (:import goog.events.EventType)) + (:import + goog.events.EventType)) (mf/defc confirm-dialog {::mf/register modal/components @@ -68,8 +71,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} deprecated-icon/close]] + [:div {:class (stl/css :modal-close-btn)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click cancel-fn + :icon i/close}]]] [:div {:class (stl/css :modal-content)} (when (and (string? message) (not= message "")) @@ -87,24 +93,19 @@ [:ul {:class (stl/css :component-list)} (for [item items] [:li {:class (stl/css :modal-item-element)} - [:span {:class (stl/css :modal-component-icon)} - deprecated-icon/component] + [:> icon* {:icon-id i/component + :class (stl/css :modal-component-icon) + :size "s"}] [:span {:class (stl/css :modal-component-name)} (:name item)]])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} (when-not (= cancel-label :omit) - [:input - {:class (stl/css :cancel-button) - :type "button" - :value cancel-label - :on-click cancel-fn}]) - - [:input - {:class (stl/css-case :accept-btn true - :danger (= accept-style :danger) - :primary (= accept-style :primary)) - :type "button" - :value accept-label - :on-click accept-fn}]]]]])) + [:> button* {:variant "secondary" + :on-click cancel-fn} + cancel-label]) + [:> button* {:variant (cond (= accept-style :danger) "destructive" + (= accept-style :primary) "primary") + :on-click accept-fn} + accept-label]]]]])) diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index e517a7b685b..09b23426f32 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -15,10 +15,9 @@ .modal-container { @extend .modal-container-base; -} - -.modal-header { - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); } .modal-title { @@ -27,12 +26,13 @@ } .modal-close-btn { - @extend .modal-close-btn-base; + position: absolute; + top: var(--sp-m); + right: var(--sp-m); } .modal-content { @include deprecated.bodyLargeTypography; - margin-bottom: deprecated.$s-24; } .modal-item-element { @@ -41,32 +41,18 @@ .modal-component-icon { @include deprecated.flexCenter; - height: deprecated.$s-16; - width: deprecated.$s-16; - svg { - @extend .button-icon-small; - stroke: var(--color); - } + color: var(--color-foreground-secondary); } + .modal-component-name { @include deprecated.bodyLargeTypography; + color: var(--color-foreground-secondary); } .action-buttons { @extend .modal-action-btns; } -.cancel-button { - @extend .modal-cancel-btn; -} - -.accept-btn { - @extend .modal-accept-btn; - &.danger { - @extend .modal-danger-btn; - } -} - .modal-scd-msg, .modal-subtitle, .modal-msg { diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 04c376def41..836ee270dfc 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -44,7 +44,6 @@ [cuerdas.core :as str] [goog.events :as events] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc dashboard-content* @@ -216,7 +215,7 @@ (fn [plugin] (if plugin (do - (st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) + (st/emit! (ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) (open-permissions-dialog plugin)) (st/emit! (notif/error (tr "dashboard.plugins.parse-error"))))) (fn [_] @@ -245,12 +244,12 @@ (dd/fetch-recent-files team-id) (dd/fetch-projects team-id) (dd/clear-selected-files) - (ptk/event ::ev/event {::ev/name "install-template-from-link-finished" - :name template-name - :url template-url}))] + (ev/event {::ev/name "install-template-from-link-finished" + :name template-name + :url template-url}))] (if valid-url? (st/emit! - (ptk/event ::ev/event {::ev/name "install-template-from-link" :name template-name :url template-url}) + (ev/event {::ev/name "install-template-from-link" :name template-name :url template-url}) (modal/show {:type :import :project-id project-id diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index e432a642c80..29dbb803b3f 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -18,7 +18,6 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private comments-icon-svg @@ -81,8 +80,8 @@ (mf/deps show?) (fn [] (when show? - (st/emit! (ptk/event ::ev/event {::ev/name "open-comment-notifications" - ::ev/origin "dashboard"}))))) + (st/emit! (ev/event {::ev/name "open-comment-notifications" + ::ev/origin "dashboard"}))))) [:div {:class (stl/css :dashboard-comments-section)} [:& dropdown {:show show? :on-close on-hide-comments :dropdown-id "dashboard-comments"} diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index 3d8d5bf8c75..71876337227 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -6,11 +6,11 @@ @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; -@use "../ds/typography.scss" as t; -@use "../ds/_borders.scss" as *; -@use "../ds/spacing.scss" as *; -@use "../ds/_sizes.scss" as *; -@use "../ds/z-index.scss" as *; +@use "ds/typography.scss" as t; +@use "ds/spacing.scss" as *; +@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; .dashboard-container { flex: 1 0 0; @@ -51,6 +51,7 @@ padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); position: sticky; top: 0; + z-index: var(--z-index-panels); } .nav-inside { diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index dfecbc779bd..06f7b29c363 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -78,7 +78,8 @@ current-team (get teams current-team-id) other-teams (remove #(= (:id %) current-team-id) (vals teams)) - current-projects (remove #(= (:id %) (:project-id file)) + file-project-ids (into #{} (map :project-id) files) + current-projects (remove #(contains? file-project-ids (:id %)) (:projects current-team)) on-new-tab diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss index 79f3563168f..838f8ea78cf 100644 --- a/frontend/src/app/main/ui/dashboard/files.scss +++ b/frontend/src/app/main/ui/dashboard/files.scss @@ -6,6 +6,8 @@ @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .dashboard-container { flex: 1 0 0; @@ -13,6 +15,7 @@ overflow-y: auto; width: 100%; border-top: deprecated.$s-1 solid var(--color-background-quaternary); + padding-block-end: var(--sp-xxxl); &.dashboard-projects { user-select: none; diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index f84d6948930..68ce4d57c65 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -9,12 +9,13 @@ (:require [app.common.data.macros :as dm] [app.common.media :as cm] + [app.common.schema :as sm] + [app.common.types.font :as ctf] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.fonts :as df] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] - [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.file-uploader :refer [file-uploader]] @@ -110,7 +111,7 @@ (mf/use-fn (fn [{:keys [id] :as item}] (swap! uploading* conj id) - (->> (rp/cmd! :create-font-variant item) + (->> (df/upload-font-variant item) (rx/delay-at-least 2000) (rx/subs! (fn [font] (swap! fonts* dissoc id) @@ -140,7 +141,8 @@ (dom/get-data "id") (uuid/parse)) name (dom/get-value target)] - (when-not (str/blank? name) + (when (and (not (str/blank? name)) + (sm/validate ctf/schema:font-family name)) (swap! fonts* df/rename-and-regroup id name installed-fonts))))) on-change-name @@ -318,7 +320,9 @@ (fn [_] (reset! edition* false) (when-not (str/blank? font-family) - (st/emit! (df/update-font {:id font-id :name font-family}))))) + (if (sm/validate ctf/schema:font-family font-family) + (st/emit! (df/update-font {:id font-id :name font-family})) + (st/emit! (ntf/error (tr "errors.font-family-invalid-chars"))))))) on-key-down (mf/use-fn diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index 3f4189c7294..e1aaef396aa 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -17,7 +17,7 @@ $thumbnail-default-height: deprecated.$s-168; // Default width height: 100%; overflow-y: auto; overflow-x: hidden; - padding: 0 deprecated.$s-16; + padding: 0 var(--sp-l) deprecated.$s-16; } .grid-row { diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 1510af2455b..9268b88806d 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -27,7 +27,6 @@ [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (log/set-level! :debug) @@ -171,14 +170,14 @@ (rx/subs! (fn [message] (when (some? (:error message)) - (st/emit! (ptk/data-event ::ev/event {::ev/name "import-files-error" - :error (:error message)}))) + (st/emit! (ev/event {::ev/name "import-files-error" + :error (:error message)}))) (swap! state update-with-analyze-result message)))))) (defn- import-files [state project-id entries] - (st/emit! (ptk/data-event ::ev/event {::ev/name "import-files" - :num-files (count entries)})) + (st/emit! (ev/event {::ev/name "import-files" + :num-files (count entries)})) (let [features (get @st/state :features)] (->> (mw/ask-many! diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index bb32d71b5c4..107460b3897 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -16,7 +16,6 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc empty-project-placeholder* @@ -27,9 +26,9 @@ on-add-library (mf/use-fn (fn [_] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-libraries-click" - ::ev/origin "dashboard" - :section "empty-placeholder-projects"})) + (st/emit! (ev/event {::ev/name "explore-libraries-click" + ::ev/origin "dashboard" + :section "empty-placeholder-projects"})) (dom/open-new-window "https://penpot.app/penpothub/libraries-templates"))) on-import diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 04ca8023524..e941cf33bc9 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -31,7 +31,6 @@ [app.util.storage :as storage] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private show-more-icon @@ -344,8 +343,8 @@ (mf/use-fn (fn [] (reset! show-team-hero* false) - (st/emit! (ptk/data-event ::ev/event {::ev/name "dont-show-team-up-hero" - ::ev/origin "dashboard"}))))] + (st/emit! (ev/event {::ev/name "dont-show-team-up-hero" + ::ev/origin "dashboard"}))))] (mf/with-effect [show-team-hero?] (swap! storage/global assoc ::show-team-hero show-team-hero?)) diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index 7df6a0f9c98..a37575c38e4 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -19,16 +19,15 @@ margin-inline-end: var(--sp-l); border-block-start: $b-1 solid var(--panel-border-color); overflow-y: auto; - padding-block-end: var(--sp-xxxl); } .dashboard-projects { user-select: none; - block-size: calc(100vh - px2rem(64)); + block-size: calc(100vh - px2rem(80)); } .with-team-hero { - block-size: calc(100vh - px2rem(280)); + block-size: calc(100vh - px2rem(360)); } .dashboard-shared { diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 420cb162ac7..de05319defa 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -44,7 +44,6 @@ [beicon.v2.core :as rx] [cuerdas.core :as str] [goog.functions :as f] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private clear-search-icon @@ -624,7 +623,12 @@ (dom/click!))))) close-teams-menu - (mf/use-fn #(reset! show-teams-menu* false))] + (mf/use-fn #(reset! show-teams-menu* false)) + + ;; SSO forward-auth setups provision teams via backend (auto-join). Hide dashboard + ;; "Create team" when enable-x-auth-request-headers is present in PENPOT_FLAGS (frontend config). + allow-dashboard-create-team? + (not (contains? cf/flags :x-auth-request-headers))] [:div {:class (stl/css :sidebar-team-switch)} [:div {:class (stl/css :switch-content)} @@ -677,7 +681,7 @@ :profile profile :teams teams :show-default-team true - :allow-create-teams true + :allow-create-teams allow-dashboard-create-team? :allow-create-org false}] [:> team-options-dropdown* {:show show-team-options-menu? @@ -869,8 +873,8 @@ (dom/get-data "url")) eventname (-> (dom/get-current-target event) (dom/get-data "eventname"))] - (st/emit! (ptk/event ::ev/event {::ev/name eventname - ::ev/origin "menu:in-app"})) + (st/emit! (ev/event {::ev/name eventname + ::ev/origin "menu:in-app"})) (dom/open-new-window url)))) handle-feedback-click @@ -914,8 +918,8 @@ (dom/get-data "url")) eventname (-> (dom/get-current-target event) (dom/get-data "eventname"))] - (st/emit! (ptk/event ::ev/event {::ev/name eventname - ::ev/origin "menu:in-app"})) + (st/emit! (ev/event {::ev/name eventname + ::ev/origin "menu:in-app"})) (dom/open-new-window url))))] [:> dropdown-menu* {:show true @@ -942,7 +946,7 @@ show-release-notes (mf/use-fn (fn [event] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version (:main version)})) + (st/emit! (ev/event {::ev/name "show-release-notes" :version (:main version)})) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) (st/emit! (modal/show {:type :release-notes :version (:main version)}))))) @@ -954,8 +958,8 @@ (dom/get-data "url")) eventname (-> (dom/get-current-target event) (dom/get-data "eventname"))] - (st/emit! (ptk/event ::ev/event {::ev/name eventname - ::ev/origin "menu:in-app"})) + (st/emit! (ev/event {::ev/name eventname + ::ev/origin "menu:in-app"})) (dom/open-new-window url))))] [:> dropdown-menu* {:show true @@ -1052,7 +1056,7 @@ on-power-up-click (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) + (st/emit! (ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) (dom/open-new-window "https://penpot.app/pricing")))] [:* diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 4c62bdcff66..145ae0207dd 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -26,7 +26,6 @@ margin: 0 var(--sp-l) 0 0; border-right: $b-1 solid var(--panel-border-color); background-color: var(--panel-background-color); - z-index: var(--z-index-dropdown); } //SIDEBAR CONTENT COMPONENT diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 1d90cf9e6d2..6bcca0b36b1 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -16,7 +16,6 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [lambdaisland.uri :as u] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn get-subscription-type @@ -124,9 +123,9 @@ go-to-manage-subscription (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "open-subscription-management" - ::ev/origin "dashboard" - :section "team-settings"})) + (st/emit! (ev/event {::ev/name "open-subscription-management" + ::ev/origin "dashboard" + :section "team-settings"})) (let [href (-> (rt/get-current-href) (rt/encode-url)) href (str "payments/subscriptions/show?returnUrl=" href)] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 96afda25636..34ad272b5ad 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -195,6 +195,11 @@ (= :email-has-complaints code)) (swap! error-text (tr "errors.email-spam-or-permanent-bounces" (:email error))) + (and (= :restriction type) + (= :email-domain-is-not-allowed code)) + (st/emit! (ntf/error (tr "errors.email-domain-not-allowed")) + (modal/hide)) + :else (st/emit! (ntf/error (tr "errors.generic")) (modal/hide))))) @@ -1013,7 +1018,31 @@ (defn- extract-status [error-code] - (-> error-code (str/split #":") second)) + (-> error-code (str/split #":") second str/trim)) + +(defn- translate-error-hint + [hint] + (cond + (= hint "invalid-uri") + (tr "errors.webhooks.invalid-uri") + + (= hint "ssl-validation-error") + (tr "errors.webhooks.ssl-validation") + + (= hint "timeout") + (tr "errors.webhooks.timeout") + + (= hint "connection-error") + (tr "errors.webhooks.connection") + + (str/starts-with? hint "unexpected-status") + (tr "errors.webhooks.unexpected-status" (extract-status hint)) + + (str/starts-with? hint "blocked-request") + (tr "errors.webhooks.connection") + + :else + (tr "errors.webhooks.unexpected"))) (mf/defc webhook-modal {::mf/register modal/components @@ -1022,7 +1051,7 @@ (let [initial (mf/with-memo [] (or (some-> webhook (update :uri str)) - {:is-active false :mtype "application/json"})) + {:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"})) form (fm/use-form :schema schema:webhook-form :initial initial) on-success @@ -1034,25 +1063,14 @@ on-error (mf/use-fn - (fn [form error] - (let [{:keys [type code hint]} (ex-data error)] + (fn [form cause] + (let [{:keys [type code hint] :as error} (ex-data cause)] (if (and (= type :validation) (= code :webhook-validation)) - (let [message (cond - (= hint "unknown") - (tr "errors.webhooks.unexpected") - (= hint "invalid-uri") - (tr "errors.webhooks.invalid-uri") - (= hint "ssl-validation-error") - (tr "errors.webhooks.ssl-validation") - (= hint "timeout") - (tr "errors.webhooks.timeout") - (= hint "connection-error") - (tr "errors.webhooks.connection") - (str/starts-with? hint "unexpected-status") - (tr "errors.webhooks.unexpected-status" (extract-status hint)))] - (swap! form assoc-in [:errors :uri] {:message message})) - (rx/throw error))))) + (let [message (translate-error-hint hint)] + (swap! form assoc-in [:extra-errors :uri] {:message message}) + (rx/empty)) + (rx/throw cause))))) on-create-submit (mf/use-fn @@ -1082,6 +1100,7 @@ (if (:id data) (on-update-submit form) (on-create-submit form)))))] + [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:& fm/form {:form form :on-submit on-submit} @@ -1207,8 +1226,10 @@ (dm/str " " (tr "errors.webhooks.ssl-validation")) (str/starts-with? error-code "unexpected-status") - (dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))))))] + (dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))) + :else + (dm/str " " (tr "errors.webhooks.unexpected")))))] [:div {:class (stl/css :table-row :webhook-row)} [:div {:class (stl/css :table-field :last-delivery) diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 5f6fdaae90b..a2ef4d1490d 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -7,7 +7,7 @@ (ns app.main.ui.dashboard.team-form (:require-macros [app.main.style :as stl]) (:require - [app.common.schema :as sm] + [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.modal :as modal] @@ -24,7 +24,7 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name [::sm/text {:max 250}]]]) + [:name ctt/schema:team-name]]) (defn- on-create-success [_form response] diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index d5f84c28621..c53f94e2a23 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -23,7 +23,6 @@ [app.util.keyboard :as kbd] [app.util.storage :as storage] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private arrow-icon @@ -40,10 +39,10 @@ (letfn [(on-finish [] (st/emit! (dd/fetch-recent-files team-id) - (ptk/event ::ev/event {::ev/name "import-template-finish" - ::ev/origin "dashboard" - :template (:name template) - :section section}) + (ev/event {::ev/name "import-template-finish" + ::ev/origin "dashboard" + :template (:name template) + :section section}) (when-not (some? project-id) (dcm/go-to-dashboard-recent @@ -51,10 +50,10 @@ :project-id default-project-id))))] (st/emit! - (ptk/event ::ev/event {::ev/name "import-template-launch" - ::ev/origin "dashboard" - :template (:name template) - :section section}) + (ev/event {::ev/name "import-template-launch" + ::ev/origin "dashboard" + :template (:name template) + :section section}) (modal/show {:type :import @@ -145,9 +144,9 @@ (mf/use-fn (mf/deps section) (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-libraries-click" - ::ev/origin "dashboard" - :section section})))) + (st/emit! (ev/event {::ev/name "explore-libraries-click" + ::ev/origin "dashboard" + :section section})))) on-key-down (mf/use-fn diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 8a5ab660c06..f3323c58f2e 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -4,10 +4,11 @@ // // Copyright (c) KALEIDOS INC +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; @use "ds/_borders.scss" as *; -@use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/_utils.scss" as *; .dashboard-templates-section { background-color: var(--color-background-tertiary); @@ -26,6 +27,8 @@ transition: bottom 300ms; width: calc(100% - $sz-12); pointer-events: none; + z-index: var(--z-index-panels); + &.collapsed { inset-block-end: calc(-1 * px2rem(228)); background-color: transparent; diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 067bd0b416f..9daa3a5ec75 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -18,6 +18,7 @@ $sz-32: px2rem(32); $sz-36: px2rem(36); $sz-40: px2rem(40); $sz-48: px2rem(48); +$sz-64: px2rem(64); $sz-88: px2rem(88); $sz-96: px2rem(96); $sz-120: px2rem(120); diff --git a/frontend/src/app/main/ui/ds/controls/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs index 29ae0cc8045..918e5a446bc 100644 --- a/frontend/src/app/main/ui/ds/controls/input.cljs +++ b/frontend/src/app/main/ui/ds/controls/input.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.main.constants :refer [max-input-length]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] @@ -52,10 +51,11 @@ :has-hint has-hint :hint-type hint-type :variant variant})] - [:div {:class (dm/str class " " (stl/css-case :input-wrapper true - :variant-dense (= variant "dense") - :variant-comfortable (= variant "comfortable") - :has-hint has-hint))} + + [:div {:class [class (stl/css-case :input-wrapper true + :variant-dense (= variant "dense") + :variant-comfortable (= variant "comfortable") + :has-hint has-hint)]} (when has-label [:> label* {:for id :is-optional is-optional} label]) [:> input-field* props] @@ -64,4 +64,3 @@ :class hint-class :message hint-message :type hint-type}])])) - diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 4751d81dcfe..05246f7f23c 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -160,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (hooks/use-portal-container) + container (hooks/use-portal-container :tooltip) id (d/nilv id internal-id) diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 7f1244dcadf..9aede980cfd 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -8,6 +8,7 @@ (:require [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.util.dom :as dom] [app.util.forms :as fm] [app.util.keyboard :as k] @@ -47,6 +48,23 @@ [:> input* props])) +(mf/defc form-select* + [{:keys [name] :as props}] + (let [select-name name + form (mf/use-ctx context) + value (get-in @form [:data select-name] "") + + handle-change + (fn [event] + (let [value (if (string? event) event (dom/get-target-val event))] + (fm/on-input-change form select-name value))) + + props + (mf/spread-props props {:on-change handle-change + :value value})] + + [:> select* props])) + (mf/defc form-submit* [{:keys [disabled on-submit] :rest props}] (let [form (mf/use-ctx context) @@ -79,4 +97,4 @@ (when (fn? on-submit) (on-submit form event))))] [:> (mf/provider context) {:value form} - [:form {:class class :on-submit on-submit'} children]])) \ No newline at end of file + [:form {:class class :on-submit on-submit'} children]])) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 42560cd8fe5..ae8ebd30d59 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,17 +380,35 @@ state)) +(defn- get-or-create-portal-container + "Returns the singleton container div for the given category, creating + and appending it to document.body on first access." + [category] + (let [body (dom/get-body) + id (str "portal-container-" category)] + (or (dom/query body (str "#" id)) + (let [container (dom/create-element "div")] + (dom/set-attribute! container "id" id) + (dom/append-child! body container) + container)))) + (defn use-portal-container - "Creates a dedicated div container for React portals. The container - is appended to document.body on mount and removed on cleanup, preventing - removeChild race conditions when multiple portals target the same body." - [] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - container)) + "Returns a shared singleton container div for React portals, identified + by a logical category. Available categories: + + :modal — modal dialogs + :popup — popups, dropdowns, context menus + :tooltip — tooltips + :default — general portal use (default) + + All portals in the same category share one <div> on document.body, + keeping the DOM clean and avoiding removeChild race conditions." + ([] + (use-portal-container :default)) + ([category] + (let [category (name category)] + (mf/with-memo [category] + (get-or-create-portal-container category))))) (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 661bee9075f..fcde4812fbb 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -31,7 +31,6 @@ [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def embed-images? true) @@ -158,10 +157,10 @@ (let [origin (if (= :workspace from) "workspace" "viewer")] - (st/emit! (ptk/event ::ev/event - {::ev/name "copy-inspect-code" - ::ev/origin origin - :type markup-type}))))) + (st/emit! (ev/event + {::ev/name "copy-inspect-code" + ::ev/origin origin + :type markup-type}))))) on-style-copied (mf/use-fn @@ -170,10 +169,10 @@ (let [origin (if (= :workspace from) "workspace" "viewer")] - (st/emit! (ptk/event ::ev/event - {::ev/name "copy-inspect-style" - ::ev/origin origin - :type style-type}))))) + (st/emit! (ev/event + {::ev/name "copy-inspect-style" + ::ev/origin origin + :type style-type}))))) {on-markup-pointer-down :on-pointer-down on-markup-lost-pointer-capture :on-lost-pointer-capture @@ -206,10 +205,10 @@ (let [origin (if (= :workspace from) "workspace" "viewer")] - (st/emit! (ptk/event ::ev/event - {::ev/name "copy-inspect-code" - ::ev/origin origin - :type "all"}))))) + (st/emit! (ev/event + {::ev/name "copy-inspect-code" + ::ev/origin origin + :type "all"}))))) ;;handle-open-review ;;(mf/use-fn diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.cljs b/frontend/src/app/main/ui/inspect/right_sidebar.cljs index 5e205b502a0..aea187a4c96 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/right_sidebar.cljs @@ -24,7 +24,6 @@ [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.shape-icon :as usi] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn- get-libraries @@ -75,7 +74,7 @@ (when on-change-section (on-change-section (keyword new-section)) (st/emit! - (ptk/event ::ev/event {::ev/name "change-inspect-tab" :tab new-section}))))) + (ev/event {::ev/name "change-inspect-tab" :tab new-section}))))) handle-expand (mf/use-fn @@ -122,7 +121,7 @@ (mf/deps shapes handle-change-tab) (fn [] (if (seq shapes) - (st/emit! (ptk/event ::ev/event {::ev/name "inspect-mode-click-element"})) + (st/emit! (ev/event {::ev/name "inspect-mode-click-element"})) (handle-change-tab (if (contains? cf/flags :inspect-styles) :styles :info))))) [:aside {:class (stl/css-case :settings-bar-right true diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 5df1cc3daa9..6e9b1df7d45 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -84,7 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (hooks/use-portal-container)] + (let [container (hooks/use-portal-container :modal)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs index d529456cc15..8af4c1c936e 100644 --- a/frontend/src/app/main/ui/onboarding/questions.cljs +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -11,14 +11,13 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.main.data.event :as-alias ev] + [app.main.data.event :as ev] [app.main.data.profile :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc step-container @@ -33,7 +32,7 @@ (assoc :label label) (assoc :step step) (assoc ::ev/name "onboarding-step"))] - (st/emit! (ptk/data-event ::ev/event params)) + (st/emit! (ev/event params)) (on-next form event))))] [:& fm/form {:form form diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index bb896e49b50..fee1ca5f79e 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.schema :as sm] + [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.profile :as du] @@ -17,7 +18,6 @@ [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc left-sidebar @@ -59,7 +59,7 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name [::sm/text {:max 250}]] + [:name ctt/schema:team-name] [:role :keyword] [:emails {:optional true} [::sm/set ::sm/email]]]) @@ -120,13 +120,13 @@ params {:name name}] (st/emit! (-> (dtm/create-team (with-meta params mdata)) (with-meta {::ev/origin :onboarding-without-invitations})) - (ptk/data-event ::ev/event - {::ev/name "onboarding-step" - :label "team:create-team-and-invite-later" - :team-name name - :step 8}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-finish"}))))) + (ev/event + {::ev/name "onboarding-step" + :label "team:create-team-and-invite-later" + :team-name name + :step 8}) + (ev/event + {::ev/name "onboarding-finish"}))))) on-invite-now (mf/use-fn @@ -136,15 +136,15 @@ (st/emit! (-> (dtm/create-team-with-invitations (with-meta params mdata)) (with-meta {::ev/origin :onboarding-with-invitations})) - (ptk/data-event ::ev/event - {::ev/name "onboarding-step" - :label "team:create-team-and-invite" - :invites (count emails) - :team-name name - :role (:role params) - :step 8}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-finish"}))))) + (ev/event + {::ev/name "onboarding-step" + :label "team:create-team-and-invite" + :invites (count emails) + :team-name name + :role (:role params) + :step 8}) + (ev/event + {::ev/name "onboarding-finish"}))))) on-submit* (mf/use-fn @@ -159,12 +159,12 @@ (mf/use-fn (fn [] (st/emit! (du/update-profile-props {:onboarding-viewed true}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-step" - :label "team:skip-team-creation" - :step 7}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-finish"}))))] + (ev/event + {::ev/name "onboarding-step" + :label "team:skip-team-creation" + :step 7}) + (ev/event + {::ev/name "onboarding-finish"}))))] [:* [:div {:class (stl/css :modal-right)} [:div {:class (stl/css :first-block)} diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index a5d2f5610bb..7919fc045b9 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -33,6 +33,7 @@ [app.main.ui.releases.v2-12] [app.main.ui.releases.v2-13] [app.main.ui.releases.v2-14] + [app.main.ui.releases.v2-15] [app.main.ui.releases.v2-2] [app.main.ui.releases.v2-3] [app.main.ui.releases.v2-4] @@ -105,4 +106,4 @@ (defmethod rc/render-release-notes "0.0" [params] - (rc/render-release-notes (assoc params :version "2.14"))) + (rc/render-release-notes (assoc params :version "2.15"))) diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs new file mode 100644 index 00000000000..8c2f61580f4 --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -0,0 +1,159 @@ +;; 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 + +(ns app.main.ui.releases.v2-15 + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.ui.releases.common :as c] + [rumext.v2 :as mf])) + +(defmethod c/render-release-notes "2.15" + [{:keys [slide klass next finish navigate version]}] + (mf/html + (case slide + :start + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-slide-0.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Penpot 2.15 is here!"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "What’s new in Penpot?"] + + [:div {:class (stl/css :version-tag)} + (dm/str "Version " version)]] + + [:div {:class (stl/css :features-block)} + [:span {:class (stl/css :feature-title)} + "One major feature: the Penpot MCP Server, with infinite workflow possibilities"] + + [:p {:class (stl/css :feature-content)} + "This release marks a major MCP milestone: Penpot MCP moves from an early technical setup to an accessible in-app experience via hosted remote setup. Whether you already know MCP or are new to it, it's now zero-friction to connect your AI client and turn prompts into real actions on real design data."] + + [:p {:class (stl/css :feature-content)} + "With 2.15, we are opening the door to truly multi-directional workflows between design and code, while staying faithful to Penpot values: openness, freedom of choice, and respect for your data."] + + [:p {:class (stl/css :feature-content)} + "Let’s dive in!"]] + + [:div {:class (stl/css :navigation)} + [:button {:class (stl/css :next-btn) + :on-click next} "Continue"]]]]]] + + 0 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-01.gif" + :class (stl/css :start-image) + :border "0" + :alt "Penpot MCP Server: AI connected to real design context"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Penpot MCP Server: AI connected to real design context"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "Penpot MCP Server is the bridge between your AI client and your Penpot file. You describe what you need in natural language, your agent picks the right operation, and MCP translates that into real actions through Penpot APIs."] + + [:p {:class (stl/css :feature-content)} + "This is not a generic 'describe and generate' flow. It is context-aware work with components, tokens, pages, layers, and structure. In short: design expressed as code, now usable through your preferred AI assistant."] + + [:p {:class (stl/css :feature-content)} + "You can run MCP in two ways. Remote MCP is hosted and simpler to set up. Local MCP runs on your machine and gives advanced teams extra control. Same vision, different operating model."]] + + [:div {:class (stl/css :navigation)} + [:& c/navigation-bullets + {:slide slide + :navigate navigate + :total 4}] + + [:button {:on-click next + :class (stl/css :next-btn)} "Continue"]]]]]] + + 1 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-02.gif" + :class (stl/css :start-image) + :border "0" + :alt "Multi-directional workflows, from design to code and back"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Multi-directional workflows, from design to code and back"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "The biggest unlock in 2.15 is multi-directionality. You can move from design to code, and from code back to design, without losing intent or structure in the process."] + + [:p {:class (stl/css :feature-content)} + "• Generate semantic HTML/CSS from real layouts."] + [:p {:class (stl/css :feature-content)} + "• Translate tokens and styles into code variables."] + [:p {:class (stl/css :feature-content)} + "• Export only assets in use."] + [:p {:class (stl/css :feature-content)} + "• Validate design-code consistency."] + [:p {:class (stl/css :feature-content)} + "• Reorganize layers, apply naming rules, and automate repetitive design system maintenance."] + + [:p {:class (stl/css :feature-content)} + "This is where MCP becomes workflow infrastructure. Less manual glue work, fewer handoff gaps, and faster iterations between designers and developers."]] + + [:div {:class (stl/css :navigation)} + [:& c/navigation-bullets + {:slide slide + :navigate navigate + :total 4}] + + [:button {:on-click next + :class (stl/css :next-btn)} "Continue"]]]]]] + + 2 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-03.gif" + :class (stl/css :start-image) + :border "0" + :alt "Your stack, your model, your control"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Your stack, your model, your control"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "With MCP, you connect Penpot to the AI client and model you already trust. Cursor, Claude, VS Code, Codex, or another MCP-compatible setup: the workflow adapts to your stack, not the other way around."] + + [:p {:class (stl/css :feature-content)} + "You can run it hosted for a faster setup, or locally when you need tighter infrastructure control. The same applies to data boundaries: Penpot provides the bridge to your design context, while your team decides how and where AI runs."] + + [:p {:class (stl/css :feature-content)} + "In practice, this means teams can automate design and code workflows without giving up tool freedom, deployment control, or ownership of their process. +"]] + [:div {:class (stl/css :navigation)} + + [:& c/navigation-bullets + {:slide slide + :navigate navigate + :total 3}] + + [:button {:on-click finish + :class (stl/css :next-btn)} "Let's go"]]]]]]))) + diff --git a/frontend/src/app/main/ui/releases/v2_15.scss b/frontend/src/app/main/ui/releases/v2_15.scss new file mode 100644 index 00000000000..e5d13841ebf --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_15.scss @@ -0,0 +1,102 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-container { + display: grid; + grid-template-columns: deprecated.$s-324 1fr; + height: deprecated.$s-500; + width: deprecated.$s-888; + border-radius: deprecated.$br-8; + background-color: var(--modal-background-color); + border: deprecated.$s-2 solid var(--modal-border-color); +} + +.start-image { + width: deprecated.$s-324; + border-radius: deprecated.$br-8 0 0 deprecated.$br-8; +} + +.modal-content { + padding: deprecated.$s-40; + display: grid; + grid-template-rows: auto 1fr deprecated.$s-32; + gap: deprecated.$s-24; + + a { + color: var(--button-primary-background-color-rest); + } +} + +.modal-header { + display: grid; + gap: deprecated.$s-8; +} + +.version-tag { + @include deprecated.flexCenter; + @include deprecated.headlineSmallTypography; + height: deprecated.$s-32; + width: deprecated.$s-96; + background-color: var(--communication-tag-background-color); + color: var(--communication-tag-foreground-color); + border-radius: deprecated.$br-8; +} + +.modal-title { + @include deprecated.headlineLargeTypography; + color: var(--modal-title-foreground-color); +} + +.features-block { + display: flex; + flex-direction: column; + gap: deprecated.$s-16; + width: deprecated.$s-440; +} + +.feature { + display: flex; + flex-direction: column; + gap: deprecated.$s-8; +} + +.feature-title { + @include deprecated.bodyLargeTypography; + color: var(--modal-title-foreground-color); +} + +.feature-content { + @include deprecated.bodyMediumTypography; + margin: 0; + color: var(--modal-text-foreground-color); +} + +.feature-list { + @include deprecated.bodyMediumTypography; + color: var(--modal-text-foreground-color); + list-style: disc; + display: grid; + gap: deprecated.$s-8; +} + +.navigation { + width: 100%; + display: grid; + grid-template-areas: "bullets button"; +} + +.next-btn { + @extend .button-primary; + width: deprecated.$s-100; + justify-self: flex-end; + grid-area: button; +} diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index e8159d38527..ca45bc51339 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -36,7 +36,7 @@ ["/feedback" :settings-feedback] ["/options" :settings-options] ["/subscriptions" :settings-subscription] - ["/access-tokens" :settings-access-tokens] + ["/integrations" :settings-integrations] ["/notifications" :settings-notifications]] ["/frame-preview" :frame-preview] diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index bc40ae20fab..2cb617c9392 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -13,10 +13,10 @@ [app.main.store :as st] [app.main.ui.hooks :as hooks] [app.main.ui.modal :refer [modal-container*]] - [app.main.ui.settings.access-tokens :refer [access-tokens-page]] [app.main.ui.settings.change-email] [app.main.ui.settings.delete-account] [app.main.ui.settings.feedback :refer [feedback-page*]] + [app.main.ui.settings.integrations :refer [integrations-page*]] [app.main.ui.settings.notifications :refer [notifications-page*]] [app.main.ui.settings.options :refer [options-page]] [app.main.ui.settings.password :refer [password-page]] @@ -73,8 +73,8 @@ :settings-subscription [:> subscription-page* {:profile profile}] - :settings-access-tokens - [:& access-tokens-page] + :settings-integrations + [:> integrations-page*] :settings-notifications [:& notifications-page* {:profile profile}])]]]])) diff --git a/frontend/src/app/main/ui/settings/access_tokens.cljs b/frontend/src/app/main/ui/settings/access_tokens.cljs deleted file mode 100644 index 29a09476b02..00000000000 --- a/frontend/src/app/main/ui/settings/access_tokens.cljs +++ /dev/null @@ -1,291 +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 - -(ns app.main.ui.settings.access-tokens - (:require-macros [app.main.style :as stl]) - (:require - [app.common.schema :as sm] - [app.common.time :as ct] - [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] - [app.main.data.profile :as du] - [app.main.store :as st] - [app.main.ui.components.context-menu-a11y :refer [context-menu*]] - [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] - [app.util.clipboard :as clipboard] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] - [app.util.keyboard :as kbd] - [okulary.core :as l] - [rumext.v2 :as mf])) - -(def ^:private clipboard-icon - (deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon))) - -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - -(def tokens-ref - (l/derived :access-tokens st/state)) - -(def token-created-ref - (l/derived :access-token-created st/state)) - -(def ^:private schema:form - [:map {:title "AccessTokenForm"} - [:name [::sm/text {:max 250}]] - [:expiration-date [::sm/text {:max 250}]]]) - -(def initial-data - {:name "" :expiration-date "never"}) - -(mf/defc access-token-modal - {::mf/register modal/components - ::mf/register-as :access-token} - [] - (let [form (fm/use-form - :initial initial-data - :schema schema:form) - - created (mf/deref token-created-ref) - created? (mf/use-state false) - - on-success - (mf/use-fn - (mf/deps created) - (fn [_] - (let [message (tr "dashboard.access-tokens.create.success")] - (st/emit! (du/fetch-access-tokens) - (ntf/success message) - (reset! created? true))))) - - on-close - (mf/use-fn - (mf/deps created) - (fn [_] - (reset! created? false) - (st/emit! (modal/hide)))) - - on-error - (mf/use-fn - (fn [_] - (st/emit! (ntf/error (tr "errors.generic")) - (modal/hide)))) - - on-submit - (mf/use-fn - (fn [form] - (let [cdata (:clean-data @form) - mdata {:on-success (partial on-success form) - :on-error (partial on-error form)} - expiration (:expiration-date cdata) - params (cond-> {:name (:name cdata) - :perms (:perms cdata)} - (not= "never" expiration) (assoc :expiration expiration))] - (st/emit! (du/create-access-token - (with-meta params mdata)))))) - - copy-token - (mf/use-fn - (mf/deps created) - (fn [event] - (dom/prevent-default event) - (clipboard/to-clipboard (:token created)) - (st/emit! (ntf/show {:level :info - :type :toast - :content (tr "dashboard.access-tokens.copied-success") - :timeout 7000}))))] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:& fm/form {:form form :on-submit on-submit} - - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")] - - [:button {:class (stl/css :modal-close-btn) - :on-click on-close} - close-icon]] - - [:div {:class (stl/css :modal-content)} - [:div {:class (stl/css :fields-row)} - [:& fm/input {:type "text" - :auto-focus? true - :form form - :name :name - :disabled @created? - :label (tr "modals.create-access-token.name.label") - :show-success? true - :placeholder (tr "modals.create-access-token.name.placeholder")}]] - - [:div {:class (stl/css :fields-row)} - [:div {:class (stl/css :select-title)} - (tr "modals.create-access-token.expiration-date.label")] - [:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"} - {:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"} - {:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"} - {:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"} - {:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}] - :default "never" - :disabled @created? - :name :expiration-date}] - (when @created? - [:span {:class (stl/css :token-created-info)} - (if (:expires-at created) - (tr "dashboard.access-tokens.token-will-expire" (ct/format-inst (:expires-at created) "PPP")) - (tr "dashboard.access-tokens.token-will-not-expire"))])] - - [:div {:class (stl/css :fields-row)} - (when @created? - [:div {:class (stl/css :custon-input-wrapper)} - [:input {:type "text" - :value (:token created "") - :class (stl/css :custom-input-token) - :read-only true}] - [:button {:title (tr "modals.create-access-token.copy-token") - :class (stl/css :copy-btn) - :on-click copy-token} - clipboard-icon]]) - #_(when @created? - [:button {:class (stl/css :copy-btn) - :title (tr "modals.create-access-token.copy-token") - :on-click copy-token} - [:span {:class (stl/css :token-value)} (:token created "")] - [:span {:class (stl/css :icon)} - i/clipboard]])]] - - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - - (if @created? - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.close") - :on-click modal/hide!}] - [:* - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] - [:> fm/submit-button* - {:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]])) - -(mf/defc access-tokens-hero - [] - (let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))] - [:div {:class (stl/css :access-tokens-hero)} - [:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")] - [:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")] - - [:button {:class (stl/css :hero-btn) - :on-click on-click} - (tr "dashboard.access-tokens.create")]])) - -(mf/defc access-token-actions - [{:keys [on-delete]}] - (let [local (mf/use-state {:menu-open false}) - show? (:menu-open @local) - options (mf/with-memo [on-delete] - [{:name (tr "labels.delete") - :id "access-token-delete" - :handler on-delete}]) - - menu-ref (mf/use-ref) - - on-menu-close - (mf/use-fn #(swap! local assoc :menu-open false)) - - on-menu-click - (mf/use-fn - (fn [event] - (dom/prevent-default event) - (swap! local assoc :menu-open true))) - - on-keydown - (mf/use-fn - (mf/deps on-menu-click) - (fn [event] - (when (kbd/enter? event) - (dom/stop-propagation event) - (on-menu-click event))))] - - [:button {:class (stl/css :menu-btn) - :tab-index "0" - :ref menu-ref - :on-click on-menu-click - :on-key-down on-keydown} - menu-icon - [:> context-menu* - {:on-close on-menu-close - :show show? - :fixed true - :min-width true - :top "auto" - :left "auto" - :options options}]])) - -(mf/defc access-token-item - {::mf/wrap [mf/memo]} - [{:keys [token] :as props}] - (let [expires-at (:expires-at token) - expires-txt (some-> expires-at (ct/format-inst "PPP")) - expired? (and (some? expires-at) (> (ct/now) expires-at)) - - delete-fn - (mf/use-fn - (mf/deps token) - (fn [] - (let [params {:id (:id token)} - mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] - (st/emit! (du/delete-access-token (with-meta params mdata)))))) - - on-delete - (mf/use-fn - (mf/deps delete-fn) - (fn [] - (st/emit! (modal/show - {:type :confirm - :title (tr "modals.delete-acces-token.title") - :message (tr "modals.delete-acces-token.message") - :accept-label (tr "modals.delete-acces-token.accept") - :on-accept delete-fn}))))] - - [:div {:class (stl/css :table-row)} - [:div {:class (stl/css :table-field :field-name)} - (str (:name token))] - - [:div {:class (stl/css-case :expiration-date true - :expired expired?)} - (cond - (nil? expires-at) (tr "dashboard.access-tokens.no-expiration") - expired? (tr "dashboard.access-tokens.expired-on" expires-txt) - :else (tr "dashboard.access-tokens.expires-on" expires-txt))] - [:div {:class (stl/css :table-field :actions)} - [:& access-token-actions - {:on-delete on-delete}]]])) - -(mf/defc access-tokens-page - [] - (let [tokens (mf/deref tokens-ref)] - (mf/with-effect [] - (dom/set-html-title (tr "title.settings.access-tokens")) - (st/emit! (du/fetch-access-tokens))) - - [:div {:class (stl/css :dashboard-access-tokens)} - [:& access-tokens-hero] - (if (empty? tokens) - [:div {:class (stl/css :access-tokens-empty)} - [:div (tr "dashboard.access-tokens.empty.no-access-tokens")] - [:div (tr "dashboard.access-tokens.empty.add-one")]] - [:div {:class (stl/css :dashboard-table)} - [:div {:class (stl/css :table-rows)} - (for [token tokens] - [:& access-token-item {:token token :key (:id token)}])]])])) - diff --git a/frontend/src/app/main/ui/settings/access_tokens.scss b/frontend/src/app/main/ui/settings/access_tokens.scss deleted file mode 100644 index 5e9f139765c..00000000000 --- a/frontend/src/app/main/ui/settings/access_tokens.scss +++ /dev/null @@ -1,202 +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 - -@use "refactor/common-refactor.scss" as deprecated; - -// ACCESS TOKENS PAGE -.dashboard-access-tokens { - display: grid; - grid-template-rows: auto 1fr; - margin: deprecated.$s-80 auto deprecated.$s-120 auto; - gap: deprecated.$s-32; - width: deprecated.$s-800; -} - -// hero -.access-tokens-hero { - display: grid; - grid-template-rows: auto auto 1fr; - gap: deprecated.$s-32; - width: deprecated.$s-500; - font-size: deprecated.$fs-14; - margin: deprecated.$s-16 auto 0 auto; -} - -.hero-title { - @include deprecated.bigTitleTipography; - color: var(--title-foreground-color-hover); -} - -.hero-desc { - color: var(--title-foreground-color); - margin-bottom: 0; - font-size: deprecated.$fs-14; -} - -.hero-btn { - @extend .button-primary; -} - -// table empty -.access-tokens-empty { - display: grid; - place-items: center; - align-content: center; - height: deprecated.$s-156; - max-width: deprecated.$s-1000; - width: 100%; - padding: deprecated.$s-32; - border: deprecated.$s-1 solid var(--panel-border-color); - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); -} - -// Access tokens table -.dashboard-table { - height: fit-content; -} - -.table-rows { - display: grid; - grid-auto-rows: deprecated.$s-64; - gap: deprecated.$s-16; - width: 100%; - height: 100%; - max-width: deprecated.$s-1000; - margin-top: deprecated.$s-16; - color: var(--title-foreground-color); -} - -.table-row { - display: grid; - grid-template-columns: 43% 1fr auto; - align-items: center; - height: deprecated.$s-64; - width: 100%; - padding: 0 deprecated.$s-16; - border-radius: deprecated.$br-8; - background-color: var(--dashboard-list-background-color); - color: var(--dashboard-list-foreground-color); -} - -.field-name { - @include deprecated.textEllipsis; - display: grid; - width: 43%; - min-width: deprecated.$s-300; -} - -.expiration-date { - @include deprecated.flexCenter; - min-width: deprecated.$s-76; - width: fit-content; - height: deprecated.$s-24; - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); -} - -.expired { - @include deprecated.headlineSmallTypography; - padding: 0 deprecated.$s-6; - color: var(--pill-foreground-color); - background-color: var(--status-widget-background-color-warning); -} - -.actions { - position: relative; -} -.menu-icon { - @extend .button-icon; - stroke: var(--icon-foreground); -} - -.menu-btn { - @include deprecated.buttonStyle; -} - -// Create access token modal -.modal-overlay { - @extend .modal-overlay-base; -} - -.modal-container { - @extend .modal-container-base; - min-width: deprecated.$s-408; -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.uppercaseTitleTipography; - color: var(--modal-title-foreground-color); -} -.modal-close-btn { - @extend .modal-close-btn-base; -} - -.modal-content { - @include deprecated.flexColumn; - gap: deprecated.$s-24; - @include deprecated.bodySmallTypography; - margin-bottom: deprecated.$s-24; -} - -.select-title { - @include deprecated.bodySmallTypography; - color: var(--modal-title-foreground-color); -} - -.custon-input-wrapper { - @include deprecated.flexRow; - border-radius: deprecated.$br-8; - height: deprecated.$s-32; - background-color: var(--input-background-color); -} - -.custom-input-token { - @extend .input-element; - @include deprecated.bodySmallTypography; - margin: 0; - flex-grow: 1; - &:focus { - outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); - } -} - -.token-value { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - flex-grow: 1; -} - -.copy-btn { - @include deprecated.flexCenter; - @extend .button-secondary; - height: deprecated.$s-28; - width: deprecated.$s-28; -} - -.clipboard-icon { - @extend .button-icon-small; -} - -.token-created-info { - color: var(--modal-text-foreground-color); -} - -.action-buttons { - @extend .modal-action-btns; - button { - @extend .modal-accept-btn; - } -} - -.cancel-button { - @extend .modal-cancel-btn; -} diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs new file mode 100644 index 00000000000..2c552ddc190 --- /dev/null +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -0,0 +1,636 @@ +;; 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 + +(ns app.main.ui.settings.integrations + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] + [app.main.data.profile :as du] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.context-menu-a11y :refer [context-menu*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]] + [app.main.ui.ds.tooltip :refer [tooltip*]] + [app.main.ui.forms :as fc] + [app.util.clipboard :as clipboard] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :as i18n :refer [tr]] + [rumext.v2 :as mf])) + +(def notification-timeout 7000) + +(def ^:private schema:form-access-token + [:map + [:name [::sm/text {:max 250}]] + [:expiration-date [::sm/text {:max 250}]]]) + +(def ^:private schema:form-mcp-key + [:map + [:expiration-date [::sm/text {:max 250}]]]) + +(def form-initial-data-access-token + {:name "" + :expiration-date "never"}) + +(def form-initial-data-mcp-key + {:expiration-date "never"}) + +(mf/defc input-copy* + {::mf/private true} + [{:keys [value on-copy-to-clipboard]}] + [:div {:class (stl/css :input-copy)} + [:> input* {:type "text" + :default-value value + :read-only true}] + [:div {:class (stl/css :input-copy-button-wrapper)} + [:> icon-button* {:variant "secondary" + :class (stl/css :input-copy-button) + :aria-label (tr "integrations.copy-to-clipboard") + :on-click on-copy-to-clipboard + :icon i/clipboard}]]]) + +(mf/defc token-created* + {::mf/private true} + [{:keys [title mcp-key?]}] + (let [token-created (mf/deref refs/access-token-created) + + on-copy-to-clipboard + (mf/use-fn + (mf/deps token-created) + (fn [event] + (dom/prevent-default event) + (clipboard/to-clipboard (:token token-created)) + (st/emit! (ntf/show {:level :info + :type :toast + :content (if mcp-key? + (tr "integrations.notification.success.mcp-key-copied") + (tr "integrations.notification.success.token-copied")) + :timeout notification-timeout}))))] + + [:div {:class (stl/css :modal-form)} + [:> text* {:as "h2" + :typography t/headline-large + :class (stl/css :color-primary)} + title] + + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (if mcp-key? + (tr "integrations.mcp-key.info.non-recuperable") + (tr "integrations.token.info.non-recuperable"))]] + + [:div {:class (stl/css :modal-content)} + [:> input-copy* {:value (:token token-created "") + :on-copy-to-clipboard on-copy-to-clipboard}] + + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-secondary)} + (if (:expires-at token-created) + (if mcp-key? + (tr "integrations.mcp-key.will-expire" (ct/format-inst (:expires-at token-created) "PPP")) + (tr "integrations.token.will-expire" (ct/format-inst (:expires-at token-created) "PPP"))) + (if mcp-key? + (tr "integrations.mcp-key.will-not-expire") + (tr "integrations.token.will-not-expire")))]] + + (when mcp-key? + [:div {:class (stl/css :modal-content)} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (tr "integrations.info.mcp-client-config")] + [:textarea {:class (stl/css :textarea) + :wrap "off" + :rows 7 + :read-only true} + (dm/str + "{\n" + " \"mcpServers\": {\n" + " \"penpot\": {\n" + " \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n" + " }\n" + " }" + "\n}")]]) + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click modal/hide!} + (tr "labels.close")]]])) + +(mf/defc create-token* + {::mf/private true} + [{:keys [title info mcp-key? on-created]}] + (let [form (fm/use-form + :initial (if mcp-key? + form-initial-data-mcp-key + form-initial-data-access-token) + :schema (if mcp-key? + schema:form-mcp-key + schema:form-access-token)) + + on-error + (mf/use-fn + #(st/emit! (ntf/error (tr "errors.generic")) + (modal/hide))) + + on-success + (mf/use-fn + #(st/emit! (du/fetch-access-tokens) + (ntf/success (tr "integrations.notification.success.created")) + (on-created))) + + on-submit + (mf/use-fn + (fn [form] + (let [cdata (:clean-data @form) + mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + expiration (:expiration-date cdata) + params (cond-> {:name (:name cdata) + :perms (:perms cdata)} + (not= "never" expiration) (assoc :expiration expiration) + (true? mcp-key?) (assoc :type "mcp" + :name "MCP key"))] + (st/emit! (du/create-access-token (with-meta params mdata))))))] + + [:> fc/form* {:form form + :class (stl/css :modal-form) + :on-submit on-submit} + + [:> text* {:as "h2" + :typography t/headline-large + :class (stl/css :color-primary)} + title] + + (when (some? info) + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + info]]) + + (if mcp-key? + [:div {:class (stl/css :modal-content)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.info.mcp-server")]] + + [:div {:class (stl/css :modal-content)} + [:> fc/form-input* {:type "text" + :auto-focus? true + :form form + :name :name + :label (tr "integrations.name.label") + :placeholder (tr "integrations.name.placeholder")}]]) + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "label" + :typography t/body-small + :for :expiration-date + :class (stl/css :color-primary)} + (tr "integrations.expiration-date.label")] + [:> fc/form-select* {:options [{:label (tr "integrations.expiration-never") :value "never" :id "never"} + {:label (tr "integrations.expiration-30-days") :value "720h" :id "720h"} + {:label (tr "integrations.expiration-60-days") :value "1440h" :id "1440h"} + {:label (tr "integrations.expiration-90-days") :value "2160h" :id "2160h"} + {:label (tr "integrations.expiration-180-days") :value "4320h" :id "4320h"}] + :default-selected "never" + :name :expiration-date}]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click modal/hide!} + (tr "labels.cancel")] + [:> fc/form-submit* {:variant "primary"} + title]]])) + +(mf/defc create-access-token-modal + {::mf/register modal/components + ::mf/register-as :create-access-token} + [] + (let [created? (mf/use-state false) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + #(reset! created? true))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.create-access-token.title.created")}] + [:> create-token* {:title (tr "integrations.create-access-token.title") + :on-created on-created}])]])) + +(mf/defc generate-mcp-key-modal + {::mf/register modal/components + ::mf/register-as :generate-mcp-key} + [] + (let [created? (mf/use-state false) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + (fn [] + (st/emit! (du/update-profile-props {:mcp-enabled true}) + (ev/event {::ev/name "generate-mcp-key" + ::ev/origin "integrations"}) + (ev/event {::ev/name "enable-mcp" + ::ev/origin "integrations" + :source "key-creation"}) + (mbc/event :mcp/enable {})) + (reset! created? true)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.generate-mcp-key.title.created") + :mcp-key? true}] + [:> create-token* {:title (tr "integrations.generate-mcp-key.title") + :mcp-key? true + :on-created on-created}])]])) + +(mf/defc regenerate-mcp-key-modal + {::mf/register modal/components + ::mf/register-as :regenerate-mcp-key} + [] + (let [created? (mf/use-state false) + + tokens (mf/deref refs/access-tokens) + mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-key-id (:id mcp-key) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + (fn [] + (st/emit! (du/delete-access-token {:id mcp-key-id}) + (du/update-profile-props {:mcp-enabled true}) + (ev/event {::ev/name "regenerate-mcp-key" + ::ev/origin "integrations"}) + (mbc/event :mcp/enable {})) + (reset! created? true)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created") + :mcp-key? true}] + [:> create-token* {:title (tr "integrations.regenerate-mcp-key.title") + :info (tr "integrations.regenerate-mcp-key.info") + :mcp-key? true + :on-created on-created}])]])) + +(mf/defc token-item* + {::mf/private true + ::mf/wrap [mf/memo]} + [{:keys [name expires-at on-delete]}] + (let [expires-txt (some-> expires-at (ct/format-inst "PPP")) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + handle-menu-close + (mf/use-fn + #(reset! menu-open* false)) + + handle-menu-click + (mf/use-fn + #(reset! menu-open* (not menu-open?))) + + handle-open-confirm-modal + (mf/use-fn + (mf/deps on-delete) + (fn [] + (st/emit! (modal/show {:type :confirm + :title (tr "integrations.delete-token.title") + :message (tr "integrations.delete-token.message") + :accept-label (tr "integrations.delete-token.accept") + :on-accept on-delete})))) + + options + (mf/with-memo [on-delete] + [{:name (tr "labels.delete") + :id "token-delete" + :handler handle-open-confirm-modal}])] + + [:div {:class (stl/css :item)} + [:> text* {:as "div" + :typography t/body-medium + :title name + :class (stl/css :item-title)} + name] + + [:> text* {:as "div" + :typography t/body-small + :class (stl/css-case :item-subtitle true + :warning expired?)} + (cond + (nil? expires-at) (tr "integrations.no-expiration") + expired? (tr "integrations.expired-on" expires-txt) + :else (tr "integrations.expires-on" expires-txt))] + + [:div {:class (stl/css :item-actions)} + [:> icon-button* {:variant "ghost" + :class (stl/css :item-button) + :aria-pressed menu-open? + :aria-label (tr "labels.options") + :on-click handle-menu-click + :icon i/menu}] + [:> context-menu* {:on-close handle-menu-close + :show menu-open? + :min-width true + :top -10 + :left -138 + :options options}]]])) + +(mf/defc mcp-server-section* + {::mf/private true} + [] + (let [tokens (mf/deref refs/access-tokens) + profile (mf/deref refs/profile) + + mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-token (:token mcp-key "") + mcp-url (dm/str cf/mcp-server-url "?userToken=" mcp-token) + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + + expires-at (:expires-at mcp-key) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + show-enabled? (and mcp-enabled? (false? expired?)) + + tooltip-id + (mf/use-id) + + handle-mcp-change + (mf/use-fn + (fn [value] + (st/emit! (du/update-profile-props {:mcp-enabled value}) + (ntf/show {:level :info + :type :toast + :content (if (true? value) + (tr "integrations.notification.success.mcp-server-enabled") + (tr "integrations.notification.success.mcp-server-disabled")) + :timeout notification-timeout}) + (ev/event {::ev/name (if (true? value) "enable-mcp" "disable-mcp") + ::ev/origin "integrations" + :source "toggle"}) + (if value + (mbc/event :mcp/enable {}) + (mbc/event :mcp/disable {}))))) + + handle-generate-mcp-key + (mf/use-fn + #(st/emit! (modal/show {:type :generate-mcp-key}))) + + handle-regenerate-mcp-key + (mf/use-fn + #(st/emit! (modal/show {:type :regenerate-mcp-key}))) + + handle-delete + (mf/use-fn + (mf/deps mcp-key) + (fn [] + (let [params {:id (:id mcp-key)} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata)) + (du/update-profile-props {:mcp-enabled false}) + (mbc/event :mcp/disable {}))))) + + on-copy-to-clipboard + (mf/use-fn + (mf/deps mcp-url) + (fn [event] + (dom/prevent-default event) + (clipboard/to-clipboard mcp-url) + (st/emit! (ntf/show {:level :info + :type :toast + :content (tr "integrations.notification.success.copied-link") + :timeout notification-timeout}) + (ev/event {::ev/name "copy-mcp-url" + ::ev/origin "integrations"}))))] + + [:section {:class (stl/css :mcp-server-section)} + [:div + [:div {:class (stl/css :title)} + [:> heading* {:level 2 + :typography t/title-medium + :class (stl/css :color-primary :mcp-server-title)} + (tr "integrations.mcp-server.title")] + [:> text* {:as "span" + :typography t/body-small + :class (stl/css :beta)} + (tr "integrations.mcp-server.title.beta")]] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.description")]] + + [:div + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status")] + + [:div {:class (stl/css :mcp-server-block)} + (when expired? + [:> notification-pill* {:level :error + :type :context} + [:div {:class (stl/css :mcp-server-notification)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status.expired.0")] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status.expired.1")]]]) + + [:div {:class (stl/css :mcp-server-switch)} + [:> switch* {:label (if show-enabled? + (tr "integrations.mcp-server.status.enabled") + (tr "integrations.mcp-server.status.disabled")) + :default-checked show-enabled? + :on-change handle-mcp-change}] + (when (and (false? mcp-enabled?) (nil? mcp-key)) + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-generate-mcp-key}]) + (when (true? expired?) + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-regenerate-mcp-key}])]]] + + (when (some? mcp-key) + [:div {:class (stl/css :mcp-server-key)} + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.mcp-keys.title")] + + [:div {:class (stl/css :mcp-server-block)} + [:div {:class (stl/css :mcp-server-regenerate)} + [:> button* {:variant "primary" + :class (stl/css :fit-content) + :on-click handle-regenerate-mcp-key} + (tr "integrations.mcp-server.mcp-keys.regenerate")] + [:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip") + :id tooltip-id} + [:> icon* {:icon-id i/info + :class (stl/css :color-secondary)}]]] + + [:div {:class (stl/css :list)} + [:> token-item* {:key (:id mcp-key) + :name (:name mcp-key) + :expires-at (:expires-at mcp-key) + :on-delete handle-delete}]]]]) + + [:> notification-pill* {:level :default + :type :context} + [:div {:class (stl/css :mcp-server-notification)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.mcp-keys.info")] + + [:> input-copy* {:value mcp-url + :on-copy-to-clipboard on-copy-to-clipboard}] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + [:a {:href cf/mcp-help-center-uri + :target "_blank" + :rel "noopener noreferrer" + :class (stl/css :mcp-server-notification-link)} + (tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]])) + +(mf/defc access-tokens-section* + {::mf/private true} + [] + (let [tokens (mf/deref refs/access-tokens) + + handle-click + (mf/use-fn + #(st/emit! (modal/show {:type :create-access-token}))) + + handle-delete + (mf/use-fn + (fn [token-id] + (let [params {:id token-id} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata))))))] + + [:section {:class (stl/css :access-tokens-section)} + [:> heading* {:level 2 + :typography t/title-medium + :class (stl/css :color-primary)} + (tr "integrations.access-tokens.personal")] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.access-tokens.personal.description")] + + [:> button* {:variant "primary" + :class (stl/css :fit-content) + :on-click handle-click} + (tr "integrations.access-tokens.create")] + + (if (empty? tokens) + [:div {:class (stl/css :frame)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary :text-center)} + [:div (tr "integrations.access-tokens.empty.no-access-tokens")] + [:div (tr "integrations.access-tokens.empty.add-one")]]] + + [:div {:class (stl/css :list)} + (for [token tokens] + (when (nil? (:type token)) + [:> token-item* {:key (:id token) + :name (:name token) + :expires-at (:expires-at token) + :on-delete (partial handle-delete (:id token))}]))])])) + +(mf/defc integrations-page* + [] + (mf/with-effect [] + (dom/set-html-title (tr "title.settings.integrations")) + (st/emit! (du/fetch-access-tokens))) + + [:div {:class (stl/css :integrations)} + [:> heading* {:level 1 + :typography t/title-large + :class (stl/css :color-primary)} + (tr "integrations.title")] + + (when (contains? cf/flags :mcp) + [:> mcp-server-section*]) + + (when (and (contains? cf/flags :mcp) + (contains? cf/flags :access-tokens)) + [:hr {:class (stl/css :separator)}]) + + (when (contains? cf/flags :access-tokens) + [:> access-tokens-section*])]) diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss new file mode 100644 index 00000000000..d7be475bb46 --- /dev/null +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -0,0 +1,239 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; + +.color-primary { + color: var(--color-foreground-primary); +} + +.color-secondary { + color: var(--color-foreground-secondary); +} + +.text-center { + text-align: center; +} + +.fit-content { + inline-size: fit-content; +} + +.beta { + color: var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); + inline-size: fit-content; + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-4; +} + +.title { + display: flex; + flex-direction: row; + align-items: baseline; + gap: var(--sp-s); +} + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-container { + @extend .modal-container-base; + inline-size: $sz-400; + max-block-size: fit-content; + position: relative; +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.modal-form { + display: flex; + flex-direction: column; + gap: var(--sp-xxxl); +} + +.modal-close-button { + position: absolute; + top: var(--sp-s); + right: var(--sp-s); +} + +.modal-footer { + display: flex; + justify-content: right; + gap: var(--sp-s); +} + +.input-copy { + position: relative; +} + +.input-copy-button-wrapper { + position: absolute; + top: 0; + right: 0; + border-start-start-radius: 0; + border-end-start-radius: 0; +} + +.input-copy-button { + border-radius: 0 $br-8 $br-8 0; +} + +.integrations { + display: grid; + grid-template-rows: auto 1fr; + margin: $sz-88 auto $sz-120 auto; + gap: $sz-32; + inline-size: $sz-500; +} + +.access-tokens-section { + display: grid; + grid-template-rows: auto auto 1fr; + gap: var(--sp-m); +} + +.mcp-server-section { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.mcp-server-key { + display: flex; + flex-direction: column; +} + +.mcp-server-notification { + display: flex; + flex-direction: column; + gap: var(--sp-m); + padding-right: var(--sp-xxl); +} + +.mcp-server-notification-link { + cursor: pointer; + color: var(--color-accent-primary); + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-xs); +} + +.mcp-server-title { + margin: var(--sp-s) 0; +} + +.mcp-server-block { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.mcp-server-regenerate { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.mcp-server-switch { + position: relative; +} + +.mcp-server-switch-cover { + position: absolute; + inset-block: 0; + inset-inline: 0; +} + +.separator { + border: $b-1 solid var(--color-background-quaternary); + margin: var(--sp-s) 0; +} + +.frame { + border: $b-1 solid var(--color-background-quaternary); + padding: var(--sp-m); + border-radius: $br-8; +} + +.list { + display: grid; + grid-auto-rows: $sz-64; + gap: var(--sp-m); +} + +.item { + display: grid; + grid-template-columns: 45% 1fr auto; + align-items: center; + background-color: var(--color-background-tertiary); + border-radius: $br-8; +} + +.item-title { + @include textEllipsis; + align-content: center; + block-size: $sz-64; + padding: 0 var(--sp-l); + color: var(--color-foreground-primary); +} + +.item-subtitle { + align-content: center; + block-size: $sz-64; + color: var(--color-foreground-secondary); + + &.warning { + padding: var(--sp-s) var(--sp-m); + block-size: fit-content; + inline-size: fit-content; + color: var(--color-foreground-primary); + background-color: var(--color-background-warning); + border: $b-1 solid var(--color-accent-warning); + border-radius: $br-8; + } +} + +.item-actions { + position: relative; +} + +.item-button { + block-size: $sz-64; + inline-size: $sz-48; + border-radius: 0 var(--sp-s) var(--sp-s) 0; +} + +.textarea { + @include t.use-typography("body-small"); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); + padding: var(--sp-xs) var(--sp-s); + border: 0; + resize: none; + + &:hover { + background-color: var(--color-background-quaternary); + } + + &:focus-visible { + outline: $b-1 solid var(--color-accent-primary); + } +} diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 763ee3c8364..bfae73f4a8e 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -44,10 +44,6 @@ form (fm/use-form :schema schema:profile-form :initial profile) - on-show-change-email - (mf/use-fn - #(modal/show! :change-email {})) - on-show-delete-account (mf/use-fn #(modal/show! :delete-account {}))] @@ -61,18 +57,16 @@ :name :fullname :label (tr "dashboard.your-name")}]] - [:div {:class (stl/css :fields-row) - :on-click on-show-change-email} + ;; Email is owned by the upstream IdP (oauth2-proxy / Cognito) — this + ;; fork has no scenario where it can be edited locally. Backend + ;; refuses :request-email-change unconditionally + ;; (rpc/commands/profile.clj + verify_token.clj). Render read-only. + [:div {:class (stl/css :fields-row)} [:& fm/input {:type "email" :name :email :disabled true - :label (tr "dashboard.your-email")}] - - [:div {:class (stl/css :options)} - [:div.change-email - [:a {:on-click on-show-change-email} - (tr "dashboard.change-email")]]]] + :label (tr "dashboard.your-email")}]] [:> fm/submit-button* {:label (tr "dashboard.save-settings") diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 0808e2299dc..7f35f4787fa 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -18,7 +18,6 @@ [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private arrow-icon @@ -43,8 +42,8 @@ (def ^:private go-settings-subscription #(st/emit! (rt/nav :settings-subscription))) -(def ^:private go-settings-access-tokens - #(st/emit! (rt/nav :settings-access-tokens))) +(def ^:private go-settings-integrations + #(st/emit! (rt/nav :settings-integrations))) (def ^:private go-settings-notifications #(st/emit! (rt/nav :settings-notifications))) @@ -52,7 +51,7 @@ (defn- show-release-notes [event] (let [version (:main cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) + (st/emit! (ev/event {::ev/name "show-release-notes" :version version})) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) @@ -66,7 +65,7 @@ options? (= section :settings-options) feedback? (= section :settings-feedback) subscription? (= section :settings-subscription) - access-tokens? (= section :settings-access-tokens) + integrations? (= section :settings-integrations) notifications? (= section :settings-notifications) team-id (or (dtm/get-last-team-id) (:default-team-id profile)) @@ -115,12 +114,13 @@ :data-testid "settings-subscription"} [:span {:class (stl/css :element-title)} (tr "subscription.labels")]]) - (when (contains? cf/flags :access-tokens) - [:li {:class (stl/css-case :current access-tokens? + (when (or (contains? cf/flags :access-tokens) + (contains? cf/flags :mcp)) + [:li {:class (stl/css-case :current integrations? :settings-item true) - :on-click go-settings-access-tokens - :data-testid "settings-access-tokens"} - [:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]]) + :on-click go-settings-integrations + :data-testid "settings-integrations"} + [:span {:class (stl/css :element-title)} (tr "labels.integrations")]]) [:hr {:class (stl/css :sidebar-separator)}] diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 8b1d0422b69..91c8d2f7acb 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -18,7 +18,6 @@ [app.main.ui.notifications.badge :refer [badge-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr c]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc plan-card* @@ -132,16 +131,16 @@ add-payment-details? "&quantity=" min-members "&returnUrl=" return-url)] (reset! form nil) - (st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription" - :type "unlimited" - :quantity min-members}) + (st/emit! (ev/event {::ev/name "create-trial-subscription" + :type "unlimited" + :quantity min-members}) (rt/nav-raw :href href)))))) subscribe-to-enterprise (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription" - :type "enterprise"})) + (st/emit! (ev/event {::ev/name "create-trial-subscription" + :type "enterprise"})) (let [return-url (-> (rt/get-current-href) (rt/encode-url)) href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)] (st/emit! (rt/nav-raw :href href))))) @@ -161,7 +160,7 @@ handle-close-dialog (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "close-subscription-modal"})) + (st/emit! (ev/event {::ev/name "close-subscription-modal"})) (modal/hide!))) show-editors-list* @@ -323,7 +322,7 @@ (let [profile (mf/deref refs/profile) handle-close-dialog (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "subscription-success"})) + (st/emit! (ev/event {::ev/name "subscription-success"})) (modal/hide!)))] [:div {:class (stl/css :modal-overlay)} @@ -434,8 +433,8 @@ ^boolean show-trial-subscription-modal? (st/emit! - (ptk/event ::ev/event {::ev/name "open-subscription-modal" - ::ev/origin "settings:from-pricing-page"}) + (ev/event {::ev/name "open-subscription-modal" + ::ev/origin "settings:from-pricing-page"}) (modal/show :management-dialog {:subscription-type (if (= params-subscription "subscription-to-penpot-unlimited") "unlimited" diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 646405676ca..952a990d12e 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -24,7 +24,6 @@ [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (log/set-level! :warn) @@ -126,10 +125,10 @@ (let [params (prepare-params options) params (assoc params :file-id (:id file))] (st/emit! (dc/create-share-link params) - (ptk/event ::ev/event {::ev/name "create-share-link" - ::ev/origin "viewer" - :can-comment (:who-comment params) - :can-inspect-code (:who-inspect params)})))) + (ev/event {::ev/name "create-share-link" + ::ev/origin "viewer" + :can-comment (:who-comment params) + :can-inspect-code (:who-inspect params)})))) copy-link (fn [_] @@ -138,8 +137,8 @@ :type :toast :content (tr "common.share-link.link-copied-success") :timeout 1000}) - (ptk/event ::ev/event {::ev/name "copy-share-link" - ::ev/origin "viewer"}))) + (ev/event {::ev/name "copy-share-link" + ::ev/origin "viewer"}))) try-delete-link (fn [_] diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 7a8dae308c8..2cc168a0b91 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -24,7 +24,6 @@ [app.util.keyboard :as kbd] [app.util.object :as obj] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc palette-item* @@ -37,10 +36,10 @@ (st/emit! (mdc/add-recent-color color) (mdc/apply-color-from-palette color (kbd/alt? event)) (when (not= selected :recent) - (ptk/data-event ::ev/event - {::ev/name "use-library-color" - ::ev/origin "color-palette" - :external-library (not= selected :file)}))))) + (ev/event + {::ev/name "use-library-color" + ::ev/origin "color-palette" + :external-library (not= selected :file)}))))) title (uc/get-color-name color)] diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 3e930e9f814..7a397a35295 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -15,7 +15,7 @@ [app.common.types.fills :as types.fills] [app.common.types.tokens-lib :as ctob] [app.config :as cfg] - [app.main.data.event :as-alias ev] + [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.shortcuts :as dsc] [app.main.data.workspace.colors :as dc] @@ -46,7 +46,6 @@ [app.util.timers :as ts] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf] [rumext.v2.util :as mfu])) @@ -174,9 +173,9 @@ (st/emit! (dc/update-colorpicker-color {:image image} true) - (ptk/data-event ::ev/event {::ev/name "toggle-image-aspect-ratio" - ::ev/origin "workspace:colorpicker" - :checked keep-aspect-ratio?}))))) + (ev/event {::ev/name "toggle-image-aspect-ratio" + ::ev/origin "workspace:colorpicker" + :checked keep-aspect-ratio?}))))) on-change-tab (mf/use-fn #(reset! active-color-tab* %)) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index e565a05754a..0bf4c4c1a4f 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -24,7 +24,6 @@ [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc libraries @@ -87,8 +86,7 @@ (mf/deps state selected on-select-color) (fn [event] (when-not (= :recent selected) - (st/emit! (ptk/event - ::ev/event + (st/emit! (ev/event {::ev/name "use-library-color" ::ev/origin "colorpicker" :external-library (not= :file selected)}))) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index b2a69eca34b..dd20c54badb 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -41,7 +41,6 @@ [app.util.timers :as timers] [beicon.v2.core :as rx] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def menu-ref @@ -676,7 +675,7 @@ :on-accept delete-fn})) do-duplicate #(st/emit! (dw/duplicate-page id) - (ptk/event ::ev/event {::ev/name "duplicate-page"})) + (ev/event {::ev/name "duplicate-page"})) do-rename #(st/emit! (dw/start-rename-page-item id))] [:* diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 08f1488d9f9..09f34268a58 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -15,7 +15,6 @@ [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] - [app.main.ui.context :as ctx] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.main-menu :as main-menu] [app.util.dom :as dom] @@ -27,12 +26,10 @@ ;; --- Header Component (mf/defc left-header* - [{:keys [file layout project page-id class]}] - (let [profile (mf/deref refs/profile) - file-id (:id file) + [{:keys [file layout project class]}] + (let [file-id (:id file) file-name (:name file) project-id (:id project) - team-id (:team-id project) shared? (:is-shared file) persistence (mf/deref refs/persistence) @@ -40,8 +37,6 @@ persistence-status (get persistence :status) - read-only? (mf/use-ctx ctx/workspace-read-only?) - editing* (mf/use-state false) editing? (deref editing*) input-ref (mf/use-ref nil) @@ -137,10 +132,5 @@ (when ^boolean shared? [:span {:class (stl/css :shared-badge)} deprecated-icon/library]) [:div {:class (stl/css :menu-section)} - [:& main-menu/menu - {:layout layout - :file file - :profile profile - :read-only? read-only? - :team-id team-id - :page-id page-id}]]])) + [:> main-menu/menu* {:layout layout + :file file}]]])) diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index a096c8144ab..5ecac0793c3 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -29,9 +29,10 @@ .project-tree { position: relative; flex-grow: 1; + flex-shrink: 1; + min-width: 0; height: deprecated.$s-32; min-height: deprecated.$s-32; - max-width: calc(100% - deprecated.$s-64); } .project-name, diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index a964d274753..f10a90c48e4 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -22,6 +23,7 @@ [app.main.data.shortcuts :as scd] [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.versions :as dwv] @@ -34,64 +36,68 @@ [app.main.ui.dashboard.subscription :refer [get-subscription-type main-menu-power-up*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks.resize :as r] - [app.main.ui.icons :as deprecated-icon] [app.plugins.register :as preg] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) -;; --- Header menu and submenus +(mf/defc shortcuts* + {::mf/private true} + [{:keys [id]}] + [:span {:class (stl/css :shortcut)} + (for [sc (scd/split-sc (sc/get-tooltip id))] + [:span {:class (stl/css :shortcut-key) + :key sc} + sc])]) (mf/defc help-info-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout on-close]}] (let [nav-to-helpc-center (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-help-center-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-help-center-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://help.penpot.app"))) nav-to-community (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-community-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-community-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://community.penpot.app"))) nav-to-youtube (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-tutorials-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-tutorials-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://www.youtube.com/c/Penpot"))) nav-to-templates (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-libraries-click" - ::ev/origin "workspace"})) + (st/emit! (ev/event {::ev/name "explore-libraries-click" + ::ev/origin "workspace"})) (dom/open-new-window "https://penpot.app/libraries-templates"))) nav-to-github (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-github-repository-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-github-repository-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://github.com/penpot/penpot"))) nav-to-terms (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-terms-service-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-terms-service-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://penpot.app/terms"))) nav-to-feedback @@ -100,6 +106,9 @@ plugins? (features/active-feature? @st/state "plugins/runtime") + mcp? + (contains? cf/flags :mcp) + show-shortcuts (mf/use-fn (mf/deps layout) @@ -115,213 +124,206 @@ (mf/use-fn (fn [event] (let [version (:main cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) + (st/emit! (ev/event {::ev/name "show-release-notes" + :version version})) (println version) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) - (st/emit! (modal/show {:type :release-notes :version version}))))))] + (st/emit! (modal/show {:type :release-notes + :version version}))))))] [:> dropdown-menu* {:show true - ;; :id "workspace-help-menu" :on-close on-close - :class (stl/css-case :sub-menu true - :help-info plugins? - :help-info-old (not plugins?))} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + :class (stl/css-case :base-menu true + :sub-menu true + :pos-final-5 (not (or plugins? mcp?)) + :pos-final-6 (not= plugins? mcp?) + :pos-final-7 (and plugins? mcp?))} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-helpc-center :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-helpc-center event))) :id "file-menu-help-center"} - [:span {:class (stl/css :item-name)} (tr "labels.help-center")]] + [:span {:class (stl/css :item-name)} + (tr "labels.help-center")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-community :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-community event))) :id "file-menu-community"} - [:span {:class (stl/css :item-name)} (tr "labels.community")]] + [:span {:class (stl/css :item-name)} + (tr "labels.community")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-youtube :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-youtube event))) :id "file-menu-youtube"} - [:span {:class (stl/css :item-name)} (tr "labels.tutorials")]] + [:span {:class (stl/css :item-name)} + (tr "labels.tutorials")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click show-release-notes :on-key-down (fn [event] (when (kbd/enter? event) (show-release-notes event))) :id "file-menu-release-notes"} - [:span {:class (stl/css :item-name)} (tr "labels.release-notes")]] + [:span {:class (stl/css :item-name)} + (tr "labels.release-notes")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-templates :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-templates event))) :id "file-menu-templates"} - [:span {:class (stl/css :item-name)} (tr "labels.libraries-and-templates")]] + [:span {:class (stl/css :item-name)} + (tr "labels.libraries-and-templates")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-github :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-github event))) :id "file-menu-github"} - [:span {:class (stl/css :item-name)} (tr "labels.github-repo")]] + [:span {:class (stl/css :item-name)} + (tr "labels.github-repo")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-terms :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-terms event))) :id "file-menu-terms"} - [:span {:class (stl/css :item-name)} (tr "auth.terms-of-service")]] + [:span {:class (stl/css :item-name)} + (tr "auth.terms-of-service")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click show-shortcuts :on-key-down (fn [event] (when (kbd/enter? event) (show-shortcuts event))) :id "file-menu-shortcuts"} - [:span {:class (stl/css :item-name)} (tr "label.shortcuts")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :show-shortcuts))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:span {:class (stl/css :item-name)} + (tr "label.shortcuts")] + [:> shortcuts* {:id :show-shortcuts}]] (when (contains? cf/flags :user-feedback) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-feedback :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-feedback event))) :id "file-menu-feedback"} - [:span {:class (stl/css-case :feedback true - :item-name true)} (tr "labels.give-feedback")]])])) + [:span {:class (stl/css :feedback :item-name)} + (tr "labels.give-feedback")]])])) (mf/defc preferences-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout profile toggle-flag on-close toggle-theme]}] - (let [show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))] + (let [show-nudge-options + (mf/use-fn + #(modal/show! {:type :nudge-option}))] [:> dropdown-menu* {:show true - ;; :id "workspace-preferences-menu" - :class (stl/css-case :sub-menu true - :preferences true) + :class (stl/css :base-menu :sub-menu :pos-4) :on-close on-close} [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "scale-text" + :data-testid "scale-text" :id "file-menu-scale-text"} [:span {:class (stl/css :item-name)} (if (contains? layout :scale-text) (tr "workspace.header.menu.disable-scale-content") (tr "workspace.header.menu.enable-scale-content"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :scale))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :scale}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-ruler-guides" + :data-testid "snap-ruler-guides" :id "file-menu-snap-ruler-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-ruler-guides) (tr "workspace.header.menu.disable-snap-ruler-guides") (tr "workspace.header.menu.enable-snap-ruler-guides"))] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-ruler-guide))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-snap-ruler-guide}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-guides" + :data-testid "snap-guides" :id "file-menu-snap-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-guides) (tr "workspace.header.menu.disable-snap-guides") (tr "workspace.header.menu.enable-snap-guides"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-guides))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-snap-guides}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "dynamic-alignment" + :data-testid "dynamic-alignment" :id "file-menu-dynamic-alignment"} [:span {:class (stl/css :item-name)} (if (contains? layout :dynamic-alignment) (tr "workspace.header.menu.disable-dynamic-alignment") (tr "workspace.header.menu.enable-dynamic-alignment"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-alignment))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-alignment}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-pixel-grid" + :data-testid "snap-pixel-grid" :id "file-menu-pixel-grid"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-pixel-grid) (tr "workspace.header.menu.disable-snap-pixel-grid") (tr "workspace.header.menu.enable-snap-pixel-grid"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :snap-pixel-grid))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :snap-pixel-grid}]] [:> dropdown-menu-item* {:on-click show-nudge-options - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (show-nudge-options event))) - :data-testid "snap-pixel-grid" + :data-testid "snap-pixel-grid" :id "file-menu-nudge"} [:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]] - [:> dropdown-menu-item* {:on-click toggle-theme - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-theme event))) - :data-testid "toggle-theme" + :data-testid "toggle-theme" :id "file-menu-toggle-theme"} [:span {:class (stl/css :item-name)} (case (:theme profile) ;; dark -> light -> system -> dark and so on "dark" (tr "workspace.header.menu.toggle-light-theme") - "light" (tr "workspace.header.menu.toggle-system-theme") + "light" (tr "workspace.header.menu.toggle-system-theme") "system" (tr "workspace.header.menu.toggle-dark-theme") (tr "workspace.header.menu.toggle-light-theme"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-theme))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]])) + [:> shortcuts* {:id :toggle-theme}]]])) (mf/defc view-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout toggle-flag on-close]}] (let [read-only? (mf/use-ctx ctx/workspace-read-only?) @@ -343,46 +345,40 @@ (vary-meta assoc ::ev/origin "workspace-menu")))))] [:> dropdown-menu* {:show true - ;; :id "workspace-view-menu" - :class (stl/css-case :sub-menu true - :view true) + :class (stl/css :base-menu :sub-menu :pos-3) :on-close on-close} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "rulers" + :data-testid "rulers" :id "file-menu-rulers"} [:span {:class (stl/css :item-name)} (if (contains? layout :rulers) (tr "workspace.header.menu.hide-rules") (tr "workspace.header.menu.show-rules"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-rulers))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-rulers}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "display-guides" + :data-testid "display-guides" :id "file-menu-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :display-guides) (tr "workspace.header.menu.hide-guides") (tr "workspace.header.menu.show-guides"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-guides))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-guides}]] (when-not ^boolean read-only? [:* - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-color-palette :on-key-down (fn [event] (when (kbd/enter? event) @@ -392,11 +388,9 @@ (if (contains? layout :colorpalette) (tr "workspace.header.menu.hide-palette") (tr "workspace.header.menu.show-palette"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-colorpalette))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-colorpalette}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-text-palette :on-key-down (fn [event] (when (kbd/enter? event) @@ -406,68 +400,68 @@ (if (contains? layout :textpalette) (tr "workspace.header.menu.hide-textpalette") (tr "workspace.header.menu.show-textpalette"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-textpalette))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]]) + [:> shortcuts* {:id :toggle-textpalette}]]]) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "display-artboard-names" + :data-testid "display-artboard-names" :id "file-menu-artboards"} [:span {:class (stl/css :item-name)} (if (contains? layout :display-artboard-names) (tr "workspace.header.menu.hide-artboard-names") (tr "workspace.header.menu.show-artboard-names"))]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "show-pixel-grid" + :data-testid "show-pixel-grid" :id "file-menu-pixel-grid"} [:span {:class (stl/css :item-name)} (if (contains? layout :show-pixel-grid) (tr "workspace.header.menu.hide-pixel-grid") (tr "workspace.header.menu.show-pixel-grid"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :show-pixel-grid))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :show-pixel-grid}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "hide-ui" + :data-testid "hide-ui" :id "file-menu-hide-ui"} [:span {:class (stl/css :item-name)} (tr "workspace.shape.menu.hide-ui")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :hide-ui))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]])) + [:> shortcuts* {:id :hide-ui}]]])) (mf/defc edit-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [on-close]}] - (let [select-all (mf/use-fn #(st/emit! (dw/select-all))) - undo (mf/use-fn #(st/emit! dwu/undo)) - redo (mf/use-fn #(st/emit! dwu/redo)) - perms (mf/use-ctx ctx/permissions) - can-edit (:can-edit perms)] + (let [perms (mf/use-ctx ctx/permissions) + can-edit (:can-edit perms) + + select-all + (mf/use-fn + #(st/emit! (dw/select-all))) + + undo + (mf/use-fn + #(st/emit! dwu/undo)) + + redo + (mf/use-fn + #(st/emit! dwu/redo))] [:> dropdown-menu* {:show true - ;; :id "workspace-edit-menu" - :class (stl/css-case :sub-menu true - :edit true) + :class (stl/css :base-menu :sub-menu :pos-2) :on-close on-close} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click select-all :on-key-down (fn [event] (when (kbd/enter? event) @@ -475,45 +469,32 @@ :id "file-menu-select-all"} [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.select-all")] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :select-all))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]] + [:> shortcuts* {:id :select-all}]] (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click undo :on-key-down (fn [event] (when (kbd/enter? event) (undo event))) :id "file-menu-undo"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :undo))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]]) + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.undo")] + [:> shortcuts* {:id :undo}]]) (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click redo :on-key-down (fn [event] (when (kbd/enter? event) (redo event))) :id "file-menu-redo"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :redo))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]])])) + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.redo")] + [:> shortcuts* {:id :redo}]])])) (mf/defc file-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-close file]}] (let [file-id (:id file) shared? (:is-shared file) @@ -536,12 +517,11 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (modal/show! - {:type :delete-shared-libraries - :origin :unpublish - :ids #{file-id} - :on-accept #(st/emit! (dwl/set-file-shared file-id false)) - :count-libraries 1}))) + (modal/show! {:type :delete-shared-libraries + :origin :unpublish + :ids #{file-id} + :on-accept #(st/emit! (dwl/set-file-shared file-id false)) + :count-libraries 1}))) on-remove-shared-key-down (mf/use-fn @@ -590,7 +570,8 @@ (on-pin-version event)))) on-export-shapes - (mf/use-fn #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"}))) + (mf/use-fn + #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"}))) on-export-shapes-key-down (mf/use-fn @@ -627,14 +608,12 @@ (on-export-frames event))))] [:> dropdown-menu* {:show true - ;; :id "workspace-file-menu" - :class (stl/css-case :sub-menu true - :file true) + :class (stl/css :base-menu :sub-menu :pos-1) :on-close on-close} (if ^boolean shared? (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-remove-shared :on-key-down on-remove-shared-key-down :id "file-menu-remove-shared"} @@ -642,7 +621,7 @@ (tr "dashboard.unpublish-shared")]]) (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-add-shared :on-key-down on-add-shared-key-down :id "file-menu-add-shared"} @@ -653,35 +632,32 @@ [:* [:div {:class (stl/css :separator)}] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-pin-version :on-key-down on-pin-version-key-down :id "file-menu-create-version"} [:span {:class (stl/css :item-name)} (tr "dashboard.create-version-menu")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-show-version-history :on-key-down on-show-version-history-key-down :id "file-menu-show-version-history"} [:span {:class (stl/css :item-name)} (tr "dashboard.show-version-history")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-history))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-history}]] [:div {:class (stl/css :separator)}]]) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-shapes :on-key-down on-export-shapes-key-down :id "file-menu-export-shapes"} - [:span {:class (stl/css :item-name)} (tr "dashboard.export-shapes")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :export-shapes))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:span {:class (stl/css :item-name)} + (tr "dashboard.export-shapes")] + [:> shortcuts* {:id :export-shapes}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-file :on-key-down on-export-file-key-down :data-format "binfile-v3" @@ -690,7 +666,7 @@ (tr "dashboard.download-binary-file")]] (when (seq frames) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-frames :on-key-down on-export-frames-key-down :id "file-menu-export-frames"} @@ -698,30 +674,26 @@ (tr "dashboard.export-frames")]])])) (mf/defc plugins-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [open-plugins on-close]}] (when (features/active-feature? @st/state "plugins/runtime") - (let [plugins (preg/plugins-list) - user-can-edit? (:can-edit (deref refs/permissions)) - permissions-peek (deref refs/plugins-permissions-peek)] + (let [plugins (preg/plugins-list) + user-can-edit? (:can-edit (deref refs/permissions)) + permissions-peek (deref refs/plugins-permissions-peek)] [:> dropdown-menu* {:show true - ;; :id "workspace-plugins-menu" - :class (stl/css-case :sub-menu true :plugins true) + :class (stl/css :base-menu :sub-menu :pos-5 :plugins) :on-close on-close} [:> dropdown-menu-item* {:on-click open-plugins - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (open-plugins event))) - :data-testid "open-plugins" + :data-testid "open-plugins" :id "file-menu-open-plugins"} [:span {:class (stl/css :item-name)} (tr "workspace.plugins.menu.plugins-manager")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :plugins))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :plugins}]] (when (d/not-empty? plugins) @@ -739,10 +711,10 @@ (fn [event] (if can-open? (do - (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin" - ::ev/origin "workspace:menu" - :name name - :host host})) + (st/emit! (ev/event {::ev/name "start-plugin" + ::ev/origin "workspace:menu" + :name name + :host host})) (dp/open-plugin! manifest user-can-edit?)) (dom/stop-propagation event)))) on-key-down @@ -751,33 +723,117 @@ (fn [event] (when can-open? (when (kbd/enter? event) - (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin" - ::ev/origin "workspace:menu" - :name name - :host host})) + (st/emit! (ev/event {::ev/name "start-plugin" + ::ev/origin "workspace:menu" + :name name + :host host})) (dp/open-plugin! manifest user-can-edit?)))))] + [:> dropdown-menu-item* {:key (dm/str "plugins-menu-" idx) :on-click on-click - :class (stl/css-case :submenu-item true :menu-disabled (not can-open?)) + :class (stl/css-case :base-menu-item true + :submenu-item true + :disabled (not can-open?)) :on-key-down on-key-down} [:span {:class (stl/css :item-name)} name] (when-not can-open? - [:span {:class (stl/css :item-icon) - :title (tr "workspace.plugins.error.need-editor")} deprecated-icon/help])]))]))) + [:span {:title (tr "workspace.plugins.error.need-editor")} + [:> icon* {:icon-id i/help + :class (stl/css :item-icon)}]])]))]))) + +(mf/defc mcp-menu* + {::mf/private true} + [{:keys [on-close]}] + (let [plugins? (features/active-feature? @st/state "plugins/runtime") + + profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + tokens (mf/deref refs/access-tokens) -(mf/defc menu - {::mf/props :obj} - [{:keys [layout file profile]}] - (let [show-menu* (mf/use-state false) - show-menu? (deref show-menu*) - sub-menu* (mf/use-state false) - sub-menu (deref sub-menu*) + expires-at (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at) + expired? (and (some? expires-at) (> (ct/now) expires-at)) - open-menu + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connection (get mcp :connection-status) + mcp-connected? (= mcp-connection "connected") + + show-enabled? (and mcp-enabled? (false? expired?)) + + on-nav-to-integrations + (mf/use-fn + (fn [] + (st/emit! (ev/event {::ev/name "manage-mpc-option" + ::ev/origin "workspace-menu"})) + (dom/open-new-window "/#/settings/integrations"))) + + on-nav-to-integrations-key-down + (mf/use-fn + (fn [event] + (when (kbd/enter? event) + (on-nav-to-integrations)))) + + on-toggle-mcp-plugin + (mf/use-fn + (fn [] + (if mcp-connected? + (st/emit! (mcp/user-disconnect-mcp) + (ev/event {::ev/name "disconnect-mcp-plugin" + ::ev/origin "workspace-menu"})) + (st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace-menu"}))))) + + on-toggle-mcp-plugin-key-down + (mf/use-fn + (fn [event] + (when (kbd/enter? event) + (on-toggle-mcp-plugin))))] + + [:> dropdown-menu* {:show true + :class (stl/css-case :base-menu true + :sub-menu true + :pos-5 (not plugins?) + :pos-6 plugins?) + :on-close on-close} + + (when (and show-enabled? (not expired?)) + [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" + :class (stl/css :base-menu-item :submenu-item) + :on-click on-toggle-mcp-plugin + :on-key-down on-toggle-mcp-plugin-key-down} + [:span {:class (stl/css :item-name)} + (if mcp-connected? + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]) + + [:> dropdown-menu-item* {:id "mcp-menu-nav-to-integrations" + :class (stl/css :base-menu-item :submenu-item) + :on-click on-nav-to-integrations + :on-key-down on-nav-to-integrations-key-down} + [:span {:class (stl/css :item-name)} + (if show-enabled? + (tr "workspace.header.menu.mcp.server.status.enabled") + (tr "workspace.header.menu.mcp.server.status.disabled"))]]])) + +(mf/defc menu* + [{:keys [layout file]}] + (let [profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + + show-menu* (mf/use-state false) + show-menu? (deref show-menu*) + selected-sub-menu* (mf/use-state nil) + selected-sub-menu (deref selected-sub-menu*) + + toggle-menu (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! show-menu* true))) + (swap! show-menu* not) + (when (not show-menu?) + (reset! selected-sub-menu* nil)))) close-menu (mf/use-fn @@ -789,13 +845,13 @@ (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! sub-menu* nil))) + (reset! selected-sub-menu* nil))) close-all-menus (mf/use-fn (fn [] (reset! show-menu* false) - (reset! sub-menu* nil))) + (reset! selected-sub-menu* nil))) on-menu-click (mf/use-fn @@ -804,12 +860,13 @@ (let [menu (-> (dom/get-current-target event) (dom/get-data "testid") (keyword))] - (reset! sub-menu* menu)))) + (reset! selected-sub-menu* menu)))) on-power-up-click (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "workspace-menu"})) + (st/emit! (ev/event {::ev/name "explore-pricing-click" + ::ev/origin "workspace-menu"})) (dom/open-new-window "https://penpot.app/pricing"))) toggle-flag @@ -823,7 +880,7 @@ (-> (dw/toggle-layout-flag flag) (vary-meta assoc ::ev/origin "workspace-menu"))) (reset! show-menu* false) - (reset! sub-menu* nil)))) + (reset! selected-sub-menu* nil)))) toggle-theme (mf/use-fn @@ -836,9 +893,10 @@ (fn [event] (dom/stop-propagation event) (reset! show-menu* false) - (reset! sub-menu* nil) + (reset! selected-sub-menu* nil) (st/emit! - (ptk/event ::ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:menu"}) + (ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:menu"}) (modal/show :plugin-management {})))) subscription (:subscription (:props profile)) @@ -853,15 +911,16 @@ [:* [:> icon-button* {:variant "ghost" + :aria-pressed show-menu? :aria-label (tr "shortcut-subsection.main-menu") - :on-click open-menu + :on-click toggle-menu :icon i/menu}] [:> dropdown-menu* {:show show-menu? :id "workspace-menu" :on-close close-menu - :class (stl/css :menu)} - [:> dropdown-menu-item* {:class (stl/css :menu-item) + :class (stl/css :base-menu :menu)} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) @@ -869,111 +928,156 @@ :on-pointer-enter on-menu-click :data-testid "file" :id "file-menu-file"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.file")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.file")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "edit" + :data-testid "edit" :id "file-menu-edit"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.edit")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "view" + :data-testid "view" :id "file-menu-view"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.view")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.view")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "preferences" + :data-testid "preferences" :id "file-menu-preferences"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.preferences")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.preferences")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] (when (features/active-feature? @st/state "plugins/runtime") - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "plugins" + :data-testid "plugins" :id "file-menu-plugins"} - [:span {:class (stl/css :item-name)} (tr "workspace.plugins.menu.title")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]]) + [:span {:class (stl/css :item-name)} + (tr "workspace.plugins.menu.title")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]]) + + (when (contains? cf/flags :mcp) + (let [tokens (mf/deref refs/access-tokens) + expires-at (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connection (get mcp :connection-status) + mcp-connected? (= mcp-connection "connected") + mcp-error? (= mcp-connection "error") + + active? (and mcp-enabled? mcp-connected?) + failed? (or (and mcp-enabled? mcp-error?) + (true? expired?))] + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) + :on-click on-menu-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-menu-click event))) + :on-pointer-enter on-menu-click + :data-testid "mcp" + :id "file-menu-mcp"} + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.mcp")] + [:span {:class (stl/css-case :item-indicator true + :active active? + :failed failed?)}] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]])) [:div {:class (stl/css :separator)}] - [:> dropdown-menu-item* {:class (stl/css-case :menu-item true) + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "help-info" + :data-testid "help-info" :id "file-menu-help-info"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.help-info")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.help-info")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - (when (and (contains? cf/flags :subscriptions) (not= "enterprise" subscription-type)) + (when (and (contains? cf/flags :subscriptions) + (not= "enterprise" subscription-type)) [:> main-menu-power-up* {:close-sub-menu close-sub-menu}]) ;; TODO remove this block when subscriptions is full implemented (when (contains? cf/flags :subscriptions-old) - [:> dropdown-menu-item* {:class (stl/css-case :menu-item true) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-power-up-click :on-key-down (fn [event] (when (kbd/enter? event) (on-power-up-click))) :on-pointer-enter close-sub-menu :id "file-menu-power-up"} - [:span {:class (stl/css :item-name)} (tr "subscription.workspace.header.menu.option.power-up")]])] + [:span {:class (stl/css :item-name)} + (tr "subscription.workspace.header.menu.option.power-up")]])] - (case sub-menu + (case selected-sub-menu :file [:> file-menu* {:file file :on-close close-sub-menu}] :edit - [:> edit-menu* - {:on-close close-sub-menu}] + [:> edit-menu* {:on-close close-sub-menu}] :view - [:> view-menu* - {:layout layout - :toggle-flag toggle-flag - :on-close close-sub-menu}] + [:> view-menu* {:layout layout + :toggle-flag toggle-flag + :on-close close-sub-menu}] :preferences - [:> preferences-menu* - {:layout layout - :profile profile - :toggle-flag toggle-flag - :toggle-theme toggle-theme - :on-close close-sub-menu}] + [:> preferences-menu* {:layout layout + :profile profile + :toggle-flag toggle-flag + :toggle-theme toggle-theme + :on-close close-sub-menu}] :plugins - [:> plugins-menu* - {:open-plugins open-plugins-manager - :on-close close-sub-menu}] + [:> plugins-menu* {:open-plugins open-plugins-manager + :on-close close-sub-menu}] + + :mcp + [:> mcp-menu* {:on-close close-sub-menu}] :help-info - [:> help-info-menu* - {:layout layout - :on-close close-sub-menu}] + [:> help-info-menu* {:layout layout + :on-close close-sub-menu}] nil)])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 7deccc70ed3..1b12e2cdbf4 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -4,125 +4,178 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; + +.base-menu { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + background-color: var(--menu-background-color); + border: $b-2 solid var(--panel-border-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); +} .menu { - @extend .menu-dropdown; - top: deprecated.$s-48; - left: calc(var(--right-sidebar-width, deprecated.$s-256) - deprecated.$s-16); - width: deprecated.$s-192; - margin: 0; + top: $sz-48; + left: calc(var(--right-sidebar-width) - $sz-40); + inline-size: $sz-192; } -.menu-item { - @extend .menu-item-base; - cursor: pointer; - - .open-arrow { - @include deprecated.flexCenter; +.sub-menu { + left: calc(var(--right-sidebar-width) + $sz-154); + min-width: $sz-284; + width: 115%; - svg { - @extend .button-icon; - stroke: var(--icon-foreground); - } + &.pos-1 { + top: calc($sz-16 + $sz-32); } - &:hover { - color: var(--menu-foreground-color-hover); - - .open-arrow { - svg { - stroke: var(--menu-foreground-color-hover); - } - } - - .shortcut-key { - color: var(--menu-shortcut-foreground-color-hover); - } + &.pos-2 { + top: calc($sz-16 + (2 * $sz-32)); } -} - -.separator { - border-top: deprecated.$s-1 solid var(--color-background-quaternary); - height: deprecated.$s-4; - left: calc(-1 * deprecated.$s-4); - margin-top: deprecated.$s-8; - position: relative; - width: calc(100% + deprecated.$s-8); -} -.shortcut { - @extend .shortcut-base; -} - -.shortcut-key { - @extend .shortcut-key-base; -} - -.sub-menu { - @extend .menu-dropdown; - left: calc(var(--right-sidebar-width, deprecated.$s-256) + deprecated.$s-180); - width: deprecated.$s-192; - min-width: calc(deprecated.$s-272 - deprecated.$s-2); - width: 110%; - - .submenu-item { - @extend .menu-item-base; - - &:hover { - color: var(--menu-foreground-color-hover); - - .shortcut-key { - color: var(--menu-shortcut-foreground-color-hover); - } - } + &.pos-3 { + top: calc($sz-16 + (3 * $sz-32)); } - .menu-disabled { - color: var(--color-foreground-secondary); + &.pos-4 { + top: calc($sz-16 + (4 * $sz-32)); + } - &:hover { - cursor: default; - color: var(--color-foreground-secondary); - background-color: var(--menu-background-color); - } + &.pos-5 { + top: calc($sz-16 + (5 * $sz-32)); } - &.file { - top: deprecated.$s-48; + &.pos-6 { + top: calc($sz-16 + (6 * $sz-32)); } - &.edit { - top: deprecated.$s-76; + &.pos-final-5 { + top: calc($sz-32 + (5 * $sz-32)); } - &.view { - top: deprecated.$s-116; + &.pos-final-6 { + top: calc($sz-32 + (6 * $sz-32)); } - &.preferences { - top: deprecated.$s-148; + &.pos-final-7 { + top: calc($sz-32 + (7 * $sz-32)); } &.plugins { - top: deprecated.$s-180; - max-height: calc(100vh - deprecated.$s-180); + max-height: calc(100vh - $sz-200); overflow-x: hidden; overflow-y: auto; } +} - &.help-info { - top: deprecated.$s-232; +.base-menu-item { + @include t.use-typography("body-small"); + display: grid; + align-items: center; + grid-template-columns: auto $sz-16 $sz-16; + grid-template-areas: "name indicator arrow"; + block-size: $sz-28; + inline-size: 100%; + padding: $sz-6; + border-radius: $br-8; + color: var(--menu-foreground-color); + background-color: var(--menu-background-color); + + &:hover { + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); + --menu-shortcut-foreground-color: var(--menu-shortcut-foreground-color-hover); + --menu-icon-foreground-color: var(--menu-foreground-color-hover); } - &.help-info-old { - top: deprecated.$s-192; + &.disabled { + --menu-foreground-color: var(--color-foreground-secondary); + pointer-events: none; } } -.item-icon { - svg { - @extend .button-icon; - stroke: var(--icon-foreground); +.menu-item { + display: grid; + align-items: center; + grid-template-columns: auto $sz-16 $sz-16; + grid-template-areas: "name indicator arrow"; +} + +.submenu-item { + display: flex; + align-items: center; + justify-content: space-between; +} + +.item-name { + grid-area: name; +} + +.item-indicator { + --menu-indicator-color: var(--color-foreground-secondary); + grid-area: indicator; + display: flex; + align-items: center; + justify-content: center; + inline-size: px2rem(8); + block-size: px2rem(8); + border-radius: $br-circle; + background-color: var(--menu-indicator-color); + + &.active { + --menu-indicator-color: var(--color-accent-primary); + } + + &.failed { + --menu-indicator-color: var(--color-foreground-error); } } + +.item-arrow { + grid-area: arrow; + color: var(--menu-icon-foreground-color); +} + +.item-icon { + color: var(--menu-icon-foreground-color); + display: flex; + align-items: center; + justify-content: center; +} + +.separator { + position: relative; + block-size: var(--sp-xs); + inline-size: calc(100% + var(--sp-s)); + border-top: $b-1 solid var(--color-background-quaternary); + left: calc(-1 * var(--sp-xs)); + margin-top: var(--sp-s); +} + +.shortcut { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-xxs); + color: var(--menu-shortcut-foreground-color); +} + +.shortcut-key { + @include t.use-typography("body-small"); + display: flex; + align-items: center; + justify-content: center; + height: px2rem(20); + padding: var(--sp-xxs) px2rem(6); + border-radius: $br-6; + background-color: var(--menu-shortcut-background-color); +} diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index ca315963ea3..c06dd6679be 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -27,7 +27,6 @@ [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private close-icon @@ -128,7 +127,7 @@ (reset! fetching-manifest? false) (if plugin (do - (st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) + (st/emit! (ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) (modal/show! :plugin-permissions {:plugin plugin @@ -148,10 +147,10 @@ handle-open-plugin (mf/use-fn (fn [manifest] - (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin" - ::ev/origin "workspace:plugins" - :name (:name manifest) - :host (:host manifest)})) + (st/emit! (ev/event {::ev/name "start-plugin" + ::ev/origin "workspace:plugins" + :name (:name manifest) + :host (:host manifest)})) (dp/open-plugin! manifest user-can-edit?) (modal/hide!))) @@ -161,9 +160,9 @@ (fn [plugin-index] (let [plugins-list (preg/plugins-list) plugin (nth plugins-list plugin-index)] - (st/emit! (ptk/event ::ev/event {::ev/name "remove-plugin" - :name (:name plugin) - :host (:host plugin)})) + (st/emit! (ev/event {::ev/name "remove-plugin" + :name (:name plugin) + :host (:host plugin)})) (dp/close-plugin! plugin) (preg/remove-plugin! plugin) (reset! plugins-state* (preg/plugins-list)))))] @@ -195,7 +194,7 @@ (when-not (empty? plugins-state) [:> i18n/tr-html* {:class (stl/css :discover) - :on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"})) + :on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"})) :content (tr "workspace.plugins.discover" cfg/plugins-list-uri)}]) [:hr] @@ -207,7 +206,7 @@ [:a {:class (stl/css :plugins-link) :href cfg/plugins-list-uri :target "_blank" - :on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"}))} + :on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"}))} (tr "workspace.plugins.plugin-list-link") deprecated-icon/external-link]] [:* @@ -298,9 +297,9 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "allow-plugin-permissions" - :host host - :permissions (->> permissions (str/join ", "))}) + (st/emit! (ev/event {::ev/name "allow-plugin-permissions" + :host host + :permissions (->> permissions (str/join ", "))}) (modal/hide)) (when on-accept (on-accept)))) @@ -308,9 +307,9 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "reject-plugin-permissions" - :host host - :permissions (->> permissions (str/join ", "))}) + (st/emit! (ev/event {::ev/name "reject-plugin-permissions" + :host host + :permissions (->> permissions (str/join ", "))}) (modal/hide)) (when on-close (on-close))))] @@ -353,9 +352,9 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "allow-plugin-permissions" - :host host - :permissions (->> permissions (str/join ", "))}) + (st/emit! (ev/event {::ev/name "allow-plugin-permissions" + :host host + :permissions (->> permissions (str/join ", "))}) (modal/hide)) (when on-accept (on-accept)))) @@ -363,9 +362,9 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "reject-plugin-permissions" - :host host - :permissions (->> permissions (str/join ", "))}) + (st/emit! (ev/event {::ev/name "reject-plugin-permissions" + :host host + :permissions (->> permissions (str/join ", "))}) (modal/hide)) (when on-close (on-close))))] @@ -406,7 +405,7 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "try-out-accept"}) + (st/emit! (ev/event {::ev/name "try-out-accept"}) (modal/hide)) (when on-accept (on-accept)))) @@ -414,7 +413,7 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "try-out-cancel"}) + (st/emit! (ev/event {::ev/name "try-out-cancel"}) (modal/hide)) (when on-close (on-close))))] diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index 71533852e82..90b27f6ee22 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -265,54 +265,68 @@ prev-transforms (mf/use-var nil)] (mf/with-effect [add-children] - (ts/raf - #(doseq [{:keys [shape]} add-children-prev] - (let [shape-node (get-shape-node shape) - mirror-node (dom/query (dm/fmt ".mirror-shape[href='#shape-%'" shape))] - (when mirror-node (.remove mirror-node)) - (dom/remove-attribute! (dom/get-parent shape-node) "display")))) - - (ts/raf - #(doseq [{:keys [frame shape]} add-children] - (let [frame-node (get-shape-node frame) - shape-node (get-shape-node shape) - - clip-id - (-> (dom/query frame-node ":scope > defs > .frame-clip-def") - (dom/get-attribute "id")) - - use-node - (dom/create-element "http://www.w3.org/2000/svg" "use") - - contents-node - (or (dom/query frame-node ".frame-children") frame-node)] - - (dom/set-attribute! use-node "href" (dm/fmt "#shape-%" shape)) - (dom/set-attribute! use-node "clip-path" (dm/fmt "url(#%)" clip-id)) - (dom/add-class! use-node "mirror-shape") - (dom/append-child! contents-node use-node) - (dom/set-attribute! (dom/get-parent shape-node) "display" "none"))))) + (let [raf-id1 + (ts/raf + #(doseq [{:keys [shape]} add-children-prev] + (let [shape-node (get-shape-node shape) + mirror-node (dom/query (dm/fmt ".mirror-shape[href='#shape-%'" shape))] + (when mirror-node (.remove mirror-node)) + (when-let [parent (some-> shape-node dom/get-parent)] + (dom/remove-attribute! parent "display"))))) + + raf-id2 + (ts/raf + #(doseq [{:keys [frame shape]} add-children] + (let [frame-node (get-shape-node frame) + shape-node (get-shape-node shape)] + (when (and (some? frame-node) (some? shape-node)) + (let [clip-id + (-> (dom/query frame-node ":scope > defs > .frame-clip-def") + (dom/get-attribute "id")) + + use-node + (dom/create-element "http://www.w3.org/2000/svg" "use") + + contents-node + (or (dom/query frame-node ".frame-children") frame-node)] + + (dom/set-attribute! use-node "href" (dm/fmt "#shape-%" shape)) + (dom/set-attribute! use-node "clip-path" (dm/fmt "url(#%)" clip-id)) + (dom/add-class! use-node "mirror-shape") + (dom/append-child! contents-node use-node) + (dom/set-attribute! (dom/get-parent shape-node) "display" "none"))))))] + (fn [] + (js/cancelAnimationFrame raf-id1) + (js/cancelAnimationFrame raf-id2)))) (mf/with-effect [transforms] (let [curr-shapes-set (into #{} (map :id) shapes) prev-shapes-set (into #{} (map :id) @prev-shapes) new-shapes (->> shapes (remove #(contains? prev-shapes-set (:id %)))) - removed-shapes (->> @prev-shapes (remove #(contains? curr-shapes-set (:id %))))] + removed-shapes (->> @prev-shapes (remove #(contains? curr-shapes-set (:id %)))) - ;; NOTE: we schedule the dom modifications to be executed - ;; asynchronously for avoid component flickering when react18 - ;; is used. + ;; NOTE: we schedule the dom modifications to be executed + ;; asynchronously for avoid component flickering when react18 + ;; is used. - (when (d/not-empty? new-shapes) - (ts/raf #(start-transform! node new-shapes))) + raf-id1 + (when (d/not-empty? new-shapes) + (ts/raf #(start-transform! node new-shapes))) - (when (d/not-empty? shapes) - (ts/raf #(update-transform! node shapes transforms modifiers))) + raf-id2 + (when (d/not-empty? shapes) + (ts/raf #(update-transform! node shapes transforms modifiers))) - (when (d/not-empty? removed-shapes) - (ts/raf #(remove-transform! node removed-shapes)))) + raf-id3 + (when (d/not-empty? removed-shapes) + (ts/raf #(remove-transform! node removed-shapes)))] - (reset! prev-modifiers modifiers) - (reset! prev-transforms transforms) - (reset! prev-shapes shapes)))) + (reset! prev-modifiers modifiers) + (reset! prev-transforms transforms) + (reset! prev-shapes shapes) + + (fn [] + (when raf-id1 (js/cancelAnimationFrame raf-id1)) + (when raf-id2 (js/cancelAnimationFrame raf-id2)) + (when raf-id3 (js/cancelAnimationFrame raf-id3))))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index f7278fd650c..547beafdbb1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -43,7 +43,6 @@ [app.main.ui.workspace.tokens.sidebar :refer [tokens-sidebar-tab*]] [app.util.debug :as dbg] [app.util.i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) ;; --- Left Sidebar (Component) @@ -119,7 +118,7 @@ (mf/defc left-sidebar* {::mf/memo true} - [{:keys [layout file page-id tokens-lib active-tokens resolved-active-tokens]}] + [{:keys [layout file tokens-lib active-tokens resolved-active-tokens]}] (let [options-mode (mf/deref refs/options-mode-global) project (mf/deref refs/project) file-id (get file :id) @@ -146,7 +145,7 @@ (fn [id] (st/emit! (dcm/go-to-workspace :layout (keyword id))) (when (= id "tokens") - (st/emit! (ptk/event ::ev/event {::ev/name "open-tokens-tab"}))))) + (st/emit! (ev/event {::ev/name "open-tokens-tab"}))))) tabs (mf/with-memo [mode-inspect? design-tokens?] @@ -185,12 +184,10 @@ :class aside-class :style {:--left-sidebar-width (dm/str width "px")}} - [:> left-header* - {:file file - :layout layout - :project project - :page-id page-id - :class (stl/css :left-header)}] + [:> left-header* {:file file + :layout layout + :project project + :class (stl/css :left-header)}] [:div {:on-pointer-down on-pointer-down :on-lost-pointer-capture on-lost-pointer-capture diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index 8eb686483ef..b6b8b46ad31 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -31,7 +31,6 @@ [app.util.keyboard :as kbd] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc color-item @@ -180,10 +179,10 @@ (mf/deps color on-asset-click read-only? file-id) (fn [event] (when-not read-only? - (st/emit! (ptk/data-event ::ev/event - {::ev/name "use-library-color" - ::ev/origin "sidebar" - :external-library (not local?)})) + (st/emit! (ev/event + {::ev/name "use-library-color" + ::ev/origin "sidebar" + :external-library (not local?)})) (when-not (on-asset-click event (:id color)) (st/emit! (dc/apply-color-from-assets file-id color (kbd/alt? event)))))))] @@ -403,8 +402,8 @@ y-position (:top bounds)] (st/emit! (dw/set-assets-section-open file-id :colors true) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "color"}) + (ev/event {::ev/name "add-asset-to-library" + :asset-type "color"}) (modal/show :colorpicker {:x x-position :y y-position diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 9f7762b861d..cae198ad9a0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -242,7 +242,11 @@ ;; afterwards, in the next render cycle. (dom/append-child! item-el counter-el) (dnd/set-drag-image! event item-el (:x offset) (:y offset)) - (ts/raf #(.removeChild ^js item-el counter-el)))) + ;; Guard against race condition: if the user navigates away + ;; before the RAF fires, item-el may have been unmounted and + ;; counter-el is no longer a child — removeChild would throw. + (ts/raf #(when (dom/child? counter-el item-el) + (dom/remove-child! item-el counter-el))))) (defn on-asset-drag-start [event file-id asset selected item-ref asset-type on-drag-start] diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs index 077742d71d6..534f6108678 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs @@ -35,7 +35,6 @@ [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def drag-data* (atom {:is-local false})) @@ -373,8 +372,8 @@ (let [params {:file-id file-id :blobs (seq blobs)}] (st/emit! (dwm/upload-media-components params) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "components"}))))) + (ev/event {::ev/name "add-asset-to-library" + :asset-type "components"}))))) on-duplicate (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs index 2fc62e001d7..60347951bff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs @@ -32,7 +32,6 @@ [app.util.keyboard :as kbd] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -94,7 +93,7 @@ (mf/use-fn (fn [ev] (dom/stop-propagation ev) - (st/emit! (ptk/data-event ::ev/event {::ev/name "navigate-to-library-file"}))))] + (st/emit! (ev/event {::ev/name "navigate-to-library-file"}))))] [:div {:class (stl/css-case :library-title true diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index 8ad40c2f4ed..b16303d963c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -28,7 +28,6 @@ [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def lens:typography-section-state @@ -97,10 +96,10 @@ (mf/deps typography on-asset-click read-only? local?) (fn [event] (when-not read-only? - (st/emit! (ptk/data-event ::ev/event - {::ev/name "use-library-typography" - ::ev/origin "sidebar" - :external-library (not local?)})) + (st/emit! (ev/event + {::ev/name "use-library-typography" + ::ev/origin "sidebar" + :external-library (not local?)})) (when-not (on-asset-click event (:id typography)) (st/emit! (dwt/apply-typography typography file-id))))))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index eac46af1169..a74d9f6203e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -49,16 +49,16 @@ • :prop → the property type (:fill, :stroke, :shadow, etc.) • :shape-id → the UUID of the shape using this color • :index → index of the color in the shape's fill/stroke list - + Example of groups: { {:color \"#9f2929\", :opacity 0.3, :token-name \"asd2\" :has-token-applied true} [{:prop :fill, :shape-id #uuid \"d0231035-25c9-80d5-8006-eae4c3dff32e\", :index 0}] - + {:color \"#1b54b6\", :opacity 1} [{:prop :fill, :shape-id #uuid \"aab34f9a-98c1-801a-8006-eae5e8236f1b\", :index 0}] } - + This structure allows fast lookups of all shapes using the same visual color, regardless of whether it comes from local fills, strokes or shadow-colors." @@ -69,12 +69,10 @@ ;; Unique color attribute maps all-colors (distinct (mapv :attrs data)) - ;; Split into: library colors, token colors, and plain colors - library-colors (filterv :ref-id all-colors) + ;; ;; Split into: library colors, token colors, and plain colors token-colors (filterv :token-name all-colors) - colors (filterv #(and (nil? (:ref-id %)) - (not (:token-name %))) - all-colors)] + library-colors (filterv (fn [c] (and (some? (:ref-id c)) (nil? (:token-name c)))) all-colors) + colors (filterv (fn [c] (and (nil? (:ref-id c)) (nil? (:token-name c)))) all-colors)] {:groups groups :all-colors all-colors :colors colors @@ -107,12 +105,11 @@ open? (deref open*) has-colors? (or (some? (seq colors)) (some? (seq library-colors))) - toggle-content (mf/use-fn #(swap! open* not)) - expand-lib-color (mf/use-state false) - expand-color (mf/use-state false) - expand-token-color (mf/use-state false) + expand-lib-color (mf/use-state false) + expand-color (mf/use-state false) + expand-token-color (mf/use-state false) ;; TODO: Review if this is still necessary. prev-colors-ref (mf/use-ref nil) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index ca3e53ee1a8..acc1e8db02f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -539,16 +539,18 @@ [:map [:values schema:layout-item-props-schema] [:applied-tokens [:maybe [:map-of :keyword :string]]] - [:ids [::sm/vec ::sm/uuid]] - [:v-sizing {:optional true} [:maybe [:= :fill]]]]) + [:ids [::sm/vec ::sm/uuid]]]) (mf/defc layout-size-constraints* {::mf/private true ::mf/schema (sm/schema schema:layout-size-constraints)} - [{:keys [values v-sizing ids applied-tokens] :as props}] + [{:keys [values ids applied-tokens] :as props}] (let [token-numeric-inputs (features/use-feature "tokens/numeric-input") + v-sizing + (:layout-item-v-sizing values) + min-w (get values :layout-item-min-w) max-w (get values :layout-item-max-w) @@ -914,5 +916,4 @@ (= v-sizing :fill)) [:> layout-size-constraints* {:ids ids :values values - :applied-tokens applied-tokens - :v-sizing v-sizing}])])])) + :applied-tokens applied-tokens}])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index c35bac471e2..b3d08a9eed6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -194,7 +194,9 @@ [:div {:class (stl/css-case :stroke-content true :stroke-content-empty (not has-strokes?))} (cond - (= :multiple strokes) + (or (= :multiple (:stroke-color applied-tokens)) + (= :multiple (:stroke-width applied-tokens)) + (= :multiple strokes)) [:div {:class (stl/css :stroke-multiple)} [:div {:class (stl/css :stroke-multiple-label)} (tr "settings.multiple")] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index 71e6ec2eb94..4cac27324ea 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -71,12 +71,13 @@ [{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}] (let [;; `active-tokens` may be provided as a `delay` (lazy computation). ;; In that case we must deref it (`@active-tokens`) to force evaluation - ;; and obtain the actual value. If it’s already realized (not a delay), + ;; and obtain the actual value. If it's already realized (not a delay), ;; we just use it directly. active-tokens (if (delay? active-tokens) @active-tokens active-tokens) + active-color-tokens (:color active-tokens) token (some #(when (= (:name %) applied-token-name) %) active-color-tokens) @@ -345,9 +346,14 @@ :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot))] + (when (= applied-token :multiple) + ;; (js/console.trace "color-row*") + (prn "color-row*" index color applied-token)) + (mf/with-effect [color prev-color disable-picker] (when (and (not disable-picker) (not= prev-color color)) (modal/update-props! :colorpicker {:data (parse-color color)}))) + [:div {:class [class row-class]} ;; Drag handler (when (some? on-reorder) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 7cb0956d113..64f34b7927a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -192,6 +192,8 @@ (when (some? on-reorder) [:> reorder-handler* {:ref dref}]) + (prn "stroke-row*" applied-tokens) + ;; Stroke Color ;; FIXME: memorize stroke color [:> color-row* {:color (ctc/stroke->color stroke) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index a58e1512ba4..4934066f7a6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -89,11 +89,13 @@ on-delete (mf/use-fn (mf/deps id) - #(st/emit! (modal/show - {:type :confirm - :title (tr "modals.delete-page.title") - :message (tr "modals.delete-page.body") - :on-accept delete-fn}))) + (fn [event] + (dom/stop-propagation event) + (st/emit! (modal/show + {:type :confirm + :title (tr "modals.delete-page.title") + :message (tr "modals.delete-page.body") + :on-accept delete-fn})))) on-double-click (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index 5325102a051..c157853fe7a 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -19,7 +19,6 @@ [app.util.i18n :refer [tr]] [app.util.object :as obj] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc typography-item @@ -38,8 +37,7 @@ :typography-ref-id (:id typography)} (dissoc typography :id :name))] - (st/emit! (ptk/event - ::ev/event + (st/emit! (ev/event {::ev/name "use-library-typography" ::ev/origin "text-palette" :external-library (not= file-id current-file-id)})) diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs index 0db9bc5fcb7..4554ce76d12 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs @@ -23,7 +23,6 @@ [app.util.i18n :refer [tr]] [app.util.webapi :as wapi] [app.util.zip :as zip] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc export-tab* @@ -61,7 +60,7 @@ (mf/deps tokens-json) (fn [] (when tokens-json - (st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens" :type "single"})) + (st/emit! (ev/event {::ev/name "export-tokens" :type "single"})) (->> (wapi/create-blob (or tokens-json "{}") "application/json") (dom/trigger-download "tokens.json")))))] [:> export-tab* {:is-disabled is-disabled @@ -88,7 +87,7 @@ (mf/use-fn (mf/deps files) (fn [] - (st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens" :type "multiple"})) + (st/emit! (ev/event {::ev/name "export-tokens" :type "multiple"})) (download-tokens-zip! files)))] [:> export-tab* {:on-export on-export :is-disabled is-disabled} diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs index 8e47009bbbe..ac2e265929e 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs @@ -27,7 +27,6 @@ [app.util.zip :as uz] [beicon.v2.core :as rx] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn- on-stream-imported @@ -35,7 +34,7 @@ (rx/sub! tokens-lib-stream (fn [lib] - (st/emit! (ptk/data-event ::ev/event {::ev/name "import-tokens" :type type}) + (st/emit! (ev/event {::ev/name "import-tokens" :type type}) (dwtl/import-tokens-lib lib)) (modal/hide!)) (fn [err] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index ab0dc6326da..8efa0ba66c9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -291,7 +291,7 @@ :r4 "Bottom Left" :r3 "Bottom Right"} :hint (tr "workspace.tokens.radius") - :on-update-shape-all dwta/update-shape-radius-all + :on-update-shape-all dwta/update-shape-radius :on-update-shape update-shape-radius-for-corners}) shadow (partial generic-attribute-actions #{:shadow} "Shadow")] {:border-radius border-radius @@ -517,7 +517,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] (mf/use-effect (mf/deps is-open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index d37e628d025..f150240cf15 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -36,7 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container) + container (hooks/use-portal-container :popup) delete-node (mf/use-fn (mf/deps mdata) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs index e7b9bf98c68..490286dff01 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs @@ -7,8 +7,7 @@ [app.main.data.notifications :as ntf] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] - [app.util.i18n :refer [tr]] - [potok.v2.core :as ptk])) + [app.util.i18n :refer [tr]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS - Shared functions for token sets management @@ -37,7 +36,7 @@ [tokens-lib parent-set name] (let [name (ctob/make-child-name parent-set name) errors (sm/validation-errors name (cfo/make-token-set-name-schema tokens-lib nil))] - (st/emit! (ptk/data-event ::ev/event {::ev/name "create-token-set" :name name}) + (st/emit! (ev/event {::ev/name "create-token-set" :name name}) (dwtl/clear-token-set-creation)) (if (empty? errors) (let [token-set (ctob/make-token-set :name name)] diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs index a9fa7e59989..d314eb4a51b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs @@ -33,7 +33,6 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as k] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) ;; Form Component -------------------------------------------------------------- @@ -412,7 +411,7 @@ on-save (mf/use-fn (fn [theme] - (st/emit! (ptk/event ::ev/event {::ev/name "create-tokens-theme"}) + (st/emit! (ev/event {::ev/name "create-tokens-theme"}) (dwtl/create-token-theme theme)))) has-prev-view (has-prev-view (:prev-type state))] diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index a8687c97195..d688588e2fb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -114,7 +114,7 @@ :is-open? true :rect rect)))))) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 54a6bca3e06..634c98ee6dc 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -24,7 +24,6 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.timers :as ts] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc image-upload @@ -209,8 +208,8 @@ :aria-label (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) :class (stl/css :main-toolbar-options-button) :on-click #(st/emit! - (ptk/data-event ::ev/event {::ev/name "open-plugins-manager" - ::ev/origin "workspace:toolbar"}) + (ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:toolbar"}) (modal/show :plugin-management {})) :data-tool "plugins" :data-testid "plugins-btn"} diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index b0e540ac713..b3f725d898d 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -81,6 +81,7 @@ selected)) (mf/defc viewport-classic* + {::mf/private true} [{:keys [selected wglobal layout file page palete-size]}] (let [{:keys [edit-path panning @@ -108,8 +109,8 @@ ;; DEREFS drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) - file-id (get file :id) + vern (get file :vern) page-id (get page :id) objects (get page :objects) background (get page :background clr/canvas) @@ -340,7 +341,7 @@ :opacity 0.6}} (when (and (:can-edit permissions) (not read-only?)) [:& stvh/viewport-texts - {:key (dm/str "texts-" page-id) + {:key (dm/str "viewport-texts-" page-id "-" vern) :page-id page-id :objects objects :modifiers modifiers @@ -366,7 +367,7 @@ :xmlnsXlink "http://www.w3.org/1999/xlink" :xmlns:penpot "https://penpot.app/xmlns" :preserveAspectRatio "xMidYMid meet" - :key (str "render" page-id) + :key (dm/str "viewport-svg-" page-id "-" vern) :width (:width vport 0) :height (:height vport 0) :view-box (utils/format-viewbox vbox) @@ -400,7 +401,7 @@ [:& (mf/provider ctx/current-vbox) {:value vbox'} [:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)} ;; Render root shape - [:& shapes/root-shape {:key page-id + [:& shapes/root-shape {:key (str page-id) :objects base-objects :active-frames @active-frames}]]]] @@ -408,7 +409,7 @@ {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" :preserveAspectRatio "xMidYMid meet" - :key (str "viewport" page-id) + :key (dm/str "viewport-controls-" page-id "-" vern) :view-box (utils/format-viewbox vbox) :ref on-viewport-ref :class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls)) @@ -719,7 +720,7 @@ (not= @hover-top-frame-id (:id frame))) [:& grid-layout/editor {:zoom zoom - :key (dm/str (:id frame)) + :key (dm/str "viewport-frame-" (:id frame)) :objects base-objects :modifiers modifiers :shape frame @@ -733,8 +734,11 @@ :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) (mf/defc viewport* - [props] - (let [wasm-renderer-enabled? (features/use-feature "render-wasm/v1")] - (if ^boolean wasm-renderer-enabled? - [:> viewport.wasm/viewport* props] - [:> viewport-classic* props]))) + [{:keys [file page] :as props}] + (let [vern (get file :vern) + page-id (get page :id) + render-wasm? (features/use-feature "render-wasm/v1")] + [:* {:key (dm/str "viewport-" page-id "-" vern)} + (if ^boolean render-wasm? + [:> viewport.wasm/viewport* props] + [:> viewport-classic* props])])) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 9d101982a77..104a409dd3d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -162,14 +162,16 @@ (let [name-input (mf/ref-val ref) name (str/trim (dom/get-value name-input))] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id name))))) + (st/emit! (dw/end-rename-shape frame-id name)) + (on-frame-leave frame-id)))) cancel-edit (mf/use-fn (mf/deps frame-id) (fn [] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id nil)))) + (st/emit! (dw/end-rename-shape frame-id nil)) + (on-frame-leave frame-id))) on-key-down (mf/use-fn diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 36f31ff1101..3ce022753e1 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -8,6 +8,7 @@ "RPC for plugins runtime." (:require ["@penpot/plugins-runtime" :as runtime] + [app.main.errors :as errors] [app.main.features :as features] [app.main.store :as st] [app.plugins.api :as api] @@ -30,6 +31,8 @@ (ptk/reify ::initialize ptk/WatchEvent (watch [_ _ stream] + (set! errors/is-plugin-error? runtime/isPluginError) + (->> stream (rx/filter (ptk/type? ::features/initialize)) (rx/observe-on :async) diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index febb24e4bae..d02bea0cff8 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -14,9 +14,11 @@ [app.common.geom.point :as gpt] [app.common.schema :as sm] [app.common.types.color :as ctc] + [app.common.types.component :as ctk] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.changes :as ch] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] @@ -26,6 +28,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.fonts :refer [fetch-font-css]] @@ -82,6 +85,10 @@ :$plugin {:enumerable false :get (fn [] plugin-id)} ;; Public properties + :version + {:this true + :get (constantly (:base cf/version))} + :root {:this true :get #(.getRoot ^js %)} @@ -110,7 +117,7 @@ (fn [_ shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :selection shapes) + (u/not-valid plugin-id :selection shapes) :else (let [ids (into (d/ordered-set) (map #(obj/get % "$id")) shapes)] @@ -175,7 +182,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :shapesColors-shapes shapes) + (u/not-valid plugin-id :shapesColors-shapes shapes) :else (let [objects (u/locate-objects) @@ -195,13 +202,13 @@ new-color (parser/parse-color-data new-color)] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :replaceColor-shapes shapes) + (u/not-valid plugin-id :replaceColor-shapes shapes) (not (sm/validate ctc/schema:color old-color)) - (u/display-not-valid :replaceColor-oldColor old-color) + (u/not-valid plugin-id :replaceColor-oldColor old-color) (not (sm/validate ctc/schema:color new-color)) - (u/display-not-valid :replaceColor-newColor new-color) + (u/not-valid plugin-id :replaceColor-newColor new-color) :else (let [file-id (:current-file-id @st/state) @@ -254,10 +261,10 @@ (fn [name url] (cond (not (string? name)) - (u/display-not-valid :uploadMedia-name name) + (u/not-valid plugin-id :uploadMedia-name name) (not (string? url)) - (u/display-not-valid :uploadMedia-url url) + (u/not-valid plugin-id :uploadMedia-url url) :else (let [file-id (:current-file-id @st/state)] @@ -266,6 +273,7 @@ (->> (dwm/upload-media-url name file-id url) (rx/take 1) (rx/map format/format-image) + (rx/tap #(st/emit! (se/event plugin-id "add-media"))) (rx/subs! resolve reject))))))) :uploadMediaData @@ -282,13 +290,14 @@ :on-svg identity}) (rx/take 1) (rx/map format/format-image) + (rx/tap #(st/emit! (se/event plugin-id "add-media"))) (rx/subs! resolve reject)))))) :group (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :group-shapes shapes) + (u/not-valid plugin-id :group-shapes shapes) :else (let [file-id (:current-file-id @st/state) @@ -303,10 +312,10 @@ (fn [group & rest] (cond (not (shape/shape-proxy? group)) - (u/display-not-valid :ungroup group) + (u/not-valid plugin-id :ungroup group) (and (some? rest) (not (every? shape/shape-proxy? rest))) - (u/display-not-valid :ungroup rest) + (u/not-valid plugin-id :ungroup rest) :else (let [shapes (concat [group] rest) @@ -346,7 +355,7 @@ (fn [text] (cond (or (not (string? text)) (empty? text)) - (u/display-not-valid :createText text) + (u/not-valid plugin-id :createText text) :else (let [page (dsh/lookup-page @st/state) @@ -377,7 +386,7 @@ (fn [svg-string] (cond (or (not (string? svg-string)) (empty? svg-string)) - (u/display-not-valid :createShapeFromSvg svg-string) + (u/not-valid plugin-id :createShapeFromSvg svg-string) :else (let [id (uuid/next) @@ -394,7 +403,7 @@ (cond (or (not (string? svg-string)) (empty? svg-string)) (do - (u/display-not-valid :createShapeFromSvg "Svg not valid") + (u/not-valid plugin-id :createShapeFromSvg "Svg not valid") (reject "Svg not valid")) :else @@ -412,10 +421,10 @@ (let [bool-type (keyword bool-type)] (cond (not (contains? cts/bool-types bool-type)) - (u/display-not-valid :createBoolean-boolType bool-type) + (u/not-valid plugin-id :createBoolean-boolType bool-type) (or (not (array? shapes)) (empty? shapes) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :createBoolean-shapes shapes) + (u/not-valid plugin-id :createBoolean-shapes shapes) :else (let [ids (into #{} (map #(obj/get % "$id")) shapes) @@ -429,10 +438,10 @@ (let [type (d/nilv (obj/get options "type") "html")] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :generateMarkup-shapes shapes) + (u/not-valid plugin-id :generateMarkup-shapes shapes) (and (some? type) (not (contains? #{"html" "svg"} type))) - (u/display-not-valid :generateMarkup-type type) + (u/not-valid plugin-id :generateMarkup-type type) :else (let [resolved-code @@ -455,6 +464,7 @@ (conj acc (cg/generate-formatted-markup-code objects type resolved-shapes)))) []))] + (st/emit! (se/event plugin-id "copy-inspect-code")) (->> resolved-code (str/join "\n")))))) :generateStyle @@ -464,16 +474,16 @@ children? (d/nilv (obj/get options "includeChildren") true)] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :generateStyle-shapes shapes) + (u/not-valid plugin-id :generateStyle-shapes shapes) (and (some? type) (not (contains? #{"css"} type))) - (u/display-not-valid :generateStyle-type type) + (u/not-valid plugin-id :generateStyle-type type) (and (some? prelude?) (not (boolean? prelude?))) - (u/display-not-valid :generateStyle-withPrelude prelude?) + (u/not-valid plugin-id :generateStyle-withPrelude prelude?) (and (some? children?) (not (boolean? children?))) - (u/display-not-valid :generateStyle-includeChildren children?) + (u/not-valid plugin-id :generateStyle-includeChildren children?) :else (let [resolved-styles @@ -501,6 +511,7 @@ (cg/generate-style-code objects type shapes resolved-shapes {:with-prelude? prelude?})))) []))] + (st/emit! (se/event plugin-id "copy-inspect-style")) (dm/str (if prelude? (cg/prelude type) "") (->> resolved-styles @@ -535,7 +546,8 @@ (fn [] (let [file-id (:current-file-id @st/state) id (uuid/next)] - (st/emit! (dw/create-page {:page-id id :file-id file-id})) + (st/emit! (-> (dw/create-page {:page-id id :file-id file-id}) + (se/add-event plugin-id))) (page/page-proxy plugin-id file-id id))) :openPage @@ -546,7 +558,7 @@ :else nil) new-window (if (boolean? new-window) new-window false)] (if (nil? id) - (u/display-not-valid :openPage "Expected a Page object or a page UUID string") + (u/not-valid plugin-id :openPage "Expected a Page object or a page UUID string") (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window))))) :alignHorizontal @@ -558,10 +570,10 @@ nil)] (cond (nil? dir) - (u/display-not-valid :alignHorizontal-direction "Direction not valid") + (u/not-valid plugin-id :alignHorizontal-direction "Direction not valid") (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :alignHorizontal-shapes "Not valid shapes") + (u/not-valid plugin-id :alignHorizontal-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -576,10 +588,10 @@ nil)] (cond (nil? dir) - (u/display-not-valid :alignVertical-direction "Direction not valid") + (u/not-valid plugin-id :alignVertical-direction "Direction not valid") (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :alignVertical-shapes "Not valid shapes") + (u/not-valid plugin-id :alignVertical-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -589,7 +601,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :distributeHorizontal-shapes "Not valid shapes") + (u/not-valid plugin-id :distributeHorizontal-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -599,7 +611,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :distributeVertical-shapes "Not valid shapes") + (u/not-valid plugin-id :distributeVertical-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -609,8 +621,42 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :flatten-shapes "Not valid shapes") + (u/not-valid plugin-id :flatten-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dw/convert-selected-to-path ids))))))) + (st/emit! (dw/convert-selected-to-path ids))))) + + :createVariantFromComponents + (fn [shapes] + (cond + (or (not (seq shapes)) + (not (every? u/is-main-component-proxy? shapes))) + (u/not-valid plugin-id :shapes shapes) + + :else + (let [file-id (obj/get (first shapes) "$file") + page-id (obj/get (first shapes) "$page") + ids (->> shapes + (map #(obj/get % "$id")) + (into #{})) + + ;; Check that every component is: + ;; - in the same page + ;; - not already a variant + valid? + (every? + (fn [id] + (let [shape (u/locate-shape file-id page-id id) + component (u/locate-library-component file-id (:component-id shape))] + (not (ctk/is-variant? component)))) + ids)] + (if valid? + (let [variant-id (uuid/next)] + (st/emit! (-> (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id}) + (se/add-event plugin-id))) + (shape/shape-proxy plugin-id variant-id)) + + (u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant"))))))) diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index f3cfdcf9549..bb1361ee717 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -17,6 +17,7 @@ [app.plugins.parser :as parser] [app.plugins.register :as r] [app.plugins.shape :as shape] + [app.plugins.system-events :as se] [app.plugins.user :as user] [app.plugins.utils :as u] [app.util.object :as obj] @@ -60,18 +61,24 @@ (let [profile (:profile @st/state)] (cond (or (not (string? content)) (empty? content)) - (u/display-not-valid :content "Not valid") + (u/not-valid plugin-id :content "Not valid") (not= (:id profile) (:owner-id data)) - (u/display-not-valid :content "Cannot change content from another user's comments") + (u/not-valid plugin-id :content "Cannot change content from another user's comments") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :content "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'comment:write' permission") :else (->> (rp/cmd! :update-comment {:id (:id data) :content content}) (rx/tap #(st/emit! (dc/retrieve-comment-threads file-id))) - (rx/subs! #(swap! data* assoc :content content))))))} + (rx/subs! + (fn [] + (st/emit! (se/event plugin-id "update-comment" + :thread-id thread-id + :id (:id data) + :content-size (count content))) + (swap! data* assoc :content content)))))))} ;; Public methods :remove @@ -81,13 +88,17 @@ (cond (not (r/check-permission plugin-id "comment:write")) (do - (u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") (reject "Plugin doesn't have 'comment:write' permission")) :else (->> (rp/cmd! :delete-comment {:id (:id data)}) (rx/tap #(st/emit! (dc/retrieve-comment-threads file-id))) - (rx/subs! #(resolve) reject))))))))) + (rx/subs! + (fn [] + (st/emit! (se/event plugin-id "update-comment" :thread-id thread-id)) + (resolve)) + reject))))))))) (defn comment-thread-proxy? [p] (obj/type-of? p "CommentThreadProxy")) @@ -120,10 +131,10 @@ (cond (or (not (sm/valid-safe-number? (:x position))) (not (sm/valid-safe-number? (:y position)))) - (u/display-not-valid :position "Not valid point") + (u/not-valid plugin-id :position "Not valid point") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :position "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'comment:write' permission") :else (do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)])) @@ -137,13 +148,14 @@ (fn [is-resolved] (cond (not (boolean? is-resolved)) - (u/display-not-valid :resolved "Not a boolean type") + (u/not-valid plugin-id :resolved "Not a boolean type") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :resolved "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :resolved "Plugin doesn't have 'comment:write' permission") :else - (do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved))) + (do (st/emit! (-> (dc/update-comment-thread (assoc @data* :is-resolved is-resolved)) + (se/add-event plugin-id))) (swap! data* assoc :is-resolved is-resolved))))} :findComments @@ -153,7 +165,7 @@ (cond (not (r/check-permission plugin-id "comment:read")) (do - (u/display-not-valid :findComments "Plugin doesn't have 'comment:read' permission") + (u/not-valid plugin-id :findComments "Plugin doesn't have 'comment:read' permission") (reject "Plugin doesn't have 'comment:read' permission")) :else @@ -169,16 +181,22 @@ (fn [content] (cond (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :reply "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :reply "Plugin doesn't have 'comment:write' permission") (or (not (string? content)) (empty? content)) - (u/display-not-valid :reply "Not valid") + (u/not-valid plugin-id :reply "Not valid") :else (js/Promise. (fn [resolve reject] (->> (rp/cmd! :create-comment {:thread-id (:id data) :content content}) - (rx/subs! #(resolve (comment-proxy plugin-id file-id page-id (:id data) %)) reject)))))) + (rx/subs! + (fn [result] + (st/emit! (se/event plugin-id "create-comment" + :thread-id (:id data) + :file-id file-id + :content-size (count content))) + (resolve (comment-proxy plugin-id file-id page-id (:id data) result))) reject)))))) :remove (fn [] @@ -186,12 +204,13 @@ owner (dsh/lookup-profile @st/state (:owner-id data))] (cond (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") (not= (:id profile) owner) - (u/display-not-valid :remove "Cannot change content from another user's comments") + (u/not-valid plugin-id :remove "Cannot change content from another user's comments") :else (js/Promise. (fn [resolve] - (st/emit! (dc/delete-comment-thread-on-workspace {:id (:id data)} #(resolve))))))))))) + (st/emit! (-> (dc/delete-comment-thread-on-workspace {:id (:id data)} #(resolve)) + (se/add-event plugin-id))))))))))) diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 54fcb2f8c3d..b53f4a59408 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -22,6 +22,7 @@ [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.register :as r] + [app.plugins.system-events :as se] [app.plugins.user :as user] [app.plugins.utils :as u] [app.util.http :as http] @@ -45,16 +46,16 @@ (fn [value] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :label "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :label "Plugin doesn't have 'content:write' permission") (or (not (string? value)) (empty? value)) - (u/display-not-valid :label value) + (u/not-valid plugin-id :label value) :else (do (swap! data assoc :label value :created-by "user") (->> (rp/cmd! :update-file-snapshot {:id (:id @data) :label value}) (rx/take 1) - (rx/subs! identity)))))} + (rx/subs! #(st/emit! (se/event "rename-version" :file-id file-id)))))))} :createdBy {:get @@ -78,7 +79,9 @@ :else (let [version-id (get @data :id)] - (st/emit! (dwv/restore-version-from-plugin file-id version-id resolve reject))))))) + (st/emit! + (dwv/restore-version-from-plugin file-id version-id resolve reject) + (se/event plugin-id "restore-version" :file-id file-id))))))) :remove (fn [] @@ -110,10 +113,12 @@ :label (ct/format-inst (:created-at @data) :localized-date)}] (->> (rx/zip (rp/cmd! :get-team-users {:file-id file-id}) (rp/cmd! :update-file-snapshot params)) - (rx/subs! (fn [[users data]] - (let [users (d/index-by :id users)] - (resolve (file-version-proxy plugin-id file-id users @data)))) - reject)))))))))) + (rx/subs! + (fn [[users data]] + (let [users (d/index-by :id users)] + (st/emit! (se/event plugin-id "pin-version" :file-id file-id)) + (resolve (file-version-proxy plugin-id file-id users @data)))) + reject)))))))))) (defn file-proxy? [p] (obj/type-of? p "FileProxy")) @@ -145,7 +150,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData-key key) + (u/not-valid plugin-id :getPluginData-key key) :else (let [file (u/locate-file id)] @@ -155,13 +160,13 @@ (fn [key value] (cond (or (not (string? key)) (empty? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (not (string? value)) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data id :file (keyword "plugin" (str plugin-id)) key value)))) @@ -175,10 +180,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [file (u/locate-file id)] @@ -188,16 +193,16 @@ (fn [namespace key value] (cond (or (not (string? namespace)) (empty? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (or (not (string? key)) (empty? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (not (string? value)) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data id :file (keyword "shared" namespace) key value)))) @@ -206,7 +211,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys namespace) :else (let [file (u/locate-file id)] @@ -216,11 +221,12 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :createPage "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :createPage "Plugin doesn't have 'content:write' permission") :else (let [page-id (uuid/next)] - (st/emit! (dw/create-page {:page-id page-id :file-id id})) + (st/emit! (-> (dw/create-page {:page-id page-id :file-id id}) + (se/add-event plugin-id))) (page/page-proxy plugin-id id page-id)))) :export @@ -269,6 +275,7 @@ :response-type :buffer})))) (rx/take 1) (rx/map #(js/Uint8Array. (:body %))) + (rx/tap #(st/emit! (se/event plugin-id "export-binary-files" :format format :type type))) (rx/subs! resolve reject)))))))) :findVersions (fn [criteria] @@ -315,7 +322,9 @@ (u/reject-not-valid reject :findVersions "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwv/create-version-from-plugins id label resolve reject)))))] + (st/emit! + (dwv/create-version-from-plugins id label resolve reject) + (se/event plugin-id "create-version" :file-id id)))))] (-> (js/Promise.all #js [users-promise create-version-promise]) (.then (fn [[users data]] diff --git a/frontend/src/app/plugins/flags.cljs b/frontend/src/app/plugins/flags.cljs index a9f1a6dce7e..c28623ac620 100644 --- a/frontend/src/app/plugins/flags.cljs +++ b/frontend/src/app/plugins/flags.cljs @@ -6,17 +6,11 @@ (ns app.plugins.flags (:require - [app.common.data.macros :as dm] [app.main.store :as st] [app.plugins.utils :as u] [app.util.object :as obj] [potok.v2.core :as ptk])) -(defn natural-child-ordering? - [plugin-id] - (boolean - (dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering]))) - (defn clear [id] (ptk/reify ::reset @@ -37,13 +31,27 @@ :naturalChildOrdering {:this false :get - (fn [] (natural-child-ordering? plugin-id)) + (fn [] (u/natural-child-ordering? plugin-id)) + + :set + (fn [value] + (cond + (not (boolean? value)) + (u/not-valid plugin-id :naturalChildOrdering value) + + :else + (st/emit! (set-flag plugin-id :natural-child-ordering value))))} + + :throwValidationErrors + {:this false + :get + (fn [] (u/throw-validation-errors? plugin-id)) :set (fn [value] (cond (not (boolean? value)) - (u/display-not-valid :naturalChildOrdering value) + (u/not-valid plugin-id :throwValidationErrors value) :else - (st/emit! (set-flag plugin-id :natural-child-ordering value))))})) + (st/emit! (set-flag plugin-id :throw-validation-errors value))))})) diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index a1c7ef754c6..2b7cd0802f7 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -12,8 +12,8 @@ [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] [app.main.store :as st] - [app.plugins.flags :refer [natural-child-ordering?]] [app.plugins.register :as r] + [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj])) @@ -39,10 +39,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/flex-direction-types value)) - (u/display-not-valid :dir value) + (u/not-valid plugin-id :dir value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :dir "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value})))))} @@ -55,10 +55,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/wrap-types value)) - (u/display-not-valid :wrap value) + (u/not-valid plugin-id :wrap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :wrap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :wrap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value})))))} @@ -71,10 +71,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-items-types value)) - (u/display-not-valid :alignItems value) + (u/not-valid plugin-id :alignItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} @@ -87,10 +87,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-content-types value)) - (u/display-not-valid :alignContent value) + (u/not-valid plugin-id :alignContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} @@ -103,10 +103,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-items-types value)) - (u/display-not-valid :justifyItems value) + (u/not-valid plugin-id :justifyItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} @@ -119,10 +119,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-content-types value)) - (u/display-not-valid :justifyContent value) + (u/not-valid plugin-id :justifyContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} @@ -134,10 +134,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowGap value) + (u/not-valid plugin-id :rowGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} @@ -149,10 +149,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnGap value) + (u/not-valid plugin-id :columnGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} @@ -164,10 +164,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :verticalPadding value) + (u/not-valid plugin-id :verticalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} @@ -179,15 +179,14 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} - :topPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) @@ -195,10 +194,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :topPadding value) + (u/not-valid plugin-id :topPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} @@ -210,10 +209,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rightPadding value) + (u/not-valid plugin-id :rightPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rightPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rightPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} @@ -225,10 +224,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :bottomPadding value) + (u/not-valid plugin-id :bottomPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} @@ -240,10 +239,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :leftPadding value) + (u/not-valid plugin-id :leftPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} @@ -256,16 +255,21 @@ (fn [child] (cond (not (shape-proxy? child)) - (u/display-not-valid :appendChild child) + (u/not-valid plugin-id :appendChild child) :else (let [child-id (obj/get child "$id") shape (u/locate-shape file-id page-id id) + child-shape (u/locate-shape file-id page-id child-id) index - (if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) + (if (and (u/natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) 0 (count (:shapes shape)))] - (st/emit! (dwsh/relocate-shapes #{child-id} id index))))) + (st/emit! + (dwsh/relocate-shapes #{child-id} id index) + (se/event plugin-id "add-layout-element" + :type (:type child-shape) + :parent-type (:type shape)))))) :horizontalSizing {:this true @@ -275,10 +279,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-h-sizing-types value)) - (u/display-not-valid :horizontalSizing value) + (u/not-valid plugin-id :horizontalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} @@ -291,10 +295,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-v-sizing-types value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))})) @@ -317,10 +321,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :absolute value) + (u/not-valid plugin-id :absolute value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :absolute "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :absolute "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-absolute value}))))} @@ -332,10 +336,10 @@ (fn [_ value] (cond (sm/valid-safe-int? value) - (u/display-not-valid :zIndex value) + (u/not-valid plugin-id :zIndex value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :zIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :zIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-z-index value}))))} @@ -348,10 +352,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-h-sizing-types value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))} @@ -364,10 +368,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-v-sizing-types value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))} @@ -380,10 +384,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-align-self-types value)) - (u/display-not-valid :alignSelf value) + (u/not-valid plugin-id :alignSelf value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-align-self value})))))} @@ -395,10 +399,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :verticalMargin value) + (u/not-valid plugin-id :verticalMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))} @@ -410,10 +414,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :horizontalMargin value) + (u/not-valid plugin-id :horizontalMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))} @@ -425,10 +429,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :topMargin value) + (u/not-valid plugin-id :topMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))} @@ -440,10 +444,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :rightMargin value) + (u/not-valid plugin-id :rightMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rightMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rightMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))} @@ -455,10 +459,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :bottomMargin value) + (u/not-valid plugin-id :bottomMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))} @@ -470,10 +474,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :leftMargin value) + (u/not-valid plugin-id :leftMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))} @@ -485,10 +489,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :maxWidth value) + (u/not-valid plugin-id :maxWidth value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :maxWidth "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :maxWidth "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-w value}))))} @@ -500,10 +504,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :minWidth value) + (u/not-valid plugin-id :minWidth value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :minWidth "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :minWidth "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-w value}))))} @@ -515,10 +519,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :maxHeight value) + (u/not-valid plugin-id :maxHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :maxHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :maxHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-h value}))))} @@ -530,10 +534,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :minHeight value) + (u/not-valid plugin-id :minHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :minHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :minHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-h value}))))})) diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 77602816f6e..13996fabc22 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -32,7 +32,7 @@ (obj/type-of? p "FontProxy")) (defn font-proxy - [{:keys [id family name variants] :as font}] + [plugin-id {:keys [id family name variants] :as font}] (when (some? font) (let [default-variant (fonts/get-default-variant font)] (obj/reify {:name "FontProxy"} @@ -55,10 +55,10 @@ (fn [text variant] (cond (not (shape/shape-proxy? text)) - (u/display-not-valid :applyToText text) + (u/not-valid plugin-id :applyToText text) (not (r/check-permission (obj/get text "$plugin") "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get text "$id") @@ -73,10 +73,10 @@ (fn [range variant] (cond (not (text/text-range-proxy? range)) - (u/display-not-valid :applyToRange range) + (u/not-valid plugin-id :applyToRange range) (not (r/check-permission (obj/get range "$plugin") "content:write")) - (u/display-not-valid :applyToRange "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToRange "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get range "$id") @@ -98,53 +98,53 @@ {:get (fn [] (format/format-array - font-proxy + (partial font-proxy plugin-id) (vals @fonts/fontsdb)))} :findById (fn [id] (cond (not (string? id)) - (u/display-not-valid :findbyId id) + (u/not-valid plugin-id :findbyId id) :else (->> (vals @fonts/fontsdb) (d/seek #(str/includes? (str/lower (:id %)) (str/lower id))) - (font-proxy)))) + (font-proxy plugin-id)))) :findByName (fn [name] (cond (not (string? name)) - (u/display-not-valid :findByName name) + (u/not-valid plugin-id :findByName name) :else (->> (vals @fonts/fontsdb) (d/seek #(str/includes? (str/lower (:name %)) (str/lower name))) - (font-proxy)))) + (font-proxy plugin-id)))) :findAllById (fn [id] (cond (not (string? id)) - (u/display-not-valid :findAllById name) + (u/not-valid plugin-id :findAllById name) :else (format/format-array (fn [font] (when (str/includes? (str/lower (:id font)) (str/lower id)) - (font-proxy font))) + (font-proxy plugin-id font))) (vals @fonts/fontsdb)))) :findAllByName (fn [name] (cond (not (string? name)) - (u/display-not-valid :findAllByName name) + (u/not-valid plugin-id :findAllByName name) :else (format/format-array (fn [font] (when (str/includes? (str/lower (:name font)) (str/lower name)) - (font-proxy font))) + (font-proxy plugin-id font))) (vals @fonts/fontsdb)))))) diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 9af11c2dded..f0ff928fc8e 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -598,3 +598,10 @@ (case axis :y "horizontal" :x "vertical")) + +(defn format-geom-rect + [{:keys [x y width height]}] + #js {:x x + :y y + :width width + :height height}) diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index f57873ec31b..351b673baf4 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -40,10 +40,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/grid-direction-types value)) - (u/display-not-valid :dir value) + (u/not-valid plugin-id :dir value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :dir "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))} @@ -64,10 +64,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-items-types value)) - (u/display-not-valid :alignItems value) + (u/not-valid plugin-id :alignItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} @@ -80,10 +80,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-content-types value)) - (u/display-not-valid :alignContent value) + (u/not-valid plugin-id :alignContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} @@ -96,10 +96,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-items-types value)) - (u/display-not-valid :justifyItems value) + (u/not-valid plugin-id :justifyItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} @@ -112,10 +112,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-content-types value)) - (u/display-not-valid :justifyContent value) + (u/not-valid plugin-id :justifyContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} @@ -127,10 +127,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowGap value) + (u/not-valid plugin-id :rowGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} @@ -142,10 +142,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnGap value) + (u/not-valid plugin-id :columnGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} @@ -157,10 +157,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :verticalPadding value) + (u/not-valid plugin-id :verticalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} @@ -172,10 +172,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} @@ -187,10 +187,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :topPadding value) + (u/not-valid plugin-id :topPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} @@ -202,10 +202,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rightPadding value) + (u/not-valid plugin-id :rightPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :righPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :righPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} @@ -217,10 +217,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :bottomPadding value) + (u/not-valid plugin-id :bottomPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} @@ -232,10 +232,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :leftPadding value) + (u/not-valid plugin-id :leftPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} @@ -245,14 +245,14 @@ (let [type (keyword type)] (cond (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addRow-type type) + (u/not-valid plugin-id :addRow-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addRow-value value) + (u/not-valid plugin-id :addRow-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value}))))) @@ -262,17 +262,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :addRowAtIndex-index index) + (u/not-valid plugin-id :addRowAtIndex-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addRowAtIndex-type type) + (u/not-valid plugin-id :addRowAtIndex-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addRowAtIndex-value value) + (u/not-valid plugin-id :addRowAtIndex-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRowAtIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRowAtIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index))))) @@ -282,14 +282,14 @@ (let [type (keyword type)] (cond (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addColumn-type type) + (u/not-valid plugin-id :addColumn-type type) (and (or (= :percent type) (= :flex type) (= :lex type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addColumn-value value) + (u/not-valid plugin-id :addColumn-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value}))))) @@ -298,17 +298,17 @@ (fn [index type value] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :addColumnAtIndex-index index) + (u/not-valid plugin-id :addColumnAtIndex-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addColumnAtIndex-type type) + (u/not-valid plugin-id :addColumnAtIndex-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addColumnAtIndex-value value) + (u/not-valid plugin-id :addColumnAtIndex-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addColumnAtIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") :else (let [type (keyword type)] @@ -318,10 +318,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :removeRow index) + (u/not-valid plugin-id :removeRow index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout-track #{id} :row index)))) @@ -330,10 +330,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :removeColumn index) + (u/not-valid plugin-id :removeColumn index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout-track #{id} :column index)))) @@ -343,17 +343,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setColumn-index index) + (u/not-valid plugin-id :setColumn-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :setColumn-type type) + (u/not-valid plugin-id :setColumn-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :setColumn-value value) + (u/not-valid plugin-id :setColumn-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value})))))) @@ -363,17 +363,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setRow-index index) + (u/not-valid plugin-id :setRow-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :setRow-type type) + (u/not-valid plugin-id :setRow-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :setRow-value value) + (u/not-valid plugin-id :setRow-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value})))))) @@ -382,7 +382,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :remove "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout #{id})))) @@ -391,16 +391,16 @@ (fn [child row column] (cond (not (shape-proxy? child)) - (u/display-not-valid :appendChild-child child) + (u/not-valid plugin-id :appendChild-child child) (or (< row 0) (not (sm/valid-safe-int? row))) - (u/display-not-valid :appendChild-row row) + (u/not-valid plugin-id :appendChild-row row) (or (< column 0) (not (sm/valid-safe-int? column))) - (u/display-not-valid :appendChild-column column) + (u/not-valid plugin-id :appendChild-column column) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id")] @@ -432,13 +432,13 @@ shape (u/proxy->shape self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :row-value value) + (u/not-valid plugin-id :row-value value) (nil? cell) - (u/display-not-valid :row-cell "cell not found") + (u/not-valid plugin-id :row-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :row "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :row "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))} @@ -452,13 +452,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowSpan-value value) + (u/not-valid plugin-id :rowSpan-value value) (nil? cell) - (u/display-not-valid :rowSpan-cell "cell not found") + (u/not-valid plugin-id :rowSpan-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowSpan "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowSpan "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))} @@ -472,13 +472,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :column-value value) + (u/not-valid plugin-id :column-value value) (nil? cell) - (u/display-not-valid :column-cell "cell not found") + (u/not-valid plugin-id :column-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :column "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :column "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))} @@ -492,13 +492,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnSpan-value value) + (u/not-valid plugin-id :columnSpan-value value) (nil? cell) - (u/display-not-valid :columnSpan-cell "cell not found") + (u/not-valid plugin-id :columnSpan-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnSpan "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnSpan "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))} @@ -512,13 +512,13 @@ cell (locate-cell self)] (cond (not (string? value)) - (u/display-not-valid :areaName-value value) + (u/not-valid plugin-id :areaName-value value) (nil? cell) - (u/display-not-valid :areaName-cell "cell not found") + (u/not-valid plugin-id :areaName-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :areaName "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :areaName "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))} @@ -533,13 +533,13 @@ value (keyword value)] (cond (not (contains? ctl/grid-position-types value)) - (u/display-not-valid :position-value value) + (u/not-valid plugin-id :position-value value) (nil? cell) - (u/display-not-valid :position-cell "cell not found") + (u/not-valid plugin-id :position-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :position "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))} @@ -554,13 +554,13 @@ cell (locate-cell self)] (cond (not (contains? ctl/grid-cell-align-self-types value)) - (u/display-not-valid :alignSelf-value value) + (u/not-valid plugin-id :alignSelf-value value) (nil? cell) - (u/display-not-valid :alignSelf-cell "cell not found") + (u/not-valid plugin-id :alignSelf-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))} @@ -575,13 +575,13 @@ cell (locate-cell self)] (cond (not (contains? ctl/grid-cell-justify-self-types value)) - (u/display-not-valid :justifySelf-value value) + (u/not-valid plugin-id :justifySelf-value value) (nil? cell) - (u/display-not-valid :justifySelf-cell "cell not found") + (u/not-valid plugin-id :justifySelf-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifySelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifySelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))}))) diff --git a/frontend/src/app/plugins/history.cljs b/frontend/src/app/plugins/history.cljs index 191dcc0d7ef..25756c47da7 100644 --- a/frontend/src/app/plugins/history.cljs +++ b/frontend/src/app/plugins/history.cljs @@ -24,7 +24,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") :else (let [id (js/Symbol)] @@ -35,10 +35,10 @@ (fn [block-id] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") (not block-id) - (u/display-not-valid :undoBlockFinish block-id) + (u/not-valid plugin-id :undoBlockFinish block-id) :else (st/emit! (dwu/commit-undo-transaction block-id)))))) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index c3dcd9ef284..da0b28b11f3 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -16,7 +16,6 @@ [app.common.types.file :as ctf] [app.common.types.typography :as ctt] [app.common.uuid :as uuid] - [app.main.data.event :as ev] [app.main.data.plugins :as dp] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] @@ -27,6 +26,7 @@ [app.plugins.parser :as parser] [app.plugins.register :as r] [app.plugins.shape :as shape] + [app.plugins.system-events :as se] [app.plugins.text :as text] [app.plugins.tokens :as tokens] [app.plugins.utils :as u] @@ -60,10 +60,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [color (u/proxy->library-color self) @@ -77,10 +77,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -94,10 +94,10 @@ (fn [self value] (cond (or (not (string? value)) (not (clr/valid-hex-color? value))) - (u/display-not-valid :color value) + (u/not-valid plugin-id :color value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :color "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :color "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -111,10 +111,10 @@ (fn [self value] (cond (or (not (number? value)) (< value 0) (> value 1)) - (u/display-not-valid :opacity value) + (u/not-valid plugin-id :opacity value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :opacity "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :opacity "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -129,10 +129,10 @@ (let [value (parser/parse-gradient value)] (cond (not (sm/validate clr/schema:gradient value)) - (u/display-not-valid :gradient value) + (u/not-valid plugin-id :gradient value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :gradient "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :gradient "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -147,10 +147,10 @@ (let [value (parser/parse-image-data value)] (cond (not (sm/validate clr/schema:image value)) - (u/display-not-valid :image value) + (u/not-valid plugin-id :image value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :image "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :image "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -161,22 +161,24 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else - (st/emit! (dwl/delete-color {:id id})))) + (st/emit! (-> (dwl/delete-color {:id id}) + (se/add-event plugin-id))))) :clone (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :clone "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission") :else (let [color-id (uuid/next) color (-> (u/locate-library-color file-id id) (assoc :id color-id))] - (st/emit! (dwl/add-color color {:rename? false})) + (st/emit! (-> (dwl/add-color color {:rename? false}) + (se/add-event plugin-id))) (lib-color-proxy plugin-id id color-id)))) :asFill @@ -207,7 +209,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData-key key) + (u/not-valid plugin-id :getPluginData-key key) :else (let [color (u/locate-library-color file-id id)] @@ -217,16 +219,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :color id (keyword "plugin" (str plugin-id)) key value)))) @@ -240,10 +242,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [color (u/locate-library-color file-id id)] @@ -253,19 +255,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :color id (keyword "shared" namespace) key value)))) @@ -274,7 +276,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys-namespace namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace) :else (let [color (u/locate-library-color file-id id)] @@ -301,15 +303,16 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [typo (u/proxy->library-typography self) value (dm/str (d/nilv (:path typo) "") " / " value)] - (st/emit! (dwl/rename-typography file-id id value)))))} + (st/emit! (-> (dwl/rename-typography file-id id value) + (se/add-event plugin-id))))))} :path {:this true @@ -318,10 +321,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -335,10 +338,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -352,10 +355,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -369,10 +372,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -386,10 +389,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -403,10 +406,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontWeight value) + (u/not-valid plugin-id :fontWeight value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -420,10 +423,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontStyle value) + (u/not-valid plugin-id :fontStyle value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -437,10 +440,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -454,10 +457,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -471,10 +474,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -485,32 +488,34 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else - (st/emit! (dwl/delete-typography {:id id})))) + (st/emit! (-> (dwl/delete-typography {:id id}) + (se/add-event plugin-id))))) :clone (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :clone "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission") :else (let [typo-id (uuid/next) typo (-> (u/locate-library-typography file-id id) (assoc :id typo-id))] - (st/emit! (dwl/add-typography typo false)) + (st/emit! (-> (dwl/add-typography typo false) + (se/add-event plugin-id))) (lib-typography-proxy plugin-id id typo-id)))) :applyToText (fn [shape] (cond (not (shape/shape-proxy? shape)) - (u/display-not-valid :applyToText shape) + (u/not-valid plugin-id :applyToText shape) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [shape-id (obj/get shape "$id") @@ -521,10 +526,10 @@ (fn [range] (cond (not (text/text-range-proxy? range)) - (u/display-not-valid :applyToText range) + (u/not-valid plugin-id :applyToText range) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [shape-id (obj/get range "$id") @@ -542,7 +547,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :typography-plugin-data-key key) + (u/not-valid plugin-id :typography-plugin-data-key key) :else (let [typography (u/locate-library-typography file-id id)] @@ -552,16 +557,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :typography id (keyword "plugin" (str plugin-id)) key value)))) @@ -575,10 +580,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [typography (u/locate-library-typography file-id id)] @@ -588,19 +593,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :typography id (keyword "shared" namespace) key value)))) @@ -609,7 +614,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys-namespace namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace) :else (let [typography (u/locate-library-typography file-id id)] @@ -662,31 +667,31 @@ :addVariant (fn [] (st/emit! - (ev/event {::ev/name "add-new-variant" ::ev/origin "plugin:add-variant"}) + (se/event plugin-id "add-new-variant") (dwv/add-new-variant id))) :addProperty (fn [] (st/emit! - (ev/event {::ev/name "add-new-property" ::ev/origin "plugin:add-property"}) + (se/event plugin-id "add-new-property") (dwv/add-new-property id {:property-value "Value 1"}))) :removeProperty (fn [pos] (if (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (st/emit! - (ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"}) + (se/event plugin-id "remove-property") (dwv/remove-property id pos)))) :renameProperty (fn [pos name] (cond (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (not (string? name)) - (u/display-not-valid :name name) + (u/not-valid plugin-id :name name) :else (st/emit! @@ -715,10 +720,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [component (u/proxy->library-component self) @@ -732,10 +737,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [component (u/proxy->library-component self) @@ -746,7 +751,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-component {:id id})))) @@ -755,18 +760,19 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :instance "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :instance "Plugin doesn't have 'content:write' permission") :else (let [id-ref (atom nil)] - (st/emit! (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin"})) + (st/emit! (-> (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin"}) + (se/add-event plugin-id))) (shape/shape-proxy plugin-id @id-ref)))) :getPluginData (fn [key] (cond (not (string? key)) - (u/display-not-valid :component-plugin-data-key key) + (u/not-valid plugin-id :component-plugin-data-key key) :else (let [component (u/locate-library-component file-id id)] @@ -776,16 +782,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :component id (keyword "plugin" (str plugin-id)) key value)))) @@ -799,10 +805,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :component-plugin-data-namespace namespace) + (u/not-valid plugin-id :component-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :component-plugin-data-key key) + (u/not-valid plugin-id :component-plugin-data-key key) :else (let [component (u/locate-library-component file-id id)] @@ -812,19 +818,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :component id (keyword "shared" namespace) key value)))) @@ -833,7 +839,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :component-plugin-data-namespace namespace) + (u/not-valid plugin-id :component-plugin-data-namespace namespace) :else (let [component (u/locate-library-component file-id id)] @@ -885,7 +891,7 @@ (when (and component (not (ctk/is-variant? component))) (st/emit! - (ev/event {::ev/name "transform-in-variant" ::ev/origin "plugin:transform-in-variant"}) + (se/event plugin-id "transform-in-variant") (dwv/transform-in-variant (:main-instance-id component)))))) :addVariant @@ -894,21 +900,21 @@ (when (and component (ctk/is-variant? component)) (st/emit! - (ev/event {::ev/name "add-new-variant" ::ev/origin "plugin:add-variant-from-component"}) + (se/event plugin-id "add-new-variant") (dwv/add-new-variant (:main-instance-id component)))))) :setVariantProperty (fn [pos value] (cond (not (nat-int? pos)) - (u/display-not-valid :pos (str pos)) + (u/not-valid plugin-id :pos (str pos)) (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! - (ev/event {::ev/name "variant-edit-property-value" ::ev/origin "plugin:edit-property-value"}) + (se/event plugin-id "variant-edit-property-value") (dwv/update-property-value id pos value)))))) (defn library-proxy? [p] @@ -970,34 +976,37 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createColor "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createColor "Plugin doesn't have 'library:write' permission") :else (let [color-id (uuid/next)] - (st/emit! (dwl/add-color {:id color-id :name "Color" :color "#000000" :opacity 1} {:rename? false})) + (st/emit! (-> (dwl/add-color {:id color-id :name "Color" :color "#000000" :opacity 1} {:rename? false}) + (se/add-event plugin-id))) (lib-color-proxy plugin-id file-id color-id)))) :createTypography (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createTypography "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createTypography "Plugin doesn't have 'library:write' permission") :else (let [typography-id (uuid/next)] - (st/emit! (dwl/add-typography (ctt/make-typography {:id typography-id :name "Typography"}) false)) + (st/emit! (-> (dwl/add-typography (ctt/make-typography {:id typography-id :name "Typography"}) false) + (se/add-event plugin-id))) (lib-typography-proxy plugin-id file-id typography-id)))) :createComponent (fn [shapes] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createComponent "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createComponent "Plugin doesn't have 'library:write' permission") :else (let [id-ref (atom nil) ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dwl/add-component id-ref ids)) + (st/emit! (-> (dwl/add-component id-ref ids) + (se/add-event plugin-id))) (lib-component-proxy plugin-id file-id @id-ref)))) ;; Plugin data @@ -1005,7 +1014,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :file-plugin-data-key key) + (u/not-valid plugin-id :file-plugin-data-key key) :else (let [file (u/locate-file file-id)] @@ -1015,13 +1024,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :file (keyword "plugin" (str plugin-id)) key value)))) @@ -1035,10 +1044,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :file-plugin-data-namespace namespace) + (u/not-valid plugin-id :file-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :file-plugin-data-key key) + (u/not-valid plugin-id :file-plugin-data-key key) :else (let [file (u/locate-file file-id)] @@ -1048,16 +1057,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :file (keyword "shared" namespace) key value)))) @@ -1066,7 +1075,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :namespace namespace) + (u/not-valid plugin-id :namespace namespace) :else (let [file (u/locate-file file-id)] @@ -1110,14 +1119,14 @@ (fn [library-id] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :connectLibrary "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :connectLibrary "Plugin doesn't have 'library:write' permission") :else (js/Promise. (fn [resolve reject] (cond (not (string? library-id)) - (do (u/display-not-valid :connectLibrary library-id) + (do (u/not-valid plugin-id :connectLibrary library-id) (reject nil)) :else @@ -1127,4 +1136,5 @@ (rx/filter (ptk/type? ::dwl/attach-library-finished)) (rx/take 1) (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) - (st/emit! (dwl/link-file-to-library file-id library-id)))))))))) + (st/emit! (-> (dwl/link-file-to-library file-id library-id) + (se/add-event plugin-id))))))))))) diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index cac6529be12..80d9b66b6b0 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -30,10 +30,10 @@ (fn [key] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :getItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :getItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :getItem "The key must be a string") + (u/not-valid plugin-id :getItem "The key must be a string") :else (.getItem ^js local-storage (prefix-key plugin-id key)))) @@ -42,10 +42,10 @@ (fn [key value] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :setItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :setItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :setItem "The key must be a string") + (u/not-valid plugin-id :setItem "The key must be a string") :else (.setItem ^js local-storage (prefix-key plugin-id key) value))) @@ -54,10 +54,10 @@ (fn [key] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :removeItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :removeItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :removeItem "The key must be a string") + (u/not-valid plugin-id :removeItem "The key must be a string") :else (.getItem ^js local-storage (prefix-key plugin-id key)))) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index b0302a19399..46266ed4561 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -28,6 +28,7 @@ [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.shape :as shape] + [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj] [beicon.v2.core :as rx] @@ -59,7 +60,7 @@ (fn [_ value] (cond (or (not (string? value)) (empty? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))} @@ -74,7 +75,7 @@ (fn [_ value] (cond (not (shape/shape-proxy? value)) - (u/display-not-valid :startingBoard value) + (u/not-valid plugin-id :startingBoard value) :else (st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))} @@ -103,10 +104,10 @@ (fn [_ value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :name "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/rename-page id value))))} @@ -127,10 +128,10 @@ (fn [_ value] (cond (or (not (string? value)) (not (cc/valid-hex-color? value))) - (u/display-not-valid :background value) + (u/not-valid plugin-id :background value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :background "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :background "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/change-canvas-color id {:color value}))))} @@ -158,7 +159,7 @@ (fn [shape-id] (cond (not (string? shape-id)) - (u/display-not-valid :getShapeById shape-id) + (u/not-valid plugin-id :getShapeById shape-id) :else (let [shape-id (uuid/parse shape-id) @@ -195,7 +196,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :page-plugin-data-key key) + (u/not-valid plugin-id :page-plugin-data-key key) :else (let [page (u/locate-page file-id id)] @@ -205,13 +206,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :page id (keyword "plugin" (str plugin-id)) key value)))) @@ -225,10 +226,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :page-plugin-data-namespace namespace) + (u/not-valid plugin-id :page-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :page-plugin-data-key key) + (u/not-valid plugin-id :page-plugin-data-key key) :else (let [page (u/locate-page file-id id)] @@ -238,16 +239,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) @@ -256,7 +257,7 @@ (fn [self namespace] (cond (not (string? namespace)) - (u/display-not-valid :page-plugin-data-namespace namespace) + (u/not-valid plugin-id :page-plugin-data-namespace namespace) :else (let [page (u/proxy->page self)] @@ -266,7 +267,7 @@ (fn [new-window] (cond (not (r/check-permission plugin-id "content:read")) - (u/display-not-valid :openPage "Plugin doesn't have 'content:read' permission") + (u/not-valid plugin-id :openPage "Plugin doesn't have 'content:read' permission") :else (let [new-window (if (boolean? new-window) new-window false)] @@ -276,66 +277,72 @@ (fn [name frame] (cond (or (not (string? name)) (empty? name)) - (u/display-not-valid :createFlow-name name) + (u/not-valid plugin-id :createFlow-name name) (not (shape/shape-proxy? frame)) - (u/display-not-valid :createFlow-frame frame) + (u/not-valid plugin-id :createFlow-frame frame) :else (let [flow-id (uuid/next)] - (st/emit! (dwi/add-flow flow-id id name (obj/get frame "$id"))) + (st/emit! + (dwi/add-flow flow-id id name (obj/get frame "$id")) + (se/event plugin-id "add-flow")) (flow-proxy plugin-id file-id id flow-id)))) :removeFlow (fn [flow] (cond (not (flow-proxy? flow)) - (u/display-not-valid :removeFlow-flow flow) + (u/not-valid plugin-id :removeFlow-flow flow) :else - (st/emit! (dwi/remove-flow id (obj/get flow "$id"))))) + (st/emit! + (dwi/remove-flow id (obj/get flow "$id")) + (se/event plugin-id "remove-flow")))) :addRulerGuide (fn [orientation value board] (let [shape (u/proxy->shape board)] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :addRulerGuide "Value not a safe number") + (u/not-valid plugin-id :addRulerGuide "Value not a safe number") (not (contains? #{"vertical" "horizontal"} orientation)) - (u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") + (u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") (and (some? shape) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape)))) - (u/display-not-valid :addRulerGuide "The shape is not a board") + (u/not-valid plugin-id :addRulerGuide "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [ruler-id (uuid/next)] (st/emit! - (dwgu/update-guides - (d/without-nils - {:id ruler-id - :axis (parser/orientation->axis orientation) - :position value - :frame-id (when board (obj/get board "$id"))}))) + (-> (dwgu/update-guides + (d/without-nils + {:id ruler-id + :axis (parser/orientation->axis orientation) + :position value + :frame-id (when board (obj/get board "$id"))})) + (se/add-event plugin-id))) (rg/ruler-guide-proxy plugin-id file-id id ruler-id))))) :removeRulerGuide (fn [value] (cond (not (rg/ruler-guide-proxy? value)) - (u/display-not-valid :removeRulerGuide "Guide not provided") + (u/not-valid plugin-id :removeRulerGuide "Guide not provided") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRulerGuide "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'comment:write' permission") :else (let [guide (u/proxy->ruler-guide value)] - (st/emit! (dwgu/remove-guide guide))))) + (st/emit! (-> (dwgu/remove-guide guide) + (se/add-event plugin-id)))))) :addCommentThread (fn [content position board] @@ -343,17 +350,17 @@ position (parser/parse-point position)] (cond (or (not (string? content)) (empty? content)) - (u/display-not-valid :addCommentThread "Content not valid") + (u/not-valid plugin-id :addCommentThread "Content not valid") (or (not (sm/valid-safe-number? (:x position))) (not (sm/valid-safe-number? (:y position)))) - (u/display-not-valid :addCommentThread "Position not valid") + (u/not-valid plugin-id :addCommentThread "Position not valid") (and (some? board) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape)))) - (u/display-not-valid :addCommentThread "Board not valid") + (u/not-valid plugin-id :addCommentThread "Board not valid") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :addCommentThread "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :addCommentThread "Plugin doesn't have 'comment:write' permission") :else (let [position @@ -364,24 +371,24 @@ (js/Promise. (fn [resolve] (st/emit! - (dc/create-thread-on-workspace - {:file-id file-id - :page-id id - :position (gpt/point position) - :content content} - - (fn [data] - (resolve (pc/comment-thread-proxy plugin-id file-id id data))) - false)))))))) + (-> (dc/create-thread-on-workspace + {:file-id file-id + :page-id id + :position (gpt/point position) + :content content} + (fn [data] + (resolve (pc/comment-thread-proxy plugin-id file-id id data))) + false) + (se/add-event plugin-id))))))))) :removeCommentThread (fn [thread] (cond (not (pc/comment-thread-proxy? thread)) - (u/display-not-valid :removeCommentThread "Comment thread not valid") + (u/not-valid plugin-id :removeCommentThread "Comment thread not valid") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :removeCommentThread "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeCommentThread "Plugin doesn't have 'content:write' permission") :else (js/Promise. @@ -400,7 +407,7 @@ (cond (not (r/check-permission plugin-id "comment:read")) (do - (u/display-not-valid :findCommentThreads "Plugin doesn't have 'comment:read' permission") + (u/not-valid plugin-id :findCommentThreads "Plugin doesn't have 'comment:read' permission") (reject "Plugin doesn't have 'comment:read' permission")) :else diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index d3ed6a46e22..0bfe911ed56 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -14,10 +14,10 @@ [app.plugins.utils :as u])) (defn ^:export centerShapes - [shapes] + [plugin-id shapes] (cond (not (every? shape/shape-proxy? shapes)) - (u/display-not-valid :centerShapes shapes) + (u/not-valid plugin-id :centerShapes shapes) :else (let [shapes (->> shapes (map u/proxy->shape))] diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index ebdee92254f..441641035f9 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -17,6 +17,10 @@ [app.util.object :as obj] [beicon.v2.core :as rx])) +;; Needs to be here because moving it to `app.main.data.workspace.mcp` will +;; cause a circular dependency +(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae") + ;; Stores the installed plugins information (defonce ^:private registry (atom {})) @@ -78,6 +82,7 @@ (d/without-nils {:plugin-id plugin-id :url (str plugin-url) + :version vers :name name :description desc :host origin @@ -127,5 +132,10 @@ (defn check-permission [plugin-id permission] (or (= plugin-id "00000000-0000-0000-0000-000000000000") + (= plugin-id mcp-plugin-id) (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] (contains? permissions permission)))) + +(defn get-plugin-data + [state plugin-id] + (get-in state [:profile :props :plugins :data plugin-id])) diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index d9c8e7c6c47..881dbbcfcb4 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -13,6 +13,7 @@ [app.main.store :as st] [app.plugins.format :as format] [app.plugins.register :as r] + [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj])) @@ -44,18 +45,19 @@ (let [shape (u/locate-shape file-id page-id (obj/get value "$id"))] (cond (not (shape-proxy? value)) - (u/display-not-valid :board "The board is not a shape proxy") + (u/not-valid plugin-id :board "The board is not a shape proxy") (not (cfh/frame-shape? shape)) - (u/display-not-valid :board "The shape is not a board") + (u/not-valid plugin-id :board "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :board "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :board "Plugin doesn't have 'content:write' permission") :else (let [board-id (when value (obj/get value "$id")) guide (-> self u/proxy->ruler-guide)] - (st/emit! (dwgu/update-guides (assoc guide :frame-id board-id)))))))} + (st/emit! (-> (dwgu/update-guides (assoc guide :frame-id board-id)) + (se/add-event plugin-id)))))))} :orientation {:this true @@ -78,10 +80,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :position "Not valid position") + (u/not-valid plugin-id :position "Not valid position") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :position "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission") :else (let [guide (u/proxy->ruler-guide self) @@ -92,9 +94,11 @@ (+ board-pos value)) value)] - (st/emit! (dwgu/update-guides (assoc guide :position position))))))} + (st/emit! (-> (dwgu/update-guides (assoc guide :position position)) + (se/add-event plugin-id))))))} :remove (fn [] (let [guide (u/locate-ruler-guide file-id page-id id)] - (st/emit! (dwgu/remove-guide guide)))))) + (st/emit! (-> (dwgu/remove-guide guide) + (se/add-event plugin-id))))))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 5a0c8f6634f..0aeb837589a 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -31,8 +31,8 @@ [app.common.types.shape.radius :as ctsr] [app.common.types.shape.shadow :as ctss] [app.common.types.text :as txt] - [app.common.types.token :as cto] [app.common.uuid :as uuid] + [app.main.data.plugins :as dp] [app.main.data.workspace :as dw] [app.main.data.workspace.groups :as dwg] @@ -47,14 +47,15 @@ [app.main.data.workspace.variants :as dwv] [app.main.repo :as rp] [app.main.store :as st] - [app.plugins.flags :refer [natural-child-ordering?]] [app.plugins.flex :as flex] [app.plugins.format :as format] [app.plugins.grid :as grid] [app.plugins.parser :as parser] [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] + [app.plugins.system-events :as se] [app.plugins.text :as text] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] @@ -91,7 +92,7 @@ (let [value (parser/parse-keyword value)] (cond (not (contains? ctsi/event-types value)) - (u/display-not-valid :trigger value) + (u/not-valid plugin-id :trigger value) :else (st/emit! (dwi/update-interaction @@ -107,7 +108,7 @@ (fn [_ value] (cond (or (not (number? value)) (not (pos? value))) - (u/display-not-valid :delay value) + (u/not-valid plugin-id :delay value) :else (st/emit! (dwi/update-interaction @@ -127,7 +128,7 @@ (d/patch-object params))] (cond (not (sm/validate ctsi/schema:interaction interaction)) - (u/display-not-valid :action interaction) + (u/not-valid plugin-id :action interaction) :else (st/emit! (dwi/update-interaction @@ -192,7 +193,8 @@ (assert (uuid? id)) (let [data (u/locate-shape file-id page-id id)] - (-> (obj/reify {:name "ShapeProxy"} + (-> (obj/reify {:name "ShapeProxy" + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (fn [] plugin-id)} :$id {:enumerable false :get (fn [] id)} :$file {:enumerable false :get (fn [] file-id)} @@ -218,10 +220,10 @@ (not (str/blank? value)))] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :name "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") (not valid?) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! (dw/rename-shape-or-variant file-id page-id id value)))))} @@ -233,10 +235,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :blocked value) + (u/not-valid plugin-id :blocked value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blocked "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blocked "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -249,10 +251,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :hidden value) + (u/not-valid plugin-id :hidden value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :hidden "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :hidden "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -265,10 +267,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :visible value) + (u/not-valid plugin-id :visible value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :visible "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :visible "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -281,10 +283,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :proportionLock value) + (u/not-valid plugin-id :proportionLock value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :proportionLock "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :proportionLock "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -299,10 +301,10 @@ value (keyword value)] (cond (not (contains? cts/horizontal-constraint-types value)) - (u/display-not-valid :constraintsHorizontal value) + (u/not-valid plugin-id :constraintsHorizontal value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :constraintsHorizontal "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :constraintsHorizontal "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-h value))))))} @@ -316,10 +318,10 @@ value (keyword value)] (cond (not (contains? cts/vertical-constraint-types value)) - (u/display-not-valid :constraintsVertical value) + (u/not-valid plugin-id :constraintsVertical value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :constraintsVertical "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :constraintsVertical "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-v value))))))} @@ -332,10 +334,10 @@ (let [id (obj/get self "$id")] (cond (or (not (sm/valid-safe-int? value)) (< value 0)) - (u/display-not-valid :borderRadius value) + (u/not-valid plugin-id :borderRadius value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadius "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadius "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-all-corners % value))))))} @@ -348,10 +350,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusTopLeft value) + (u/not-valid plugin-id :borderRadiusTopLeft value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusTopLeft "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusTopLeft "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r1 value))))))} @@ -364,10 +366,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusTopRight value) + (u/not-valid plugin-id :borderRadiusTopRight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusTopRight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusTopRight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r2 value))))))} @@ -380,10 +382,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusBottomRight value) + (u/not-valid plugin-id :borderRadiusBottomRight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusBottomRight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusBottomRight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r3 value))))))} @@ -396,10 +398,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusBottomLeft value) + (u/not-valid plugin-id :borderRadiusBottomLeft value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusBottomLeft "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusBottomLeft "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r4 value))))))} @@ -412,10 +414,10 @@ (let [id (obj/get self "$id")] (cond (or (not (sm/valid-safe-number? value)) (< value 0) (> value 1)) - (u/display-not-valid :opacity value) + (u/not-valid plugin-id :opacity value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :opacity "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :opacity "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :opacity value))))))} @@ -429,10 +431,10 @@ value (keyword value)] (cond (not (contains? cts/blend-modes value)) - (u/display-not-valid :blendMode value) + (u/not-valid plugin-id :blendMode value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blendMode "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blendMode "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blend-mode value))))))} @@ -446,10 +448,10 @@ value (mapv #(shadow-defaults (parser/parse-shadow %)) value)] (cond (not (sm/validate [:vector ctss/schema:shadow] value)) - (u/display-not-valid :shadows value) + (u/not-valid plugin-id :shadows value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :shadows "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :shadows "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :shadow value))))))} @@ -465,10 +467,10 @@ value (blur-defaults (parser/parse-blur value))] (cond (not (sm/validate ctsb/schema:blur value)) - (u/display-not-valid :blur value) + (u/not-valid plugin-id :blur value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blur "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blur "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))} @@ -482,10 +484,10 @@ value (parser/parse-exports value)] (cond (not (sm/validate [:vector ctse/schema:export] value)) - (u/display-not-valid :exports value) + (u/not-valid plugin-id :exports value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :exports "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :exports "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :exports value))))))} @@ -499,10 +501,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :x value) + (u/not-valid plugin-id :x value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :x "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :x "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-position id @@ -517,10 +519,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :y value) + (u/not-valid plugin-id :y value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :y "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :y "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-position id @@ -562,10 +564,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :parentX value) + (u/not-valid plugin-id :parentX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :parentX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :parentX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -589,10 +591,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :parentY value) + (u/not-valid plugin-id :parentY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :parentY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :parentY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -616,10 +618,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :frameX value) + (u/not-valid plugin-id :frameX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :frameX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :frameX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -643,10 +645,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :frameY value) + (u/not-valid plugin-id :frameY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :frameY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :frameY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -680,10 +682,10 @@ (fn [self value] (cond (not (number? value)) - (u/display-not-valid :rotation value) + (u/not-valid plugin-id :rotation value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rotation "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rotation "Plugin doesn't have 'content:write' permission") :else (let [shape (u/proxy->shape self)] @@ -696,10 +698,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :flipX value) + (u/not-valid plugin-id :flipX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :flipX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :flipX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -712,10 +714,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :flipY value) + (u/not-valid plugin-id :flipY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :flipY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :flipY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -734,13 +736,13 @@ value (parser/parse-fills value)] (cond (not (sm/validate [:vector types.fills/schema:fill] value)) - (u/display-not-valid :fills value) + (u/not-valid plugin-id :fills value) (cfh/text-shape? shape) (st/emit! (dwt/update-attrs id {:fills value})) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fills "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))} @@ -754,10 +756,10 @@ value (parser/parse-strokes value)] (cond (not (sm/validate [:vector cts/schema:stroke] value)) - (u/display-not-valid :strokes value) + (u/not-valid plugin-id :strokes value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :strokes "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))} @@ -784,10 +786,8 @@ (when (ctl/grid-layout-immediate-child-id? objects id) (grid/layout-cell-proxy plugin-id file-id page-id id))))} - ;; Interactions - :interactions {:this true :get @@ -802,13 +802,13 @@ (fn [width height] (cond (or (not (sm/valid-safe-number? width)) (<= width 0)) - (u/display-not-valid :resize width) + (u/not-valid plugin-id :resize width) (or (not (sm/valid-safe-number? height)) (<= height 0)) - (u/display-not-valid :resize height) + (u/not-valid plugin-id :resize height) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-dimensions [id] :width width) @@ -819,13 +819,13 @@ (let [center (when center {:x (obj/get center "x") :y (obj/get center "y")})] (cond (not (number? angle)) - (u/display-not-valid :rotate-angle angle) + (u/not-valid plugin-id :rotate-angle angle) (and (some? center) (or (not (number? (:x center))) (not (number? (:y center))))) - (u/display-not-valid :rotate-center center) + (u/not-valid plugin-id :rotate-center center) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rotate "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rotate "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/increase-rotation [id] angle {:center center :delta? true}))))) @@ -835,7 +835,7 @@ (let [ret-v (atom nil)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :clone "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dws/duplicate-shapes #{id} :change-selection? false :return-ref ret-v)) @@ -845,7 +845,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :remove "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/delete-shapes #{id})))) @@ -855,7 +855,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData key) + (u/not-valid plugin-id :getPluginData key) :else (let [shape (u/locate-shape file-id page-id id)] @@ -865,13 +865,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "plugin" (str plugin-id)) key value)))) @@ -885,10 +885,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [shape (u/locate-shape file-id page-id id)] @@ -898,16 +898,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "shared" namespace) key value)))) @@ -916,7 +916,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys namespace) :else (let [shape (u/locate-shape file-id page-id id)] @@ -931,12 +931,12 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :getChildren (:type shape)) + (u/not-valid plugin-id :getChildren (:type shape)) :else (let [is-reversed? (ctl/flex-layout? shape) reverse-fn - (if (and (natural-child-ordering? plugin-id) is-reversed?) + (if (and (u/natural-child-ordering? plugin-id) is-reversed?) reverse identity)] (->> (u/locate-shape file-id page-id id) (:shapes) @@ -951,22 +951,27 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :appendChild (:type shape)) + (u/not-valid plugin-id :appendChild (:type shape)) (not (shape-proxy? child)) - (u/display-not-valid :appendChild-child child) + (u/not-valid plugin-id :appendChild-child child) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id") + child-shape (u/locate-shape file-id page-id child-id) is-reversed? (ctl/flex-layout? shape) index - (if (or (not (natural-child-ordering? plugin-id)) is-reversed?) + (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) 0 (count (:shapes shape)))] - (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) + (st/emit! + (dwsh/relocate-shapes #{child-id} id index) + (se/event plugin-id (if (ctl/any-layout? shape) "add-layout-element" "add-element") + :type (:type child-shape) + :parent-type (:type shape))))))) :insertChild (fn [index child] @@ -976,22 +981,27 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :insertChild (:type shape)) + (u/not-valid plugin-id :insertChild (:type shape)) (not (shape-proxy? child)) - (u/display-not-valid :insertChild-child child) + (u/not-valid plugin-id :insertChild-child child) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :insertChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :insertChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id") + child-shape (u/locate-shape file-id page-id child-id) is-reversed? (ctl/flex-layout? shape) index - (if (or (not (natural-child-ordering? plugin-id)) is-reversed?) + (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) (- (count (:shapes shape)) index) index)] - (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) + (st/emit! + (dwsh/relocate-shapes #{child-id} id index) + (se/event plugin-id (if (ctl/any-layout? shape) "add-layout-element" "add-element") + :type (:type child-shape) + :parent-type (:type shape))))))) ;; Only for frames :addFlexLayout @@ -999,13 +1009,15 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/frame-shape? shape)) - (u/display-not-valid :addFlexLayout (:type shape)) + (u/not-valid plugin-id :addFlexLayout (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addFlexLayout "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addFlexLayout "Plugin doesn't have 'content:write' permission") :else - (do (st/emit! (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false)) + (do (st/emit! + (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false) + (se/event plugin-id "create-layout" :layout "flex")) (flex/flex-layout-proxy plugin-id file-id page-id id))))) :addGridLayout @@ -1013,13 +1025,14 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/frame-shape? shape)) - (u/display-not-valid :addGridLayout (:type shape)) + (u/not-valid plugin-id :addGridLayout (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addGridLayout "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addGridLayout "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dwsl/create-layout-from-id id :grid :from-frame? true :calculate-params? false)) + (se/event plugin-id "create-layout" :layout "grid") (grid/grid-layout-proxy plugin-id file-id page-id id))))) ;; Make masks for groups @@ -1028,23 +1041,25 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/group-shape? shape)) - (u/display-not-valid :makeMask (:type shape)) + (u/not-valid plugin-id :makeMask (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :makeMask "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :makeMask "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwg/mask-group #{id}))))) + (st/emit! + (dwg/mask-group #{id}) + (se/event plugin-id "create-shape" :type "mask"))))) :removeMask (fn [] (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/mask-shape? shape)) - (u/display-not-valid :removeMask (:type shape)) + (u/not-valid plugin-id :removeMask (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeMask "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeMask "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwg/unmask-group #{id}))))) @@ -1055,7 +1070,7 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (and (not (cfh/path-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :toD (:type shape)) + (u/not-valid plugin-id :toD (:type shape)) :else (.toString (:content shape))))) @@ -1066,13 +1081,13 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/text-shape? shape)) - (u/display-not-valid :getRange-shape "shape is not text") + (u/not-valid plugin-id :getRange-shape "shape is not text") (or (not (sm/valid-safe-int? start)) (< start 0) (> start end)) - (u/display-not-valid :getRange-start start) + (u/not-valid plugin-id :getRange-start start) (not (sm/valid-safe-int? end)) - (u/display-not-valid :getRange-end end) + (u/not-valid plugin-id :getRange-end end) :else (text/text-range-proxy plugin-id file-id page-id id start end)))) @@ -1082,13 +1097,13 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (lib-typography-proxy? typography)) - (u/display-not-valid :applyTypography-typography typography) + (u/not-valid plugin-id :applyTypography-typography typography) (not (cfh/text-shape? shape)) - (u/display-not-valid :applyTypography-shape (:type shape)) + (u/not-valid plugin-id :applyTypography-shape (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyTypography "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyTypography "Plugin doesn't have 'content:write' permission") :else (let [typography (u/proxy->library-typography typography)] @@ -1099,10 +1114,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setParentIndex index) + (u/not-valid plugin-id :setParentIndex index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setParentIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setParentIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/set-shape-index file-id page-id id index)))) @@ -1197,7 +1212,7 @@ (let [value (parser/parse-export value)] (cond (not (sm/validate ctse/schema:export value)) - (u/display-not-valid :export value) + (u/not-valid plugin-id :export value) :else (let [shape (u/locate-shape file-id page-id id) @@ -1223,8 +1238,10 @@ (rx/map :body)))) (rx/mapcat #(.arrayBuffer %)) (rx/map #(js/Uint8Array. %)) + (rx/tap #(st/emit! (se/event plugin-id "export-shapes"))) (rx/subs! resolve reject)))))))) + ;; Interactions :addInteraction (fn [trigger action delay] @@ -1233,21 +1250,25 @@ (d/patch-object (parser/parse-interaction trigger action delay)))] (cond (not (sm/validate ctsi/schema:interaction interaction)) - (u/display-not-valid :addInteraction interaction) + (u/not-valid plugin-id :addInteraction interaction) :else (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] - (st/emit! (dwi/add-interaction page-id id interaction)) + (st/emit! + (dwi/add-interaction page-id id interaction) + (se/event plugin-id "add-interaction")) (interaction-proxy plugin-id file-id page-id id index))))) :removeInteraction (fn [interaction] (cond (not (interaction-proxy? interaction)) - (u/display-not-valid :removeInteraction interaction) + (u/not-valid plugin-id :removeInteraction interaction) :else - (st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index"))))) + (st/emit! + (dwi/remove-interaction {:id id} (obj/get interaction "$index")) + (se/event plugin-id "remove-interaction")))) ;; Ruler guides :addRulerGuide @@ -1255,16 +1276,16 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :addRulerGuide "Value not a safe number") + (u/not-valid plugin-id :addRulerGuide "Value not a safe number") (not (contains? #{"vertical" "horizontal"} orientation)) - (u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") + (u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") (not (cfh/frame-shape? shape)) - (u/display-not-valid :addRulerGuide "The shape is not a board") + (u/not-valid plugin-id :addRulerGuide "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [id (uuid/next) @@ -1274,54 +1295,58 @@ board-pos (get frame axis) position (+ board-pos value)] (st/emit! - (dwgu/update-guides - {:id id - :axis axis - :position position - :frame-id id})) + (-> (dwgu/update-guides + {:id id + :axis axis + :position position + :frame-id id}) + (se/add-event plugin-id))) (rg/ruler-guide-proxy plugin-id file-id page-id id))))) :removeRulerGuide (fn [_ value] (cond (not (rg/ruler-guide-proxy? value)) - (u/display-not-valid :removeRulerGuide "Guide not provided") + (u/not-valid plugin-id :removeRulerGuide "Guide not provided") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [guide (u/proxy->ruler-guide value)] - (st/emit! (dwgu/remove-guide guide))))) + (st/emit! (-> (dwgu/remove-guide guide) + (se/add-event plugin-id)))))) :tokens {:this true :get (fn [_] - (let [tokens + (let [applied-tokens (-> (u/locate-shape file-id page-id id) - (get :applied-tokens))] + (get :applied-tokens) + (applied-tokens-plugin->applied-tokens))] (reduce (fn [acc [prop name]] (obj/set! acc (json/write-camel-key prop) name)) #js {} - tokens)))} + applied-tokens)))} :applyToken {:enumerable false :schema [:tuple [:fn token-proxy?] - [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) - kw-attrs (into #{} (map keyword attrs))] - (if (some #(not (cto/token-attr? %)) kw-attrs) - (u/display-not-valid :applyToken attrs) + kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] + (if (some #(not (token-attr? %)) kw-attrs) + (u/not-valid plugin-id :applyToken attrs) (st/emit! - (dwta/toggle-token {:token token - :attrs kw-attrs - :shape-ids [id] - :expand-with-children false})))))} + (-> (dwta/toggle-token {:token token + :attrs kw-attrs + :shape-ids [id] + :expand-with-children false}) + (se/add-event plugin-id))))))} :isVariantHead (fn [] @@ -1338,29 +1363,45 @@ (fn [pos value] (cond (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (not (string? value)) - (u/display-not-valid :value value) + (u/not-valid plugin-id :value value) :else (let [shape (u/locate-shape file-id page-id id) component (u/locate-library-component file-id (:component-id shape))] (when (and component (ctk/is-variant? component)) - (st/emit! (dwv/variants-switch {:shapes [shape] :pos pos :val value})))))) + (st/emit! (-> (dwv/variants-switch {:shapes [shape] :pos pos :val value}) + (se/add-event plugin-id))))))) :combineAsVariants (fn [ids] - (if (or (not (seq ids)) (not (every? uuid/parse* ids))) - (u/display-not-valid :ids ids) - (let [shape (u/locate-shape file-id page-id id) - component (u/locate-library-component file-id (:component-id shape)) - ids (->> ids + (cond + (or (not (seq ids)) (not (every? uuid/parse* ids))) + (u/not-valid plugin-id :ids ids) + + :else + (let [ids (->> ids (map uuid/uuid) - (into #{id}))] - (when (and component (not (ctk/is-variant? component))) - (st/emit! - (dwv/combine-as-variants ids {:trigger "plugin:combine-as-variants"}))))))) + (into #{id})) + valid? + (every? + (fn [id] + (let [shape (u/locate-shape file-id page-id id) + component (u/locate-library-component file-id (:component-id shape))] + (not (ctk/is-variant? component)))) + ids)] + + (if valid? + (let [variant-id (uuid/next)] + (st/emit! (-> (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id}) + (se/add-event plugin-id))) + (shape-proxy plugin-id variant-id)) + + (u/not-valid plugin-id :ids "One of the components is not on the same page or is already a variant")))))) (cond-> (or (cfh/frame-shape? data) (cfh/group-shape? data) (cfh/svg-raw-shape? data) (cfh/bool-shape? data)) (crc/add-properties! @@ -1375,21 +1416,21 @@ (fn [^js self children] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :children "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :children "Plugin doesn't have 'content:write' permission") (not (every? shape-proxy? children)) - (u/display-not-valid :children "Every children needs to be shape proxies") + (u/not-valid plugin-id :children "Every children needs to be shape proxies") :else (let [shape (u/proxy->shape self) file-id (obj/get self "$file") page-id (obj/get self "$page") - reverse-fn (if (natural-child-ordering? plugin-id) reverse identity) + reverse-fn (if (u/natural-child-ordering? plugin-id) reverse identity) ids (->> children reverse-fn (map #(obj/get % "$id")))] (cond (not= (set ids) (set (:shapes shape))) - (u/display-not-valid :children "Not all children are present in the input") + (u/not-valid plugin-id :children "Not all children are present in the input") :else (st/emit! (dw/reorder-children file-id page-id (:id shape) ids))))))})) @@ -1405,10 +1446,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :clipContent value) + (u/not-valid plugin-id :clipContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :clipContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :clipContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :show-content (not value))))))} @@ -1421,10 +1462,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :showInViewMode value) + (u/not-valid plugin-id :showInViewMode value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :showInViewMode "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :showInViewMode "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :hide-in-viewer (not value))))))} @@ -1456,10 +1497,10 @@ value (parser/parse-frame-guides value)] (cond (not (sm/validate [:vector ::ctg/grid] value)) - (u/display-not-valid :guides value) + (u/not-valid plugin-id :guides value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :guides "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :guides "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :grids value))))))} @@ -1481,10 +1522,10 @@ value (keyword value)] (cond (not (contains? #{:fix :auto} value)) - (u/display-not-valid :horizontalSizing value) + (u/not-valid plugin-id :horizontalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} @@ -1497,10 +1538,10 @@ value (keyword value)] (cond (not (contains? #{:fix :auto} value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))} @@ -1524,10 +1565,10 @@ (let [segments (parser/parse-commands value)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :content "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'content:write' permission") (not (sm/validate path/schema:segments segments)) - (u/display-not-valid :content segments) + (u/not-valid plugin-id :content segments) :else (let [selrect (path/calc-selrect segments) @@ -1550,13 +1591,13 @@ value)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :content "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'content:write' permission") (not (cfh/path-shape? data)) - (u/display-not-valid :content-type type) + (u/not-valid plugin-id :content-type type) (not (sm/validate path/schema:segments segments)) - (u/display-not-valid :content segments) + (u/not-valid plugin-id :content segments) :else (let [selrect (path/calc-selrect segments) diff --git a/frontend/src/app/plugins/system_events.cljs b/frontend/src/app/plugins/system_events.cljs index e8816cdfc3f..e8759284e22 100644 --- a/frontend/src/app/plugins/system_events.cljs +++ b/frontend/src/app/plugins/system_events.cljs @@ -7,17 +7,34 @@ (ns app.plugins.system-events (:require [app.main.data.event :as ev] - [app.main.store :as st])) + [app.main.store :as st] + [app.plugins.register :as r])) ;; Formats an event from the plugin system (defn event [plugin-id name & {:as props}] - (let [plugin-data (get-in @st/state [:profile :props :plugins :data plugin-id])] + (if (= plugin-id r/mcp-plugin-id) (-> props (assoc ::ev/name name) - (assoc ::ev/origin "plugin") - (assoc ::ev/context - {:plugin-name (:name plugin-data) - :plugin-url (:url plugin-data)}) - (ev/event)))) + (assoc ::ev/origin "mcp") + (ev/event)) + (let [plugin-data (r/get-plugin-data @st/state plugin-id)] + (-> props + (assoc ::ev/name name) + (assoc ::ev/origin "plugin") + (assoc ::ev/context + {:plugin-name (:name plugin-data) + :plugin-url (:url plugin-data)}) + (ev/event))))) + +(defn add-event + [event plugin-id] + (let [plugin-data (r/get-plugin-data @st/state plugin-id)] + (with-meta + event + (if (= plugin-id r/mcp-plugin-id) + {::ev/origin "mcp"} + {::ev/origin "plugin" + ::ev/context {:plugin-name (:name plugin-data) + :plugin-url (:url plugin-data)}})))) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 1a1e83ac200..1154af2366d 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -8,9 +8,10 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.shapes.text :as gst] [app.common.record :as crc] [app.common.schema :as sm] - [app.common.types.shape :as cts] + [app.common.types.fills :as types.fills] [app.common.types.text :as txt] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] @@ -118,10 +119,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (font-data font variant))))))} @@ -140,10 +141,10 @@ variant (fonts/get-default-variant font)] (cond (not (string? value)) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (font-data font variant))))))} @@ -161,10 +162,10 @@ variant (fonts/get-variant font value)] (cond (not (string? value)) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -181,10 +182,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches font-size-re value))) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:font-size value})))))} @@ -208,10 +209,10 @@ (fonts/find-variant font {:weight weight}))] (cond (nil? variant) - (u/display-not-valid :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -234,10 +235,10 @@ (fonts/find-variant font {:style style}))] (cond (nil? variant) - (u/display-not-valid :fontStyle (dm/str "Font style '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontStyle (dm/str "Font style '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -254,10 +255,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches line-height-re value))) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:line-height value})))))} @@ -274,10 +275,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (re-matches letter-spacing-re value)) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:letter-spacing value})))))} @@ -293,10 +294,10 @@ (fn [_ value] (cond (and (string? value) (not (re-matches text-transform-re value))) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-transform value}))))} @@ -312,10 +313,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-decoration-re value)) - (u/display-not-valid :textDecoration value) + (u/not-valid plugin-id :textDecoration value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDecoration "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDecoration "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-decoration value}))))} @@ -331,10 +332,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-direction-re value)) - (u/display-not-valid :direction value) + (u/not-valid plugin-id :direction value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :direction "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :direction "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:direction value}))))} @@ -350,10 +351,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-align-re value)) - (u/display-not-valid :align value) + (u/not-valid plugin-id :align value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :align "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :align "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-align value}))))} @@ -369,11 +370,11 @@ (fn [_ value] (let [value (parser/parse-fills value)] (cond - (not (sm/validate [:vector ::cts/fill] value)) - (u/display-not-valid :fills value) + (not (sm/validate [:vector types.fills/schema:fill] value)) + (u/not-valid plugin-id :fills value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fills "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:fills value})))))} @@ -400,10 +401,10 @@ ;; editor as well (cond (or (not (string? value)) (empty? value)) - (u/display-not-valid :characters value) + (u/not-valid plugin-id :characters value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :characters "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :characters "Plugin doesn't have 'content:write' permission") (contains? (:workspace-editor-state @st/state) id) (let [shape (u/proxy->shape self) @@ -427,10 +428,10 @@ value (keyword value)] (cond (not (contains? #{:auto-width :auto-height :fixed} value)) - (u/display-not-valid :growType value) + (u/not-valid plugin-id :growType value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :growType "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :growType "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))} @@ -444,10 +445,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (font-data font variant))))))} @@ -461,10 +462,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (font-data font variant))))))} @@ -478,10 +479,10 @@ variant (fonts/get-variant font value)] (cond (not variant) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -494,10 +495,10 @@ value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches font-size-re value))) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:font-size value})))))} @@ -516,10 +517,10 @@ (fonts/find-variant font {:weight weight}))] (cond (nil? variant) - (u/display-not-valid :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -538,10 +539,10 @@ (fonts/find-variant font {:style style}))] (cond (nil? variant) - (u/display-not-valid :fontStyle (dm/str "Font style '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontStyle (dm/str "Font style '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -554,10 +555,10 @@ value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches line-height-re value))) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:line-height value})))))} @@ -570,10 +571,10 @@ value (str/trim (dm/str value))] (cond (or (not (string? value)) (not (re-matches letter-spacing-re value))) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:letter-spacing value})))))} @@ -585,10 +586,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-transform-re value))) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-transform value})))))} @@ -600,10 +601,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-decoration-re value))) - (u/display-not-valid :textDecoration value) + (u/not-valid plugin-id :textDecoration value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDecoration "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDecoration "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-decoration value})))))} @@ -615,10 +616,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-direction-re value))) - (u/display-not-valid :textDirection value) + (u/not-valid plugin-id :textDirection value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDirection "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDirection "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-direction value})))))} @@ -630,10 +631,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-align-re value))) - (u/display-not-valid :align value) + (u/not-valid plugin-id :align value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :align "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :align "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-align value})))))} @@ -645,10 +646,13 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches vertical-align-re value))) - (u/display-not-valid :verticalAlign value) + (u/not-valid plugin-id :verticalAlign value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalAlign "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalAlign "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwt/update-attrs id {:vertical-align value})))))})) + (st/emit! (dwt/update-attrs id {:vertical-align value})))))} + + {:name "textBounds" + :get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 268652e334b..e2124e8d7db 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -17,22 +17,75 @@ [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] + [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj] - [clojure.datafy :refer [datafy]])) + [clojure.datafy :refer [datafy]] + [clojure.set :refer [map-invert]])) ;; === Token +;; Give more semantic names to the shape attributes that tokens can be applied to +(def ^:private map:token-attr->token-attr-plugin + {:r1 :border-radius-top-left + :r2 :border-radius-top-right + :r3 :border-radius-bottom-right + :r4 :border-radius-bottom-left + + :p1 :padding-top-left + :p2 :padding-top-right + :p3 :padding-bottom-right + :p4 :padding-bottom-left + + :m1 :margin-top-left + :m2 :margin-top-right + :m3 :margin-bottom-right + :m4 :margin-bottom-left}) + +(def ^:private map:token-attr-plugin->token-attr + (map-invert map:token-attr->token-attr-plugin)) + +(defn token-attr->token-attr-plugin + [k] + (get map:token-attr->token-attr-plugin k k)) + +(defn token-attr-plugin->token-attr + "Resolve a plugin-side token attribute reference to its canonical + internal keyword. + + Accepts either a Clojure keyword (the canonical form, e.g. `:r1`, + `:fill`) or a string (the natural shape that arrives from a JS plugin + call such as `shape.applyToken(token, [\"fill\"])`). Converts strings + to keywords first, then maps verbose plugin-side aliases (e.g. + `:border-radius-top-left`) to their internal short form (e.g. `:r1`). + Inputs that are already in canonical form (`:r1`, `:fill`, `\"fill\"`, + …) pass through unchanged." + [k] + (let [k (cond-> k (string? k) keyword)] + (get map:token-attr-plugin->token-attr k k))) + +(defn applied-tokens-plugin->applied-tokens + [value] + (into {} + (map (fn [[k v]] [(token-attr->token-attr-plugin k) v])) + value)) + +(defn token-attr? + [attr] + (cto/token-attr? (token-attr-plugin->token-attr attr))) + (defn- apply-token-to-shapes - [file-id set-id id shape-ids attrs] + [plugin-id file-id set-id id shape-ids attrs] + (let [token (u/locate-token file-id set-id id)] - (if (some #(not (cto/token-attr? %)) attrs) - (u/display-not-valid :applyToSelected attrs) + (if (some #(not (token-attr? %)) attrs) + (u/not-valid plugin-id :applyToSelected attrs) (st/emit! - (dwta/toggle-token {:token token - :attrs attrs - :shape-ids shape-ids - :expand-with-children false}))))) + (-> (dwta/toggle-token {:token token + :attrs (into #{} (map token-attr-plugin->token-attr) attrs) + :shape-ids shape-ids + :expand-with-children false}) + (se/add-event plugin-id)))))) (defn- get-resolved-value [token tokens-tree] @@ -52,7 +105,7 @@ (defn token-proxy [plugin-id file-id set-id id] (obj/reify {:name "TokenProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$set-id {:enumerable false :get (constantly set-id)} @@ -72,7 +125,8 @@ (ctob/get-tokens set-id))) :set (fn [_ value] - (st/emit! (dwtl/update-token set-id id {:name value})))} + (st/emit! (-> (dwtl/update-token set-id id {:name value}) + (se/add-event plugin-id))))} :type {:this true @@ -122,7 +176,8 @@ :schema cfo/schema:token-description :set (fn [_ value] - (st/emit! (dwtl/update-token set-id id {:description value})))} + (st/emit! (-> (dwtl/update-token set-id id {:description value}) + (se/add-event :plugin-id))))} :duplicate (fn [] @@ -135,27 +190,29 @@ token' (ctob/make-token (-> (datafy token) (dissoc :id :modified-at)))] - (st/emit! (dwtl/create-token set-id token')) + (st/emit! (-> (dwtl/create-token set-id token') + (se/add-event plugin-id))) (token-proxy plugin-id file-id set-id (:id token')))) :remove (fn [] - (st/emit! (dwtl/delete-token set-id id))) + (st/emit! (-> (dwtl/delete-token set-id id) + (se/add-event plugin-id)))) :applyToShapes {:enumerable false :schema [:tuple [:vector [:fn shape-proxy?]] - [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] - (apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))} + (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false - :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + :schema [:tuple [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] - (apply-token-to-shapes file-id set-id id selected attrs)))})) + (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) ;; === Token Set @@ -165,7 +222,7 @@ (defn token-set-proxy [plugin-id file-id id] (obj/reify {:name "TokenSetProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -247,15 +304,19 @@ :addToken {:enumerable false :schema (fn [args] - [:tuple (-> (cfo/make-token-schema - (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) + (let [tokens-tree (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id) + ;; Convert to the adecuate format for schema + (ctob/tokens-tree))] + [:tuple (-> (cfo/make-token-schema + tokens-tree + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] (let [tokens-lib (u/locate-tokens-lib file-id) @@ -268,9 +329,10 @@ (get resolved-tokens (:name token))] (if resolved-value - (do (st/emit! (dwtl/create-token id token)) + (do (st/emit! (-> (dwtl/create-token id token) + (se/add-event plugin-id))) (token-proxy plugin-id file-id id (:id token))) - (do (u/display-not-valid :addToken (str errors)) + (do (u/not-valid plugin-id :addToken (str errors)) nil))))} :duplicate @@ -287,7 +349,7 @@ (defn token-theme-proxy [plugin-id file-id id] (obj/reify {:name "TokenThemeProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -394,7 +456,7 @@ (defn tokens-catalog [plugin-id file-id] (obj/reify {:name "TokensCatalog" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$id {:enumerable false :get (constantly file-id)} diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index dfb8242a121..19de73fcde8 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,14 +9,17 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.json :as json] + [app.common.i18n :as i18n :refer [tr]] [app.common.schema :as sm] + [app.common.schema.messages :as csm] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn locate-file [id] @@ -221,6 +224,16 @@ (resolve value)))))] [ret-v ret-p])) +(defn natural-child-ordering? + [plugin-id] + (boolean + (dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering]))) + +(defn throw-validation-errors? + [plugin-id] + (boolean + (dm/get-in @st/state [:plugins :flags plugin-id :throw-validation-errors]))) + (defn display-not-valid [code value] (if (some? value) @@ -228,34 +241,54 @@ (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))) nil) +(defn throw-not-valid + [code value] + (if (some? value) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))) + nil) + +(defn not-valid + [plugin-id code value] + (if (throw-validation-errors? plugin-id) + (throw-not-valid code value) + (display-not-valid code value))) + (defn reject-not-valid [reject code value] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] (.error js/console msg) (reject msg))) -(defn coerce - "Decodes a javascript object into clj and check against schema. If schema validation fails, - displays a not-valid message with the code and hint provided and returns nil." - [attrs schema code hint] - (let [decoder (sm/decoder schema sm/json-transformer) - explainer (sm/explainer schema) - attrs (-> attrs json/->clj decoder)] - (if-let [explain (explainer attrs)] - (display-not-valid code (str hint " " (sm/humanize-explain explain))) - attrs))) - (defn mixed-value [values] (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) +(defn error-messages + [explain] + (->> (:errors explain) + (reduce csm/interpret-schema-problem {}) + (mapcat (comp seq val)) + (map (fn [[field {:keys [message]}]] + (tr "plugins.validation.message" (name field) message))) + (str/join ". "))) + (defn handle-error "Function to be used in plugin proxies methods to handle errors and print a readable message to the console." - [cause] - (display-not-valid (ex-message cause) nil) - (if-let [explain (-> cause ex-data ::sm/explain)] - (println (sm/humanize-explain explain)) - (js/console.log (ex-data cause))) - (js/console.log (.-stack cause))) \ No newline at end of file + [plugin-id] + (fn [cause] + (let [message + (if-let [explain (-> cause ex-data ::sm/explain)] + (do + (js/console.error (sm/humanize-explain explain)) + (error-messages explain)) + (ex-data cause))] + (js/console.log (.-stack cause)) + (not-valid plugin-id :error message)))) + +(defn is-main-component-proxy? + [p] + (when-let [shape (proxy->shape p)] + (ctk/main-instance? shape))) diff --git a/frontend/src/app/plugins/viewport.cljs b/frontend/src/app/plugins/viewport.cljs index a581e3ba608..8b5b73ac577 100644 --- a/frontend/src/app/plugins/viewport.cljs +++ b/frontend/src/app/plugins/viewport.cljs @@ -38,10 +38,10 @@ new-y (obj/get value "y")] (cond (not (sm/valid-safe-number? new-x)) - (u/display-not-valid :center-x new-x) + (u/not-valid plugin-id :center-x new-x) (not (sm/valid-safe-number? new-y)) - (u/display-not-valid :center-y new-y) + (u/not-valid plugin-id :center-y new-y) :else (let [vb (dm/get-in @st/state [:workspace-local :vbox]) @@ -63,7 +63,7 @@ (fn [value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :zoom value) + (u/not-valid plugin-id :zoom value) :else (let [z (dm/get-in @st/state [:workspace-local :zoom])] @@ -87,7 +87,7 @@ (fn [shapes] (cond (not (every? ps/shape-proxy? shapes)) - (u/display-not-valid :zoomIntoView "Argument should be valid shapes") + (u/not-valid plugin-id :zoomIntoView "Argument should be valid shapes") :else (let [ids (->> shapes diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index 642cd0a11c8..60f6ebb2555 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -278,9 +278,6 @@ (let [el (dom/get-element "app")] (mf/create-root el))) -(declare ^:private render-single-object) -(declare ^:private render-components) -(declare ^:private render-objects) (defn- parse-params [loc] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 4e068cc5d3d..71a5d9665e0 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -319,6 +319,18 @@ ([document ^js text] (.createTextNode document text))) +(defn escape-html + "Escapes special HTML characters in a string so that it can be safely used + as innerHTML without risk of XSS." + [^js text] + (when (some? text) + (-> text + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">") + (str/replace "\"" """) + (str/replace "'" "'")))) + (defn set-html! [^js el html] (when (some? el) diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 253d32470cd..58bbe3569a1 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -10,84 +10,10 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.util.i18n :as i18n :refer [tr]] + [app.common.schema.messages :as csm] [cuerdas.core :as str] - [malli.core :as m] [rumext.v2 :as mf])) -;; --- Handlers Helpers - -(defn- translate-code - [code] - (if (vector? code) - (tr (nth code 0) (i18n/c (nth code 1))) - (tr code))) - -(defn- handle-error-fn - [props problem] - (let [v-fn (:error/fn props) - result (v-fn problem)] - (if (string? result) - {:message result} - {:message (or (some-> (get result :code) - (translate-code)) - (get result :message) - (tr "errors.invalid-data"))}))) - -(defn- handle-error-message - [props] - {:message (get props :error/message)}) - -(defn- handle-error-code - [props] - (let [code (get props :error/code)] - {:message (translate-code code)})) - -(defn- interpret-schema-problem - [acc {:keys [schema in value type] :as problem}] - (let [props (m/properties schema) - tprops (m/type-properties schema) - field (or (:error/field props) - in) - field (if (vector? field) - field - [field])] - - (if (and (= 1 (count field)) - (contains? acc (first field))) - acc - (cond - (or (nil? field) - (empty? field)) - acc - - (or (= type :malli.core/missing-key) - (nil? value)) - (assoc-in acc field {:message (tr "errors.field-missing")}) - - ;; --- CHECK on schema props - (contains? props :error/fn) - (assoc-in acc field (handle-error-fn props problem)) - - (contains? props :error/message) - (assoc-in acc field (handle-error-message props)) - - (contains? props :error/code) - (assoc-in acc field (handle-error-code props)) - - ;; --- CHECK on type props - (contains? tprops :error/fn) - (assoc-in acc field (handle-error-fn tprops problem)) - - (contains? tprops :error/message) - (assoc-in acc field (handle-error-message tprops)) - - (contains? tprops :error/code) - (assoc-in acc field (handle-error-code tprops)) - - :else - (assoc-in acc field {:message (tr "errors.invalid-data")}))))) - (defn- use-rerender-fn [] (let [state (mf/useState 0) @@ -97,24 +23,6 @@ (fn [] (render-fn inc))))) -(defn- apply-validators - [validators state errors] - (reduce (fn [errors validator-fn] - (merge errors (validator-fn errors (:data state)))) - errors - validators)) - -(defn- collect-schema-errors - [schema validators state] - (let [explain (sm/explain schema (:data state)) - errors (->> (reduce interpret-schema-problem {} (:errors explain)) - (apply-validators validators state))] - - (-> (:errors state) - (merge errors) - (d/without-nils) - (not-empty)))) - (defn- wrap-update-schema-fn [f {:keys [schema validators]}] (fn [& args] @@ -124,7 +32,7 @@ errors (when-not valid? - (collect-schema-errors schema validators state)) + (csm/collect-schema-errors schema validators state)) extra-errors (not-empty (:extra-errors state))] diff --git a/frontend/src/app/util/object.cljc b/frontend/src/app/util/object.cljc index bb5ae6c5a4e..090effd7108 100644 --- a/frontend/src/app/util/object.cljc +++ b/frontend/src/app/util/object.cljc @@ -464,6 +464,13 @@ (let [o (get o type-symbol)] (= o t)))) +#?(:cljs + (def Proxy + (app.util.object/class + :name "Proxy" + :extends js/Object + :constructor (constantly nil)))) + (defmacro reify "A domain specific variation of reify that creates anonymous objects on demand with the ability to assign protocol implementations and @@ -481,7 +488,7 @@ obj-sym (gensym "obj-")] - `(let [~obj-sym (cljs.core/js-obj) + `(let [~obj-sym (new Proxy) ~f-sym (fn [] ~type-name)] (add-properties! ~obj-sym {:name ~'js/Symbol.toStringTag diff --git a/frontend/src/app/util/sse.cljs b/frontend/src/app/util/sse.cljs index 8e1044ec376..40a02f4535b 100644 --- a/frontend/src/app/util/sse.cljs +++ b/frontend/src/app/util/sse.cljs @@ -17,22 +17,30 @@ (defn read-stream [^js/ReadableStream stream decode-fn] - (letfn [(read-items [^js reader] - (->> (rx/from (.read reader)) - (rx/mapcat (fn [result] - (if (.-done result) - (rx/empty) - (rx/concat - (rx/of (.-value result)) - (read-items reader)))))))] - (->> (read-items (.getReader stream)) - (rx/mapcat (fn [^js event] - (let [type (.-event event) - data (.-data event) - data (decode-fn data)] - (if (= "error" type) - (rx/throw (ex-info "stream exception" data)) - (rx/of #js {:type type :data data})))))))) + (->> (rx/create + (fn [subs] + (let [reader (.getReader stream)] + (letfn [(pump [] + (-> (.read reader) + (.then (fn [result] + (if (.-done result) + (rx/end! subs) + (do + (rx/push! subs (.-value result)) + (pump))))) + (.catch (fn [cause] + (rx/error! subs cause)))))] + (pump) + ;; teardown: cancel the reader when unsubscribed + (fn [] (.cancel reader)))))) + (rx/mapcat (fn [^js event] + (let [type (.-event event) + data (.-data event) + data (decode-fn data)] + (if (= "error" type) + (rx/throw (ex-info "stream exception" data)) + (rx/of #js {:type type :data data}))))))) + (defn get-type [event] diff --git a/frontend/src/app/util/worker.cljs b/frontend/src/app/util/worker.cljs index b23bbbee921..8d87a76795e 100644 --- a/frontend/src/app/util/worker.cljs +++ b/frontend/src/app/util/worker.cljs @@ -90,8 +90,8 @@ "Return a initialized webworker instance." [path on-error] (let [instance (js/Worker. path) - bus (rx/subject) - worker (Worker. instance (rx/to-observable bus)) + bus (rx/subject) + worker (Worker. instance (rx/to-observable bus)) handle-message (fn [event] diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index a191b9466f5..20c314f0128 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -11,6 +11,7 @@ [app.common.logging :as log] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.main.data.uploads :as uploads] [app.main.repo :as rp] [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] @@ -129,6 +130,23 @@ (->> (rx/from files) (rx/merge-map analyze-file))) +(defn- import-blob-via-upload + "Fetches `uri` as a Blob, uploads it using the generic chunked-upload + session API and calls `import-binfile` with the resulting upload-id. + Returns an observable of SSE events from the import stream." + [uri {:keys [name version project-id]}] + (->> (slurp-uri uri :blob) + (rx/mapcat + (fn [blob] + (->> (uploads/upload-blob-chunked blob) + (rx/mapcat + (fn [{:keys [session-id]}] + (rp/cmd! ::sse/import-binfile + {:name name + :upload-id session-id + :version version + :project-id project-id})))))))) + (defmethod impl/handler :import-files [{:keys [project-id files]}] (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) @@ -138,31 +156,22 @@ (->> (rx/from binfile-v1) (rx/merge-map (fn [data] - (->> (http/send! - {:uri (:uri data) - :response-type :blob - :method :get}) - (rx/map :body) - (rx/mapcat - (fn [file] - (->> (rp/cmd! ::sse/import-binfile - {:name (str/replace (:name data) #".penpot$" "") - :file file - :version 1 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/map (fn [_] - {:status :finish - :file-id (:file-id data)}))))) - + (->> (import-blob-via-upload (:uri data) + {:name (str/replace (:name data) #".penpot$" "") + :version 1 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (if (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/map (fn [_] + {:status :finish + :file-id (:file-id data)})) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" @@ -179,29 +188,24 @@ (rx/mapcat identity) (rx/merge-map (fn [[uri entries]] - (->> (slurp-uri uri :blob) - (rx/mapcat (fn [content] - ;; FIXME: implement the naming and filtering - (->> (rp/cmd! ::sse/import-binfile - {:name (-> entries first :name) - :file content - :version 3 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/mapcat (fn [_] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :finish - :file-id (:file-id entry)})))))))) - + (->> (import-blob-via-upload uri + {:name (-> entries first :name) + :version 3 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (if (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/mapcat (fn [_] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :finish + :file-id (:file-id entry)}))))) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" @@ -213,5 +217,3 @@ {:status :error :error (ex-message cause) :file-id (:file-id entry)})))))))))))) - - diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fa2641c7b3f..e586dc3d1a0 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -462,3 +462,9 @@ (defn print-last-exception [] (some-> errors/last-exception ex/print-throwable)) + + +(defn ^:export dbg + [o] + (app.common.pprint/pprint o {:level 100 :length 100})) + diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs new file mode 100644 index 00000000000..1512fcb90bc --- /dev/null +++ b/frontend/test/frontend_tests/data/uploads_test.cljs @@ -0,0 +1,117 @@ +;; 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 + +(ns frontend-tests.data.uploads-test + "Integration tests for the generic chunked-upload logic in + app.main.data.uploads." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.data.uploads :as uploads] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.http :as http])) + +;; --------------------------------------------------------------------------- +;; Local helpers +;; --------------------------------------------------------------------------- + +(defn- make-blob + "Creates a JS Blob of exactly `size` bytes." + [size] + (let [buf (js/Uint8Array. size)] + (js/Blob. #js [buf] #js {:type "application/octet-stream"}))) + +;; --------------------------------------------------------------------------- +;; upload-blob-chunked tests +;; --------------------------------------------------------------------------- + +(t/deftest upload-blob-chunked-creates-session-and-uploads-chunks + (t/testing "upload-blob-chunked calls create-upload-session then upload-chunk for each slice" + (t/async done + (let [session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Exactly two full chunks + blob-size (* 2 chunk-size) + blob (make-blob blob-size) + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (http/make-transit-response + {:session-id session-id :index 0}) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (uploads/upload-blob-chunked blob) + (rx/subs! + (fn [{:keys [session-id]}] + (t/is (uuid? session-id))) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (let [cmd-seq @calls] + ;; First call must create the session + (t/is (= :create-upload-session (first cmd-seq))) + ;; Two chunk uploads + (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq)))) + ;; No assemble call here — that's the caller's responsibility + (t/is (not (some #(= :assemble-file-media-object %) cmd-seq)))) + (done)))))))) + +(t/deftest upload-blob-chunked-chunk-count-matches-blob + (t/testing "number of upload-chunk calls equals ceil(blob-size / chunk-size)" + (t/async done + (let [session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Three chunks: 2 full + 1 partial + blob-size (+ (* 2 chunk-size) 1) + blob (make-blob blob-size) + chunk-calls (atom 0) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (do (swap! chunk-calls inc) + (http/make-transit-response + {:session-id session-id :index 0})) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (uploads/upload-blob-chunked blob) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (t/is (= 3 @chunk-calls)) + (done)))))))) diff --git a/frontend/test/frontend_tests/data/workspace_media_test.cljs b/frontend/test/frontend_tests/data/workspace_media_test.cljs new file mode 100644 index 00000000000..915adb203be --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_media_test.cljs @@ -0,0 +1,189 @@ +;; 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 + +(ns frontend-tests.data.workspace-media-test + "Integration tests for the chunked-upload logic in + app.main.data.workspace.media." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.data.workspace.media :as media] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.http :as http])) + +;; --------------------------------------------------------------------------- +;; Local helpers +;; --------------------------------------------------------------------------- + +(defn- make-blob + "Creates a JS Blob of exactly `size` bytes with the given `mtype`." + [size mtype] + (let [buf (js/Uint8Array. size)] + (js/Blob. #js [buf] #js {:type mtype}))) + +;; --------------------------------------------------------------------------- +;; Small-file path: direct upload (no chunking) +;; --------------------------------------------------------------------------- + +(t/deftest small-file-uses-direct-upload + (t/testing "blobs below chunk-size use :upload-file-media-object directly" + (t/async done + (let [file-id (uuid/next) + ;; One byte below the threshold so the blob takes the direct path + blob-size (dec cf/upload-chunk-size) + blob (make-blob blob-size "image/jpeg") + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (http/make-json-response + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)})))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + ;; Should call :upload-file-media-object, NOT the chunked API + (t/is (= 1 (count @calls))) + (t/is (= :upload-file-media-object (first @calls))) + (done)))))))) + +;; --------------------------------------------------------------------------- +;; Large-file path: chunked upload via uploads namespace +;; --------------------------------------------------------------------------- + +(t/deftest large-file-uses-chunked-upload + (t/testing "blobs at or above chunk-size use the three-step session API" + (t/async done + (let [file-id (uuid/next) + session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Exactly two full chunks + blob-size (* 2 chunk-size) + blob (make-blob blob-size "image/jpeg") + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (http/make-json-response + (case cmd + :create-upload-session + {:session-id (str session-id)} + + :upload-chunk + {:session-id (str session-id) :index 0} + + :assemble-file-media-object + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)} + + ;; Default: return an error response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (let [cmd-seq @calls] + ;; First call must create the session + (t/is (= :create-upload-session (first cmd-seq))) + ;; Two chunk uploads + (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq)))) + ;; Last call must assemble + (t/is (= :assemble-file-media-object (last cmd-seq))) + ;; Direct upload must NOT be called + (t/is (not (some #(= :upload-file-media-object %) cmd-seq)))) + (done)))))))) + +(t/deftest chunked-upload-chunk-count-matches-blob + (t/testing "number of chunk upload calls equals ceil(blob-size / chunk-size)" + (t/async done + (let [file-id (uuid/next) + session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Three chunks: 2 full + 1 partial + blob-size (+ (* 2 chunk-size) 1) + blob (make-blob blob-size "image/jpeg") + chunk-calls (atom 0) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (http/make-json-response + (case cmd + :create-upload-session + {:session-id (str session-id)} + + :upload-chunk + (do (swap! chunk-calls inc) + {:session-id (str session-id) :index 0}) + + :assemble-file-media-object + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)} + + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (t/is (= 3 @chunk-calls)) + (done)))))))) diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs new file mode 100644 index 00000000000..8d217fca04d --- /dev/null +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -0,0 +1,95 @@ +;; 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 + +(ns frontend-tests.errors-test + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true])) + +(defn- make-error + "Create a JS Error-like object with the given name, message, and optional stack." + [error-name message & {:keys [stack] :or {stack ""}}] + (let [err (js/Error. message)] + (set! (.-name err) error-name) + (when (some? stack) + (set! (.-stack err) stack)) + err)) + +;; --------------------------------------------------------------------------- +;; is-ignorable-exception? tests +;; --------------------------------------------------------------------------- + +(t/deftest test-ignorable-chrome-extension + (t/testing "Errors from Chrome extensions are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at chrome-extension://abc123/content.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-moz-extension + (t/testing "Errors from Firefox extensions are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at moz-extension://abc123/content.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-posthog + (t/testing "Errors from PostHog are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at https://app.posthog.com/static/array.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-debug-evaluate + (t/testing "Debug-evaluate side-effect errors are ignorable" + (let [cause (make-error "Error" "Possible side-effect in debug-evaluate")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-unexpected-end-of-input + (t/testing "Unexpected end of input errors are ignorable" + (let [cause (make-error "SyntaxError" "Unexpected end of input")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-invalid-props + (t/testing "Invalid React props errors are ignorable" + (let [cause (make-error "Error" "invalid props on component Foo")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-unexpected-token + (t/testing "Unexpected token errors are ignorable" + (let [cause (make-error "SyntaxError" "Unexpected token <")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-abort-error + (t/testing "AbortError DOMException is ignorable" + (let [cause (make-error "AbortError" "The operation was aborted")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-zone-js-tostring + (t/testing "Zone.js toString read-only property error is ignorable" + (let [cause (make-error "TypeError" + "Cannot assign to read only property 'toString' of function 'function () { [native code] }'")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-not-found-error-remove-child + (t/testing "NotFoundError with removeChild message is ignorable" + (let [cause (make-error "NotFoundError" + "Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node." + :stack "NotFoundError: Failed to execute 'removeChild'\n at zLe (libs.js:1:1)")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-not-found-error-other + (t/testing "NotFoundError without removeChild is NOT ignorable" + (let [cause (make-error "NotFoundError" + "Failed to execute 'insertBefore' on 'Node': something else")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-regular-error + (t/testing "Regular application errors are NOT ignorable" + (let [cause (make-error "Error" "Cannot read property 'x' of undefined")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-type-error + (t/testing "Regular TypeError is NOT ignorable" + (let [cause (make-error "TypeError" "undefined is not a function")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) diff --git a/frontend/test/frontend_tests/helpers/http.cljs b/frontend/test/frontend_tests/helpers/http.cljs new file mode 100644 index 00000000000..28895f40495 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/http.cljs @@ -0,0 +1,61 @@ +;; 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 + +(ns frontend-tests.helpers.http + "Helpers for intercepting and mocking the global `fetch` function in + ClojureScript tests. The underlying HTTP layer (`app.util.http`) calls + `(js/fetch url params)` directly, so replacing `globalThis.fetch` is the + correct interception point." + (:require + [app.common.transit :as t] + [clojure.string :as str])) + +(defn install-fetch-mock! + "Replaces the global `js/fetch` with `handler-fn`. + + `handler-fn` is called with `[url opts]` where `url` is a plain string + such as `\"http://localhost/api/main/methods/some-cmd\"`. It must return + a JS Promise that resolves to a fetch Response object. + + Returns the previous `globalThis.fetch` value so callers can restore it + with [[restore-fetch!]]." + [handler-fn] + (let [prev (.-fetch js/globalThis)] + (set! (.-fetch js/globalThis) handler-fn) + prev)) + +(defn restore-fetch! + "Restores `globalThis.fetch` to `orig` (the value returned by + [[install-fetch-mock!]])." + [orig] + (set! (.-fetch js/globalThis) orig)) + +(defn make-json-response + "Creates a minimal fetch `Response` that returns `body-clj` serialised as + plain JSON with HTTP status 200." + [body-clj] + (let [json-str (.stringify js/JSON (clj->js body-clj)) + headers (js/Headers. #js {"content-type" "application/json"})] + (js/Response. json-str #js {:status 200 :headers headers}))) + +(defn make-transit-response + "Creates a minimal fetch `Response` that returns `body-clj` serialised as + Transit+JSON with HTTP status 200. Use this helper when the code under + test inspects typed values (UUIDs, keywords, etc.) from the response body, + since the HTTP layer only decodes transit+json content automatically." + [body-clj] + (let [transit-str (t/encode-str body-clj {:type :json-verbose}) + headers (js/Headers. #js {"content-type" "application/transit+json"})] + (js/Response. transit-str #js {:status 200 :headers headers}))) + +(defn url->cmd + "Extracts the RPC command keyword from a URL string. + + Example: `\"http://…/api/main/methods/create-upload-session\"` + → `:create-upload-session`." + [url] + (when (string? url) + (keyword (last (str/split url #"/"))))) diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 689ac76b636..9e830c9a76d 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -141,7 +141,7 @@ events [(dwta/apply-token {:shape-ids [(cthi/id :frame1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "test-token-2") - :on-update-shape dwta/update-shape-radius-all})] + :on-update-shape dwta/update-shape-radius})] step2 (fn [_] (let [events2 [(dwl/sync-file (:id file) (:id file))]] @@ -249,11 +249,11 @@ events [(dwta/apply-token {:shape-ids [(cthi/id :c-frame1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "test-token-2") - :on-update-shape dwta/update-shape-radius-all}) + :on-update-shape dwta/update-shape-radius}) (dwta/apply-token {:shape-ids [(cthi/id :frame1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "test-token-3") - :on-update-shape dwta/update-shape-radius-all})] + :on-update-shape dwta/update-shape-radius})] step2 (fn [_] (let [events2 [(dwl/sync-file (:id file) (:id file))]] @@ -293,7 +293,7 @@ (dwta/apply-token {:shape-ids [(cthi/id :frame1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "test-token-3") - :on-update-shape dwta/update-shape-radius-all})] + :on-update-shape dwta/update-shape-radius})] step2 (fn [_] (let [events2 [(dwl/sync-file (:id file) (:id file))]] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs new file mode 100644 index 00000000000..5dc17476589 --- /dev/null +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -0,0 +1,136 @@ +;; 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 + +(ns frontend-tests.main-errors-test + "Unit tests for app.main.errors. + + Tests cover: + - stale-asset-error? – pure predicate + - exception->error-data – pure transformer + - on-error re-entrancy guard – prevents recursive invocations + - flash schedules async emit – ntf/show is not emitted synchronously" + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +;; --------------------------------------------------------------------------- +;; stale-asset-error? +;; --------------------------------------------------------------------------- + +(t/deftest stale-asset-error-nil + (t/testing "nil cause returns nil/falsy" + (t/is (not (errors/stale-asset-error? nil))))) + +(t/deftest stale-asset-error-keyword-cst-undefined + (t/testing "error with $cljs$cst$ and 'is undefined' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is undefined")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-keyword-cst-null + (t/testing "error with $cljs$cst$ and 'is null' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is null")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-protocol-dispatch-undefined + (t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-not-a-function + (t/testing "error with $cljs$cst$ and 'is not a function' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is not a function")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-unrelated-message + (t/testing "ordinary error without stale-asset signature is NOT recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")] + (t/is (not (errors/stale-asset-error? err)))))) + +(t/deftest stale-asset-error-only-cst-no-undefined + (t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised" + (let [err (js/Error. "foo$cljs$cst$bar exploded")] + (t/is (not (errors/stale-asset-error? err)))))) + +;; --------------------------------------------------------------------------- +;; exception->error-data +;; --------------------------------------------------------------------------- + +(t/deftest exception->error-data-plain-error + (t/testing "plain JS Error is converted to a data map with :hint and ::instance" + (let [err (js/Error. "something went wrong") + data (errors/exception->error-data err)] + (t/is (= "something went wrong" (:hint data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info + (t/testing "ex-info error preserves existing :hint and attaches ::instance" + (let [err (ex-info "original" {:hint "my-hint" :type :network}) + data (errors/exception->error-data err)] + (t/is (= "my-hint" (:hint data))) + (t/is (= :network (:type data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info-no-hint + (t/testing "ex-info without :hint falls back to ex-message" + (let [err (ex-info "fallback message" {:type :validation}) + data (errors/exception->error-data err)] + (t/is (= "fallback message" (:hint data)))))) + +;; --------------------------------------------------------------------------- +;; on-error dispatches to ptk/handle-error +;; +;; We use a dedicated test-only error type so we can add/remove a +;; defmethod without touching the real handlers. +;; --------------------------------------------------------------------------- + +(def ^:private test-handled (atom nil)) + +(defmethod ptk/handle-error ::test-dispatch + [err] + (reset! test-handled err)) + +(t/deftest on-error-dispatches-map-error + (t/testing "on-error dispatches a map error to ptk/handle-error using its :type" + (reset! test-handled nil) + (errors/on-error {:type ::test-dispatch :hint "hello"}) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (= "hello" (:hint @test-handled))))) + +(t/deftest on-error-wraps-exception-then-dispatches + (t/testing "on-error wraps a JS Error into error-data before dispatching" + (reset! test-handled nil) + (let [err (ex-info "wrapped" {:type ::test-dispatch})] + (errors/on-error err) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (identical? err (::errors/instance @test-handled)))))) + +;; --------------------------------------------------------------------------- +;; on-error re-entrancy guard +;; +;; The guard is implemented via the `handling-error?` volatile inside +;; app.main.errors. We can verify its effect by registering a +;; handle-error method that itself calls on-error and checking that +;; only one invocation gets through. +;; --------------------------------------------------------------------------- + +(def ^:private reentrant-call-count (atom 0)) + +(defmethod ptk/handle-error ::test-reentrant + [_err] + (swap! reentrant-call-count inc) + ;; Simulate a secondary error inside the error handler + ;; (e.g. the notification emit itself throws). + ;; Without the re-entrancy guard this would recurse indefinitely. + (when (= 1 @reentrant-call-count) + (errors/on-error {:type ::test-reentrant :hint "secondary"}))) + +(t/deftest on-error-reentrancy-guard-prevents-recursion + (t/testing "a second on-error call while handling an error is suppressed by the guard" + (reset! reentrant-call-count 0) + (errors/on-error {:type ::test-reentrant :hint "first"}) + ;; The guard must have allowed only the first invocation through. + (t/is (= 1 @reentrant-call-count)))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs new file mode 100644 index 00000000000..3c0d1cda1a8 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -0,0 +1,82 @@ +;; 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 + +(ns frontend-tests.plugins.tokens-test + (:require + [app.plugins.tokens :as ptok] + [cljs.test :as t :include-macros true])) + +;; Regression coverage for issue #9162. +;; +;; Plugin code calling `shape.applyToken(token, ["fill"])` or +;; `token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS +;; array of strings. Penpot's plugin proxies expect a Clojure set of +;; keywords. Two coupled defects made these calls silently no-op (or, with +;; `throwValidationErrors` enabled, throw a "check error"): +;; +;; 1. `token-attr-plugin->token-attr` only consulted its alias map when +;; the input was already a keyword — string inputs like "fill" or +;; "border-radius-top-left" fell through to the identity branch +;; unchanged, so the downstream `cto/token-attr?` predicate (which +;; checks against a set of keywords) returned false. +;; 2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas used +;; plain `[:set ...]`, which does not have a `:decode/json` +;; transformer for the JS array → Clojure set coercion. Penpot's +;; custom `[::sm/set ...]` does. Switching to the registered set type +;; lets the standard JSON decoder pipeline turn the JS argument into +;; a set of strings, after which the `[:and ::sm/keyword [:fn +;; token-attr?]]` element schema coerces each string to a keyword and +;; validates it. +;; +;; These helper-level tests pin the string-friendly conversion contract; +;; the schema-level fix is covered by the existing plugin integration +;; suite that exercises `applyToken` end-to-end. + +(t/deftest token-attr-plugin->token-attr-passes-canonical-form-through + ;; Both already-canonical short names and unaliased names pass through + ;; unchanged. + (t/is (= :fill (ptok/token-attr-plugin->token-attr :fill))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr :stroke-color))) + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :r1))) + (t/is (= :p2 (ptok/token-attr-plugin->token-attr :p2)))) + +(t/deftest token-attr-plugin->token-attr-resolves-verbose-plugin-aliases + ;; Plugin-side verbose names (e.g. `:border-radius-top-left`) map to + ;; their canonical short internal form (`:r1`) so plugin authors can + ;; spell the corner explicitly without the engine having to know both. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :border-radius-top-left))) + (t/is (= :r2 (ptok/token-attr-plugin->token-attr :border-radius-top-right))) + (t/is (= :r3 (ptok/token-attr-plugin->token-attr :border-radius-bottom-right))) + (t/is (= :r4 (ptok/token-attr-plugin->token-attr :border-radius-bottom-left))) + (t/is (= :p1 (ptok/token-attr-plugin->token-attr :padding-top-left))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom-right)))) + +(t/deftest token-attr-plugin->token-attr-coerces-string-input + ;; This is the actual regression — JS plugin calls supply strings. + (t/is (= :fill (ptok/token-attr-plugin->token-attr "fill"))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr "stroke-color"))) + ;; Verbose plugin aliases work via the string path too. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr "border-radius-top-left"))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr "margin-bottom-right")))) + +(t/deftest token-attr?-accepts-keyword-input + (t/is (true? (boolean (ptok/token-attr? :fill)))) + (t/is (true? (boolean (ptok/token-attr? :stroke-color)))) + (t/is (true? (boolean (ptok/token-attr? :r1)))) + (t/is (true? (boolean (ptok/token-attr? :p2))))) + +(t/deftest token-attr?-accepts-string-input + ;; Same JS-array-of-strings reproducer as the issue, exercised at the + ;; predicate layer the plugin schemas call into. + (t/is (true? (boolean (ptok/token-attr? "fill")))) + (t/is (true? (boolean (ptok/token-attr? "stroke-color")))) + (t/is (true? (boolean (ptok/token-attr? "r1")))) + (t/is (true? (boolean (ptok/token-attr? "m3"))))) + +(t/deftest token-attr?-rejects-unknown-input + (t/is (false? (boolean (ptok/token-attr? :not-a-real-attr)))) + (t/is (false? (boolean (ptok/token-attr? "not-a-real-attr")))) + (t/is (false? (boolean (ptok/token-attr? nil))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 3cd38c12f0b..8260e62a451 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -3,10 +3,13 @@ [cljs.test :as t] [frontend-tests.basic-shapes-test] [frontend-tests.data.repo-test] + [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.errors-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -14,7 +17,9 @@ [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.tokens-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] [frontend-tests.tokens.logic.token-actions-test] @@ -41,8 +46,12 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.errors-test + 'frontend-tests.main-errors-test + 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.helpers-shapes-test @@ -53,6 +62,7 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.tokens-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.logic.token-actions-test diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index cb6f2e39d8b..e468e420bb5 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -64,7 +64,7 @@ events [(dwta/apply-token {:shape-ids [(:id rect-1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "borderRadius.md") - :on-update-shape dwta/update-shape-radius-all})]] + :on-update-shape dwta/update-shape-radius})]] (tohs/run-store-async store done events (fn [new-state] @@ -89,11 +89,11 @@ events [(dwta/apply-token {:shape-ids [(:id rect-1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "borderRadius.sm") - :on-update-shape dwta/update-shape-radius-all}) + :on-update-shape dwta/update-shape-radius}) (dwta/apply-token {:shape-ids [(:id rect-1)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "borderRadius.md") - :on-update-shape dwta/update-shape-radius-all})]] + :on-update-shape dwta/update-shape-radius})]] (tohs/run-store-async store done events (fn [new-state] @@ -117,14 +117,14 @@ (dwta/apply-token {:attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "borderRadius.sm") :shape-ids [(:id rect-1)] - :on-update-shape dwta/update-shape-radius-all}) + :on-update-shape dwta/update-shape-radius}) ;; Apply single `:r1` attribute to same shape ;; while removing other attributes from the border-radius set ;; but keep `:r4` for testing purposes (dwta/apply-token {:attributes #{:r1 :r2 :r3} :token (toht/get-token file "borderRadius.md") :shape-ids [(:id rect-1)] - :on-update-shape dwta/update-shape-radius-all})]] + :on-update-shape dwta/update-shape-radius})]] (tohs/run-store-async store done events (fn [new-state] @@ -153,7 +153,7 @@ (dwta/apply-token {:shape-ids [(:id rect-2)] :attributes #{:r1 :r2 :r3 :r4} :token (toht/get-token file "borderRadius.sm") - :on-update-shape dwta/update-shape-radius-all})]] + :on-update-shape dwta/update-shape-radius})]] (tohs/run-store-async store done events (fn [new-state] @@ -762,7 +762,7 @@ rect-2 (cths/get-shape file :rect-2) events [(dwta/toggle-token {:shape-ids [(:id rect-1) (:id rect-2)] :token-type-props {:attributes #{:r1 :r2 :r3 :r4} - :on-update-shape dwta/update-shape-radius-all} + :on-update-shape dwta/update-shape-radius} :token (toht/get-token file "borderRadius.md")})]] (tohs/run-store-async store done events diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index bf8aad0c352..1f1609f3444 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -57,3 +57,29 @@ (t/is (= :error.token/number-too-large (get-in resolved-tokens ["borderRadius.largeFn" :errors 0 :error/code]))) (done)))))))) + +(t/deftest resolve-tokens-interactive-test + (t/async + done + (t/testing "resolves tokens interactively using backtrace ids map" + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:value "{borderRadius.sm} * 2" + :name "borderRadius.md" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (-> (sd/resolve-tokens-interactive tokens) + (rx/sub! + (fn [resolved-tokens] + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))) + (t/is (= "px" (get-in resolved-tokens ["borderRadius.sm" :unit]))) + (t/is (= 24 (get-in resolved-tokens ["borderRadius.md" :resolved-value]))) + (t/is (= "px" (get-in resolved-tokens ["borderRadius.md" :unit]))) + (done)))))))) + diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a39cfaf85ce..041ed51aba9 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -338,77 +338,6 @@ msgstr "You're going to restore %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restore file" -#: src/app/main/ui/settings/access_tokens.cljs:103 -msgid "dashboard.access-tokens.copied-success" -msgstr "Copied token" - -#: src/app/main/ui/settings/access_tokens.cljs:189 -msgid "dashboard.access-tokens.create" -msgstr "Generate new token" - -#: src/app/main/ui/settings/access_tokens.cljs:64 -msgid "dashboard.access-tokens.create.success" -msgstr "Access token created successfully." - -#: src/app/main/ui/settings/access_tokens.cljs:286 -msgid "dashboard.access-tokens.empty.add-one" -msgstr "Press the button \"Generate new token\" to generate one." - -#: src/app/main/ui/settings/access_tokens.cljs:285 -msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "You have no tokens so far." - -#: src/app/main/ui/settings/access_tokens.cljs:135 -msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 days" - -#: src/app/main/ui/settings/access_tokens.cljs:132 -msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 days" - -#: src/app/main/ui/settings/access_tokens.cljs:133 -msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 days" - -#: src/app/main/ui/settings/access_tokens.cljs:134 -msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 days" - -#: src/app/main/ui/settings/access_tokens.cljs:131 -msgid "dashboard.access-tokens.expiration-never" -msgstr "Never" - -#: src/app/main/ui/settings/access_tokens.cljs:268 -msgid "dashboard.access-tokens.expired-on" -msgstr "Expired on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:269 -msgid "dashboard.access-tokens.expires-on" -msgstr "Expires on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:267 -msgid "dashboard.access-tokens.no-expiration" -msgstr "No expiration date" - -#: src/app/main/ui/settings/access_tokens.cljs:184 -msgid "dashboard.access-tokens.personal" -msgstr "Personal access tokens" - -#: src/app/main/ui/settings/access_tokens.cljs:185 -msgid "dashboard.access-tokens.personal.description" -msgstr "" -"Personal access tokens function like an alternative to our login/password " -"authentication system and can be used to allow an application to access the " -"internal Penpot API" - -#: src/app/main/ui/settings/access_tokens.cljs:142 -msgid "dashboard.access-tokens.token-will-expire" -msgstr "The token will expire on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:143 -msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "The token has no expiration date" - #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Add file" @@ -1506,6 +1435,14 @@ msgstr "The recovery token is invalid." msgid "errors.invalid-text" msgstr "Invalid text" +#: common/src/app/common/types/team.cljc:26 +msgid "errors.team-name-invalid-chars" +msgstr "The team name can't contain any of the following characters:'.', ':' or '/'" + +#: common/src/app/common/types/font.cljc +msgid "errors.font-family-invalid-chars" +msgstr "The font family name can only contain letters, numbers, spaces, hyphens, underscores, and dots." + #: src/app/main/ui/static.cljs:74 msgid "errors.invite-invalid" msgstr "Invite invalid" @@ -2138,6 +2075,237 @@ msgstr "Resolved value:" msgid "inspect.tabs.styles.variants-panel" msgstr "Variant Properties" +#: src/app/main/ui/settings/integrations.cljs:189 +msgid "integrations.access-tokens.create" +msgstr "Create new access token" + +#: src/app/main/ui/settings/integrations.cljs:286 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Press the button \"Create new access token\" to generate one." + +#: src/app/main/ui/settings/integrations.cljs:285 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "You have no tokens so far." + +#: src/app/main/ui/settings/integrations.cljs:184 +msgid "integrations.access-tokens.personal" +msgstr "Personal access tokens" + +#: src/app/main/ui/settings/integrations.cljs:185 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Personal access tokens function like an alternative to our login/password " +"authentication system and can be used to allow an application to access the " +"internal Penpot API" + +#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158 +msgid "integrations.copy-to-clipboard" +msgstr "Copy to clipboard" + +#: src/app/main/ui/settings/integrations.cljs:432 +msgid "integrations.create-access-token.title" +msgstr "Create access token" + +#: src/app/main/ui/settings/integrations.cljs:433 +msgid "integrations.create-access-token.title.created" +msgstr "Access token created" + +#: src/app/main/ui/settings/integrations.cljs:257 +msgid "integrations.delete-token.accept" +msgstr "Delete token" + +#: src/app/main/ui/settings/integrations.cljs:256 +msgid "integrations.delete-token.message" +msgstr "Are you sure you want to delete this token?" + +#: src/app/main/ui/settings/integrations.cljs:255 +msgid "integrations.delete-token.title" +msgstr "Delete token" + +#: src/app/main/ui/settings/integrations.cljs:135 +msgid "integrations.expiration-180-days" +msgstr "180 days" + +#: src/app/main/ui/settings/integrations.cljs:132 +msgid "integrations.expiration-30-days" +msgstr "30 days" + +#: src/app/main/ui/settings/integrations.cljs:133 +msgid "integrations.expiration-60-days" +msgstr "60 days" + +#: src/app/main/ui/settings/integrations.cljs:134 +msgid "integrations.expiration-90-days" +msgstr "90 days" + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.expiration-never" +msgstr "Never" + +#: src/app/main/ui/settings/integrations.cljs:268 +msgid "integrations.expired-on" +msgstr "Expired on %s" + +#: src/app/main/ui/settings/integrations.cljs:269 +msgid "integrations.expires-on" +msgstr "Expires on %s" + +#: src/app/main/ui/settings/integrations.cljs:267 +msgid "integrations.no-expiration" +msgstr "No expiration date" + +#: src/app/main/ui/settings/integrations.cljs:130 +msgid "integrations.expiration-date.label" +msgstr "Expiration date" + +#: src/app/main/ui/settings/integrations.cljs:290 +msgid "integrations.generate-mcp-key.title" +msgstr "Generate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:291 +msgid "integrations.generate-mcp-key.title.created" +msgstr "MCP key generated" + +#: src/app/main/ui/settings/integrations.cljs:113 +msgid "integrations.info.mcp-client-config" +msgstr "Add this configuration to your MCP client (e.g. ~/​​​​​​.mcp.json)." + +#: src/app/main/ui/settings/integrations.cljs:183 +msgid "integrations.info.mcp-server" +msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files." + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.token.info.non-recuperable" +msgstr "This unique token is non-recuperable. If you lose it, you will need to create a new one." + +#: src/app/main/ui/settings/integrations.cljs:131 +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.mcp-key.info.non-recuperable" +msgstr "This unique MCP key is non-recoverable. If you lose it, you will need to create a new one." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title" +msgstr "MCP Server" + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:347 +msgid "integrations.mcp-server.description" +msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files." + +#: src/app/main/ui/settings/integrations.cljs:353 +msgid "integrations.mcp-server.status" +msgstr "Status" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.disabled" +msgstr "Disabled" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.enabled" +msgstr "Enabled" + +#: src/app/main/ui/settings/integrations.cljs:363 +msgid "integrations.mcp-server.status.expired.0" +msgstr "The MCP key used to connect to the MCP server has expired. As a result, the connection cannot be established." + +#: src/app/main/ui/settings/integrations.cljs:368 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Please regenerate the MCP key and update your client configuration with the new key." + +#: src/app/main/ui/settings/integrations.cljs:415 +msgid "integrations.mcp-server.mcp-keys.copy" +msgstr "Copy link" + +#: src/app/main/ui/settings/integrations.cljs:422 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "How to configure MCP clients" + +#: src/app/main/ui/settings/integrations.cljs:405 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "This is the server url you'll need to configure your MCP client in order to connect it to the Penpot MCP server." + +#: src/app/main/ui/settings/integrations.cljs:387 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Regenerate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:381 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "MCP key" + +#: src/app/main/ui/settings/integrations.cljs:388 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "The MCP key is needed for the MCP client set up" + +#: src/app/main/ui/settings/integrations.cljs:124 +msgid "integrations.name.label" +msgstr "Name" + +#: src/app/main/ui/settings/integrations.cljs:126 +msgid "integrations.name.placeholder" +msgstr "The name can help to know what's the token for" + +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.token-copied" +msgstr "Copied token" + +#: src/app/main/ui/settings/integrations.cljs:103 +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "MCP key copied" + +#: src/app/main/ui/settings/integrations.cljs:64 +msgid "integrations.notification.success.created" +msgstr "Token created successfully" + +#: src/app/main/ui/settings/integrations.cljs:327 +msgid "integrations.notification.success.copied-link" +msgstr "Link copied to clipboard" + +#: src/app/main/ui/settings/integrations.cljs:293 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "MCP server disabled" + +#: src/app/main/ui/settings/integrations.cljs:299 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "MCP server enabled" + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.info" +msgstr "Regenerating the MCP key will immediately revoke the current one. Any application using it will stop working." + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Regenerate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:318 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "MCP key regenerated" + +#: src/app/main/ui/settings/integrations.cljs:480 +msgid "integrations.title" +msgstr "Integrations" + +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.token.will-expire" +msgstr "The token will expire on %s" + +#: src/app/main/ui/settings/integrations.cljs:142 +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.mcp-key.will-expire" +msgstr "The MCP key will expire on %s" + +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.token.will-not-expire" +msgstr "The token has no expiration date" + +#: src/app/main/ui/settings/integrations.cljs:143 +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.mcp-key.will-not-expire" +msgstr "The MCP key has no expiration date" + #: src/app/main/ui/dashboard/comments.cljs:96 msgid "label.mark-all-as-read" msgstr "Mark all as read" @@ -2354,6 +2522,9 @@ msgstr "Director" msgid "labels.discard" msgstr "Discard" +msgid "labels.dismiss" +msgstr "Dismiss" + #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 msgid "labels.download" msgstr "Download %s" @@ -2489,6 +2660,10 @@ msgstr "Info" msgid "labels.installed-fonts" msgstr "Installed fonts" +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.integrations" +msgstr "Integrations" + #: src/app/main/ui/static.cljs:405 msgid "labels.internal-error.desc-message-first" msgstr "Something bad happened." @@ -3148,30 +3323,6 @@ msgstr "Change email" msgid "modals.change-email.title" msgstr "Change your email" -#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158 -msgid "modals.create-access-token.copy-token" -msgstr "Copy token" - -#: src/app/main/ui/settings/access_tokens.cljs:130 -msgid "modals.create-access-token.expiration-date.label" -msgstr "Expiration date" - -#: src/app/main/ui/settings/access_tokens.cljs:124 -msgid "modals.create-access-token.name.label" -msgstr "Name" - -#: src/app/main/ui/settings/access_tokens.cljs:126 -msgid "modals.create-access-token.name.placeholder" -msgstr "The name can help to know what's the token for" - -#: src/app/main/ui/settings/access_tokens.cljs:178 -msgid "modals.create-access-token.submit-label" -msgstr "Create token" - -#: src/app/main/ui/settings/access_tokens.cljs:111 -msgid "modals.create-access-token.title" -msgstr "Generate access token" - #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" msgstr "Create webhook" @@ -3188,18 +3339,6 @@ msgstr "Payload URL" msgid "modals.create-webhook.url.placeholder" msgstr "https://example.com/postreceive" -#: src/app/main/ui/settings/access_tokens.cljs:257 -msgid "modals.delete-acces-token.accept" -msgstr "Delete token" - -#: src/app/main/ui/settings/access_tokens.cljs:256 -msgid "modals.delete-acces-token.message" -msgstr "Are you sure you want to delete this token?" - -#: src/app/main/ui/settings/access_tokens.cljs:255 -msgid "modals.delete-acces-token.title" -msgstr "Delete token" - #: src/app/main/ui/settings/delete_account.cljs:56 msgid "modals.delete-account.cancel" msgstr "Cancel and keep my account" @@ -3699,6 +3838,12 @@ msgstr "Invitation sent successfully" msgid "notifications.invitation-link-copied" msgstr "Invitation link copied" +msgid "notifications.mcp.active-in-another-tab" +msgstr "MCP is active in another tab. Switch here?" + +msgid "notifications.mcp.active-in-this-tab" +msgstr "MCP is now active in this tab." + #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" msgstr "You can't delete your profile. Reassign your teams before proceed." @@ -5093,14 +5238,14 @@ msgstr "Shared Libraries - %s - Penpot" msgid "title.default" msgstr "Penpot - Design Freedom for Teams" -#: src/app/main/ui/settings/access_tokens.cljs:278 -msgid "title.settings.access-tokens" -msgstr "Profile - Access tokens" - #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" msgstr "Give feedback - Penpot" +#: src/app/main/ui/settings/integrations.cljs:278 +msgid "title.settings.integrations" +msgstr "Integrations - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Notifications - Penpot" @@ -5594,6 +5739,18 @@ msgstr "Hide rulers" msgid "workspace.header.menu.hide-textpalette" msgstr "Hide fonts palette" +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Connect" + +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Disconnect" + +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Manage (Status: enabled)" + +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Manage (Status: disabled)" + #: src/app/main/ui/workspace/main_menu.cljs:884 msgid "workspace.header.menu.option.edit" msgstr "Edit" @@ -5606,6 +5763,9 @@ msgstr "File" msgid "workspace.header.menu.option.help-info" msgstr "Help & info" +msgid "workspace.header.menu.option.mcp" +msgstr "MCP server" + #: src/app/main/ui/workspace/main_menu.cljs:916 #, unused msgid "workspace.header.menu.option.power-up" @@ -7234,11 +7394,14 @@ msgid "workspace.plugins.empty-plugins" msgstr "No plugins installed yet" #: src/app/main/ui/workspace/plugins.cljs:193 -msgid "workspace.plugins.error.manifest" -msgstr "The plugin manifest is incorrect." + msgid "workspace.plugins.error.manifest" + msgstr "The plugin manifest is incorrect." + +msgid "plugins.validation.message" +msgstr "Field %s is invalid: %s" #: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84 -msgid "workspace.plugins.error.need-editor" + msgid "workspace.plugins.error.need-editor" msgstr "You need to be an editor to use this plugin" #: src/app/main/ui/workspace/plugins.cljs:189 diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f30b9e12e32..a13e9c56b77 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -347,77 +347,6 @@ msgstr "Vas a restaurar %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restaurar archivo" -#: src/app/main/ui/settings/access_tokens.cljs:103 -msgid "dashboard.access-tokens.copied-success" -msgstr "Token copiado" - -#: src/app/main/ui/settings/access_tokens.cljs:189 -msgid "dashboard.access-tokens.create" -msgstr "Generar nuevo token" - -#: src/app/main/ui/settings/access_tokens.cljs:64 -msgid "dashboard.access-tokens.create.success" -msgstr "Access token creado con éxito." - -#: src/app/main/ui/settings/access_tokens.cljs:286 -msgid "dashboard.access-tokens.empty.add-one" -msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno." - -#: src/app/main/ui/settings/access_tokens.cljs:285 -msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "Todavía no tienes ningún token." - -#: src/app/main/ui/settings/access_tokens.cljs:135 -msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 días" - -#: src/app/main/ui/settings/access_tokens.cljs:132 -msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 días" - -#: src/app/main/ui/settings/access_tokens.cljs:133 -msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 días" - -#: src/app/main/ui/settings/access_tokens.cljs:134 -msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 días" - -#: src/app/main/ui/settings/access_tokens.cljs:131 -msgid "dashboard.access-tokens.expiration-never" -msgstr "Nunca" - -#: src/app/main/ui/settings/access_tokens.cljs:268 -msgid "dashboard.access-tokens.expired-on" -msgstr "Expiró el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:269 -msgid "dashboard.access-tokens.expires-on" -msgstr "Expira el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:267 -msgid "dashboard.access-tokens.no-expiration" -msgstr "Sin fecha de expiración" - -#: src/app/main/ui/settings/access_tokens.cljs:184 -msgid "dashboard.access-tokens.personal" -msgstr "Access tokens personales" - -#: src/app/main/ui/settings/access_tokens.cljs:185 -msgid "dashboard.access-tokens.personal.description" -msgstr "" -"Los access tokens personales funcionan como una alternativa a nuestro " -"sistema de autenticación usuario/password y se pueden usar para permitir a " -"otras aplicaciones acceso a la API interna de Penpot" - -#: src/app/main/ui/settings/access_tokens.cljs:142 -msgid "dashboard.access-tokens.token-will-expire" -msgstr "El token expirará el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:143 -msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "El token no tiene fecha de expiración" - #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Añadir archivo" @@ -2105,6 +2034,237 @@ msgstr "Valor resuelto:" msgid "inspect.tabs.styles.variants-panel" msgstr "Propiedades de las variantes" +#: src/app/main/ui/settings/integrations.cljs:189 +msgid "integrations.access-tokens.create" +msgstr "Crear nuevo token de acceso" + +#: src/app/main/ui/settings/integrations.cljs:286 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno." + +#: src/app/main/ui/settings/integrations.cljs:285 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "Todavía no tienes ningún token." + +#: src/app/main/ui/settings/integrations.cljs:184 +msgid "integrations.access-tokens.personal" +msgstr "Tokens de acceso personales" + +#: src/app/main/ui/settings/integrations.cljs:185 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Los tokens de accesso personales funcionan como una alternativa a nuestro " +"sistema de autenticación usuario/password y se pueden usar para permitir a " +"otras aplicaciones acceso a la API interna de Penpot" + +#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158 +msgid "integrations.copy-to-clipboard" +msgstr "Copiar al portapapeles" + +#: src/app/main/ui/settings/integrations.cljs:432 +msgid "integrations.create-access-token.title" +msgstr "Crear token de accesso" + +#: src/app/main/ui/settings/integrations.cljs:433 +msgid "integrations.create-access-token.title.created" +msgstr "Token de acceso creado" + +#: src/app/main/ui/settings/integrations.cljs:257 +msgid "integrations.delete-token.accept" +msgstr "Borrar token" + +#: src/app/main/ui/settings/integrations.cljs:256 +msgid "integrations.delete-token.message" +msgstr "¿Seguro que deseas borrar este token?" + +#: src/app/main/ui/settings/integrations.cljs:255 +msgid "integrations.delete-token.title" +msgstr "Borrar token" + +#: src/app/main/ui/settings/integrations.cljs:135 +msgid "integrations.expiration-180-days" +msgstr "180 días" + +#: src/app/main/ui/settings/integrations.cljs:132 +msgid "integrations.expiration-30-days" +msgstr "30 días" + +#: src/app/main/ui/settings/integrations.cljs:133 +msgid "integrations.expiration-60-days" +msgstr "60 días" + +#: src/app/main/ui/settings/integrations.cljs:134 +msgid "integrations.expiration-90-days" +msgstr "90 días" + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.expiration-never" +msgstr "Nunca" + +#: src/app/main/ui/settings/integrations.cljs:268 +msgid "integrations.expired-on" +msgstr "Expiró el %s" + +#: src/app/main/ui/settings/integrations.cljs:269 +msgid "integrations.expires-on" +msgstr "Expira el %s" + +#: src/app/main/ui/settings/integrations.cljs:267 +msgid "integrations.no-expiration" +msgstr "Sin fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:130 +msgid "integrations.expiration-date.label" +msgstr "Fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:290 +msgid "integrations.generate-mcp-key.title" +msgstr "Generar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:291 +msgid "integrations.generate-mcp-key.title.created" +msgstr "Clave MCP generada" + +#: src/app/main/ui/settings/integrations.cljs:113 +msgid "integrations.info.mcp-client-config" +msgstr "Agrega esta configuración a tu cliente MCP (por ejemplo, ~/.mcp.json)." + +#: src/app/main/ui/settings/integrations.cljs:183 +msgid "integrations.info.mcp-server" +msgstr "El servidor MCP de Penpot permite a los clientes MCP interactuar directamente con los archivos de diseño de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.token.info.non-recuperable" +msgstr "Esta clave única no es recuperable. Si la pierdes, tendrás que crear una nueva." + +#: src/app/main/ui/settings/integrations.cljs:131 +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.mcp-key.info.non-recuperable" +msgstr "Esta clave MCP única no es recuperable. Si la pierdes, tendrás que crear una nueva." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title" +msgstr "Servidor MCP" + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:347 +msgid "integrations.mcp-server.description" +msgstr "El servidor MCP de Penpot permite que los clientes MCP interactúen directamente con los archivos de diseño de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:353 +msgid "integrations.mcp-server.status" +msgstr "Estado" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.enabled" +msgstr "Habilitado" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.disabled" +msgstr "Deshabilitado" + +#: src/app/main/ui/settings/integrations.cljs:363 +msgid "integrations.mcp-server.status.expired.0" +msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como resultado, no se puede establecer la conexión." + +#: src/app/main/ui/settings/integrations.cljs:368 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave." + +#: src/app/main/ui/settings/integrations.cljs:415 +msgid "integrations.mcp-server.mcp-keys.copy" +msgstr "Copiar enlace" + +#: src/app/main/ui/settings/integrations.cljs:422 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "Cómo configurar clientes MCP" + +#: src/app/main/ui/settings/integrations.cljs:405 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "Esta es la URL del servidor que necesitarás configurar en tu cliente MCP para conectarlo al servidor MCP de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:387 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Regenerar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:381 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "Clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:388 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "La clave MCP es necesaria para la configuración del cliente MCP" + +#: src/app/main/ui/settings/integrations.cljs:124 +msgid "integrations.name.label" +msgstr "Nombre" + +#: src/app/main/ui/settings/integrations.cljs:126 +msgid "integrations.name.placeholder" +msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" + +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.token-copied" +msgstr "Token copiado" + +#: src/app/main/ui/settings/integrations.cljs:103 +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "Clave MCP copiada" + +#: src/app/main/ui/settings/integrations.cljs:64 +msgid "integrations.notification.success.created" +msgstr "Token creado con éxito" + +#: src/app/main/ui/settings/integrations.cljs:327 +msgid "integrations.notification.success.copied-link" +msgstr "Enlace copiado al portapapeles" + +#: src/app/main/ui/settings/integrations.cljs:293 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "Servidor MCP deshabilitado" + +#: src/app/main/ui/settings/integrations.cljs:299 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "Servidor MCP habilitado" + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.info" +msgstr "Regenerar la clave MCP revocará inmediatamente la actual. Cualquier aplicación que la esté utilizando dejará de funcionar." + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Regenerar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:318 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "Clave MCP regenerada" + +#: src/app/main/ui/settings/integrations.cljs:480 +msgid "integrations.title" +msgstr "Integraciones" + +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.token.will-expire" +msgstr "El token expirará el %s" + +#: src/app/main/ui/settings/integrations.cljs:142 +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.mcp-key.will-expire" +msgstr "La clave MCP expirará el %s" + +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.token.will-not-expire" +msgstr "El token no tiene fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:143 +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.mcp-key.will-not-expire" +msgstr "La clave MCP no tiene fecha de expiración" + #: src/app/main/ui/dashboard/comments.cljs:96 msgid "label.mark-all-as-read" msgstr "Marcar todo como leído" @@ -2321,6 +2481,9 @@ msgstr "Director" msgid "labels.discard" msgstr "Descartar" +msgid "labels.dismiss" +msgstr "Cancelar" + #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 msgid "labels.download" msgstr "Descargar %s" @@ -2456,6 +2619,10 @@ msgstr "Información" msgid "labels.installed-fonts" msgstr "Fuentes instaladas" +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.integrations" +msgstr "Integraciones" + #: src/app/main/ui/static.cljs:405 msgid "labels.internal-error.desc-message-first" msgstr "Ha ocurrido algo extraño." @@ -3111,30 +3278,6 @@ msgstr "Cambiar correo" msgid "modals.change-email.title" msgstr "Cambiar tu correo" -#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158 -msgid "modals.create-access-token.copy-token" -msgstr "Copiar token" - -#: src/app/main/ui/settings/access_tokens.cljs:130 -msgid "modals.create-access-token.expiration-date.label" -msgstr "Fecha de expiración" - -#: src/app/main/ui/settings/access_tokens.cljs:124 -msgid "modals.create-access-token.name.label" -msgstr "Nombre" - -#: src/app/main/ui/settings/access_tokens.cljs:126 -msgid "modals.create-access-token.name.placeholder" -msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" - -#: src/app/main/ui/settings/access_tokens.cljs:178 -msgid "modals.create-access-token.submit-label" -msgstr "Crear token" - -#: src/app/main/ui/settings/access_tokens.cljs:111 -msgid "modals.create-access-token.title" -msgstr "Generar access token" - #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" msgstr "Crear webhook" @@ -3151,18 +3294,6 @@ msgstr "Payload URL" msgid "modals.create-webhook.url.placeholder" msgstr "https://example.com/postreceive" -#: src/app/main/ui/settings/access_tokens.cljs:257 -msgid "modals.delete-acces-token.accept" -msgstr "Borrar token" - -#: src/app/main/ui/settings/access_tokens.cljs:256 -msgid "modals.delete-acces-token.message" -msgstr "¿Seguro que deseas borrar este token?" - -#: src/app/main/ui/settings/access_tokens.cljs:255 -msgid "modals.delete-acces-token.title" -msgstr "Borrar token" - #: src/app/main/ui/settings/delete_account.cljs:56 msgid "modals.delete-account.cancel" msgstr "Cancelar y mantener mi cuenta" @@ -3666,6 +3797,12 @@ msgstr "Invitación enviada con éxito" msgid "notifications.invitation-link-copied" msgstr "Enlace de invitacion copiado" +msgid "notifications.mcp.active-in-another-tab" +msgstr "MCP está activo en otra pestaña. ¿Cambiar a esta?" + +msgid "notifications.mcp.active-in-this-tab" +msgstr "MCP está ahora activo en esta pestaña." + #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir." @@ -5068,14 +5205,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot" msgid "title.default" msgstr "Penpot - Diseño Libre para Equipos" -#: src/app/main/ui/settings/access_tokens.cljs:278 -msgid "title.settings.access-tokens" -msgstr "Perfil - Access tokens" - #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" msgstr "Danos tu opinión - Penpot" +#: src/app/main/ui/settings/integrations.cljs:278 +msgid "title.settings.integrations" +msgstr "Integraciones - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Notificaciones - Penpot" @@ -5571,6 +5708,18 @@ msgstr "Ocultar reglas" msgid "workspace.header.menu.hide-textpalette" msgstr "Ocultar paleta de textos" +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Conectar" + +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Desconectar" + +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Gestionar (estado: habilitado)" + +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Gestionar (estado: deshabilitado)" + #: src/app/main/ui/workspace/main_menu.cljs:884 msgid "workspace.header.menu.option.edit" msgstr "Editar" @@ -5583,6 +5732,9 @@ msgstr "Archivo" msgid "workspace.header.menu.option.help-info" msgstr "Ayuda e información" +msgid "workspace.header.menu.option.mcp" +msgstr "Servidor MCP" + #: src/app/main/ui/workspace/main_menu.cljs:906 msgid "workspace.header.menu.option.preferences" msgstr "Preferencias" diff --git a/library/deps.edn b/library/deps.edn index 9edca59650b..18861c635e6 100644 --- a/library/deps.edn +++ b/library/deps.edn @@ -9,12 +9,12 @@ :aliases {:outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} + {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}} :main-opts ["-m" "antq.core"]} :jvm-repl {:extra-deps - {com.bhauman/rebel-readline {:mvn/version "RELEASE"}} + {com.bhauman/rebel-readline {:mvn/version "0.1.5"}} :main-opts ["-m" "rebel-readline.main"] :jvm-opts ["--sun-misc-unsafe-memory-access=allow"]} @@ -22,9 +22,9 @@ {:extra-paths ["dev"] :extra-deps {thheller/shadow-cljs {:mvn/version "3.2.1"} - com.bhauman/rebel-readline {:mvn/version "RELEASE"} - org.clojure/tools.namespace {:mvn/version "RELEASE"} - criterium/criterium {:mvn/version "RELEASE"}}} + com.bhauman/rebel-readline {:mvn/version "0.1.5"} + org.clojure/tools.namespace {:mvn/version "1.5.0"} + criterium/criterium {:mvn/version "0.4.6"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"] diff --git a/mcp/.gitignore b/mcp/.gitignore index 8a245a5dcaa..039f51d722e 100644 --- a/mcp/.gitignore +++ b/mcp/.gitignore @@ -1,6 +1,8 @@ .idea +.claude node_modules dist +*.tgz *.bak *.orig temp diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md index 528976b0771..7e80a5e2d51 100644 --- a/mcp/.serena/memories/project_overview.md +++ b/mcp/.serena/memories/project_overview.md @@ -1,7 +1,10 @@ # Penpot MCP Project Overview - Updated ## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication. +This project is a Model Context Protocol (MCP) server for Penpot integration. +The MCP server communicates with a Penpot plugin via WebSockets, allowing +the MCP server to send tasks to the plugin and receive results, +enabling advanced AI-driven features in Penpot. ## Tech Stack - **Language**: TypeScript @@ -13,21 +16,22 @@ This project is a Model Context Protocol (MCP) server for Penpot integration. It ## Project Structure ``` -penpot-mcp/ -├── common/ # Shared type definitions +/ (project root) +├── packages/common/ # Shared type definitions │ ├── src/ │ │ ├── index.ts # Exports for shared types │ │ └── types.ts # PluginTaskResult, request/response interfaces │ └── package.json # @penpot-mcp/common package -├── mcp-server/ # Main MCP server implementation +├── packages/server/ # Main MCP server implementation │ ├── src/ │ │ ├── index.ts # Main server entry point │ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation │ │ ├── PluginTask.ts # Now supports result promises │ │ ├── tasks/ # PluginTask implementations │ │ └── tools/ # Tool implementations +| ├── data/ # Contains resources, such as API info and prompts │ └── package.json # Includes @penpot-mcp/common dependency -├── penpot-plugin/ # Penpot plugin with response capability +├── packages/plugin/ # Penpot plugin with response capability │ ├── src/ │ │ ├── main.ts # Enhanced WebSocket handling with response forwarding │ │ └── plugin.ts # Now sends task responses back to server @@ -37,55 +41,24 @@ penpot-mcp/ ## Key Tasks +### Adjusting the System Prompt + +The system prompt file is located in `packages/server/data/initial_instructions.md`. + ### Adding a new Tool -1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface. +1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. 2. Register the tool in `PenpotMcpServer`. -Look at `PrintTextTool` as an example. - -Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`. +Tools can be associated with a `PluginTask` that is executed in the plugin. +Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution. ### Adding a new PluginTask -1. Implement the input data interface for the task in `common/src/types.ts`. -2. Implement the `PluginTask` class in `mcp-server/src/tasks/`. -3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`). +1. Implement the input data interface for the task in `packages/common/src/types.ts`. +2. Implement the `PluginTask` class in `packages/server/src/tasks/`. +3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). * In the success case, call `task.sendSuccess`. * In the failure case, just throw an exception, which will be handled centrally! - * Look at `PrintTextTaskHandler` as an example. -4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list. - - -## Key Components - -### Enhanced WebSocket Protocol -- **Request Format**: `{id: string, task: string, params: any}` -- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}` -- **Request/Response Correlation**: Using unique UUIDs for task tracking -- **Timeout Handling**: 30-second timeout with automatic cleanup -- **Type Safety**: Shared definitions via @penpot-mcp/common package - -### Core Classes -- **PenpotMcpServer**: Enhanced with pending task tracking and response handling -- **PluginTask**: Now creates result promises that resolve when plugin responds -- **Tool implementations**: Now properly await task completion and report results -- **Plugin handlers**: Send structured responses back to server - -### New Features -1. **Bidirectional Communication**: Plugin now responds with success/failure status -2. **Task Result Promises**: Every executePluginTask() sets and returns a promise -3. **Error Reporting**: Failed tasks properly report error messages to tools -4. **Shared Type Safety**: Common package ensures consistency across projects -5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit) -6. **Request Correlation**: Unique IDs match requests to responses - -## Task Flow - -``` -LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API - ↑ ↓ - Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result -``` - +4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index c9ed0f73309..e5729836cd8 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -1,5 +1,3 @@ - - # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 ignore_all_files_in_gitignore: true @@ -19,7 +17,7 @@ read_only: false # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, +# To make sure you have the latest list of tools, and to view their descriptions, # execute `uv run scripts/print_tool_overview.py`. # # * `activate_project`: Activates a project by name. @@ -62,15 +60,17 @@ excluded_tools: [] # (contrary to the memories, which are loaded on demand). initial_prompt: | IMPORTANT: You use an idiomatic, object-oriented style. - In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions + In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions rather than mere functions (i.e. use the strategy pattern, for example). - Comments: + Always read the "project_overview" memory. + + Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless - the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is + the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is required for sentences). # the name by which the project can be referenced within Serena project_name: "penpot-mcp" @@ -128,3 +128,39 @@ encoding: utf-8 # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: - typescript + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/mcp/README.md b/mcp/README.md index 9e9821c065a..07fa01ac265 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,31 +50,65 @@ Follow the steps below to enable the integration. ### Prerequisites -The project requires [Node.js](https://nodejs.org/) (tested with v22.x -with corepack). +The project requires [Node.js](https://nodejs.org/) (tested with v22.x). -Following the installation of Node.js, the tools `pnpm` and `npx` -should be available in your terminal. For ensure corepack installed -and enabled correctly, just execute the `./scripts/setup`. +### 1. Starting the MCP Server and the Plugin Server -It is also required to have `caddy` executeable in the path, it is -used for start a local server for generate types documentation from -the current branch. If you want to run it outside devenv where all -dependencies are already provided, please download caddy from -[here](https://caddyserver.com/download). +#### Running a Released Version via npx -You should probably be using penpot devenv, where all this -dependencies are already present and correctly setup. But nothing -prevents you execute this outside of devenv if you satisfy the -specified dependencies. +The easiest way to launch the servers is to use `npx` to run the appropriate +version that matches your Penpot version. +* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: + ```shell + npx -y @penpot/mcp@latest + ``` +* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: + ```shell + npx -y @penpot/mcp@beta + ``` -### 1. Build & Launch the MCP Server and the Plugin Server +Once the servers are running, continue with step 2. -If it's your first execution, install the required dependencies: +#### Running the Source Version from the Repository + +The tools `corepack` and `npx` should be available in your terminal. + +On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts. + +##### Clone the Appropriate Branch of the Repository + +> [!IMPORTANT] +> The branches are subject to change in the future. +> Be sure to check the instructions for the latest information on which branch to use. + +Clone the Penpot repository, using the proper branch depending on the +version of Penpot you want to use the MCP server with. + + * For the current Penpot release 2.14, use the `mcp-prod-2.14.1` branch: + + ```shell + git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.1 --depth 1 + ``` + + * For the MCP beta-test, use the `staging` branch: + + ```shell + git clone https://github.com/penpot/penpot.git --branch staging --depth 1 + ``` + +Then change into the `mcp` directory: + +```shell +cd penpot/mcp +``` + +##### Build & Launch the MCP Server and the Plugin Server + +If it's your first execution, install the required dependencies. +(If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.) ```shell -cd mcp/ ./scripts/setup ``` @@ -86,9 +120,9 @@ pnpm run bootstrap This bootstrap command will: - * install dependencies for all components (`pnpm -r run install`) - * build all components (`pnpm -r run build`) - * start all components (`pnpm -r --parallel run start`) + * install dependencies for all components + * build all components + * start all components ### 2. Load the Plugin in Penpot and Establish the Connection @@ -123,6 +157,19 @@ This bootstrap command will: ### 3. Connect an MCP Client +> [!IMPORTANT] +> **Use an appropriate model.** +> +> We recommend that you ... +> * use the most capable model at your disposal. +> You will achieve the best results with frontier models, +> especially when dealing with more complex tasks. +> Weaker models, including most locally hosted ones, +> are unlikely to produce usable results for anything beyond simple tasks. +> * use a vision language model (VLM), as many design tasks necessitate visual +> inspection. +> (If you are using a standard commercial model, it almost certainly supports vision already.) + By default, the server runs on port 4401 and provides: - **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp` @@ -140,14 +187,9 @@ NOTE: only relevant if you are executing this outside of devenv The `mcp-remote` package can proxy stdio transport to HTTP/SSE, allowing clients that support only stdio to connect to the MCP server indirectly. +Use it to provide the launch command for your MCP client as follows: -1. Install `mcp-remote` globally if you haven't already: - - npm install -g mcp-remote - -2. Use `mcp-remote` to provide the launch command for your MCP client: - - npx -y mcp-remote http://localhost:4401/sse --allow-http + npx -y mcp-remote http://localhost:4401/mcp --allow-http #### Example: Claude Desktop @@ -170,7 +212,7 @@ Add a `penpot` entry under `mcpServers` with the following content: "mcpServers": { "penpot": { "command": "npx", - "args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"] + "args": ["-y", "mcp-remote", "http://localhost:4401/mcp", "--allow-http"] } } } @@ -195,37 +237,36 @@ To add the Penpot MCP server to a Claude Code project, issue the command This repository is a monorepo containing four main components: -1. **Common Types** (`common/`): +1. **Common Types** (`packages/common/`): - Shared TypeScript definitions for request/response protocol - Ensures type safety across server and plugin components -2. **Penpot MCP Server** (`mcp-server/`): +2. **Penpot MCP Server** (`packages/server/`): - Provides MCP tools to LLMs for Penpot interaction - Runs a WebSocket server accepting connections from the Penpot MCP plugin - Implements request/response correlation with unique task IDs - Handles task timeouts and proper error reporting -3. **Penpot MCP Plugin** (`penpot-plugin/`): +3. **Penpot MCP Plugin** (`packages/plugin/`): - Connects to the MCP server via WebSocket - Executes tasks in Penpot using the Plugin API - Sends structured responses back to the server# -4. **Helper Scripts** (`python-scripts/`): - - Python scripts that prepare data for the MCP server (development use) +4. **Types Generator** (`types-generator/`): + - Generates data on API types for the MCP server (development use) The core components are written in TypeScript, rendering interactions with the Penpot Plugin API both natural and type-safe. ## Configuration -The Penpot MCP server can be configured using environment variables. All configuration -options use the `PENPOT_MCP_` prefix for consistency. +The Penpot MCP server can be configured using environment variables. ### Server Configuration | Environment Variable | Description | Default | |------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` | +| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | | `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | @@ -237,13 +278,13 @@ options use the `PENPOT_MCP_` prefix for consistency. | Environment Variable | Description | Default | |------------------------|------------------------------------------------------|----------| | `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` | -| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` | +| `PENPOT_MCP_LOG_DIR` | Directory for log files; file logging is enabled iff this is set to a non-empty value | (unset) | ### Plugin Server Configuration | Environment Variable | Description | Default | |-------------------------------------------|-----------------------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | +| `PENPOT_MCP_PLUGIN_SERVER_HOST` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | ## Beyond Local Execution @@ -263,3 +304,17 @@ you may set the following environment variables to configure the two servers * `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address where the MCP server can be reached. The Penpot MCP Plugin uses this to construct the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`). + +## Development + +* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply +* Auto-formatting: Use `pnpm run fmt` +* Generating API type data: See [types-generator/README.md](types-generator/README.md) +* Versioning: Use `bash scripts/set-version` to set the version for the MCP package (in `package.json`). + - Ensure that at least the major, minor and patch components of the version are always up-to-date. + - The MCP plugin assumes that a mismatch between the MCP version and the Penpot version (as returned by the API) + indicates incompatibility, resulting in the display of a warning message in the plugin UI. +* Packaging and publishing: + 1. Ensure release version is set correctly in package.json (call `bash scripts/set-version` to update it automatically) + 2. Create npm package: `bash scripts/pack` (creates `penpot-mcp-<version>.tgz` for publishing) + 3. Publish to npm: `npm publish penpot-mcp-<version>.tgz --access public` diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js new file mode 100644 index 00000000000..65a2b0f763f --- /dev/null +++ b/mcp/bin/mcp-local.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); + +function run(command) { + execSync(command, { cwd: root, stdio: "inherit" }); +} + +// pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml +// and restored here before bootstrap runs. +const distLock = path.join(root, "pnpm-lock.dist.yaml"); +const lock = path.join(root, "pnpm-lock.yaml"); +if (fs.existsSync(distLock)) { + fs.copyFileSync(distLock, lock); +} + +try { + run("corepack pnpm run bootstrap"); +} catch (error) { + if (error.code === "ENOENT") { + console.error( + "corepack is required but was not found. It ships with Node.js >= 16." + ); + process.exit(1); + } + process.exit(error.status ?? 1); +} diff --git a/mcp/package.json b/mcp/package.json index fdb75dd03b4..bf3802c3d84 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,15 +1,18 @@ { - "name": "mcp-meta", - "version": "1.0.0", - "description": "", + "name": "@penpot/mcp", + "version": "2.15.0", + "description": "MCP server for Penpot integration", + "bin": { + "penpot-mcp": "./bin/mcp-local.js" + }, "scripts": { "build": "pnpm -r run build", "build:multi-user": "pnpm -r run build:multi-user", "build:types": "bash ./scripts/build-types", "start": "pnpm -r --parallel run start", - "start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user", + "start:multi-user": "pnpm -r --parallel run start:multi-user", "bootstrap": "pnpm -r install && pnpm run build && pnpm run start", - "bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user", + "bootstrap:multi-user": "pnpm -r install && pnpm run build && pnpm run start:multi-user", "fmt": "prettier --write packages/", "fmt:check": "prettier --check packages/" }, @@ -17,8 +20,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", - "private": true, + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "devDependencies": { "concurrently": "^9.2.1", "prettier": "^3.0.0" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 6c014b34ac4..fc6d9c9cfdf 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index b2c08b5dae2..de2ff5853cc 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -3,12 +3,87 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Penpot plugin example + Penpot MCP Plugin - +
+ -
Not connected
+
+ + Not connected +
+ + + + +
+ + + Execution status + + +
+ Current task +
+ + --- +
+ +
+ Executed code + +
+ +
+
+
diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 2fca8aeaae0..0ccf2761812 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -5,9 +5,8 @@ "type": "module", "scripts": { "start": "vite build --watch --config vite.config.ts", - "start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts", + "start:multi-user": "pnpm run start", "build": "tsc && vite build --config vite.release.config.ts", - "build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" }, diff --git a/mcp/packages/plugin/public/icon.jpg b/mcp/packages/plugin/public/icon.jpg new file mode 100644 index 00000000000..9df8dd26a4a Binary files /dev/null and b/mcp/packages/plugin/public/icon.jpg differ diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index e2a769c7f83..aa97095b301 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -1,6 +1,7 @@ { "name": "Penpot MCP Plugin", "code": "plugin.js", + "icon": "icon.jpg", "version": 2, "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] diff --git a/mcp/packages/plugin/src/PenpotUtils.ts b/mcp/packages/plugin/src/PenpotUtils.ts index 964cf70f4cf..2c2cc3e4e5f 100644 --- a/mcp/packages/plugin/src/PenpotUtils.ts +++ b/mcp/packages/plugin/src/PenpotUtils.ts @@ -1,4 +1,4 @@ -import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types"; +import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types"; export class PenpotUtils { /** @@ -189,6 +189,24 @@ export class PenpotUtils { return penpot.generateStyle([shape], { type: "css", includeChildren: true }); } + /** + * Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property. + * However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content, + * so we use the `textBounds` property instead. + * + * @param shape - The shape to get the bounds for + */ + public static getBounds(shape: Shape): Bounds { + if (shape.type === "text") { + const text = shape as Text; + // TODO: Remove ts-ignore once type definitions are updated + // @ts-ignore + return text.textBounds; + } else { + return shape.bounds; + } + } + /** * Checks if a child shape is fully contained within its parent's bounds. * Visual containment means all edges of the child are within the parent's bounding box. @@ -198,11 +216,13 @@ export class PenpotUtils { * @returns true if child is fully contained within parent bounds, false otherwise */ public static isContainedIn(child: Shape, parent: Shape): boolean { + const childBounds = this.getBounds(child); + const parentBounds = this.getBounds(parent); return ( - child.x >= parent.x && - child.y >= parent.y && - child.x + child.width <= parent.x + parent.width && - child.y + child.height <= parent.y + parent.height + childBounds.x >= parentBounds.x && + childBounds.y >= parentBounds.y && + childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width && + childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height ); } @@ -298,39 +318,16 @@ export class PenpotUtils { /** * Decodes a base64 string to a Uint8Array. - * This is required because the Penpot plugin environment does not provide the atob function. * * @param base64 - The base64-encoded string to decode * @returns The decoded data as a Uint8Array */ - public static atob(base64: string): Uint8Array { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - const lookup = new Uint8Array(256); - for (let i = 0; i < chars.length; i++) { - lookup[chars.charCodeAt(i)] = i; + public static base64ToByteArray(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); } - - let bufferLength = base64.length * 0.75; - if (base64[base64.length - 1] === "=") { - bufferLength--; - if (base64[base64.length - 2] === "=") { - bufferLength--; - } - } - - const bytes = new Uint8Array(bufferLength); - let p = 0; - for (let i = 0; i < base64.length; i += 4) { - const encoded1 = lookup[base64.charCodeAt(i)]; - const encoded2 = lookup[base64.charCodeAt(i + 1)]; - const encoded3 = lookup[base64.charCodeAt(i + 2)]; - const encoded4 = lookup[base64.charCodeAt(i + 3)]; - - bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); - bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); - bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); - } - return bytes; } @@ -360,7 +357,7 @@ export class PenpotUtils { height: number | undefined ): Promise { // convert base64 to Uint8Array - const bytes = PenpotUtils.atob(base64); + const bytes = PenpotUtils.base64ToByteArray(base64); // upload the image data to Penpot const imageData = await penpot.uploadMediaData(name, bytes, mimeType); @@ -423,6 +420,11 @@ export class PenpotUtils { * - For mode="fill", it will be whatever format the fill image is stored in. */ public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise { + // Updates are asynchronous in Penpot, so wait a tick to ensure any pending updates are applied before export. + // The constant wait time is a temporary workardound until a better solution for penpot/penpot-mcp#27 + // is implemented. + await new Promise((resolve) => setTimeout(resolve, 200)); + // Perform export switch (mode) { case "shape": return shape.export({ type: asSVG ? "svg" : "png" }); diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts new file mode 100644 index 00000000000..42587c83046 --- /dev/null +++ b/mcp/packages/plugin/src/index.d.ts @@ -0,0 +1,21 @@ +import "@penpot/plugin-types"; + +declare module "@penpot/plugin-types" { + interface Penpot { + /** The Penpot application version string. */ + version: string; + } +} + +interface McpOptions { + getToken(): string; + getServerUrl(): string; + setMcpStatus(status: string); + on(eventType: "disconnect" | "connect", cb: () => void); +} + +declare global { + const mcp: undefined | McpOptions; +} + +export {}; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 18877d35dd0..40b5bd7ba80 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,29 +1,74 @@ import "./style.css"; // get the current theme from the URL -const searchParams = new URLSearchParams(window.location.search); +const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// Determine whether multi-user mode is enabled based on URL parameters -const isMultiUserMode = searchParams.get("multiUser") === "true"; -console.log("Penpot MCP multi-user mode:", isMultiUserMode); - // WebSocket connection management let ws: WebSocket | null = null; -const statusElement = document.getElementById("connection-status"); + +const statusPill = document.getElementById("connection-status") as HTMLElement; +const statusText = document.getElementById("status-text") as HTMLElement; +const currentTaskEl = document.getElementById("current-task") as HTMLElement; +const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement; +const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement; +const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; +const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement; +const versionWarningEl = document.getElementById("version-warning") as HTMLElement; +const versionWarningTextEl = document.getElementById("version-warning-text") as HTMLElement; + +/** + * Updates the status pill and button visibility based on connection state. + * + * @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error") + * @param label - human-readable label to display inside the pill + */ +function updateConnectionStatus(code: string, label: string): void { + if (statusPill) { + statusPill.dataset.status = code; + } + if (statusText) { + statusText.textContent = label; + } + + const isConnected = code === "connected"; + if (connectBtn) connectBtn.hidden = isConnected; + if (disconnectBtn) disconnectBtn.hidden = !isConnected; + + parent.postMessage( + { + type: "update-connection-status", + status: code, + }, + "*" + ); +} /** - * Updates the connection status display element. + * Updates the "Current task" display with the currently executing task name. * - * @param status - the base status text to display - * @param isConnectedState - whether the connection is in a connected state (affects color) - * @param message - optional additional message to append to the status + * @param taskName - the task name to display, or null to reset to "---" */ -function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void { - if (statusElement) { - const displayText = message ? `${status}: ${message}` : status; - statusElement.textContent = displayText; - statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; +function updateCurrentTask(taskName: string | null): void { + if (currentTaskEl) { + currentTaskEl.textContent = taskName ?? "---"; + } + if (taskName === null) { + updateExecutedCode(null); + } +} + +/** + * Updates the executed code textarea with the last code run during task execution. + * + * @param code - the code string to display, or null to clear + */ +function updateExecutedCode(code: string | null): void { + if (executedCodeEl) { + executedCodeEl.value = code ?? ""; + } + if (copyCodeBtn) { + copyCodeBtn.disabled = !code; } } @@ -44,31 +89,41 @@ function sendTaskResponse(response: any): void { /** * Establishes a WebSocket connection to the MCP server. */ -function connectToMcpServer(): void { +function connectToMcpServer(baseUrl?: string, token?: string): void { if (ws?.readyState === WebSocket.OPEN) { - updateConnectionStatus("Already connected", true); + updateConnectionStatus("connected", "Connected"); return; } try { - let wsUrl = PENPOT_MCP_WEBSOCKET_URL; - if (isMultiUserMode) { - // TODO obtain proper userToken from penpot - const userToken = "dummyToken"; - wsUrl += `?userToken=${encodeURIComponent(userToken)}`; + let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL; + let wsError: unknown | undefined; + + if (token) { + wsUrl += `?userToken=${encodeURIComponent(token)}`; } + ws = new WebSocket(wsUrl); - updateConnectionStatus("Connecting...", false); + updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { - console.log("Connected to MCP server"); - updateConnectionStatus("Connected to MCP server", true); + setTimeout(() => { + if (ws) { + console.log("Connected to MCP server"); + updateConnectionStatus("connected", "Connected"); + } + }, 100); }; ws.onmessage = (event) => { - console.log("Received from MCP server:", event.data); try { + console.log("Received from MCP server:", event.data); const request = JSON.parse(event.data); + // Track the current task received from the MCP server + if (request.task) { + updateCurrentTask(request.task); + updateExecutedCode(request.params?.code ?? null); + } // Forward the task request to the plugin for execution parent.postMessage(request, "*"); } catch (error) { @@ -77,34 +132,70 @@ function connectToMcpServer(): void { }; ws.onclose = (event: CloseEvent) => { - console.log("Disconnected from MCP server"); - const message = event.reason || undefined; - updateConnectionStatus("Disconnected", false, message); + // If we've send the error update we don't send the disconnect as well + if (!wsError) { + console.log("Disconnected from MCP server"); + const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected"; + updateConnectionStatus("disconnected", label); + updateCurrentTask(null); + } ws = null; }; ws.onerror = (error) => { console.error("WebSocket error:", error); + wsError = error; // note: WebSocket error events typically don't contain detailed error messages - updateConnectionStatus("Connection error", false); + updateConnectionStatus("error", "Connection error"); }; } catch (error) { console.error("Failed to connect to MCP server:", error); - const message = error instanceof Error ? error.message : undefined; - updateConnectionStatus("Connection failed", false, message); + const reason = error instanceof Error ? error.message : undefined; + const label = reason ? `Connection failed: ${reason}` : "Connection failed"; + updateConnectionStatus("error", label); } } -document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => { +copyCodeBtn?.addEventListener("click", () => { + const code = executedCodeEl?.value; + if (!code) return; + + navigator.clipboard.writeText(code).then(() => { + copyCodeBtn.classList.add("copied"); + setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500); + }); +}); + +connectBtn?.addEventListener("click", () => { connectToMcpServer(); }); +disconnectBtn?.addEventListener("click", () => { + ws?.close(); +}); + // Listen plugin.ts messages window.addEventListener("message", (event) => { - if (event.data.source === "penpot") { + if (event.data.type === "start-server") { + connectToMcpServer(event.data.url, event.data.token); + } + if (event.data.type === "version-mismatch") { + if (versionWarningEl && versionWarningTextEl) { + versionWarningTextEl.innerHTML = + `Version mismatch detected: This version of the MCP server is intended for Penpot ` + + `${event.data.mcpVersion} while the current version is ${event.data.penpotVersion}. ` + + `Executions may not work or produce suboptimal results.`; + versionWarningEl.hidden = false; + } + } + if (event.data.type === "stop-server") { + ws?.close(); + } else if (event.data.source === "penpot") { document.body.dataset.theme = event.data.theme; } else if (event.data.type === "task-response") { // Forward task response back to MCP server sendTaskResponse(event.data.response); } }); + +parent.postMessage({ type: "ui-initialized" }, "*"); diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index e6a1fad33e5..3827db70ebe 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,22 +1,58 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * Extracts the major.minor.patch prefix from a version string. + * + * @param version - a version string starting with major.minor.patch + * @returns the major.minor.patch prefix, or the original string if it does not match + */ +function extractVersionPrefix(version: string): string { + const match = version.match(/^(\d+\.\d+\.\d+)/); + return match ? match[1] : version; +} + +mcp?.setMcpStatus("connecting"); + /** * Registry of all available task handlers. */ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; -// Determine whether multi-user mode is enabled based on build-time configuration -declare const IS_MULTI_USER_MODE: boolean; -const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false; - // Open the plugin UI (main.ts) -penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 }); +penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { + width: 236, + height: 210, + hidden: !!mcp, +} as any); -// Handle messages -penpot.ui.onMessage((message) => { - // Handle plugin task requests - if (typeof message === "object" && message.task && message.id) { +// Register message handlers +penpot.ui.onMessage((message) => { + if (typeof message === "object" && message.type === "ui-initialized") { + // Check Penpot version compatibility + const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info + const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); + console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`); + const isLocalPenpotVersion = penpotVersionPrefix == "0.0.0"; + if (penpotVersionPrefix !== mcpVersionPrefix && !isLocalPenpotVersion) { + penpot.ui.sendMessage({ + type: "version-mismatch", + mcpVersion: mcpVersionPrefix, + penpotVersion: penpotVersionPrefix, + }); + } + // Initiate connection to remote MCP server (if enabled) + if (mcp) { + penpot.ui.sendMessage({ + type: "start-server", + url: mcp?.getServerUrl(), + token: mcp?.getToken(), + }); + } + } else if (typeof message === "object" && message.type === "update-connection-status") { + mcp?.setMcpStatus(message.status || "unknown"); + } else if (typeof message === "object" && message.task && message.id) { + // Handle plugin tasks submitted by the MCP server handlePluginTaskRequest(message).catch((error) => { console.error("Error in handlePluginTaskRequest:", error); }); @@ -59,6 +95,21 @@ async function handlePluginTaskRequest(request: { id: string; task: string; para } } +if (mcp) { + mcp.on("disconnect", async () => { + penpot.ui.sendMessage({ + type: "stop-server", + }); + }); + mcp.on("connect", async () => { + penpot.ui.sendMessage({ + type: "start-server", + url: mcp?.getServerUrl(), + token: mcp?.getToken(), + }); + }); +} + // Handle theme change in the iframe penpot.on("themechange", (theme) => { penpot.ui.sendMessage({ diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css index 030f2204e97..53e0a9da3d8 100644 --- a/mcp/packages/plugin/src/style.css +++ b/mcp/packages/plugin/src/style.css @@ -1,10 +1,190 @@ @import "@penpot/plugin-styles/styles.css"; body { + margin: 0; + padding: 0; +} + +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-8); + box-sizing: border-box; +} + +/* ── Status pill ─────────────────────────────────────────────────── */ + +.status-pill { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + color: var(--foreground-secondary); + width: 100%; + box-sizing: border-box; +} + +.status-pill[data-status="connected"] { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.status-pill[data-status="disconnected"], +.status-pill[data-status="error"] { + border-color: var(--error-500); + color: var(--error-500); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: currentColor; + flex-shrink: 0; +} + +/* ── Collapsible section ─────────────────────────────────────────── */ + +.collapsible-section { + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-8); + overflow: hidden; +} + +.collapsible-header { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + cursor: pointer; + color: var(--foreground-secondary); + list-style: none; + user-select: none; +} + +.collapsible-header::-webkit-details-marker { + display: none; +} + +.collapsible-arrow { + flex-shrink: 0; + transition: transform 0.2s ease; +} + +details[open] > .collapsible-header .collapsible-arrow { + transform: rotate(90deg); +} + +.collapsible-body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-12) var(--spacing-12); + border-top: 1px solid var(--background-quaternary); +} + +/* ── Tool section ────────────────────────────────────────────────── */ + +.tool-label { + color: var(--foreground-secondary); +} + +.tool-display { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + min-height: 32px; + box-sizing: border-box; +} + +.tool-icon { + flex-shrink: 0; + opacity: 0.7; +} + +/* ── Code section ────────────────────────────────────────────────── */ + +.code-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-8); +} + +.code-textarea { + width: 100%; + height: 100px; + resize: vertical; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + font-family: monospace; + font-size: 11px; line-height: 1.5; - padding: 10px; + box-sizing: border-box; + outline: none; } -p { - margin-block-end: 0.75rem; +.copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-4); + background-color: transparent; + color: var(--foreground-secondary); + cursor: pointer; + flex-shrink: 0; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.copy-btn:hover:not(:disabled) { + background-color: var(--background-tertiary); + color: var(--foreground-primary); +} + +.copy-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.copy-btn.copied { + color: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* ── Version warning ─────────────────────────────────────────────── */ + +.version-warning { + align-items: flex-start; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--warning-500, #f59e0b); + color: var(--warning-500, #f59e0b); + width: 100%; + box-sizing: border-box; +} + +/* ── Action buttons ──────────────────────────────────────────────── */ + +#connect-btn, +#disconnect-btn { + width: 100%; + margin-top: var(--spacing-4); } diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 86fc62710f7..b464c599ef3 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -195,13 +195,54 @@ export class ExecuteCodeTaskHandler extends TaskHandler { const context = this.context; const code = task.params.code; - let result: any = await (async (ctx) => { - const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); - return fn(...Object.values(ctx)); - })(context); + // set the flags naturalChildOrdering and throwValidationErrors to true during code execution. + // TODO: Remove all ts-ignore once Penpot types have been updated + let originalNaturalChildOrdering: any, originalThrowValidationErrors: any; + // @ts-ignore + if (penpot.flags) { + // @ts-ignore + originalNaturalChildOrdering = penpot.flags.naturalChildOrdering; + // @ts-ignore + penpot.flags.naturalChildOrdering = true; + // @ts-ignore + originalThrowValidationErrors = penpot.flags.throwValidationErrors; + // @ts-ignore + penpot.flags.throwValidationErrors = true; + } else { + // TODO: This can be removed once `flags` has been merged to PROD + throw new Error( + "You are using a version of the Penpot MCP server which is incompatible " + + "with the connected Penpot version. " + + "Read the documentation for instructions on which version to use: " + + "https://github.com/penpot/penpot/tree/develop/mcp\n" + + "If you are an LLM, tell the user about this!" + ); + } + + let result: any; + try { + // execute the code in an async function with the context variables as parameters + result = await (async (ctx) => { + const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); + return fn(...Object.values(ctx)); + })(context); + } finally { + // restore the original value of the flags + // @ts-ignore + penpot.flags.naturalChildOrdering = originalNaturalChildOrdering; + // @ts-ignore + penpot.flags.throwValidationErrors = originalThrowValidationErrors; + } console.log("Code execution result:", result); + // transform a top-level Uint8Array result into a compact base64 envelope to avoid the + // ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects + // with numeric string keys (see penpot/penpot#9420) + if (result instanceof Uint8Array) { + result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result); + } + // return result and captured log let resultData: ExecuteCodeTaskResultData = { result: result, @@ -209,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler { }; task.sendSuccess(resultData); } + + /** + * Base64-encodes the given bytes and wraps the result in a tagged envelope that the + * server side recognizes (see `ImageContent.byteData`). + * + * @param bytes - the raw binary data to encode + * @returns an envelope of the form `{ __type: "base64", data: }` + */ + private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } { + // build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly + // would overflow the call stack for large arrays + const chunkSize = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + return { __type: "base64", data: btoa(binary) }; + } } diff --git a/mcp/packages/plugin/src/vite-env.d.ts b/mcp/packages/plugin/src/vite-env.d.ts index ddbf746e043..252ff654afa 100644 --- a/mcp/packages/plugin/src/vite-env.d.ts +++ b/mcp/packages/plugin/src/vite-env.d.ts @@ -2,3 +2,4 @@ declare const IS_MULTI_USER_MODE: boolean; declare const PENPOT_MCP_WEBSOCKET_URL: string; +declare const PENPOT_MCP_VERSION: string; diff --git a/mcp/packages/plugin/vite.config.ts b/mcp/packages/plugin/vite.config.ts index 38e610f2479..9e79ca7f732 100644 --- a/mcp/packages/plugin/vite.config.ts +++ b/mcp/packages/plugin/vite.config.ts @@ -1,11 +1,16 @@ import { defineConfig } from "vite"; import livePreview from "vite-live-preview"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const rootPkg = require("../../package.json"); let WS_URI = process.env.WS_URI || "http://localhost:4402"; -let MULTI_USER_MODE = process.env.MULTI_USER_MODE === "true"; +let SERVER_HOST = process.env.PENPOT_MCP_PLUGIN_SERVER_HOST ?? "localhost"; +let MCP_VERSION = JSON.stringify(rootPkg.version); -console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(MULTI_USER_MODE)); -console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI)); +console.log("PENPOT_MCP_WEBSOCKET_URL:", JSON.stringify(WS_URI)); +console.log("PENPOT_MCP_VERSION:", MCP_VERSION); export default defineConfig({ base: "./", @@ -31,13 +36,13 @@ export default defineConfig({ }, }, preview: { - host: "0.0.0.0", + host: SERVER_HOST, port: 4400, cors: true, allowedHosts: [], }, define: { - IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"), PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI), + PENPOT_MCP_VERSION: MCP_VERSION, }, }); diff --git a/mcp/packages/plugin/vite.release.config.ts b/mcp/packages/plugin/vite.release.config.ts index 462fc9f5980..7156a2f85cd 100644 --- a/mcp/packages/plugin/vite.release.config.ts +++ b/mcp/packages/plugin/vite.release.config.ts @@ -4,7 +4,6 @@ import baseConfig from "./vite.config"; export default mergeConfig( baseConfig, defineConfig({ - base: "./", plugins: [], }) ); diff --git a/mcp/packages/server/.gitignore b/mcp/packages/server/.gitignore index e69de29bb2d..54e8e7dc16e 100644 --- a/mcp/packages/server/.gitignore +++ b/mcp/packages/server/.gitignore @@ -0,0 +1 @@ +/pnpm-lock.yaml diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 18079c5c3d2..54b4100e4ae 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -11,7 +11,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number }, + options?: { width: number; height: number; hidden: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -26,6 +26,7 @@ Penpot: props?: { [key: string]: unknown }, ): symbol; off(listenerId: symbol): void; + version: string; root: Shape | null; currentFile: File | null; currentPage: Page | null; @@ -72,7 +73,7 @@ Penpot: generateFontFaces(shapes: Shape[]): Promise; openViewer(): void; createPage(): Page; - openPage(page: Page, newWindow?: boolean): void; + openPage(page: string | Page, newWindow?: boolean): void; alignHorizontal( shapes: Shape[], direction: "center" | "left" | "right", @@ -84,6 +85,7 @@ Penpot: distributeHorizontal(shapes: Shape[]): void; distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; + createVariantFromComponents(shapes: Board[]): VariantContainer; } ``` @@ -99,7 +101,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number }, + options?: { width: number; height: number; hidden: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -110,7 +112,7 @@ Penpot: Type Declaration - * open: (name: string, url: string, options?: { width: number; height: number }) => void + * open: ( name: string, url: string, options?: { width: number; height: number; hidden: boolean },) => void Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter. @@ -161,6 +163,12 @@ Penpot: ``` penpot.closePlugin(); ``` + version: |- + ``` + readonly version: string + ``` + + Returns the current penpot version. root: |- ``` readonly root: Shape | null @@ -724,19 +732,19 @@ Penpot: Returns Page openPage: |- ``` - openPage(page: Page, newWindow?: boolean): void + openPage(page: string | Page, newWindow?: boolean): void ``` Changes the current open page to given page. Requires `content:read` permission. Parameters - * page: Page + * page: string | Page - the page to open + the page to open (a Page object or a page UUID string) * newWindow: boolean - if true opens the page in a new window + if true opens the page in a new window, defaults to false Returns void @@ -823,6 +831,24 @@ Penpot: to flatten Returns Path[] + createVariantFromComponents: |- + ``` + createVariantFromComponents(shapes: Board[]): VariantContainer + ``` + + Combine several standard Components into a VariantComponent. Similar to doing it + with the contextual menu on the Penpot interface. + All the shapes passed as arguments should be main instances. + + Parameters + + * shapes: Board[] + + A list of main instances of the components to combine. + + Returns VariantContainer + + The variant container created ActiveUser: overview: |- Interface ActiveUser @@ -1062,7 +1088,7 @@ Board: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -1071,10 +1097,10 @@ Board: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -1090,14 +1116,14 @@ Board: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -1114,7 +1140,7 @@ Board: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -1193,12 +1219,20 @@ Board: ``` The horizontal sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. verticalSizing: |- ``` verticalSizing?: "auto" | "fix" ``` The vertical sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. fills: |- ``` fills: Fill[] @@ -1456,7 +1490,7 @@ Board: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -1469,10 +1503,10 @@ Board: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -1488,14 +1522,14 @@ Board: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -1881,7 +1915,7 @@ Board: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -1894,7 +1928,9 @@ Board: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -2171,7 +2207,7 @@ VariantContainer: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -2180,10 +2216,10 @@ VariantContainer: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -2199,14 +2235,14 @@ VariantContainer: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -2223,7 +2259,7 @@ VariantContainer: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -2250,7 +2286,7 @@ VariantContainer: * Board + VariantContainer - Referenced by: ContextTypesUtils + Referenced by: Board, Boolean, Context, ContextTypesUtils, Ellipse, Group, Image, Path, Penpot, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer members: Properties: type: |- @@ -2301,12 +2337,20 @@ VariantContainer: ``` The horizontal sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. verticalSizing: |- ``` verticalSizing?: "auto" | "fix" ``` The vertical sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. fills: |- ``` fills: Fill[] @@ -2568,7 +2612,7 @@ VariantContainer: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -2581,10 +2625,10 @@ VariantContainer: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -2600,14 +2644,14 @@ VariantContainer: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -2993,7 +3037,7 @@ VariantContainer: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -3006,7 +3050,9 @@ VariantContainer: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -3270,7 +3316,7 @@ Boolean: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -3279,10 +3325,10 @@ Boolean: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -3298,14 +3344,14 @@ Boolean: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -3322,7 +3368,7 @@ Boolean: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -3629,7 +3675,7 @@ Boolean: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -3642,10 +3688,10 @@ Boolean: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -3661,14 +3707,14 @@ Boolean: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -4005,7 +4051,7 @@ Boolean: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -4018,7 +4064,9 @@ Boolean: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -4575,8 +4623,8 @@ CommonLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; } ``` @@ -4706,26 +4754,26 @@ CommonLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. Methods: remove: |- ``` @@ -4744,6 +4792,7 @@ Context: ``` interface Context { + version: string; root: Shape | null; currentFile: File | null; currentPage: Page | null; @@ -4796,7 +4845,7 @@ Context: removeListener(listenerId: symbol): void; openViewer(): void; createPage(): Page; - openPage(page: Page, newWindow?: boolean): void; + openPage(page: string | Page, newWindow?: boolean): void; alignHorizontal( shapes: Shape[], direction: "center" | "left" | "right", @@ -4808,10 +4857,17 @@ Context: distributeHorizontal(shapes: Shape[]): void; distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; + createVariantFromComponents(shapes: Board[]): VariantContainer; } ``` members: Properties: + version: |- + ``` + readonly version: string + ``` + + Returns the current penpot version. root: |- ``` readonly root: Shape | null @@ -5350,19 +5406,19 @@ Context: Returns Page openPage: |- ``` - openPage(page: Page, newWindow?: boolean): void + openPage(page: string | Page, newWindow?: boolean): void ``` Changes the current open page to given page. Requires `content:read` permission. Parameters - * page: Page + * page: string | Page - the page to open + the page to open (a Page object or a page UUID string) * newWindow: boolean - if true opens the page in a new window + if true opens the page in a new window, defaults to false Returns void @@ -5449,6 +5505,24 @@ Context: to flatten Returns Path[] + createVariantFromComponents: |- + ``` + createVariantFromComponents(shapes: Board[]): VariantContainer + ``` + + Combine several standard Components into a VariantComponent. Similar to doing it + with the contextual menu on the Penpot interface. + All the shapes passed as arguments should be main instances. + + Parameters + + * shapes: Board[] + + A list of main instances of the components to combine. + + Returns VariantContainer + + The variant container created ContextGeometryUtils: overview: |- Interface ContextGeometryUtils @@ -5850,7 +5924,7 @@ Ellipse: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -5859,10 +5933,10 @@ Ellipse: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -5878,14 +5952,14 @@ Ellipse: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -5902,7 +5976,7 @@ Ellipse: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -6179,7 +6253,7 @@ Ellipse: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -6192,10 +6266,10 @@ Ellipse: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -6211,14 +6285,14 @@ Ellipse: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -6500,7 +6574,7 @@ Ellipse: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -6513,7 +6587,9 @@ Ellipse: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -6783,7 +6859,7 @@ Export: ``` interface Export { - type: "svg" | "png" | "jpeg" | "pdf"; + type: "svg" | "png" | "jpeg" | "webp" | "pdf"; scale?: number; suffix?: string; skipChildren?: boolean; @@ -6795,10 +6871,10 @@ Export: Properties: type: |- ``` - type: "svg" | "png" | "jpeg" | "pdf" + type: "svg" | "png" | "jpeg" | "webp" | "pdf" ``` - Type of the file to export. Can be one of the following values: png, jpeg, svg, pdf + Type of the file to export. Can be one of the following values: png, jpeg, webp, svg, pdf scale: |- ``` scale?: number @@ -7187,6 +7263,7 @@ Flags: ``` interface Flags { naturalChildOrdering: boolean; + throwValidationErrors: boolean; } ``` @@ -7202,6 +7279,14 @@ Flags: Also, appendChild method will be append the children in the top-most position. The insertchild method is changed acordingly to respect this ordering. Defaults to false + throwValidationErrors: |- + ``` + throwValidationErrors: boolean + ``` + + If `true` the validation errors will throw an exception instead of displaying an + error in the debugger console. + Defaults to false FlexLayout: overview: |- Interface FlexLayout @@ -7244,8 +7329,8 @@ FlexLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "row-reverse" | "column" | "column-reverse"; wrap?: "wrap" | "nowrap"; @@ -7379,26 +7464,26 @@ FlexLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. dir: |- ``` dir: "row" | "row-reverse" | "column" | "column-reverse" @@ -7802,8 +7887,8 @@ GridLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "column"; rows: Track[]; @@ -7946,26 +8031,26 @@ GridLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. dir: |- ``` dir: "row" | "column" @@ -8279,7 +8364,7 @@ Group: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -8288,10 +8373,10 @@ Group: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -8307,14 +8392,14 @@ Group: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -8331,7 +8416,7 @@ Group: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -8614,7 +8699,7 @@ Group: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -8627,10 +8712,10 @@ Group: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -8646,14 +8731,14 @@ Group: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -9001,7 +9086,7 @@ Group: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -9014,7 +9099,9 @@ Group: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -9523,7 +9610,7 @@ Image: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -9532,10 +9619,10 @@ Image: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -9551,14 +9638,14 @@ Image: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -9575,7 +9662,7 @@ Image: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -9852,7 +9939,7 @@ Image: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -9865,10 +9952,10 @@ Image: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -9884,14 +9971,14 @@ Image: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -10173,7 +10260,7 @@ Image: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -10186,7 +10273,9 @@ Image: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -10444,6 +10533,8 @@ LayoutCellProperties: position?: "area" | "auto" | "manual"; } ``` + + Referenced by: Board, Boolean, Ellipse, Group, Image, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer members: Properties: row: |- @@ -12986,7 +13077,7 @@ Path: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -12995,10 +13086,10 @@ Path: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -13014,14 +13105,14 @@ Path: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -13038,7 +13129,7 @@ Path: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -13339,7 +13430,7 @@ Path: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -13352,10 +13443,10 @@ Path: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -13371,14 +13462,14 @@ Path: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -13674,7 +13765,7 @@ Path: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -13687,7 +13778,9 @@ Path: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -14313,7 +14406,7 @@ Rectangle: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -14322,10 +14415,10 @@ Rectangle: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -14341,14 +14434,14 @@ Rectangle: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -14365,7 +14458,7 @@ Rectangle: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -14644,7 +14737,7 @@ Rectangle: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -14657,10 +14750,10 @@ Rectangle: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -14676,14 +14769,14 @@ Rectangle: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -14965,7 +15058,7 @@ Rectangle: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -14978,7 +15071,9 @@ Rectangle: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -15349,7 +15444,7 @@ ShapeBase: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -15358,10 +15453,10 @@ ShapeBase: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -15377,14 +15472,14 @@ ShapeBase: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -15401,7 +15496,7 @@ ShapeBase: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -15679,7 +15774,7 @@ ShapeBase: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -15692,10 +15787,10 @@ ShapeBase: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -15711,14 +15806,14 @@ ShapeBase: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -16000,7 +16095,7 @@ ShapeBase: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -16013,7 +16108,9 @@ ShapeBase: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -16273,7 +16370,7 @@ Stroke: strokeColorRefFile?: string; strokeColorRefId?: string; strokeOpacity?: number; - strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed"; + strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"; strokeWidth?: number; strokeAlignment?: "center" | "inner" | "outer"; strokeCapStart?: StrokeCap; @@ -16312,7 +16409,7 @@ Stroke: Defaults to 1 if omitted. strokeStyle: |- ``` - strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed" + strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed" ``` The optional style of the stroke. @@ -16415,7 +16512,7 @@ SvgRaw: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -16424,10 +16521,10 @@ SvgRaw: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -16443,14 +16540,14 @@ SvgRaw: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -16467,7 +16564,7 @@ SvgRaw: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -16739,7 +16836,7 @@ SvgRaw: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -16752,10 +16849,10 @@ SvgRaw: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -16771,14 +16868,14 @@ SvgRaw: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17064,7 +17161,7 @@ SvgRaw: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -17077,7 +17174,9 @@ SvgRaw: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -17334,7 +17433,7 @@ Text: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -17343,10 +17442,10 @@ Text: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -17362,14 +17461,14 @@ Text: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17386,7 +17485,7 @@ Text: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -17421,6 +17520,7 @@ Text: direction: "mixed" | "ltr" | "rtl" | null; align: "center" | "left" | "right" | "mixed" | "justify" | null; verticalAlign: "center" | "top" | "bottom" | null; + textBounds: { x: number; y: number; width: number; height: number }; getRange(start: number, end: number): TextRange; applyTypography(typography: LibraryTypography): void; } @@ -17675,7 +17775,7 @@ Text: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -17688,10 +17788,10 @@ Text: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -17707,14 +17807,14 @@ Text: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17835,6 +17935,13 @@ Text: ``` The vertical alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used. + textBounds: |- + ``` + readonly textBounds: { x: number; y: number; width: number; height: number } + ``` + + Return the bounding box for the text as a (x, y, width, height) rectangle + This is the box that covers the text even if it overflows its selection rectangle. Methods: getPluginData: |- ``` @@ -18097,7 +18204,7 @@ Text: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -18110,7 +18217,9 @@ Text: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -22608,7 +22717,11 @@ TokenBorderRadiusProps: ================================= ``` - TokenBorderRadiusProps: "r1" | "r2" | "r3" | "r4" + TokenBorderRadiusProps: + | "borderRadiusTopLeft" + | "borderRadiusTopRight" + | "borderRadiusBottomRight" + | "borderRadiusBottomLeft" ``` The properties that a BorderRadius token can be applied to. @@ -22760,14 +22873,14 @@ TokenSpacingProps: TokenSpacingProps: | "rowGap" | "columnGap" - | "p1" - | "p2" - | "p3" - | "p4" - | "m1" - | "m2" - | "m3" - | "m4" + | "paddingLeft" + | "paddingTop" + | "paddingRight" + | "paddingBottom" + | "marginLeft" + | "marginTop" + | "marginRight" + | "marginBottom" ``` The properties that a Spacing token can be applied to. diff --git a/mcp/packages/server/data/base_instructions.md b/mcp/packages/server/data/base_instructions.md new file mode 100644 index 00000000000..10245a2b45f --- /dev/null +++ b/mcp/packages/server/data/base_instructions.md @@ -0,0 +1,2 @@ +You have access to Penpot tools in order to interact with Penpot designs. +Before working with these tools, be sure to read the 'Penpot High-Level Overview' via the `high_level_overview` tool. diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 8aa60b1a1bb..3f6c19bd10d 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -1,10 +1,6 @@ You have access to Penpot tools in order to interact with a Penpot design project directly. As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin. -IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. - NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to - non-creative defaults such as white/black if you are lacking information). - # Executing Code One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API @@ -43,18 +39,30 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image` * `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board. To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`. * `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions. - * `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds. + * `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`. **Other Writable Properties**: * `name` - Shape name - * `fills`, `strokes` - Styling properties - * `rotation`, `opacity`, `blocked`, `hidden`, `visible` + * `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties + - Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`; + - Reusing objects in another shape: `targetShape.fills = sourceShape.fills` or more granular `targetShape.fills = [{ fillOpacity: 1, fillImage: sourceShape.fills[0].fillImage }]` + The objects are not shared references; you can modify properties of the fills in the target shape without affecting the source shape. + - Colors: Use hex strings with caps only (e.g. '#FF5533') + - IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them! + * `borderRadius` - Uniform border radius for all corners + * `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii. + * `blur: Blur` - Blur properties + * `blendMode` - Blend mode (e.g. `"normal"`, `"multiply"`, `"overlay"`, etc.) + * `rotation` (deg), `opacity`, `blocked`, `hidden`, `visible` + * `proportionLock` - Whether width and height are locked to the same ratio + * `constraintsHorizontal` - Horizontal resize constraint (`"left"`, `"right"`, `"center"`, `"leftright"`, `"scale"`) + * `constraintsVertical` - Vertical resize constraint (`"top"`, `"bottom"`, `"center"`, `"topbottom"`, `"scale"`) + * `flipX`, `flipY` - Horizontal/vertical flip **Z-Order**: * The z-order of shapes is determined by the order in the `children` array of the parent shape. Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order (i.e. add background shapes first, then foreground shapes later). - CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)` * To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`, and, for precise control, `setParentIndex(index)` (0-based). @@ -67,15 +75,15 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image` **Hierarchical Structure**: * `parent` - The parent shape (null for root shapes) Note: Hierarchical nesting does not necessarily imply visual containment - * CRITICAL: To add children to a parent shape (e.g. a `Board`): - - ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append - - NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards) + * To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)` * Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent - Automatically removes the shape from its old parent - Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position) Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original. +Annotations: Don't add text elements to the design that just repeat a shape's name. In the Penpot UI, the name is displayed anyway. + # Images The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an @@ -87,10 +95,10 @@ Use the `export_shape` and `import_image` tools to export and import images. Boards can have layout systems that automatically control the positioning and spacing of their children: * If a board has a layout system, then child positions are controlled by the layout system. - For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`: + After adding a shape to the layout as a child, key properties of the child within the layout are controlled in `child.layoutChild: LayoutChildProperties`: - `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent! - margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`) - - sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix") + - sizing (`verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill") - controls child resizing depending on the layout's sizing mode (see below) - min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`) - `zIndex: number` (higher numbers on top) @@ -99,18 +107,11 @@ Boards can have layout systems that automatically control the positioning and sp - `dir`: "row" | "column" | "row-reverse" | "column-reverse" - Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding` - To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions. - Optionally, adjust indivudual child margins via `child.layoutChild`. - - When a board has flex layout, - - child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); - appending or inserting children automatically positions them according to the layout rules. - - CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order! - Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa. - ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"! - - CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that - they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front - of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance. - To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column" - or dir="row". + Optionally, adjust individual child margins via `child.layoutChild`. + - When a board has flex layout, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); + appending or inserting children automatically positions them according to the layout rules. + - To append children to a flex layout board such that they appear visually at the end, use the Board's method `board.appendChild(shape)`, i.e. call it in the order of visual appearance. + To insert at a specific index, use `board.insertChild(index, shape)`. - Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`. IMPORTANT: When adding a flex layout to a container that already has children, use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children. @@ -122,9 +123,14 @@ Boards can have layout systems that automatically control the positioning and sp Check with: `if (board.grid) { ... }` - Properties: `rows`, `columns`, `rowGap`, `columnGap` - Children are positioned via 1-based row/column indices - - Add to grid via `board.flex.appendChild(shape, row, column)` + - Add to grid via `board.grid.appendChild(shape, row, column)` - Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties` + * Auto-sizing: both types of layouts have properties `verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill" + - `fix` (default): no resizing (size determined by shape's own width/height) + - `auto`: size determined by content (container will resize depending on children's dimensions); ALWAYS set this if you want the container size to adapt to contents/margins/spacings! + - `fill`: resize children to fill the container's size (child resizing is controlled by each child's `layoutChild` properties) + * When working with boards: - ALWAYS check if the board has a layout system before attempting to reposition children - Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly @@ -132,13 +138,23 @@ Boards can have layout systems that automatically control the positioning and sp # Text Elements -The rendered content of `Text` element is given by the `characters` property. - -To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, -it only changes the formal bounding box; if the text does not fit it, it will overflow. -The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height". -`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing! -The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. +`Text` elements: + * The text to be rendered is given by the `characters` property. + * To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, + it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. + * Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height". + `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing! + The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. + * Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive + * Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle` + - To discover valid values, check available fonts in `penpot.fonts: FontContext` + - `FontContext` provides `Font` instances; each font has property `variants: FontVariant[]` + - Example: Determine available weights for a font using `penpot.fonts.findByName("Laila").variants.map(v => v.fontWeight)` + - To apply a `Font` to a `Text` instance and set all font properties at once: + - `font.applyToText(text: Text, variant?: FontVariant)` + - `applyToRange(range: TextRange, variant?: FontVariant)` + * Further writable properties: `align`, `verticalAlign`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration` (see API info) + * Method `applyTypography(typography: LibraryTypography)` # The `penpot` and `penpotUtils` Objects, Exploring Designs @@ -210,19 +226,6 @@ Common tasks - Quick Reference (ALWAYS use penpotUtils for these): }); Always validate against the root container that is supposed to contain the shapes. -# Visual Inspection of Designs - -For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose! - -# Revising Designs - -* Before applying design changes, ask: "Would a designer consider this appropriate?" -* When dealing with containment issues, ask: Is the parent too small OR is the child too large? - Container sizes are usually intentional, check content first. -* Check for reasonable font sizes and typefaces -* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. - Consider converting boards to flex layout when appropriate. - # Asset Libraries Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files. @@ -242,31 +245,75 @@ Each `Library` object has: * `colors: LibraryColor[]` - Array of colors * `typographies: LibraryTypography[]` - Array of typographies +## Colors and Typographies + +Adding a color: +``` +const newColor: LibraryColor = penpot.library.local.createColor(); +newColor.name = 'Brand Primary'; +newColor.color = '#0066FF'; +``` + +Adding a typography: +``` +const newTypo: LibraryTypography = penpot.library.local.createTypography(); +newTypo.name = 'Heading Large'; +// Set typography properties... +``` + +## Components + Using library components: * find a component in the library by name: - const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button')); + `const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));` * create a new instance of the component on the current page: - const instance: Shape = component.instance(); + `const instance: Shape = component.instance();` This returns a `Shape` (often a `Board` containing child elements). After instantiation, modify the instance's properties as desired. * get the reference to the main component shape: - const mainShape: Shape = component.mainInstance(); - -Adding assets to a library: - * const newColor: LibraryColor = penpot.library.local.createColor(); - newColor.name = 'Brand Primary'; - newColor.color = '#0066FF'; - * const newTypo: LibraryTypography = penpot.library.local.createTypography(); - newTypo.name = 'Heading Large'; - // Set typography properties... - * const shapes: Shape[] = [shape1, shape2]; // shapes to include - const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); - newComponent.name = 'My Button'; + `const mainShape: Shape = component.mainInstance();` + +Adding a component to a library: +``` +const shapes: Shape[] = [shape1, shape2]; // shapes to include +const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); +newComponent.name = 'My Button'; +``` Detaching: * When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification. * Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work. +### Variants + +Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances. + +* `VariantContainer` (extends `Board`): The board that physically groups all variant components together. + - check with `isVariantContainer()` + - property `variants: Variants`. +* `Variants`: Defines the combinations of property values for which component variants can exist and manages the concrete component variants. + - `properties: string[]` (ordered list of property names); `addProperty(): void`, `renameProperty(pos, name)`, `currentValues(property)` + - `variantComponents(): LibraryVariantComponent[]` +* `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true. + - `variantProps: { [property: string]: string }` (this component's value for each property) + - `variantError` (non-null if e.g. two variants share the same combination of property values) + - `setVariantProperty(pos, value)` + +Properties are often addressed positionally: `pos` parameter in various methods = index in `Variants.properties`. + +**Creating a variant group**: +- `penpot.createVariantFromComponents(mainInstances: Board[]): VariantContainer`: Combines several main component instances into a new variant group. + All components end up inside a single new container on the canvas. + The container's `Variants` instance is initialised with one property `Property 1`, with the property values set to the respective component's name. +- After creation, edit properties using `variants.renameProperty(pos, name)`, `variants.addProperty()`, and `comp.setVariantProperty(pos, value)`. + +**Adding a variant to an existing group**: +Use `variantContainer.appendChild(mainInstance)` to move a component's main instance into the container, then set its position manually and assign property values via `setVariantProperty`. + +**Using Variants**: +- `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same. +- To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`. + # Design Tokens Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling. @@ -274,21 +321,22 @@ Design tokens are reusable design values (colors, dimensions, typography, etc.) The token library: `penpot.library.local.tokens` (type: `TokenCatalog`) * `sets: TokenSet[]` - Token collections (order matters for precedence) * `themes: TokenTheme[]` - Presets that activate specific sets - * `addSet(name: string): TokenSet` - Create new set + * `addSet({name: string}): TokenSet` - Create new set * `addTheme(group: string, name: string): TokenTheme` - Create new theme `TokenSet` contains tokens with unique names: * `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();` * `tokens: Token[]` - All tokens in set - * `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set. + * `addToken({type: TokenType, name: string, value: TokenValueString}): Token` - Creates a token, adding it to the set. - `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase" + - `value`: depends on the type of token (inspect `Token` and related types) - Examples: - const token = set.addToken("color", "color.primary", "#0066FF"); // direct value - const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token + const token = set.addToken({type: "color", name: "color.primary", value: "#0066FF"}); // direct value + const token2 = set.addToken({type: "color", name: "color.accent", value: "{color.primary}"}); // reference to another token -`Token`: - * `name: string` - Token name (may include group path like "color.base.white") - * `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}") +`Token`: union type encompassing various token types, with common properties: + * `name: string` - Token name (typically structured, e.g. "color.base.white") + * `value` - Raw value (direct value or reference to another token like "{color.primary}") * `resolvedValue` - Computed final value (follows references) * `type: TokenType` @@ -303,21 +351,21 @@ Applying tokens: (if properties is undefined, use a default property based on the token type - not usually recommended). `TokenProperty` is a union type; possible values are: - "all": applies the token to all properties it can control - - TokenBorderRadiusProps: "r1", "r2", "r3", "r4" + - TokenBorderRadiusProps: "borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft" - TokenShadowProps: "shadow" - - TokenColorProps: "fill", "stroke-color" - - TokenDimensionProps: "x", "y", "stroke-width" - - TokenFontFamiliesProps: "font-families" - - TokenFontSizesProps: "font-size" - - TokenFontWeightProps: "font-weight" - - TokenLetterSpacingProps: "letter-spacing" - - TokenNumberProps: "rotation", "line-height" + - TokenColorProps: "fill", "strokeColor" + - TokenDimensionProps: "x", "y", "strokeWidth" + - TokenFontFamiliesProps: "fontFamilies" + - TokenFontSizesProps: "fontSize" + - TokenFontWeightProps: "fontWeight" + - TokenLetterSpacingProps: "letterSpacing" + - TokenNumberProps: "rotation" - TokenOpacityProps: "opacity" - - TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h" - - TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4" - - TokenBorderWidthProps: "stroke-width" - - TokenTextCaseProps: "text-case" - - TokenTextDecorationProps: "text-decoration" + - TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH" + - TokenSpacingProps: "rowGap", "columnGap", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", "marginLeft", "marginTop", "marginRight", "marginBottom" + - TokenBorderWidthProps: "strokeWidth" + - TokenTextCaseProps: "textCase" + - TokenTextDecorationProps: "textDecoration" - TokenTypographyProps: "typography" * `token.applyToShapes(shapes, properties)` - Apply from token * Application is **asynchronous** (wait for ~100ms to see the effects) @@ -326,8 +374,30 @@ Applying tokens: - The actual shape properties that the tokens control will reflect the token's resolved value. Removing tokens: - Simply set the respective property directly - token binding is automatically removed, e.g. + Simply set the respective property directly - token binding is automatically removed, e.g. shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token +# Visual Inspection of Designs + +For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose! + +# Creating and Translating Designs + +* When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. + NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to + non-creative defaults such as white/black if you are lacking information). +* When creating new designs, + - ensure a clean internal structure by applying flex and grid layouts when appropriate + - ensure proper semantic naming of elements. + +# Revising Designs + +* Before applying design changes, ask: "Would a designer consider this appropriate?" +* When dealing with containment issues, ask: Is the parent too small OR is the child too large? + Container sizes are usually intentional, check content first. +* Check for reasonable font sizes and typefaces +* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. + Consider converting boards to flex layout when appropriate. + -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 09e47a4dcec..68d33859ac1 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -5,9 +5,8 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp", - "build": "pnpm run build:server && cp -r src/static dist/static && cp -r data dist/data", - "build:multi-user": "pnpm run build", + "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp", + "build": "pnpm run build:server && node scripts/copy-resources.js", "build:types": "tsc --emitDeclarationOnly --outDir dist", "start": "node dist/index.js", "start:multi-user": "node dist/index.js --multi-user", @@ -23,7 +22,7 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "dependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "class-transformer": "^0.5.1", @@ -32,6 +31,7 @@ "js-yaml": "^4.1.1", "penpot-mcp": "file:..", "pino": "^9.10.0", + "pino-loki": "^2.6.0", "pino-pretty": "^13.1.1", "reflect-metadata": "^0.1.13", "sharp": "^0.34.5", @@ -39,6 +39,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "cross-env": "^7.0.3", "@penpot/mcp-common": "workspace:../common", "@types/express": "^4.17.0", "@types/js-yaml": "^4.0.9", diff --git a/mcp/packages/server/scripts/copy-resources.js b/mcp/packages/server/scripts/copy-resources.js new file mode 100644 index 00000000000..08b3604f37e --- /dev/null +++ b/mcp/packages/server/scripts/copy-resources.js @@ -0,0 +1,5 @@ +import { cpSync } from "fs"; + +// copy static assets and data to dist +cpSync("src/static", "dist/static", { recursive: true }); +cpSync("data", "dist/data", { recursive: true }); diff --git a/mcp/packages/server/src/ConfigurationLoader.ts b/mcp/packages/server/src/ConfigurationLoader.ts index 390522ff242..2b4b11288ec 100644 --- a/mcp/packages/server/src/ConfigurationLoader.ts +++ b/mcp/packages/server/src/ConfigurationLoader.ts @@ -4,15 +4,12 @@ import { createLogger } from "./logger.js"; /** * Configuration loader for prompts and server settings. - * - * Handles loading and parsing of YAML configuration files, - * providing type-safe access to configuration values with - * appropriate fallbacks for missing files or values. */ export class ConfigurationLoader { private readonly logger = createLogger("ConfigurationLoader"); private readonly baseDir: string; - private initialInstructions: string; + private readonly initialInstructions: string; + private readonly baseInstructions: string; /** * Creates a new configuration loader instance. @@ -22,6 +19,7 @@ export class ConfigurationLoader { constructor(baseDir: string) { this.baseDir = baseDir; this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md")); + this.baseInstructions = this.loadFileContent(join(this.baseDir, "data", "base_instructions.md")); } private loadFileContent(filePath: string): string { @@ -32,11 +30,22 @@ export class ConfigurationLoader { } /** - * Gets the initial instructions for the MCP server. + * Gets the initial instructions for the MCP server corresponding to the + * 'Penpot High-Level Overview' * - * @returns The initial instructions string, or undefined if not configured + * @returns The initial instructions string */ public getInitialInstructions(): string { return this.initialInstructions; } + + /** + * Gets the base instructions which shall be provided to clients when connecting to + * the MCP server + * + * @returns The initial instructions string + */ + public getBaseInstructions(): string { + return this.baseInstructions; + } } diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 3aa741dd83a..6bd3ca33e55 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -21,34 +21,83 @@ export interface SessionContext { userToken?: string; } +/** + * Represents an active Streamable HTTP session, grouping the transport, MCP server, and session metadata. + */ +class StreamableSession { + constructor( + public readonly transport: StreamableHTTPServerTransport, + public readonly userToken: string | undefined, + public lastActiveTime: number + ) {} +} + +/** + * Holds information about a registered tool, including its instance, name, and configuration. + */ +class ToolInfo { + constructor( + public readonly instance: Tool, + public readonly name: string, + public readonly config: { description: string; inputSchema: any } + ) {} +} + export class PenpotMcpServer { + /** + * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + */ + private static readonly SESSION_TIMEOUT_MINUTES = 60; + + /** + * Returns a short, non-reversible fingerprint of a user token, suitable for + * correlating log lines without exposing the full credential. + * + * Penpot tokens are JWEs in compact serialization (RFC 7516 §7.1) with five + * dot-separated segments; we use the first 8 chars of the wrapped CEK + * (segment 1) as a stable per-token identifier. For malformed tokens (e.g. + * test stubs that aren't real JWEs), we fall back to the first 8 chars of + * the raw token. + * + * @param token - the token to fingerprint, or `undefined` + * @returns a short fingerprint, or `` if no token was given + */ + private static tokenFingerprint(token: string | undefined): string { + if (!token) { + return ""; + } + const segments = token.split("."); + const source = segments.length === 5 ? segments[1] : token; + return source.slice(0, 8); + } + private readonly logger = createLogger("PenpotMcpServer"); - private readonly server: McpServer; - private readonly tools: Map>; + private readonly tools: ToolInfo[]; public readonly configLoader: ConfigurationLoader; private app: any; public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer; private apiDocs: ApiDocs; + private readonly penpotHighLevelOverview: string; + private readonly connectionInstructions: string; /** * Manages session-specific context, particularly user tokens for each request. */ private readonly sessionContext = new AsyncLocalStorage(); - private readonly transports = { - streamable: {} as Record, - sse: {} as Record, - }; + private readonly streamableTransports: Record = {}; + private readonly sseTransports: Record = {}; public readonly host: string; public readonly port: number; public readonly webSocketPort: number; public readonly replPort: number; + private sessionTimeoutInterval: ReturnType | undefined; constructor(private isMultiUser: boolean = false) { // read port configuration from environment variables - this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "0.0.0.0"; + this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10); this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10); this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10); @@ -56,21 +105,16 @@ export class PenpotMcpServer { this.configLoader = new ConfigurationLoader(process.cwd()); this.apiDocs = new ApiDocs(); - this.server = new McpServer( - { - name: "penpot-mcp-server", - version: "1.0.0", - }, - { - instructions: this.getInitialInstructions(), - } - ); + // prepare instructions + let instructions = this.configLoader.getInitialInstructions(); + instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); + this.penpotHighLevelOverview = instructions; + this.connectionInstructions = this.configLoader.getBaseInstructions(); - this.tools = new Map>(); - this.pluginBridge = new PluginBridge(this, this.webSocketPort); - this.replServer = new ReplServer(this.pluginBridge, this.replPort); + this.tools = this.initTools(); - this.registerTools(); + this.pluginBridge = new PluginBridge(this, this.webSocketPort); + this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); } /** @@ -104,10 +148,11 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } - public getInitialInstructions(): string { - let instructions = this.configLoader.getInitialInstructions(); - instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); - return instructions; + /** + * Retrieves the high-level overview instructions explaining core Penpot usage. + */ + public getHighLevelOverviewInstructions(): string { + return this.penpotHighLevelOverview; } /** @@ -119,88 +164,138 @@ export class PenpotMcpServer { return this.sessionContext.getStore(); } - private registerTools(): void { - // Create relevant tool instances (depending on file system access) + private initTools(): ToolInfo[] { const toolInstances: Tool[] = [ new ExecuteCodeTool(this), new HighLevelOverviewTool(this), new PenpotApiInfoTool(this, this.apiDocs), - new ExportShapeTool(this), // tool adapts to file system access internally + new ExportShapeTool(this), ]; if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } - for (const tool of toolInstances) { - const toolName = tool.getToolName(); - this.tools.set(toolName, tool); - - // Register each tool with McpServer - this.logger.info(`Registering tool: ${toolName}`); - this.server.registerTool( - toolName, - { - description: tool.getToolDescription(), - inputSchema: tool.getInputSchema(), - }, - async (args) => { - return tool.execute(args); + return toolInstances.map((instance) => { + this.logger.info(`Registering tool: ${instance.getToolName()}`); + return new ToolInfo(instance, instance.getToolName(), { + description: instance.getToolDescription(), + inputSchema: instance.getInputSchema(), + }); + }); + } + + /** + * Creates a fresh {@link McpServer} instance with all tools registered. + */ + private createMcpServer(): McpServer { + const server = new McpServer( + { name: "penpot", version: "1.0.0" }, + { instructions: this.connectionInstructions } + ); + + for (const tool of this.tools) { + server.registerTool(tool.name, tool.config, async (args: any) => tool.instance.execute(args)); + } + + return server; + } + + /** + * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. + */ + private startSessionTimeoutChecker(): void { + const timeoutMs = PenpotMcpServer.SESSION_TIMEOUT_MINUTES * 60 * 1000; + const checkIntervalMs = timeoutMs / 2; + this.sessionTimeoutInterval = setInterval(() => { + this.logger.info("Checking for stale sessions..."); + const now = Date.now(); + let removed = 0; + for (const session of Object.values(this.streamableTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + session.transport.close(); + removed++; } + } + this.logger.info( + `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` ); - } + }, checkIntervalMs); } private setupHttpEndpoints(): void { /** - * Modern Streamable HTTP connection endpoint + * Modern Streamable HTTP connection endpoint. + * + * New sessions are created on initialize requests (no mcp-session-id header). + * Subsequent requests for an existing session are routed to the stored transport, + * with the session context populated from the stored userToken. */ this.app.all("/mcp", async (req: any, res: any) => { - const userToken = req.query.userToken as string | undefined; - - await this.sessionContext.run({ userToken }, async () => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let userToken: string | undefined = undefined; + let transport: StreamableHTTPServerTransport; + + // obtain transport and user token for the session, either from an existing session or by creating a new one + if (sessionId && this.streamableTransports[sessionId]) { + // existing session: reuse stored transport and token + const session = this.streamableTransports[sessionId]; + transport = session.transport; + userToken = session.userToken; + session.lastActiveTime = Date.now(); + this.logger.info( + `Received request for existing session with id=${sessionId}; userTokenFp=${PenpotMcpServer.tokenFingerprint(session.userToken)}` + ); + } else { + // new session: create a fresh McpServer and transport + userToken = req.query.userToken as string | undefined; + this.logger.info( + `Received new session request; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + ); const { randomUUID } = await import("node:crypto"); + const server = this.createMcpServer(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); + this.logger.info( + `Session initialized with id=${id} for userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}; total sessions: ${Object.keys(this.streamableTransports).length}` + ); + }, + }); + transport.onclose = () => { + if (transport.sessionId) { + this.logger.info( + `Closing session with id=${transport.sessionId} for userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + ); + delete this.streamableTransports[transport.sessionId]; + } + }; + await server.connect(transport); + } - const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && this.transports.streamable[sessionId]) { - transport = this.transports.streamable[sessionId]; - } else { - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id: string) => { - this.transports.streamable[id] = transport; - }, - }); - - transport.onclose = () => { - if (transport.sessionId) { - delete this.transports.streamable[transport.sessionId]; - } - }; - - await this.server.connect(transport); - } - + // handle the request + await this.sessionContext.run({ userToken }, async () => { await transport.handleRequest(req, res, req.body); }); }); /** - * Legacy SSE connection endpoint + * Legacy SSE connection endpoint. */ this.app.get("/sse", async (req: any, res: any) => { const userToken = req.query.userToken as string | undefined; await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.transports.sse[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken }; + const server = this.createMcpServer(); + await server.connect(transport); res.on("close", () => { - delete this.transports.sse[transport.sessionId]; + delete this.sseTransports[transport.sessionId]; + server.close(); }); - - await this.server.connect(transport); }); }); @@ -209,7 +304,7 @@ export class PenpotMcpServer { */ this.app.post("/messages", async (req: any, res: any) => { const sessionId = req.query.sessionId as string; - const session = this.transports.sse[sessionId]; + const session = this.sseTransports[sessionId]; if (session) { await this.sessionContext.run({ userToken: session.userToken }, async () => { @@ -236,8 +331,9 @@ export class PenpotMcpServer { this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); - // start the REPL server + // start the REPL server and session timeout checker await this.replServer.start(); + this.startSessionTimeoutChecker(); resolve(); }); @@ -251,6 +347,7 @@ export class PenpotMcpServer { */ public async stop(): Promise { this.logger.info("Stopping Penpot MCP Server..."); + clearInterval(this.sessionTimeoutInterval); await this.replServer.stop(); this.logger.info("Penpot MCP Server stopped"); } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 413ec2fa70f..35d39aa728e 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -5,9 +5,12 @@ import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +const KEEP_ALIVE_TIME = 30000; // 30 seconds + interface ClientConnection { socket: WebSocket; userToken: string | null; + pingInterval: NodeJS.Timeout; } /** @@ -56,14 +59,21 @@ export class PluginBridge { this.logger.info("New WebSocket connection established"); } + // start the per-connection keep-alive ping interval + const pingInterval = setInterval(() => { + ws.ping(); + }, KEEP_ALIVE_TIME); + // register the client connection with both indexes - const connection: ClientConnection = { socket: ws, userToken }; + const connection: ClientConnection = { socket: ws, userToken, pingInterval }; this.connectedClients.set(ws, connection); if (userToken) { // ensure only one connection per userToken if (this.clientsByToken.has(userToken)) { this.logger.warn("Duplicate connection for given user token; rejecting new connection"); + this.removeConnection(ws); ws.close(1008, "Duplicate connection for given user token; close previous connection first."); + return; } this.clientsByToken.set(userToken, connection); @@ -81,26 +91,39 @@ export class PluginBridge { ws.on("close", () => { this.logger.info("WebSocket connection closed"); - const connection = this.connectedClients.get(ws); - this.connectedClients.delete(ws); - if (connection?.userToken) { - this.clientsByToken.delete(connection.userToken); - } + this.removeConnection(ws); }); ws.on("error", (error) => { this.logger.error(error, "WebSocket connection error"); - const connection = this.connectedClients.get(ws); - this.connectedClients.delete(ws); - if (connection?.userToken) { - this.clientsByToken.delete(connection.userToken); - } + this.removeConnection(ws); }); }); this.logger.info("WebSocket mcpServer started on port %d", this.port); } + /** + * Removes a client connection and releases all resources associated with it. + * + * Clears the per-connection keep-alive interval and removes the connection + * from both the socket-keyed and token-keyed indexes. Safe to call with a + * socket that is not (or no longer) registered. + * + * @param ws - The WebSocket whose connection state should be removed + */ + private removeConnection(ws: WebSocket): void { + const connection = this.connectedClients.get(ws); + if (!connection) { + return; + } + clearInterval(connection.pingInterval); + this.connectedClients.delete(ws); + if (connection.userToken) { + this.clientsByToken.delete(connection.userToken); + } + } + /** * Handles responses from the plugin for completed tasks. * diff --git a/mcp/packages/server/src/ReplServer.ts b/mcp/packages/server/src/ReplServer.ts index 1496037521e..1894ade79a3 100644 --- a/mcp/packages/server/src/ReplServer.ts +++ b/mcp/packages/server/src/ReplServer.ts @@ -17,13 +17,16 @@ export class ReplServer { private readonly logger = createLogger("ReplServer"); private readonly app: express.Application; private readonly port: number; + private readonly host: string; private server: any; constructor( private readonly pluginBridge: PluginBridge, - port: number = 4403 + port: number = 4403, + host: string = "localhost" ) { this.port = port; + this.host = host; this.app = express(); this.setupMiddleware(); this.setupRoutes(); @@ -86,9 +89,9 @@ export class ReplServer { */ public async start(): Promise { return new Promise((resolve) => { - this.server = this.app.listen(this.port, () => { + this.server = this.app.listen(this.port, this.host, () => { this.logger.info(`REPL server started on port ${this.port}`); - this.logger.info(`REPL interface URL: http://${this.pluginBridge.mcpServer.host}:${this.port}`); + this.logger.info(`REPL interface URL: http://${this.host}:${this.port}`); resolve(); }); }); diff --git a/mcp/packages/server/src/Tool.ts b/mcp/packages/server/src/Tool.ts index 65cfe539bdf..df4e1f22665 100644 --- a/mcp/packages/server/src/Tool.ts +++ b/mcp/packages/server/src/Tool.ts @@ -22,6 +22,9 @@ export class EmptyToolArgs { export abstract class Tool { private readonly logger = createLogger("Tool"); + /** monotonically increasing counter for unique tool execution IDs */ + private static executionCounter = 0; + protected constructor( protected mcpServer: PenpotMcpServer, private inputSchema: z.ZodRawShape @@ -34,17 +37,21 @@ export abstract class Tool { * delegating to the type-safe implementation. */ async execute(args: unknown): Promise { + const executionId = ++Tool.executionCounter; try { let argsInstance: TArgs = args as TArgs; - this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance)); + this.logger.info("Tool execution #%d starting: %s", executionId, this.getToolName()); + if (this.logger.isLevelEnabled("debug")) { + this.logger.debug("Tool execution #%d arguments: %s", executionId, this.formatArgs(argsInstance)); + } // execute the actual tool logic let result = await this.executeCore(argsInstance); - this.logger.info("Tool execution completed: %s", this.getToolName()); + this.logger.info("Tool execution #%d complete: %s", executionId, this.getToolName()); return result; } catch (error) { - this.logger.error(error); + this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error); return new TextResponse(`Tool execution failed: ${String(error)}`); } } diff --git a/mcp/packages/server/src/ToolResponse.ts b/mcp/packages/server/src/ToolResponse.ts index 6055f24ccfe..db5babe4db9 100644 --- a/mcp/packages/server/src/ToolResponse.ts +++ b/mcp/packages/server/src/ToolResponse.ts @@ -37,19 +37,26 @@ export class ImageContent implements ImageItem { /** * Utility function for ensuring a consistent Uint8Array representation of byte data. - * Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array - * from the plugin). + * Input can be one of: + * - a `Uint8Array` (already in the desired form); + * - a base64 envelope `{ __type: "base64", data: }` produced by the plugin + * to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420); + * - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback). * - * @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array) - * @return data as Uint8Array + * @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object + * @return data as `Uint8Array` */ public static byteData(data: Uint8Array | object): Uint8Array { - if (typeof data === "object") { - // convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array - return new Uint8Array(Object.values(data) as number[]); - } else { + if (data instanceof Uint8Array) { return data; } + // recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler + const envelope = data as { __type?: unknown; data?: unknown }; + if (envelope.__type === "base64" && typeof envelope.data === "string") { + return new Uint8Array(Buffer.from(envelope.data, "base64")); + } + // legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array + return new Uint8Array(Object.values(data) as number[]); } } diff --git a/mcp/packages/server/src/index.ts b/mcp/packages/server/src/index.ts index 356c2cea810..84ef23f204d 100644 --- a/mcp/packages/server/src/index.ts +++ b/mcp/packages/server/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { PenpotMcpServer } from "./PenpotMcpServer"; -import { createLogger, logFilePath } from "./logger"; +import { createLogger, logActiveTransports } from "./logger"; /** * Entry point for Penpot MCP Server @@ -14,8 +14,8 @@ import { createLogger, logFilePath } from "./logger"; async function main(): Promise { const logger = createLogger("main"); - // log the file path early so it appears before any potential errors - logger.info(`Logging to file: ${logFilePath}`); + // announce active transports early so they appear before any potential errors + logActiveTransports(logger); try { const args = process.argv.slice(2); diff --git a/mcp/packages/server/src/logger.ts b/mcp/packages/server/src/logger.ts index 1e8f96e5ecc..45cb359e901 100644 --- a/mcp/packages/server/src/logger.ts +++ b/mcp/packages/server/src/logger.ts @@ -1,12 +1,21 @@ -import pino from "pino"; +import pino, { type TransportTargetOptions } from "pino"; import { join, resolve } from "path"; /** - * Configuration for log file location and level. + * Configured log level (defaults to `info`). */ -const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR || "logs"; const LOG_LEVEL = process.env.PENPOT_MCP_LOG_LEVEL || "info"; +/** + * Configured log directory; file logging is enabled iff this is set to a non-empty value. + */ +const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR; + +/** + * Loki host URI; if set and non-empty, Loki logging is enabled. + */ +const LOKI_URI = process.env.PENPOT_LOGGERS_LOKI_URI; + /** * Generates a timestamped log file name. * @@ -24,56 +33,204 @@ function generateLogFileName(): string { } /** - * Absolute path to the log file being written. + * The pino transport target spec, as expected in `transport.targets[]`. */ -export const logFilePath = resolve(join(LOG_DIR, generateLogFileName())); +type TransportTargetSpec = TransportTargetOptions; + +/** + * Provides a single pino transport target, either active or inactive. + * + * Implementations decide their own activation based on environment configuration. + * An inactive provider returns `null` from {@link getTarget} and is skipped. + */ +interface LogTransportProvider { + /** + * Returns the pino transport target spec, or `null` if this transport is disabled. + */ + getTarget(): TransportTargetSpec | null; + + /** + * Returns a human-readable startup message describing the transport, or `null` if disabled. + */ + getStartupMessage(): string | null; +} + +/** + * Console transport with pretty-printed, colorized output. Always active. + */ +class ConsoleLogTransport implements LogTransportProvider { + public getTarget(): TransportTargetSpec { + return { + target: "pino-pretty", + level: LOG_LEVEL, + options: { + colorize: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + }, + }; + } + + public getStartupMessage(): string { + return "Logging to console"; + } +} + +/** + * File transport writing pretty-formatted logs to a timestamped file in a configurable directory. + * Active iff `PENPOT_MCP_LOG_DIR` is set and non-empty. + */ +class FileLogTransport implements LogTransportProvider { + private readonly enabled: boolean; + private readonly filePath: string | null; + + public constructor(logDir: string | undefined) { + this.enabled = logDir !== undefined && logDir !== ""; + this.filePath = this.enabled ? resolve(join(logDir as string, generateLogFileName())) : null; + } + + public isEnabled(): boolean { + return this.enabled; + } + + public getTarget(): TransportTargetSpec | null { + if (!this.enabled) { + return null; + } + return { + target: "pino-pretty", + level: LOG_LEVEL, + options: { + destination: this.filePath, + colorize: false, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + mkdir: true, + }, + }; + } + + public getStartupMessage(): string | null { + return this.enabled ? `Logging to file: ${this.filePath}` : null; + } + + /** + * Returns the absolute path of the active log file, or `undefined` if file logging is disabled. + */ + public getFilePath(): string | undefined { + return this.filePath ?? undefined; + } +} /** - * Logger instance configured for both console and file output with metadata. + * Loki transport forwarding logs to a Grafana Loki instance via `pino-loki`. * - * Both console and file output use pretty formatting for human readability. - * Console output includes colors, while file output is plain text. + * Active iff `PENPOT_LOGGERS_LOKI_URI` is set and non-empty. + */ +class LokiLogTransport implements LogTransportProvider { + private readonly host: string | null; + + public constructor(lokiUri: string | undefined) { + this.host = lokiUri !== undefined && lokiUri !== "" ? lokiUri : null; + } + + public isEnabled(): boolean { + return this.host !== null; + } + + public getTarget(): TransportTargetSpec | null { + if (this.host === null) { + return null; + } + return { + target: "pino-loki", + level: LOG_LEVEL, + options: { + host: this.host, + json: false, + batching: true, + interval: 5, + replaceTimestamp: true, + labels: this.buildLabels(), + }, + }; + } + + /** + * Builds the set of static labels to attach to every log entry sent to Loki. + * + * The `environment` and `instance` labels are only included if their respective + * environment variables are set and non-empty. + */ + private buildLabels(): Record { + const labels: Record = { + job: process.env.PENPOT_LOGGERS_LOKI_JOB || "mcp", + }; + const environment = process.env.PENPOT_LOGGERS_LOKI_ENVIRONMENT; + if (environment) { + labels.environment = environment; + } + const instance = process.env.PENPOT_LOGGERS_LOKI_INSTANCE; + if (instance) { + labels.instance = instance; + } + return labels; + } + + public getStartupMessage(): string | null { + return this.host !== null ? `Logging to Loki: ${this.host}` : null; + } +} + +// build the transport providers; each decides its own activation independently +const consoleTransport = new ConsoleLogTransport(); +const fileTransport = new FileLogTransport(LOG_DIR); +const lokiTransport = new LokiLogTransport(LOKI_URI); + +const transports: LogTransportProvider[] = [consoleTransport, fileTransport, lokiTransport]; + +/** + * Absolute path to the log file being written, or `undefined` if file logging is disabled. + */ +export const logFilePath: string | undefined = fileTransport.getFilePath(); + +/** + * Logger instance configured with the active transports (console, optional file, optional Loki). */ export const logger = pino({ level: LOG_LEVEL, timestamp: pino.stdTimeFunctions.isoTime, transport: { - targets: [ - { - // console transport with pretty formatting - target: "pino-pretty", - level: LOG_LEVEL, - options: { - colorize: true, - translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", - ignore: "pid,hostname", - messageFormat: "{msg}", - levelFirst: true, - }, - }, - { - // file transport with pretty formatting (same as console) - target: "pino-pretty", - level: LOG_LEVEL, - options: { - destination: logFilePath, - colorize: false, - translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", - ignore: "pid,hostname", - messageFormat: "{msg}", - levelFirst: true, - mkdir: true, - }, - }, - ], + targets: transports + .map((t) => t.getTarget()) + .filter((target): target is TransportTargetSpec => target !== null), }, }); +/** + * Logs a startup line for each active transport, allowing the user to see at a glance + * where logs are being written. + * + * @param log - the logger to emit the startup messages on + */ +export function logActiveTransports(log: pino.Logger): void { + for (const t of transports) { + const msg = t.getStartupMessage(); + if (msg !== null) { + log.info(msg); + } + } +} + /** * Creates a child logger with the specified name/origin. * - * @param name - The name/origin identifier for the logger - * @returns Child logger instance with the specified name + * @param name - the name/origin identifier for the logger + * @returns child logger instance with the specified name */ export function createLogger(name: string) { return logger.child({ name }); diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index adcb6339fa0..6a514e0cf47 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -53,9 +53,10 @@ export class ExecuteCodeTool extends Tool { "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + "you can also store functions and thus build up a library).\n" + "Think of the code being executed as the body of a function: " + - "The tool call returns whatever you return in the applicable `return` statement, if any.\n" + + "The tool call returns whatever you return in the applicable `return` statement, if any. " + + "You can return arbitrary JS objects; no need to apply JSON.stringify.\n" + "If an exception occurs, the exception's message will be returned to you.\n" + - "Any output that you generate via the `console` object will be returned to you separately; so you may use it" + + "Any output that you generate via the `console` object will be returned to you separately; so you may use it " + "to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " + "VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" + "VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " + diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index d032b0b7705..7b8ceed4a6f 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -16,8 +16,8 @@ export class ExportShapeArgs { .string() .min(1, "shapeId cannot be empty") .describe( - "Identifier of the shape to export. Use the special identifier 'selection' to " + - "export the first shape currently selected by the user." + "Identifier of the shape to export. " + + "Special identifiers you can use: 'selection' (first shape currently selected by the user), 'page' (entire current page)" ), format: z.enum(["svg", "png"]).default("png").describe("The output format, either 'png' (default) or 'svg'."), mode: z @@ -71,7 +71,7 @@ export class ExportShapeTool extends Tool { public getToolDescription(): string { let description = "Exports a shape (or a shape's image fill) from the Penpot design to a PNG or SVG image, " + - "such that you can get an impression of what it looks like. "; + "such that you can get an impression of what it looks like."; if (this.mcpServer.isFileSystemAccessEnabled()) { description += "\nAlternatively, you can save it to a file."; } @@ -88,6 +88,8 @@ export class ExportShapeTool extends Tool { let shapeCode: string; if (args.shapeId === "selection") { shapeCode = `penpot.selection[0]`; + } else if (args.shapeId === "page") { + shapeCode = `penpot.root`; } else { shapeCode = `penpotUtils.findShapeById("${args.shapeId}")`; } diff --git a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts index ada88297710..b16edcb68b8 100644 --- a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts +++ b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts @@ -21,6 +21,6 @@ export class HighLevelOverviewTool extends Tool { } protected async executeCore(args: EmptyToolArgs): Promise { - return new TextResponse(this.mcpServer.getInitialInstructions()); + return new TextResponse(this.mcpServer.getHighLevelOverviewInstructions()); } } diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 15088c97913..c59ac34c3f7 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: pino: specifier: ^9.10.0 version: 9.14.0 + pino-loki: + specifier: ^2.6.0 + version: 2.6.0 pino-pretty: specifier: ^13.1.1 version: 13.1.3 @@ -97,6 +100,9 @@ importers: '@types/ws': specifier: ^8.5.10 version: 8.18.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 esbuild: specifier: ^0.25.0 version: 0.25.12 @@ -1265,6 +1271,10 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + pino-loki@2.6.0: + resolution: {integrity: sha512-Qy+NeIdb0YmZe/M5mgnO5aGaAyVaeqgwn45T6VajhRXZlZVfGe1YNYhFa9UZyCeNFAPGaUkD2e9yPGjx+2BBYA==} + hasBin: true + pino-pretty@13.1.3: resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} hasBin: true @@ -2478,6 +2488,11 @@ snapshots: dependencies: split2: 4.2.0 + pino-loki@2.6.0: + dependencies: + pino-abstract-transport: 2.0.0 + pump: 3.0.3 + pino-pretty@13.1.3: dependencies: colorette: 2.0.20 diff --git a/mcp/scripts/build b/mcp/scripts/build index 41af7a46849..e31b9a5ef2d 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -25,12 +25,16 @@ set -e popd pnpm -r --filter "!mcp-plugin" install; -pnpm -r --filter "mcp-server" run build:multi-user; +pnpm -r --filter "mcp-server" run build; -rsync -avr packages/server/dist/ ./dist/; +# Normalize cpSync output modes before cp -a propagates them. On macOS Docker Desktop, Node's fs.cpSync writes a gRPC-FUSE xattr with mode 200 that cp -a turns into a real host mode 200 (unreadable). chmod here while host mode is still 644 fixes both layers. +chmod -R u=rwX,go=rX packages/server/dist; + +mkdir -p ./dist; +cp -a packages/server/dist/. ./dist/; cp packages/server/package.json ./dist/; -cp packages/server/pnpm-lock.yaml ./dist/; +cp pnpm-lock.yaml ./dist/; touch ./dist/pnpm-workspace.yaml; diff --git a/mcp/scripts/pack b/mcp/scripts/pack new file mode 100644 index 00000000000..16a869cf00f --- /dev/null +++ b/mcp/scripts/pack @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# pnpm-lock.yaml is hard-excluded by npm, but we need it; ship it under a neutral name +cp pnpm-lock.yaml pnpm-lock.dist.yaml +trap 'rm -f pnpm-lock.dist.yaml' EXIT + +npm pack diff --git a/mcp/scripts/set-version b/mcp/scripts/set-version new file mode 100755 index 00000000000..170aa6267d8 --- /dev/null +++ b/mcp/scripts/set-version @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Derives a valid npm semver version from the Git tag produced by +# `git describe` and writes it into the root package.json. +# +# Examples of the conversion: +# 2.14.0 -> 2.14.0 +# 2.14.0-RC1 -> 2.14.0-rc.1 +# 2.14.0-RC1-140-g9f2ca9965 -> 2.14.0-rc.1.140 +# 2.14.0-140-g9f2ca9965 -> 2.14.1-dev.140 +# +# The last case (commits after a release tag) bumps the patch level so +# the resulting semver sorts higher than the release. + +set -euo pipefail + +raw=$(git describe --tags --match "*.*.*") + +# Parse: ..[-