Compare commits

..

No commits in common. "develop" and "2.13.0-RC7" have entirely different histories.

1764 changed files with 123824 additions and 223367 deletions

View File

@ -45,15 +45,6 @@
:potok/reify-type
{:level :error}
:redundant-primitive-coercion
{:level :off}
:unused-excluded-var
{:level :off}
:unresolved-excluded-var
{:level :off}
:missing-protocol-method
{:level :off}

View File

@ -2,11 +2,6 @@
:remove-multiple-non-indenting-spaces? false
:remove-surrounding-whitespace? true
:remove-consecutive-blank-lines? false
:indent-line-comments? true
:parallel? true
:align-form-columns? false
;; :align-map-columns? false
;; :align-single-column-lines? false
:extra-indents {rumext.v2/fnc [[:inner 0]]
cljs.test/async [[:inner 0]]
promesa.exec/thread [[:inner 0]]

View File

@ -1,38 +0,0 @@
---
name: New Render Bug Report
about: Create a report about the bugs you have found in the new render
title: ''
labels: new render
assignees: claragvinola
---
**Describe the bug**
A clear and concise description of what the bug is.
**Steps to Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots or screen recordings**
If applicable, add screenshots or screen recording to help illustrate your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -40,7 +40,7 @@ on:
jobs:
build-bundle:
name: Build and Upload Penpot Bundle
runs-on: penpot-runner-01
runs-on: ubuntu-24.04
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@ -48,7 +48,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.gh_ref }}

View File

@ -1,7 +1,6 @@
name: _DEVELOP
on:
workflow_dispatch:
schedule:
- cron: '16 5-20 * * 1-5'

View File

@ -7,28 +7,23 @@ jobs:
build-and-push:
name: Build and push DevEnv Docker image
environment: release-admins
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Build and push DevEnv Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'penpotapp/devenv'
with:

View File

@ -19,16 +19,11 @@ on:
jobs:
build-and-push:
name: Build and Push Penpot Docker Images
runs-on: penpot-runner-02
runs-on: ubuntu-24.04-arm
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.gh_ref }}
@ -59,43 +54,32 @@ jobs:
mv penpot/frontend bundle-frontend
mv penpot/exporter bundle-exporter
mv penpot/storybook bundle-storybook
mv penpot/mcp bundle-mcp
popd
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the “429 Too Many Requests” error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
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@v6
uses: docker/metadata-action@v5
with:
images:
frontend
backend
exporter
storybook
mcp
labels: |
bundle_version=${{ steps.bundles.outputs.bundle_version }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'backend'
BUNDLE_PATH: './bundle-backend'
@ -110,7 +94,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@v7
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'frontend'
BUNDLE_PATH: './bundle-frontend'
@ -125,7 +109,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@v7
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'exporter'
BUNDLE_PATH: './bundle-exporter'
@ -140,7 +124,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@v7
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'storybook'
BUNDLE_PATH: './bundle-storybook'
@ -154,21 +138,6 @@ jobs:
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
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@v7
env:
DOCKER_IMAGE: 'mcp'
BUNDLE_PATH: './bundle-mcp'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.mcp
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master

View File

@ -1,16 +1,15 @@
name: _MAIN-STAGING
name: _NITRATE MODULE
on:
workflow_dispatch:
schedule:
- cron: '26 5-20 * * 1-5'
- cron: '36 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "main-staging"
gh_ref: "nitrate-module"
build_wasm: "yes"
build_storybook: "yes"
@ -19,4 +18,4 @@ jobs:
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "main-staging"
gh_ref: "nitrate-module"

View File

@ -0,0 +1,14 @@
name: _STAGING RENDER
on:
schedule:
- cron: '36 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "staging-render"
build_wasm: "yes"
build_storybook: "yes"

View File

@ -1,7 +1,6 @@
name: _STAGING
on:
workflow_dispatch:
schedule:
- cron: '36 5-20 * * 1-5'

View File

@ -1,7 +1,6 @@
name: _TAG
on:
workflow_dispatch:
push:
tags:
- '*'
@ -34,7 +33,7 @@ jobs:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}*
🐳 *[PENPOT] Docker image available: {{ github.ref_name }}*
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@ -6,14 +6,12 @@ on:
- edited
- reopened
- synchronize
- ready_for_review
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
push:
branches:
- main
@ -22,14 +20,13 @@ on:
jobs:
check-commit-message:
if: ${{ !github.event.pull_request.draft }}
name: Check Commit Message
runs-on: ubuntu-latest
steps:
- name: Check Commit Type
uses: gsactions/commit-message-checker@v2
with:
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
flags: 'gm'
error: 'Commit should match CONTRIBUTING.md guideline'
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request

View File

@ -7,11 +7,11 @@ on:
- staging
- main
paths:
- 'plugins/libs/plugin-types/index.d.ts'
- 'plugins/libs/plugin-types/REAME.md'
- 'plugins/tools/typedoc.css'
- 'plugins/CHANGELOG.md'
- 'plugins/wrangler-penpot-plugins-api-doc.toml'
- "plugins/libs/plugin-types/index.d.ts"
- "plugins/libs/plugin-types/REAME.md"
- "plugins/tools/typedoc.css"
- "plugins/CHANGELOG.md"
- "plugins/wrangle-penpot-plugins-api-doc.toml"
workflow_dispatch:
inputs:
gh_ref:
@ -37,7 +37,7 @@ jobs:
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
@ -62,7 +62,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
@ -86,57 +86,16 @@ jobs:
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV
echo "WORKER_URI=doc.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV
echo "WORKER_URI=doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV
echo "WORKER_URI=doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV ;;
staging) echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV ;;
develop) echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
esac
- name: Set the custom url
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml
- name: Add noindex header and robots.txt files for non-production environments
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
working-directory: plugins
shell: bash
run: |
ASSETS_DIR="dist/doc"
cat > "${ASSETS_DIR}/_headers" << 'EOF'
/*
X-Robots-Tag: noindex, nofollow
EOF
cat > "${ASSETS_DIR}/robots.txt" << 'EOF'
User-agent: *
Disallow: /
EOF
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangler-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🧩📚 *[PENPOT PLUGINS] Error deploying API documentation.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra
command: deploy --config wrangle-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}

View File

@ -1,127 +0,0 @@
name: Plugins/package deployer
on:
# Deploy package from manual action
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch'
type: choice
required: true
default: 'develop'
options:
- develop
- staging
- main
plugin_name:
description: 'Pluging name (like plugins/apps/<plugin_name>-plugin)'
type: string
required: true
workflow_call:
inputs:
gh_ref:
description: 'Name of the branch'
type: string
required: true
default: 'develop'
plugin_name:
description: 'Publig name (from plugins/apps/<plugin_name>-plugin)'
type: string
required: true
permissions:
contents: read
jobs:
deploy:
runs-on: penpot-runner-01
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Enable PNPM
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: "Build package for ${{ inputs.plugin_name }}-plugin"
working-directory: plugins
shell: bash
run: pnpm --filter ${{ inputs.plugin_name }}-plugin build
- name: Select Worker name
run: |
REF="${{ inputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
esac
- name: Set the custom url
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config apps/${{ inputs.plugin_name }}-plugin/wrangler.toml --name ${{ env.WORKER_NAME }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🧩📦 *[PENPOT PLUGINS] Error deploying ${{ env.WORKER_NAME }}.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
Plugin name: `${{ inputs.plugin_name }}-plugin`
Cloudflare worker name: `${{ env.WORKER_NAME }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@ -1,143 +0,0 @@
name: Plugins/packages deployer
on:
push:
branches:
- develop
- staging
- main
paths:
- 'plugins/apps/*-plugin/**'
- 'libs/plugins-styles/**'
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch'
type: choice
required: true
default: 'develop'
options:
- develop
- staging
- main
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
colors_to_tokens: ${{ steps.filter.outputs.colors_to_tokens }}
create_palette: ${{ steps.filter.outputs.create_palette }}
lorem_ipsum: ${{ steps.filter.outputs.lorem_ipsum }}
rename_layers: ${{ steps.filter.outputs.rename_layers }}
contrast: ${{ steps.filter.outputs.contrast }}
icons: ${{ steps.filter.outputs.icons }}
poc_state: ${{ steps.filter.outputs.poc_state }}
table: ${{ steps.filter.outputs.table }}
# [For new plugins]
# Add more outputs here
steps:
- uses: actions/checkout@v6
- id: filter
uses: dorny/paths-filter@v4
with:
filters: |
colors_to_tokens:
- 'plugins/apps/colors-to-tokens-plugin/**'
- 'libs/plugins-styles/**'
contrast:
- 'plugins/apps/contrast-plugin/**'
- 'libs/plugins-styles/**'
create_palette:
- 'plugins/apps/create-palette-plugin/**'
- 'libs/plugins-styles/**'
icons:
- 'plugins/apps/icons-plugin/**'
- 'libs/plugins-styles/**'
lorem_ipsum:
- 'plugins/apps/lorem-ipsum-plugin/**'
- 'libs/plugins-styles/**'
rename_layers:
- 'plugins/apps/rename-layers-plugin/**'
- 'libs/plugins-styles/**'
table:
- 'plugins/apps/table-plugin/**'
- 'libs/plugins-styles/**'
# [For new plugins]
# Add more plugin filters here
# another_plugin:
# - 'plugins/apps/another-plugin/**'
# - 'libs/plugins-styles/**'
colors-to-tokens-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.colors_to_tokens == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: colors-to-tokens
contrast-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.contrast == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: contrast
create-palette-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.create_palette == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: create-palette
icons-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.icons == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: icons
lorem-ipsum-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.lorem_ipsum == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: lorem-ipsum
rename-layers-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rename_layers == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: rename-layers
table-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.table == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: table
# [For new plugins]
# Add more jobs for other plugins below, following the same pattern
# another-plugin:
# needs: detect-changes
# if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.another_plugin == 'true'
# uses: ./.github/workflows/plugins-deploy-package.yml
# secrets: inherit
# with:
# gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
# plugin_name: another

View File

@ -1,140 +0,0 @@
name: Plugins/styles-doc deployer
on:
push:
branches:
- develop
- staging
- main
paths:
- 'plugins/apps/example-styles/**'
- 'plugins/libs/plugins-styles/**'
- 'plugins/wrangler-penpot-plugins-styles-doc.toml'
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch'
type: choice
required: true
default: 'develop'
options:
- develop
- staging
- main
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Extract some useful variables
id: vars
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Enable PNPM
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: Build styles
working-directory: plugins
shell: bash
run: pnpm run build:styles-example
- name: Select Worker name
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV
echo "WORKER_URI=styles-doc.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=penpot-plugins-styles-doc-pre" >> $GITHUB_ENV
echo "WORKER_URI=styles-doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=penpot-plugins-styles-doc-hourly" >> $GITHUB_ENV
echo "WORKER_URI=styles-doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
esac
- name: Set the custom url
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml
- name: Add noindex header and robots.txt files for non-production environments
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
working-directory: plugins
shell: bash
run: |
ASSETS_DIR="dist/apps/example-styles"
cat > "${ASSETS_DIR}/_headers" << 'EOF'
/*
X-Robots-Tag: noindex, nofollow
EOF
cat > "${ASSETS_DIR}/robots.txt" << 'EOF'
User-agent: *
Disallow: /
EOF
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangler-penpot-plugins-styles-doc.toml --name ${{ env.WORKER_NAME }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🧩💅 *[PENPOT PLUGINS] Error deploying Styles documentation.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@ -31,7 +31,7 @@ jobs:
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
@ -64,14 +64,13 @@ jobs:
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
IMAGES=("frontend" "backend" "exporter" "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 "$SHORT_TAG"; do
for alias in main latest; do
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/penpotapp/$image:$alias
@ -94,7 +93,7 @@ jobs:
# --- Create GitHub release ---
- name: Create GitHub release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@ -1,47 +0,0 @@
name: "MCP CI"
on:
pull_request:
branches:
- develop
- staging
- main
types:
- opened
- synchronize
- ready_for_review
paths:
- 'mcp/**'
push:
branches:
- develop
- staging
- main
paths:
- 'mcp/**'
jobs:
test-mcp:
if: ${{ !github.event.pull_request.draft }}
name: "Test MCP"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup
working-directory: ./mcp
run: ./scripts/setup
- name: Check
working-directory: ./mcp
run: |
pnpm run fmt:check;
pnpm -r run build;
pnpm -r run types:check;

View File

@ -9,7 +9,6 @@ on:
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
@ -21,88 +20,43 @@ concurrency:
jobs:
lint:
if: ${{ !github.event.pull_request.draft }}
name: "Linter"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Lint Common
working-directory: ./common
- name: Check clojure code format
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Lint Frontend
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Lint Backend
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Exporter
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Library
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
./scripts/lint
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Run tests
- name: Run tests on JVM
working-directory: ./common
run: |
clojure -M:dev:test
- name: Run tests on NODE
working-directory: ./common
run: |
./scripts/test
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node
id: setup-node
@ -132,11 +86,7 @@ jobs:
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
run: pnpm run build
- name: Build plugins
working-directory: ./plugins
@ -147,14 +97,13 @@ jobs:
run: pnpm run build:styles-example
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Unit Tests
working-directory: ./frontend
@ -169,14 +118,13 @@ jobs:
./scripts/test-components
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Format
working-directory: ./render-wasm
@ -194,9 +142,8 @@ jobs:
./test
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
services:
@ -220,7 +167,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Run tests
working-directory: ./backend
@ -234,14 +181,13 @@ 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
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Run tests
working-directory: ./library
@ -249,39 +195,38 @@ jobs:
./scripts/test
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
./scripts/build 0.0.0
- name: Store Bundle Cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 1/3"
runs-on: penpot-runner-02
name: "Integration Tests 1/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
@ -289,10 +234,10 @@ jobs:
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="1/3";
./scripts/test-e2e --shard="1/4";
- name: Upload test result
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-1
@ -301,18 +246,17 @@ jobs:
retention-days: 3
test-integration-2:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 2/3"
runs-on: penpot-runner-02
name: "Integration Tests 2/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
@ -320,10 +264,10 @@ jobs:
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="2/3";
./scripts/test-e2e --shard="2/4";
- name: Upload test result
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-2
@ -332,18 +276,17 @@ jobs:
retention-days: 3
test-integration-3:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 3/3"
runs-on: penpot-runner-02
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
@ -351,13 +294,43 @@ jobs:
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="3/3";
./scripts/test-e2e --shard="3/4";
- name: Upload test result
uses: actions/upload-artifact@v7
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: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- 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="4/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-4
path: frontend/test-results/
overwrite: true
retention-days: 3

32
.gitignore vendored
View File

@ -1,4 +1,11 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnpm-store
*-init.clj
*.css.json
*.jar
@ -13,6 +20,7 @@
.nyc_output
.rebel_readline_history
.repl
.shadow-cljs
/*.jpg
/*.md
/*.png
@ -24,13 +32,8 @@
/.clj-kondo/.cache
/_dump
/notes
/.opencode/package-lock.json
/plans
/prompts
/playground/
/backend/*.md
!/backend/AGENTS.md
/backend/.shadow-cljs
/backend/*.sql
/backend/*.txt
/backend/assets/
@ -41,34 +44,27 @@
/backend/resources/public/media
/backend/target/
/backend/experiments
/backend/scripts/_env.local
/bundle*
/cd.md
/clj-profiler/
/common/coverage
/common/target
/common/.shadow-cljs
/deploy
/docker/images/bundle*
/exporter/target
/exporter/.shadow-cljs
/frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html
/frontend/playwright-report/
/frontend/playwright/ui/visual-specs/
/frontend/text-editor/src/wasm/
/frontend/dist/
/frontend/npm-debug.log
/frontend/out/
/frontend/package-lock.json
/frontend/resources/fonts/experiments
/frontend/resources/public/*
/frontend/src/app/render_wasm/api/shared.js
/frontend/storybook-static/
/frontend/target/
/frontend/test-results/
/frontend/.shadow-cljs
/other/
/scripts/
/nexus/
/telemetry/
/tmp/
/vendor/**/target
/vendor/svgclean/bundle*.js
@ -76,13 +72,13 @@
/library/target/
/library/*.zip
/external
/penpot-nitrate
clj-profiler/
node_modules
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/render-wasm/target/
/**/node_modules
/**/.yarn/*
/.pnpm-store
/.vscode

105
.gitpod.yml Normal file
View File

@ -0,0 +1,105 @@
image:
file: docker/gitpod/Dockerfile
ports:
# nginx
- port: 3449
onOpen: open-preview
# frontend nREPL
- port: 3447
onOpen: ignore
visibility: private
# frontend shadow server
- port: 3448
onOpen: ignore
visibility: private
# backend
- port: 6060
onOpen: ignore
# exporter shadow server
- port: 9630
onOpen: ignore
visibility: private
# exporter http server
- port: 6061
onOpen: ignore
# mailhog web interface
- port: 8025
onOpen: ignore
# mailhog postfix
- port: 1025
onOpen: ignore
# postgres
- port: 5432
onOpen: ignore
# redis
- port: 6379
onOpen: ignore
# openldap
- port: 389
onOpen: ignore
tasks:
# https://github.com/gitpod-io/gitpod/issues/666#issuecomment-534347856
- name: gulp
command: >
cd $GITPOD_REPO_ROOT/frontend/;
yarn && gp sync-done 'frontend-yarn';
npx gulp --theme=${PENPOT_THEME} watch
- name: frontend shadow watch
command: >
cd $GITPOD_REPO_ROOT/frontend/;
gp sync-await 'frontend-yarn';
npx shadow-cljs watch main
- init: gp await-port 5432 && psql -f $GITPOD_REPO_ROOT/docker/gitpod/files/postgresql_init.sql
name: backend
command: >
cd $GITPOD_REPO_ROOT/backend/;
./scripts/start-dev
- name: exporter shadow watch
command:
cd $GITPOD_REPO_ROOT/exporter/;
gp sync-await 'frontend-yarn';
yarn && npx shadow-cljs watch main
- name: exporter web server
command: >
cd $GITPOD_REPO_ROOT/exporter/;
./scripts/wait-and-start.sh
- name: signed terminal
before: >
[[ ! -z ${GNUGPG} ]] &&
cd ~ &&
rm -rf .gnupg &&
echo ${GNUGPG} | base64 -d | tar --no-same-owner -xzvf -
init: >
[[ ! -z ${GNUGPG_KEY} ]] &&
git config --global commit.gpgsign true &&
git config --global user.signingkey ${GNUGPG_KEY}
command: cd $GITPOD_REPO_ROOT
- name: redis
command: redis-server
- before: go get github.com/mailhog/MailHog
name: mailhog
command: MailHog
- name: Nginx
command: >
nginx &&
multitail /var/log/nginx/access.log -I /var/log/nginx/error.log

2
.nvmrc
View File

@ -1 +1 @@
v22.22.0
v22.21.1

View File

@ -1,33 +0,0 @@
---
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: <imperative subject>`.
* 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.

View File

@ -1,37 +0,0 @@
---
name: Penpot Engineer
description: Senior Full-Stack Software Engineer
mode: primary
---
Role: You are a high-autonomy Senior Full-Stack Software Engineer working on
Penpot, an open-source design tool. You have full permission to navigate the
codebase, modify files, and execute commands to fulfill your tasks. Your goal is
to solve complex technical tasks with high precision while maintaining a strong
focus on maintainability and performance.
Tech stack: Clojure (backend), ClojureScript (frontend/exporter), Rust/WASM
(render-wasm), TypeScript (plugins/mcp), SCSS.
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, analyze the task in depth and describe your plan. If the
task is complex, break it down into atomic steps.
* When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default.
* Do **not** touch unrelated modules unless the task explicitly requires it.
* Only reference functions, namespaces, or APIs that actually exist in the
codebase. Verify their existence before citing them. If unsure, search first.
* Be concise and autonomous — avoid unnecessary explanations.
* After making changes, 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`.

View File

@ -1,61 +0,0 @@
---
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.
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-<plan-one-line-title>.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.

View File

@ -1,59 +0,0 @@
---
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-<prompt-one-line-title>.md for future use.

View File

@ -1,90 +0,0 @@
---
name: backport-commit
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
---
# Backport Commit
Port changes from a specific Git commit to the current branch by manually
applying the diff, avoiding `git cherry-pick` when it would introduce
complex conflicts.
## When to Use
Use this skill whenever the user asks to backport a commit, especially when:
- The commit touches multiple modules or files with significant divergence
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
- The target commit is old enough that conflicts are likely
- The commit introduces both source changes AND new files (tests, etc.)
- You need full control over how each hunk is applied
## Workflow
### 1. Identify the target commit
```bash
# Verify the commit exists and understand what it does
git log --oneline -1 <commit-sha>
# Get the full diff (including new/deleted files)
git show <commit-sha>
# Capture the original commit message for later reuse
git log --format='%B' -1 <commit-sha>
```
### 2. Identify affected modules
From the file paths in the diff, determine which Penpot modules are affected
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
files **before** making any changes. If a module has no `AGENTS.md`, skip
that step — verify with `ls <module>/AGENTS.md` first.
### 3. Read the current state of each affected file
For every file the diff touches, read the current version on disk to understand
context and ensure correct placement before editing.
### 4. Apply changes manually (the core of this approach)
Process every hunk in the diff using the appropriate tool:
| Diff action | Tool to use |
|-------------|-------------|
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
| Add new file | `write` — include proper license header and namespace conventions matching project style |
| Delete file | `bash rm <path>` |
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
> Use separate calls when hunks are far apart to keep `oldString` short and
> unambiguous.
Repeat until **all** hunks in the diff are ported.
### 5. Validate
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
indentation, verify the logic is still semantically correct. All checks must
pass before moving on.
### 6. 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

View File

@ -1,210 +0,0 @@
---
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

View File

@ -1,194 +0,0 @@
---
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`

View File

@ -1,112 +0,0 @@
---
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/

View File

@ -1,150 +0,0 @@
---
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

9
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"files.exclude": {
"**/.clj-kondo": true,
"**/.cpcache": true,
"**/.lsp": true,
"**/.shadow-cljs": true,
"**/node_modules": true
}
}

11
.yarnrc.yml Normal file
View File

@ -0,0 +1,11 @@
enableGlobalCache: true
enableImmutableCache: false
enableImmutableInstalls: false
enableTelemetry: false
httpTimeout: 600000
nodeLinker: node-modules

View File

@ -1,93 +0,0 @@
# AI Agent Guide
This document provides the core context and operating guidelines for AI agents
working in this repository.
## Before You Start
Before responding to any user request, you must:
1. Read this file completely.
2. Identify which modules are affected by the task.
3. Load the `AGENTS.md` file **only** for each affected module (see the
architecture table below). Not all modules have an `AGENTS.md` — verify the
file exists before attempting to read it.
4. Do **not** load `AGENTS.md` files for unrelated modules.
## Role: Senior Software Engineer
You are a high-autonomy Senior Full-Stack Software Engineer. You have full
permission to navigate the codebase, modify files, and execute commands to
fulfill your tasks. Your goal is to solve complex technical tasks with high
precision while maintaining a strong focus on maintainability and performance.
### Operational Guidelines
1. Before writing code, describe your plan. If the task is complex, break it
down into atomic steps.
2. Be concise and autonomous.
3. Do **not** touch unrelated modules unless the task explicitly requires it.
4. Commit only when explicitly asked. Follow the commit format rules in
`CONTRIBUTING.md`.
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default.
## 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:
| Directory | Language | Purpose | Has `AGENTS.md` |
|-----------|----------|---------|:----------------:|
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes |
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes |
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes |
| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes |
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No |
| `mcp/` | TypeScript | Model Context Protocol integration | No |
| `plugins/` | TypeScript | Plugin runtime and example plugins | No |
Some submodules use `pnpm` workspaces. The root `package.json` and
`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`.
### Module Dependency Graph
```
frontend ──> common
backend ──> common
exporter ──> common
frontend ──> render-wasm (loads compiled WASM)
```
`common` is referenced as a local dependency (`{:local/root "../common"}`) by
both `frontend` and `backend`. Changes to `common` can therefore affect multiple
modules — test across consumers when modifying shared code.

View File

@ -1,309 +1,13 @@
# CHANGELOG
## 2.17.0 (Unreleased)
## 2.13.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :sparkles: New features & Enhancements
- Add "Delete group" option to the assets panel context menu for components, colors and typographies (by @FairyPigDev) [Github #9141](https://github.com/penpot/penpot/issues/9141)
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree rooted at that layer in the Layers sidebar; symmetric with the existing `Shift+click` collapse-all gesture, and removes the O(siblings × depth) click cost of unfolding a deep subtree one level at a time [Github #7736](https://github.com/penpot/penpot/issues/7736)
- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328)
- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987)
- Add loader feedback while importing and exporting files [Github #9020](https://github.com/penpot/penpot/issues/9020)
- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912)
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
- Import Tokens from linked library (by @dfelinto) [Github #8391](https://github.com/penpot/penpot/pull/8391)
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/8498)
- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137)
- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653)
- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568)
- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713)
- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466)
- Add CSS linter [Taiga #13790](https://tree.taiga.io/project/penpot/us/13790)
- Save and restore selection state in undo/redo (by @eureka0928) [Github #6007](https://github.com/penpot/penpot/issues/6007)
- Fix warnings for unsupported token $type (by @Dexterity104) [Github #8790](https://github.com/penpot/penpot/issues/8790)
- Add per-group add button for typographies (by @eureka0928) [Github #5275](https://github.com/penpot/penpot/issues/5275)
- Add Find & Replace for text content and layer names (by @statxc) [Github #7108](https://github.com/penpot/penpot/issues/7108)
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [Github #8773](https://github.com/penpot/penpot/issues/8773)
- Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602)
- Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438)
- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572)
- Add Paste to replace (Cmd+Shift+V) to replace the selected shape with clipboard contents (by @eureka0928) [Github #4240](https://github.com/penpot/penpot/issues/4240)
- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794)
- Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358)
- Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647)
- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [Github #2910](https://github.com/penpot/penpot/issues/2910)
- Add customizable colors for ruler guides (by @Dexterity104) [Github #5199](https://github.com/penpot/penpot/issues/5199)
- Persist asset search query and section filter when switching sidebar tabs (by @eureka0928) [Github #2913](https://github.com/penpot/penpot/issues/2913)
- Add delete and duplicate buttons to typography dialog (by @eureka0928) [Github #5270](https://github.com/penpot/penpot/issues/5270)
- Edit ruler guide position by double-clicking the guide pill (by @eureka0928) [Github #2311](https://github.com/penpot/penpot/issues/2311)
- Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653)
- Add a search bar to filter board size presets (by @eureka0928) [Github #4658](https://github.com/penpot/penpot/issues/4658)
- Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027)
- Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806)
- Preserve vector content when pasting from external tools such as Inkscape: recognise SVG sent as text/plain (with optional XML declaration and HTML comments), skip the raster preview when an SVG sibling is on the clipboard, and ignore empty SVG blobs that some tools advertise alongside the real payload, so pasted graphics arrive editable without spurious "SVG is invalid" warnings [Github #546](https://github.com/penpot/penpot/issues/546)
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457)
- Adds a **Pixel grid color** picker in the viewport settings, next to the existing canvas color control [Github #7750](https://github.com/penpot/penpot/issues/7750)
- Add HEX, HSB and HSL support to the third color tab via an inline model switcher: relabel the existing HSVA tab as HSBA (the math was already HSB-equivalent), add an HSB ↔ HSL pill toggle that updates input labels, slider gradients and round-trip values without changing how colors are stored, and persist the chosen model across sessions [Github #9133](https://github.com/penpot/penpot/issues/9133)
- Show specific invitation-link error messages instead of a single generic "Invite invalid" page: distinguish expired invitations, email-mismatch (signed in with the wrong account) and corrupted/invalid tokens, each with an actionable recovery hint [Github #9220](https://github.com/penpot/penpot/issues/9220)
- Show detailed messages on file import errors to help diagnose why a file could not be imported (by @jsdevninja) [Github #9004](https://github.com/penpot/penpot/issues/9004)
- Add read-only preview mode for saved versions — click a version name to open a dedicated preview view (by @wdeveloper16) [Github #8976](https://github.com/penpot/penpot/issues/8976)
- Add clipboard read/write permissions to the plugin system (by @wdeveloper16) [Github #9053](https://github.com/penpot/penpot/issues/9053)
### :bug: Bugs fixed
- Fix layers-panel rename input opening with the type-based default (e.g. "Ellipse") instead of the user's saved name when re-entering edit mode on a previously renamed layer; the silent revert could overwrite the saved name on confirm. The `default-value` `mf/with-memo` was missing `shape-name` from its dependency list, so once the memo cached the original default it never refreshed. Adds `shape-name` to the deps and force-syncs the input's DOM value on every entry into edit mode [Github #9230](https://github.com/penpot/penpot/issues/9230)
- Suppress the browser context menu when right-clicking empty space in the workspace sidebars while preserving it on text inputs so paste/select-all still work [Github #5127](https://github.com/penpot/penpot/issues/5127)
- Fix release notes modal appearing behind the dashboard sidebar [Github #8296](https://github.com/penpot/penpot/issues/8296)
- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure [Github #9092](https://github.com/penpot/penpot/issues/9092)
- Fix imported stroke-only SVG paths losing their rounded join when authoring tools (e.g. Figma → Heroicons) split a continuous polyline into adjacent `M…L M…L` subpaths sharing an endpoint; on import these are now folded back into one chain so `stroke-linejoin` renders the elbow correctly in both editor and exports [Github #5283](https://github.com/penpot/penpot/issues/5283)
- Fix plugin API `library.connectLibrary()` returning a non-Promise (or throwing synchronously) when the plugin lacks `library:write` permission — the method now always returns a `Promise` and rejects with a structured error message, matching the contract used by every other Promise-returning plugin method (`restore`, `remove`, `pin`, `saveVersion`, `findVersions`, …)
- Fix LDAP provider params schema typo (`bind-passwor``bind-password`) introduced during the `clojure.spec``malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide``:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
- Fix Plugin API `shape.applyToken()` / `token.applyToShapes()` / `token.applyToSelected()` rejecting JS-array attribute lists like `["fill"]`: switched the inner schemas to `[::sm/set ...]` (which has the JS array → Clojure set decoder) and made `token-attr-plugin->token-attr` accept string inputs by coercing them to keywords before consulting the alias map [Github #9162](https://github.com/penpot/penpot/issues/9162)
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored (`userinfo` / `token`) in the OIDC callback, causing "incomplete user info" failures during registration [Github #9108](https://github.com/penpot/penpot/issues/9108)
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled [Github #8877](https://github.com/penpot/penpot/issues/8877)
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
- Preserve OpenType variant name table for custom fonts in the dashboard [Github #8924](https://github.com/penpot/penpot/issues/8924)
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [Github #8577](https://github.com/penpot/penpot/issues/8577)
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
- Fix hyphens stripped from export filenames (by @jamesrayammons) [Github #8901](https://github.com/penpot/penpot/issues/8901)
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
- Fix non-functional clear icon in change email modal inputs (by @Dexterity104) [Github #8977](https://github.com/penpot/penpot/issues/8977)
- Disable save button after saving account profile settings (by @Dexterity104) [Github #8979](https://github.com/penpot/penpot/issues/8979)
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
- Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067)
- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516)
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [Github #9137](https://github.com/penpot/penpot/issues/9137)
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
- Fix restore-deleted-team-files failing due to a typo in the reduce accumulator (by @Dexterity104) [Github #9241](https://github.com/penpot/penpot/issues/9241)
- Fix internal error on layer prev/next sibling selection (by @jsdevninja) [Github #9003](https://github.com/penpot/penpot/issues/9003)
- Fix tooltip appearing two times when nested elements [Github #9031](https://github.com/penpot/penpot/issues/9031)
- Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070)
## 2.16.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :sparkles: New features & Enhancements
- Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714)
### :bug: Bugs fixed
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527)
- Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627)
- Fix title on shared button [Taiga #13730](https://tree.taiga.io/project/penpot/issue/13730)
- Fix hover on layers [Taiga #13799](https://tree.taiga.io/project/penpot/issue/13799)
- Fix highlight after name edition [Taiga #13783](https://tree.taiga.io/project/penpot/issue/13783)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- 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 color dropdown option update [Taiga #14035](https://tree.taiga.io/project/penpot/issue/14035)
- Fix themes modal height [Taiga #14046](https://tree.taiga.io/project/penpot/issue/14046)
## 2.15.0 (Unreleased)
### :sparkles: New features & Enhancements
- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
### :bug: Bugs fixed
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
## 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 component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- 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
- Add protection for stale JS asset cache to force reload on version mismatch [Github #8638](https://github.com/penpot/penpot/pull/8638)
- Normalize newsletter opt-in checkbox across different register flows [Github #8839](https://github.com/penpot/penpot/pull/8839)
### :bug: Bugs fixed
- Fix PathData corruption root causes across WASM and CLJS (unsafe transmute and byteOffset handling)
- Handle corrupted PathData segments gracefully instead of crashing
- Fix swapped move-to/line-to type codes in PathData binary readers
- Fix non-integer row/column values in grid cell position inputs [Github #8869](https://github.com/penpot/penpot/pull/8869)
- Fix nil path content crash by exposing safe public API [Github #8806](https://github.com/penpot/penpot/pull/8806)
- Fix infinite recursion in get-frame-ids for thumbnail extraction [Github #8807](https://github.com/penpot/penpot/pull/8807)
- Fix stale-asset detector missing protocol-dispatch errors
- Ignore Zone.js toString TypeError in uncaught error handler [Github #8804](https://github.com/penpot/penpot/pull/8804)
- Prevent thumbnail frame recursion overflow [Github #8763](https://github.com/penpot/penpot/pull/8763)
- Fix vector index out of bounds in viewer zoom-to-fit/fill [Github #8834](https://github.com/penpot/penpot/pull/8834)
- Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858)
- Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803)
## 2.14.1
### :sparkles: New features & Enhancements
- Add automatic retry with backoff for idempotent RPC requests on network failures [Github #8792](https://github.com/penpot/penpot/pull/8792)
- Add scroll and zoom throttling to one state update per animation frame [Github #8812](https://github.com/penpot/penpot/pull/8812)
- Improve error handling and exception formatting [Github #8757](https://github.com/penpot/penpot/pull/8757)
### :bug: Bugs fixed
- Fix crash in apply-text-modifier with nil selrect or modifier [Github #8762](https://github.com/penpot/penpot/pull/8762)
- Fix incorrect attrs references on generate-sync-shape [Github #8776](https://github.com/penpot/penpot/pull/8776)
- Fix regression on subpath support [Github #8793](https://github.com/penpot/penpot/pull/8793)
- Improve error reporting on request parsing failures [Github #8805](https://github.com/penpot/penpot/pull/8805)
- Fix fetch abort errors escaping the unhandled exception handler [Github #8801](https://github.com/penpot/penpot/pull/8801)
- Fix nil deref on missing bounds in layout modifier propagation [Github #8735](https://github.com/penpot/penpot/pull/8735)
- Fix TypeError when token error map lacks :error/fn key [Github #8767](https://github.com/penpot/penpot/pull/8767)
- Fix dissoc error when detaching stroke color from library [Github #8738](https://github.com/penpot/penpot/pull/8738)
- Fix crash when pasting image into text editor
- Fix null text crash on paste in text editor
- Ensure path content is always PathData when saving
- Fix error when get-parent-with-data encounters non-Element nodes
## 2.14.0
### :boom: Breaking changes & Deprecations
- Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`.
### :sparkles: New features & Enhancements
- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990)
- Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202)
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
### :bug: Bugs fixed
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
- Fix prototype connections lost when switching between variants [Taiga #12812](https://tree.taiga.io/project/penpot/issue/12812)
- 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)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
- Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661)
- Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569)
- Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578)
- Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623)
- Fix crash when pasting non-map transit clipboard data [Github #8580](https://github.com/penpot/penpot/pull/8580)
- Fix `penpot.openPage()` plugin API not navigating in the same tab; change default to same-tab navigation and allow passing a UUID string instead of a Page object [Github #8520](https://github.com/penpot/penpot/issues/8520)
## 2.13.3
### :bug: Bugs fixed
- Revert yetti (http server) update, because that caused a regression on multipart uploads
## 2.13.2
### :bug: Bugs fixed
- Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317)
- Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)
## 2.13.1
### :bug: Bugs fixed
- Fix PDF Exporter outputs empty page when board has A4 format [Taiga #13181](https://tree.taiga.io/project/penpot/issue/13181)
## 2.13.0
### :heart: Community contributions (Thank you!)
- Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689)
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
### :sparkles: New features & Enhancements
@ -319,26 +23,13 @@
- Fix wrong board size presets in Android [Taiga #12339](https://tree.taiga.io/project/penpot/issue/12339)
- Fix problem with grid layout components and auto sizing [Github #7797](https://github.com/penpot/penpot/issues/7797)
- Fix some alignments on inspect tab [Taiga #12915](https://tree.taiga.io/project/penpot/issue/12915)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
- Fix color assets from shared libraries not appearing as assets in Selected colors panel [Taiga #12957](https://tree.taiga.io/project/penpot/issue/12957)
- Fix CSS generated box-shadow property [Taiga #12997](https://tree.taiga.io/project/penpot/issue/12997)
- Fix inner shadow selector on shadow token [Taiga #12951](https://tree.taiga.io/project/penpot/issue/12951)
- 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)
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
## 2.12.1
@ -348,6 +39,7 @@
- Fix problem with style in fonts input [Taiga #12935](https://tree.taiga.io/project/penpot/issue/12935)
- Fix problem with path editor and right click [Github #7917](https://github.com/penpot/penpot/issues/7917)
## 2.12.0
### :boom: Breaking changes & Deprecations
@ -359,6 +51,7 @@ The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
compatibility; however, if you are a user of this API, it is strongly
recommended that you adapt your code to use the new PATH.
#### Updated SSO Callback URL
The OAuth / Single Sign-On (SSO) callback endpoint has changed to
@ -391,6 +84,7 @@ This update standardizes all authentication flows under the single URL
and makis it more modular, enabling the ability to configure SSO auth
provider dinamically.
#### Changes on default docker compose
We have updated the `docker/images/docker-compose.yaml` with a small
@ -454,6 +148,7 @@ example. It's still usable as before, we just removed the example.
- Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be
removed in future versions:
- The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its
values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`)
- The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY`

View File

@ -1,292 +1,213 @@
# Contributing Guide
# Contributing Guide #
Thank you for your interest in contributing to Penpot. This guide covers
how to propose changes, submit fixes, and follow project conventions.
Thank you for your interest in contributing to Penpot. This is a
generic guide that details how to contribute to the project in a way that
is efficient for everyone. If you are looking for specific documentation on
different parts of the platform, please refer to the `docs/` directory,
or the rendered version at the [Help Center](https://help.penpot.app/).
For architecture details, module-specific guidelines, and AI-agent
instructions, see [AGENTS.md](AGENTS.md). For final user technical
documentation, see the `docs/` directory or the rendered [Help
Center](https://help.penpot.app/).
## Reporting Bugs ##
## Table of Contents
We are using [GitHub Issues](https://github.com/penpot/penpot/issues)
for our public bugs. We keep a close eye on them and try to make it
clear when we have an internal fix in progress. Before filing a new
task, try to make sure your problem doesn't already exist.
- [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)
- [Developer's Certificate of Origin (DCO)](#developers-certificate-of-origin-dco)
If you found a bug, please report it, as far as possible, with:
## Prerequisites
- a detailed explanation of steps to reproduce the error
- the browser and browser version used
- a dev tools console exception stack trace (if available)
- **Language**: Penpot is written primarily in Clojure (backend), ClojureScript
(frontend/exporter), and Rust (render-wasm). Familiarity with the Clojure
ecosystem is expected for most contributions.
- **Issue tracker**: We use [GitHub Issues](https://github.com/penpot/penpot/issues)
for public bugs and [Taiga](https://tree.taiga.io/project/penpot/) for
internal project management. Changelog entries reference both.
If you found a bug which you think is better to discuss in private (for
example, security bugs), consider first sending an email to
`support@penpot.app`.
## Reporting Bugs
**We don't have a formal bug bounty program for security reports; this
is an open source application, and your contribution will be recognized
in the changelog.**
Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues).
Before filing, search existing issues to avoid duplicates.
Include the following when possible:
## Pull Requests ##
1. Steps to reproduce the error.
2. Browser and browser version used.
3. DevTools console exception stack trace (if available).
If you want to propose a change or bug fix via a pull request (PR),
you should first carefully read the section **Developer's Certificate of
Origin**. You must also format your code and commits according to the
instructions below.
For security bugs or issues better discussed in private, email
`support@penpot.app` or report them on [Github Security
Advisories](https://github.com/penpot/penpot/security/advisories)
If you intend to fix a bug, it's fine to submit a pull request right
away, but we still recommend filing an issue detailing what you're
fixing. This is helpful in case we don't accept that specific fix but
want to keep track of the issue.
> **Note:** We do not have a formal bug bounty program. Security
> contributions are recognized in the changelog.
If you want to implement or start working on a new feature, please
open a **question*- / **discussion*- issue for it. No PR
will be accepted without a prior discussion about the changes,
whether it is a new feature, an already planned one, or a quick win.
## Pull Requests
If it is your first PR, you can learn how to proceed from
[this free video
series](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github)
### Workflow
We use the `easy fix` tag to indicate issues that are appropriate for beginners.
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 [GitHub
Issue](https://github.com/penpot/penpot/issues) before starting work on
a new feature or significant change. For planned features on the roadmap,
reference the corresponding Taiga story. Do not expect your contribution
to be accepted if you submit it without prior discussion — this applies
to new features, planned features, and quick wins alike.
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.
## Commit Guidelines ##
### Title format
We have very precise rules on how our git commit messages must be formatted.
Pull request titles **must** follow the same convention as commit subjects:
The commit message format is:
```
: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
- We are a small team and maintainers juggle reviews alongside other
tasks. Please do not expect your code to be reviewed instantly.
- Reviews are handled in dedicated blocks of time, usually in the order
PRs arrive. It may take a few days to get a first review, especially
when urgent tasks come up.
- 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.
## Commit Guidelines
Commit messages must follow this format:
```
:emoji: <subject>
<type> <subject>
[body]
[footer]
```
### Commit types
Where type is:
| Emoji | Description |
|-------|-------------|
| :bug: | Bug fix |
| :sparkles: | Improvement or enhancement |
| :tada: | New feature |
| :recycle: | Refactor |
| :lipstick: | Cosmetic changes |
| :ambulance: | Critical bug fix |
| :books: | Documentation |
| :construction: | Work in progress |
| :boom: | Breaking change |
| :wrench: | Configuration update |
| :zap: | Performance improvement |
| :whale: | Docker-related change |
| :paperclip: | Other non-relevant changes |
| :arrow_up: | Dependency update |
| :arrow_down: | Dependency downgrade |
| :fire: | Removal of code or files |
| :globe_with_meridians: | Add or update translations |
| :rocket: | Epic or highlight |
- :bug: `:bug:` a commit that fixes a bug
- :sparkles: `:sparkles:` a commit that adds an improvement
- :tada: `:tada:` a commit with a new feature
- :recycle: `:recycle:` a commit that introduces a refactor
- :lipstick: `:lipstick:` a commit with cosmetic changes
- :ambulance: `:ambulance:` a commit that fixes a critical bug
- :books: `:books:` a commit that improves or adds documentation
- :construction: `:construction:` a WIP commit
- :boom: `:boom:` a commit with breaking changes
- :wrench: `:wrench:` a commit for config updates
- :zap: `:zap:` a commit with performance improvements
- :whale: `:whale:` a commit for Docker-related stuff
- :paperclip: `:paperclip:` a commit with other non-relevant changes
- :arrow_up: `:arrow_up:` a commit with dependency updates
- :arrow_down: `:arrow_down:` a commit with dependency downgrades
- :fire: `:fire:` a commit that removes files or code
- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates
translations
### Rules
More info:
- Use the **imperative mood** in the subject (e.g. "Fix", not "Fixed")
- Capitalize the first letter of the subject
- Add clear and concise description on the body
- Do not end the subject with a period
- Keep the subject to **70 characters** or fewer
- Separate the subject from the body with a **blank line**
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
- https://gist.github.com/rxaviers/7360908
### Examples
Each commit should have:
```
:bug: Fix unexpected error on launching modal
:sparkles: Enable new modal for profile
:zap: Improve performance of dashboard navigation
:ambulance: Fix critical bug on user registration process
:tada: Add new approach for user registration
```
- A concise subject using the imperative mood.
- The subject should capitalize the first letter, omit the period
at the end, and be no longer than 65 characters.
- A blank line between the subject line and the body.
- An entry in the CHANGES.md file if applicable, referencing the
GitHub or Taiga issue/user story using these same rules.
## Formatting and Linting
Examples of good commit messages:
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
- `:bug: Fix unexpected error on launching modal`
- `:bug: Set proper error message on generic error`
- `:sparkles: Enable new modal for profile`
- `:zap: Improve performance of dashboard navigation`
- `:wrench: Update default backend configuration`
- `:books: Add more documentation for authentication process`
- `:ambulance: Fix critical bug on user registration process`
- `:tada: Add new approach for user registration`
## Formatting and Linting ##
You will want to make sure your code is formatted and linted before submitting
a PR. We use [cljfmt](https://github.com/weavejester/cljfmt) and
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for this. After installing
them on your system, you can run them with:
```bash
# Check formatting (does not modify files)
./scripts/check-fmt
# Check formatting
yarn fmt:clj:check
# Fix formatting (modifies files in place)
./scripts/fmt
# Check and fix formatting
yarn fmt:clj
# Lint
./scripts/lint
# Run the linter
yarn lint:clj
```
Ideally, run these as git pre-commit hooks.
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
setting this up.
There are more choices in `package.json`.
## Changelog
Ideally, you should run these commands as git pre-commit hooks. A convenient way
of defining them is to use [Husky](https://typicode.github.io/husky/#/).
When your change is user-facing or otherwise notable, add an entry to
[CHANGES.md](CHANGES.md) following the same commit-type conventions. Reference
the relevant GitHub issue or Taiga user story.
## Code of Conduct ##
## Code of Conduct
As contributors and maintainers of this project, we pledge to respect
all people who contribute through reporting issues, posting feature
requests, updating documentation, submitting pull requests or patches,
and other activities.
This project follows the [Contributor Covenant](https://www.contributor-covenant.org/).
The full Code of Conduct is available at
[help.penpot.app/contributing-guide/coc](https://help.penpot.app/contributing-guide/coc/)
and in the repository's [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
We are committed to making participation in this project a
harassment-free experience for everyone, regardless of level of
experience, gender, gender identity and expression, sexual
orientation, disability, personal appearance, body size, race,
ethnicity, age, or religion.
To report unacceptable behavior, open an issue or contact a project maintainer
directly.
Examples of unacceptable behavior by participants include the use of
sexual language or imagery, derogatory comments or personal attacks,
trolling, public or private harassment, insults, or other
unprofessional conduct.
Project maintainers have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned with this Code of Conduct. Project
maintainers who do not follow the Code of Conduct may be removed from
the project team.
This Code of Conduct applies both within project spaces and in public
spaces when an individual is representing the project or its
community.
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported by opening an issue or contacting one or more of the
project maintainers.
This Code of Conduct is adapted from the Contributor Covenant, version
1.1.0, available from [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/)
## Developer's Certificate of Origin (DCO)
By submitting code you agree to and can certify the following:
> **Developer's Certificate of Origin 1.1**
>
> By making a contribution to this project, I certify that:
>
> (a) The contribution was created in whole or in part by me and I have the
> right to submit it under the open source license indicated in the file; or
>
> (b) The contribution is based upon previous work that, to the best of my
> knowledge, is covered under an appropriate open source license and I have
> the right under that license to submit that work with modifications,
> whether created in whole or in part by me, under the same open source
> license (unless I am permitted to submit under a different license), as
> indicated in the file; or
>
> (c) The contribution was provided directly to me by some other person who
> certified (a), (b) or (c) and I have not modified it.
>
> (d) I understand and agree that this project and the contribution are public
> and that a record of the contribution (including all personal information
> I submit with it, including my sign-off) is maintained indefinitely and
> may be redistributed consistent with this project or the open source
> license(s) involved.
Developer's Certificate of Origin 1.1
### Signed-off-by
By making a contribution to this project, I certify that:
All code patches (**documentation is excluded**) must contain a sign-off line
at the end of the commit body. Add it automatically with `git commit -s`.
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Then, all your code patches (**documentation is excluded**) should
contain a sign-off at the end of the patch/commit description body. It
can be automatically added by adding the `-s` parameter to `git commit`.
This is an example of what the line should look like:
```
Signed-off-by: Your Real Name <your.email@example.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
```
- Use your **real name** — pseudonyms and anonymous contributions are not
allowed.
- The `Signed-off-by` line is **mandatory** and must match the commit author.
Please, use your real name (sorry, no pseudonyms or anonymous
contributions are allowed).

138
README.md
View File

@ -1,56 +1,53 @@
<img width="100%" src="https://github.com/user-attachments/assets/da17b160-f289-436f-b140-972083a08602" />
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://penpot.app/images/readme/github-dark-mode.png">
<source media="(prefers-color-scheme: light)" srcset="https://penpot.app/images/readme/github-light-mode.png">
<img alt="penpot header image" src="https://penpot.app/images/readme/github-light-mode.png">
</picture>
<p align="center">
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg">
</a>
<a href="https://community.penpot.app" rel="nofollow">
<img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app">
</a>
<a href="https://tree.taiga.io/project/penpot/" rel="nofollow">
<img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg">
</a>
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow">
<img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod">
</a>
<a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
<a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a>
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
</p>
<p align="center">
<a href="https://penpot.app/"><b>Website</b></a>
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a>
<a href="https://penpot.app/learning-center"><b>Learning Center</b></a>
<a href="https://community.penpot.app/"><b>Community</b></a>
<a href="https://penpot.app/"><b>Website</b></a>
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a>
<a href="https://penpot.app/learning-center"><b>Learning Center</b></a>
<a href="https://community.penpot.app/"><b>Community</b></a>
</p>
<p align="center">
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a>
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a>
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a>
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a>
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a>
<a href="https://bsky.app/profile/penpot.app"><b>Bluesky</b></a>
<a href="https://twitter.com/penpotapp"><b>X</b></a>
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a>
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a>
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a>
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a>
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a>
<a href="https://bsky.app/profile/penpot.app"><b>Bluesky</b></a>
<a href="https://twitter.com/penpotapp"><b>X</b></a>
</p>
[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332)
<br />
Penpot is the open-source design platform for teams that build digital products at scale.
[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332
)
Penpots key strength lies in giving you **full ownership of your design infrastructure**. Built on open source and designed for [self-hosting](https://help.penpot.app/technical-guide/getting-started/), it puts teams in complete control of their design environment supporting strict compliance and governance requirements. Whether used in the **browser or deployed on your own servers**, Penpot **works with open standards** like SVG, CSS, HTML, and JSON.
<br />
Real-time collaboration strengthens this foundation, helping teams scale and bring design closer to the product through top-tier capabilities. Additionally, developers feel at home using Penpot, because design is expressed as code, enabling a direct translation and shipping products faster.
Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama.
Best-in-class native [Design Tokens](https://penpot.dev/collaboration/design-tokens) provide a single source of truth between design and development. They ensure consistency, improve collaboration, and make it easier to manage complex design systems.
Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and its free!
The [MCP server](https://penpot.app/penpot-mcp-server) takes it further by enabling multi-directional workflows between design and code. A [powerful open API](https://help.penpot.app/mcp/#quick-start) and plugin system makes the workspace programmable, enabling automation, AI-driven workflows, and integrations with the tools and systems you already use.
The latest updates take Penpot even further. Its the first design tool to integrate native [design tokens](https://penpot.dev/collaboration/design-tokens)—a single source of truth to improve efficiency and collaboration between product design and development.
With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more.
For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us)
With [CSS Grid and Flex Layout](https://help.penpot.app/user-guide/designing/flexible-layouts/), teams can design responsive interfaces that behave like real code from the start.
Combined, these features turn Penpot into a **full-stack design platform** for building scalable design systems and fully integrated product development processes.
If your organization is scaling and needs extra support, were here to help. [Talk to us](https://penpot.app/talk-to-us)
🎇 Design, code, and Open Source meet at [Penpot Fest](https://penpot.app/penpotfest)! Be part of the 2025 edition in Madrid, Spain, on October 9-10.
## Table of contents ##
@ -63,78 +60,101 @@ If your organization is scaling and needs extra support, were here to help. [
## Why Penpot ##
Penpot connects design, code, and AI workflows through a code-based approach, making designs readable by developers and AI via the MCP server. This approach helps teams ship whats actually designed and manage design systems at scale with powerful design tokens. As a self-hosted, open-source and real-time collaboration platform, Penpot offers full flexibility, security, and ownership without vendor lock-in. Learn more about [why Penpot](https://penpot.app/why-penpot) is the platform for your team.
Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration.
### Plugin system ###
[Penpot plugins](https://penpot.app/penpothub/plugins) let you expand the platform's capabilities, give you the flexibility to integrate it with other apps, and design custom solutions.
### Designed for developers ###
Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo".
### Inspect mode ###
Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code.
### Self host your own instance ###
Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server.
### Integrations ###
Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
Penpot offers [integration](https://penpot.app/integrations-api) into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
### Building Design Systems: design tokens, components and variants ###
Penpot brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms.
### Building Design Systems: design tokens, components and variants ###
Penpot brings [design systems](https://penpot.app/design/design-systems) to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms.
<br />
<img width="100%" alt="Penpot Design Systems" src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
<p align="center">
<img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
</p>
<br />
## Getting started ##
Penpot is the only design & prototype platform that is deployment agnostic. You can use it in our [SAAS](https://design.penpot.app) or deploy it anywhere.
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
<br />
<p align="center">
<img src="https://site-assets.plasmic.app/2168cf524dd543caeff32384eb9ea0a1.svg" alt="Open Source" style="width: 65%;">
</p>
<br />
## Community ##
We love the Open Source software community. Contributing is our passion and if its yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
Want to go a step further? Become a [Penpot Ambassador](https://penpot.app/ambassador-program) and help grow the Penpot community in your region while contributing to a global, open design ecosystem.
If you need help or have any questions; if youd like to share your experience using Penpot or get inspired; if youd rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)!
Categories include:
You will find the following categories:
- [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6)
- [Troubleshooting](https://community.penpot.app/c/technical/8)
- [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7)
- [#MadeWithPenpot](https://community.penpot.app/c/madewithpenpot/9)
- [Events and Announcements](https://community.penpot.app/c/announcements/5)
- [Inside Penpot](https://community.penpot.app/c/inside-penpot/21)
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
- [Education](https://community.penpot.app/c/education/28)
- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22)
<img width="100%" alt="Pentpot Community" src="https://github.com/user-attachments/assets/4b2a4360-12b5-4994-bd45-641449f86c4e" />
<br />
<p align="center">
<img src="https://github.com/penpot/penpot/assets/5446186/6ac62220-a16c-46c9-ab21-d24ae357ed03" alt="Community" style="width: 65%;">
</p>
<br />
### Code of Conduct ###
Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the
[code of conduct](https://help.penpot.app/contributing-guide/coc/) and foster a positive and safe environment.
### Contributing ###
## Contributing ##
Any contribution will make a difference to improve Penpot. How can you get involved?
Choose your way:
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community.
- Invite your [team to join](https://design.penpot.app/#/auth/register).
- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app).
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community
- Invite your [team to join](https://design.penpot.app/#/auth/register)
- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app)
- Participate in the [Community](https://community.penpot.app/) space by asking and answering questions; reacting to others articles; opening your own conversations and following along on decisions affecting the project.
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues).
- Become a [translator](https://help.penpot.app/contributing-guide/translations).
- Give feedback: [Email us](mailto:support@penpot.app).
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpots repository and make changes in both front and back end.
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues)
- Become a [translator](https://help.penpot.app/contributing-guide/translations)
- Give feedback: [Email us](mailto:support@penpot.app)
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpots repository and make changes in both front and back end
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/).
<img width="100%" alt="Penpot hub" src="https://github.com/user-attachments/assets/0abc02f0-625c-45ab-ad81-4927bec7a055" />
<br />
<p align="center">
<img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
</p>
<br />
## Resources ##
@ -150,8 +170,6 @@ You can ask and answer questions, have open-ended conversations, and follow alon
📚 [Dev Diaries](https://penpot.app/dev-diaries.html)
🧑‍🏫​ [UI Design Course](https://penpot.app/courses/)
## License ##

View File

@ -2,30 +2,4 @@
## Reporting a Vulnerability
We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities via email to: **[support@penpot.app]**
### What to include:
* A brief description of the vulnerability.
* Steps to reproduce the issue.
* Potential impact if exploited.
We appreciate your patience and your commitment to **responsible disclosure**.
---
## Security Contributors
We are incredibly grateful to the following individuals and
organizations for their help in keeping this project safe.
* **Ali Maharramli** for identifying critical path traversal vulnerability
> **Note:** This list is a work in progress. If you have contributed
> to the security of this project and would like to be recognized (or
> prefer to remain anonymous), please let us know.
Please report security issues to `support@penpot.app`

7
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -1,259 +0,0 @@
# Penpot Backend Agent Instructions
Clojure backend (RPC) service running on the JVM.
Uses Integrant for dependency injection, PostgreSQL for storage, and
Redis for messaging/caching.
## General Guidelines
To ensure consistency across the Penpot JVM stack, all contributions must adhere
to these criteria:
### 1. Testing & Validation
* **Coverage:** If code is added or modified in `src/`, corresponding
tests in `test/backend_tests/` must be added or updated.
* **Execution:**
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace.
* **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas.
### 2. Code Quality & Formatting
* **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`)
* **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.
* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in
performance-critical paths to avoid reflection overhead.
## Code Conventions
### Namespace Overview
The source is located under `src` directory and this is a general overview of
namespaces structure:
- `app.rpc.commands.*` RPC command implementations (`auth`, `files`, `teams`, etc.)
- `app.http.*` HTTP routes and middleware
- `app.db.*` Database layer
- `app.tasks.*` Background job tasks
- `app.main` Integrant system setup and entrypoint
- `app.loggers` Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`)
### RPC
The RPC methods are implemented using a multimethod-like structure via the
`app.util.services` namespace. The main RPC methods are collected under
`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`.
The RPC method accepts POST and GET requests indistinctly and uses the `Accept`
header to negotiate the response encoding (which can be Transit — the default —
or plain JSON). It also accepts Transit (default) or JSON as input, which should
be indicated using the `Content-Type` header.
The main convention is: use `get-` prefix on RPC name when we want READ
operation.
Example of RPC method definition:
```clojure
(sv/defmethod ::my-command
{::rpc/auth true ;; requires auth
::doc/added "1.18"
::sm/params [:map ...] ;; malli input schema
::sm/result [:map ...]} ;; malli output schema
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
;; return a plain map or throw
{:id (uuid/next)})
```
Look under `src/app/rpc/commands/*.clj` to see more examples.
### Tests
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
### Integrant System
The `src/app/main.clj` declares the system map. Each key is a component; values
are config maps with `::ig/ref` for dependencies. Components implement
`ig/init-key` / `ig/halt-key!`.
### 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.
```clojure
;; Query helpers
(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing)
(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil)
(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update
(db/delete! cfg-or-pool :table {:id id}) ; delete
;; Run multiple statements/queries on single connection
(db/run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row1)
(db/insert! conn :table row2))
;; Transactions
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row)))
```
Almost all methods in the `app.db` namespace accept `pool`, `conn`, or
`cfg` as params.
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
### Error Handling
The exception helpers are defined on Common module, and are available under
`app.common.exceptions` namespace.
Example of raising an exception:
```clojure
(ex/raise :type :not-found
:code :object-not-found
:hint "File does not exist"
:file-id id)
```
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
### Performance Macros (`app.common.data.macros`)
Always prefer these macros over their `clojure.core` equivalents — they provide
optimized implementations:
```clojure
(dm/select-keys m [:a :b]) ;; faster than core/select-keys
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
(dm/str "a" "b" "c") ;; string concatenation
```
### Configuration
`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"
```

View File

@ -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.2"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
@ -28,8 +28,8 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti
{:git/tag "v11.9"
:git/sha "5fad7a9"
{:git/tag "v11.8"
:git/sha "1d1b33f"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
@ -39,7 +39,7 @@
metosin/reitit-core {:mvn/version "0.9.1"}
nrepl/nrepl {:mvn/version "1.4.0"}
org.postgresql/postgresql {:mvn/version "42.7.9"}
org.postgresql/postgresql {:mvn/version "42.7.7"}
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.2"}
org.jsoup/jsoup {:mvn/version "1.21.2"}
org.im4java/im4java
@ -66,7 +66,7 @@
;; 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.33.10"}}
:paths ["src" "resources" "target/classes"]
:aliases

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@ -19,9 +19,8 @@
"ws": "^8.17.0"
},
"scripts": {
"lint": "clj-kondo --parallel --lint ../common/src src/",
"check-fmt": "cljfmt check --parallel=true src/ test/",
"fmt": "cljfmt fix --parallel=true src/ test/",
"test": "clojure -M:dev:test"
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel --lint src/"
}
}

306
backend/pnpm-lock.yaml generated
View File

@ -1,306 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
luxon:
specifier: ^3.4.4
version: 3.7.2
sax:
specifier: ^1.4.1
version: 1.4.3
devDependencies:
nodemon:
specifier: ^3.1.2
version: 3.1.11
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.17.0
version: 8.18.3
packages:
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
engines: {node: '>=10'}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sax@1.4.3:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
touch@3.1.1:
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
hasBin: true
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
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
snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
braces@3.0.3:
dependencies:
fill-range: 7.1.1
buffer-from@1.1.2: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
concat-map@0.0.1: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 5.5.0
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
fsevents@2.3.3:
optional: true
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
has-flag@3.0.0: {}
ignore-by-default@1.0.1: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
luxon@3.7.2: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
ms@2.1.3: {}
nodemon@3.1.11:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.7.3
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
undefsafe: 2.0.5
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
sax@1.4.3: {}
semver@7.7.3: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
touch@3.1.1: {}
undefsafe@2.0.5: {}
ws@8.18.3: {}

View File

@ -1,264 +0,0 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<b>{{invited-by|abbreviate:25}}</b> sent you an invitation to join the organization:
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
background="{{organization-logo}}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
{% if organization-initials %}{{organization-initials}}{% endif %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
“{{ organization-name|abbreviate:25 }}”
</span>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> ACCEPT INVITE </a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -1 +0,0 @@
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”

View File

@ -1,10 +0,0 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”.
Accept invitation using this link:
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The Penpot team.

View File

@ -186,8 +186,7 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
</td>
</tr>
<tr>

View File

@ -1,6 +1,6 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.
Accept invitation using this link:

View File

@ -5,6 +5,7 @@
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
<style>
{% include "app/templates/styles.css" %}
</style>

View File

@ -12,22 +12,43 @@ Debug Main Page
</nav>
<main class="dashboard">
<section class="widget">
<fieldset>
<legend>Error reports</legend>
<desc><a href="/dbg/error">CLICK HERE TO SEE THE ERROR REPORTS</a> </desc>
</fieldset>
<fieldset>
<legend>CURRENT PROFILE</legend>
<desc>
<p>
Name: <b>{{profile.fullname}}</b> <br />
Email: <b>{{profile.email}}</b>
</p>
</desc>
<legend>Profile Management</legend>
<form method="post" action="/dbg/actions/resend-email-verification">
<div class="row">
<input type="email" name="email" placeholder="example@example.com" value="" />
</div>
<div class="row">
<label for="force-verify">Are you sure?</label>
<input id="force-verify" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" name="resend" value="Resend Verification" />
<input type="submit" name="verify" value="Verify" />
</div>
<div class="row">
<input type="submit" class="danger" name="block" value="Block" />
<input type="submit" class="danger" name="unblock" value="Unblock" />
</div>
</form>
</fieldset>
<fieldset>
<legend>VIRTUAL CLOCK</legend>
<desc>
<p><b>IMPORTANT:</b> The virtual clock is profile based and only affects the currently logged-in profile.</p>
<p>
CURRENT CLOCK: <b>{{current-clock}}</b>
<br />
@ -60,93 +81,8 @@ Debug Main Page
</form>
</fieldset>
<fieldset>
<legend>ERROR REPORTS</legend>
<desc><a href="/dbg/error">CLICK HERE TO SEE THE ERROR REPORTS</a> </desc>
</fieldset>
</section>
<section class="widget">
<fieldset>
<legend>Profile Management</legend>
<form method="post" action="/dbg/actions/resend-email-verification">
<div class="row">
<input type="email" name="email" placeholder="example@example.com" value="" />
</div>
<div class="row">
<label for="force-verify">Are you sure?</label>
<input id="force-verify" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" name="resend" value="Resend Verification" />
<input type="submit" name="verify" value="Verify" />
</div>
<div class="row">
<input type="submit" class="danger" name="block" value="Block" />
<input type="submit" class="danger" name="unblock" value="Unblock" />
</div>
</form>
</fieldset>
<fieldset>
<legend>Feature Flags for Team</legend>
<desc>Add a feature flag to a team</desc>
<form method="post" action="/dbg/actions/handle-team-features">
<div class="row">
<input type="text" style="width:300px" name="team-id" placeholder="team-id" />
</div>
<div class="row">
<select type="text" style="width:100px" name="feature">
{% for feature in supported-features %}
<option value="{{feature}}">{{feature}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<select style="width:100px" name="action">
<option value="">Action...</option>
<option value="show">Show</option>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
</select>
</div>
<div class="row">
<label for="check-feature">Skip feature check</label>
<input id="check-feature" type="checkbox" name="skip-check" />
<br />
<small>
Do not check if the feature is supported
</small>
</div>
<div class="row">
<label for="force-version">Are you sure?</label>
<input id="force-version" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" value="Submit" />
</div>
</form>
</fieldset>
</section>
<section class="widget">
<fieldset>
@ -237,5 +173,55 @@ Debug Main Page
</form>
</fieldset>
</section>
<section class="widget">
<fieldset>
<legend>Feature Flags for Team</legend>
<desc>Add a feature flag to a team</desc>
<form method="post" action="/dbg/actions/handle-team-features">
<div class="row">
<input type="text" style="width:300px" name="team-id" placeholder="team-id" />
</div>
<div class="row">
<select type="text" style="width:100px" name="feature">
{% for feature in supported-features %}
<option value="{{feature}}">{{feature}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<select style="width:100px" name="action">
<option value="">Action...</option>
<option value="show">Show</option>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
</select>
</div>
<div class="row">
<label for="check-feature">Skip feature check</label>
<input id="check-feature" type="checkbox" name="skip-check" />
<br />
<small>
Do not check if the feature is supported
</small>
</div>
<div class="row">
<label for="force-version">Are you sure?</label>
<input id="force-version" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" value="Submit" />
</div>
</form>
</fieldset>
</section>
</main>
{% endblock %}

View File

@ -5,26 +5,23 @@ penpot - error list
{% endblock %}
{% block content %}
<nav>
<div class="title">
<a href="/dbg"> [BACK]</a>
<h1>Error reports (last 300)</h1>
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
</div>
</nav>
<main class="horizontal-list">
<ul>
{% for item in items %}
<li>
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<a class="hint" href="/dbg/error/{{item.id}}">
<span class="title">{{item.hint|abbreviate:150}}</span>
</a>
</li>
{% endfor %}
</ul>
</main>
<nav>
<div class="title">
<h1>Error reports (last 200)
<a href="/dbg">[GO BACK]</a>
</h1>
</div>
</nav>
<main class="horizontal-list">
<ul>
{% for item in items %}
<li>
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<a class="hint" href="/dbg/error/{{item.id}}">
<span class="title">{{item.hint|abbreviate:150}}</span>
</a>
</li>
{% endfor %}
</ul>
</main>
{% endblock %}

View File

@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div>

View File

@ -1,46 +0,0 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
{% endif %}
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="head" class="table-key">HEAD</div>
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
{% if report %}
<div class="table-row multiline">
<div id="report" class="table-key">REPORT:</div>
<div class="table-val">
<pre>{{report}}</pre>
</div>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@ -1,40 +0,0 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
<div>[<a href="#result">result</a>]</div>
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="head" class="table-key">HEAD:</div>
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
<div class="table-row multiline">
<div id="result" class="table-key">RESULT: </div>
<div class="table-val">
<pre>{{result}}</pre>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,5 +1,5 @@
* {
font-family: monospace;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
}
@ -36,10 +36,6 @@ small {
color: #888;
}
.strong {
font-weight: 900;
}
.not-important {
color: #888;
font-weight: 200;
@ -61,26 +57,14 @@ nav {
nav > .title {
display: flex;
justify-content: center;
width: 100%;
}
nav > .title > a {
color: black;
text-decoration: none;
}
nav > .title > a.strong {
text-decoration: underline;
}
nav > .title > h1 {
padding: 0px;
margin: 0px;
font-size: 11px;
display: block;
}
nav > .title > * {
padding: 0px 6px;
}
nav > div {

View File

@ -3,9 +3,9 @@
{:default
[[:default :window "200000/h"]]
;; #{:main/get-teams}
;; #{:command/get-teams}
;; [[:burst :bucket "5/5/5s"]]
;; #{:main/get-profile}
;; #{:command/get-profile}
;; [[:burst :bucket "60/60/1m"]]
}

View File

@ -1,25 +1,18 @@
#!/usr/bin/env bash
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
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
export PENPOT_HOST=devenv
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 \
disable-login-with-github \
disable-login-with-gitlab \
disable-telemetry \
enable-backend-worker \
enable-backend-asserts \
disable-feature-fdata-pointer-map \
@ -45,10 +38,6 @@ export PENPOT_FLAGS="\
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"
@ -66,8 +55,6 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \

View File

@ -3,10 +3,6 @@
SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/_env;
if [ -f $SCRIPT_DIR/_env.local ]; then
source $SCRIPT_DIR/_env.local;
fi
# Initialize MINIO config
setup_minio;

View File

@ -3,11 +3,6 @@
SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/_env;
if [ -f $SCRIPT_DIR/_env.local ]; then
source $SCRIPT_DIR/_env.local;
fi
export OPTIONS="-A:dev"
entrypoint=${1:-app.main};

View File

@ -3,10 +3,6 @@
SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/_env;
if [ -f $SCRIPT_DIR/_env.local ]; then
source $SCRIPT_DIR/_env.local;
fi
# Initialize MINIO config
setup_minio;

View File

@ -111,7 +111,7 @@
[:host {:optional true} :string]
[:port {:optional true} ::sm/int]
[:bind-dn {:optional true} :string]
[:bind-password {:optional true} :string]
[:bind-passwor {:optional true} :string]
[:query {:optional true} :string]
[:base-dn {:optional true} :string]
[:attrs-email {:optional true} :string]

View File

@ -401,9 +401,8 @@
(defn- parse-attr-path
[provider path]
(let [separator (if (str/includes? path "__") "__" ".")
[fitem & items] (str/split path separator)]
(into [(keyword (:type provider) (str/kebab fitem))] (map keyword) items)))
(let [[fitem & items] (str/split path "__")]
(into [(keyword (:type provider) fitem)] (map keyword) items)))
(defn- build-redirect-uri
[]
@ -424,7 +423,7 @@
(defn- qualify-prop-key
[provider k]
(keyword (:type provider) (-> k name str/kebab)))
(keyword (:type provider) (name k)))
(defn- qualify-props
[provider props]
@ -489,9 +488,9 @@
(let [attr-ph (parse-attr-path provider "nickname")]
(get-in props attr-ph))))]
(let [info (assoc info :provider-id (str (:id provider)))
props (qualify-props provider info)
email (get-email props)]
(let [info (assoc info :provider-id (str (:id provider)))
props (qualify-props provider info)
email (get-email props)]
{:backend (:type provider)
:fullname (or (get-name props) email)
:email email
@ -548,29 +547,16 @@
(def ^:private valid-info?
(sm/validator schema:info))
(defn- select-user-info-source
"Normalise the provider's configured user-info source into a keyword the
dispatch below can match. The raw value comes from config as a string
per the malli schema in `app.config` (`\"token\"`, `\"userinfo\"`, or
`\"auto\"`) and from hard-coded per-provider maps as strings as well;
any unrecognised or missing value falls back to `:auto` (prefer claims,
use userinfo as fallback)."
[source]
(case source
"token" :token
"userinfo" :userinfo
:auto))
(defn- get-info
[cfg provider state code]
(let [tdata (fetch-access-token cfg provider code)
claims (get-id-token-claims provider tdata)
info (case (select-user-info-source (get provider :user-info-source))
:token (dissoc claims :exp :iss :iat :aud :sid)
info (case (get provider :user-info-source)
:token (dissoc claims :exp :iss :iat :aud :sub :sid)
:userinfo (fetch-user-info cfg provider tdata)
:auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid))
(fetch-user-info cfg provider tdata)))
(or (some-> claims (dissoc :exp :iss :iat :aud :sub :sid))
(fetch-user-info cfg provider tdata)))
info (process-user-info provider tdata info)]

View File

@ -40,8 +40,8 @@
[promesa.util :as pu]
[yetti.adapter :as yt])
(:import
com.github.luben.zstd.ZstdInputStream
com.github.luben.zstd.ZstdIOException
com.github.luben.zstd.ZstdInputStream
com.github.luben.zstd.ZstdOutputStream
java.io.DataInputStream
java.io.DataOutputStream

View File

@ -873,8 +873,11 @@
(import-storage-objects cfg)
(let [files (get manifest :files)
result (reduce (fn [result file]
result (reduce (fn [result {:keys [id] :as file}]
(let [name' (get file :name)
name' (if (map? name)
(get name id)
name')
file (assoc file :name name')]
(conj result (import-file cfg file))))
[]

View File

@ -82,10 +82,7 @@
:initial-project-skey "initial-project"
;; time to avoid email sending after profile modification
:email-verify-threshold "15m"
:quotes-upload-sessions-per-profile 5
:quotes-upload-chunks-per-session 20})
:email-verify-threshold "15m"})
(def schema:config
(do #_sm/optional-keys
@ -101,12 +98,10 @@
[:http-server-port {:optional true} ::sm/int]
[:http-server-host {:optional true} :string]
[:http-server-max-body-size {:optional true} ::sm/int]
[:http-server-max-multipart-body-size {:optional true} ::sm/int]
[:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int]
[: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]
@ -157,8 +152,6 @@
[: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]
@ -232,8 +225,6 @@
[:netty-io-threads {:optional true} ::sm/int]
[:executor-threads {:optional true} ::sm/int]
[:nitrate-backend-uri {:optional true} ::sm/uri]
;; DEPRECATED
[:assets-storage-backend {:optional true} :keyword]
[:storage-assets-fs-directory {:optional true} :string]
@ -332,7 +323,7 @@
(defn logging-context
[]
{:backend/version (:full version)})
{:version/backend (:full version)})
;; Set value for all new threads bindings.
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))

View File

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

View File

@ -22,13 +22,13 @@
[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))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -412,21 +412,6 @@
:id ::invite-to-team
:schema schema:invite-to-team))
(def ^:private schema:invite-to-org
[:map
[:invited-by ::sm/text]
[:organization-name ::sm/text]
[:organization-initials [:maybe :string]]
[:organization-logo ::sm/uri]
[:user-name [:maybe ::sm/text]]
[:token ::sm/text]])
(def invite-to-org
"Org member invitation email."
(template-factory
:id ::invite-to-org
:schema schema:invite-to-org))
(def ^:private schema:join-team
[:map
[:invited-by ::sm/text]

View File

@ -36,18 +36,10 @@
:cause cause)))))
(defn contains?
"Check if email is in the blacklist. Also matches subdomains: if
'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also
be rejected."
"Check if email is in the blacklist."
[{:keys [::email/blacklist]} email]
(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)))))))
(let [[_ domain] (str/split email "@" 2)]
(c/contains? blacklist (str/lower domain))))
(defn enabled?
"Check if the blacklist is enabled"

View File

@ -112,9 +112,8 @@
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
END"))
(defn get-snapshot-data
"Get a fully decoded snapshot for read-only preview or restoration.
Returns the snapshot map with decoded :data field."
(defn- get-snapshot
"Get snapshot with decoded data"
[cfg file-id snapshot-id]
(let [now (ct/now)]
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
@ -139,7 +138,6 @@
c.deleted_at
FROM snapshots1 AS c
WHERE c.file_id = ?
ORDER BY c.created_at DESC
), snapshots3 AS (
(SELECT * FROM snapshots2
WHERE created_by = 'system'
@ -152,7 +150,8 @@
AND deleted_at IS NULL
LIMIT 500)
)
SELECT * FROM snapshots3;"))
SELECT * FROM snapshots3
ORDER BY created_at DESC"))
(defn get-visible-snapshots
"Return a list of snapshots fecheable from the API, it has a limited
@ -327,7 +326,7 @@
(sto/resolve cfg {::db/reuse-conn true})
snapshot
(get-snapshot-data cfg file-id snapshot-id)]
(get-snapshot cfg file-id snapshot-id)]
(when-not snapshot
(ex/raise :type :not-found

View File

@ -42,8 +42,8 @@
(def default-params
{::port 6060
::host "0.0.0.0"
::max-body-size 367001600 ; default 350 MiB
})
::max-body-size 31457280 ; default 30 MiB
::max-multipart-body-size 367001600}) ; default 350 MiB
(defmethod ig/expand-key ::server
[k v]
@ -56,6 +56,7 @@
[::io-threads {:optional true} ::sm/int]
[::max-worker-threads {:optional true} ::sm/int]
[::max-body-size {:optional true} ::sm/int]
[::max-multipart-body-size {:optional true} ::sm/int]
[::router {:optional true} [:fn r/router?]]
[::handler {:optional true} ::sm/fn]])
@ -78,7 +79,7 @@
{:http/port port
:http/host host
:http/max-body-size (::max-body-size cfg)
:http/max-multipart-body-size (::max-body-size cfg)
:http/max-multipart-body-size (::max-multipart-body-size cfg)
:xnio/direct-buffers false
:xnio/io-threads (::io-threads cfg)
:xnio/max-worker-threads (::max-worker-threads cfg)

View File

@ -31,6 +31,7 @@
[app.srepl.main :as srepl]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[app.util.blob :as blob]
[app.util.template :as tmpl]
[cuerdas.core :as str]
[datoteka.io :as io]
@ -48,16 +49,13 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn index-handler
[cfg request]
(let [profile-id (::session/profile-id request)
offset (clock/get-offset profile-id)
profile (profile/get-profile cfg profile-id)]
[_cfg _request]
(let [{:keys [clock offset]} @clock/current]
{::yres/status 200
::yres/headers {"content-type" "text/html"}
::yres/body (-> (io/resource "app/templates/debug.tmpl")
(tmpl/render {:version (:full cf/version)
:profile profile
:current-clock ct/*clock*
:current-clock (str clock)
:current-offset (if offset
(ct/format-duration offset)
"NO OFFSET")
@ -70,7 +68,8 @@
(defn- get-resolved-file
[cfg file-id]
(bfc/get-file cfg file-id :migrate? false :decode? false))
(some-> (bfc/get-file cfg file-id :migrate? false)
(update :data blob/encode)))
(defn prepare-download
[file filename]
@ -230,30 +229,13 @@
(-> (io/resource "app/templates/error-report.v3.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 3)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v4 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v4.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 4)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v5 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v5.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 5)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
(if-let [report (get-report request)]
(let [result (case (:version report)
1 (render-template-v1 report)
2 (render-template-v2 report)
3 (render-template-v3 report)
4 (render-template-v4 report)
5 (render-template-v5 report))]
3 (render-template-v3 report))]
{::yres/status 200
::yres/body result
::yres/headers {"content-type" "text/html; charset=utf-8"
@ -261,22 +243,20 @@
{::yres/status 404
::yres/body "not found"})))
(def ^:private sql:error-reports
(def sql:error-reports
"SELECT id, created_at,
content->>'~:hint' AS hint
FROM server_error_report
WHERE version = ?
ORDER BY created_at DESC
LIMIT 300")
LIMIT 200")
(defn- error-list-handler
[{:keys [::db/pool]} {:keys [params]}]
(let [version (or (some-> (get params :version) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports version])
(map #(update % :created-at ct/format-inst :rfc1123)))]
(defn error-list-handler
[{:keys [::db/pool]} _request]
(let [items (->> (db/exec! pool [sql:error-reports])
(map #(update % :created-at ct/format-inst :rfc1123)))]
{::yres/status 200
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items :version version}))
(tmpl/render {:items items}))
::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}}))
@ -467,16 +447,15 @@
(defn- set-virtual-clock
[_ {:keys [params] :as request}]
(let [offset (some-> params :offset str/trim not-empty ct/duration)
profile-id (::session/profile-id request)
reset? (contains? params :reset)]
(let [offset (some-> params :offset str/trim not-empty ct/duration)
reset? (contains? params :reset)]
(if (= "production" (cf/get :tenant))
{::yres/status 501
::yres/body "OPERATION NOT ALLOWED"}
(do
(if (or reset? (zero? (inst-ms offset)))
(clock/assign-offset profile-id nil)
(clock/assign-offset profile-id offset))
(clock/set-offset! nil)
(clock/set-offset! offset))
{::yres/status 302
::yres/headers {"location" "/dbg"}}))))
@ -516,7 +495,7 @@
(defn authorized?
[pool {:keys [::session/profile-id]}]
(or (and (= "devenv" (cf/get :host)) profile-id)
(or (= "devenv" (cf/get :host))
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
admins (or (cf/get :admins) #{})]
(contains? admins (:email profile)))))

View File

@ -32,7 +32,7 @@
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
(fn [cause _ _]
@ -220,14 +220,12 @@
(assoc :hint (ex-message error)))}))))
(defmethod handle-exception java.io.IOException
[cause request _]
(binding [l/*context* (request->context request)]
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception
:hint (ex-message cause)
:path (:path request)}}))
[cause _ _]
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception
:hint (ex-message cause)}})
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]

View File

@ -13,13 +13,13 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.http.middleware :as mw]
[app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.worker :as-alias wrk]
[integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as-alias yres]))
;; ---- ROUTES
@ -49,40 +49,28 @@
(fn [cfg request]
(db/tx-run! cfg handler request)))))})
(def ^:private shared-key-auth
{:name ::shared-key-auth
:compile
(fn [_ _]
(fn [handler key]
(if key
(fn [request]
(if-let [key' (yreq/get-header request "x-shared-key")]
(if (= key key')
(handler request)
{::yres/status 403})
{::yres/status 403}))
(fn [_ _]
{::yres/status 403}))))})
(defmethod ig/init-key ::routes
[_ cfg]
[_ {:keys [::setup/props] :as cfg}]
["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["" {:middleware [[mw/shared-key-auth management-key]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]])
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
;; ---- HELPERS

View File

@ -16,6 +16,7 @@
[app.http.errors :as errors]
[app.tokens :as tokens]
[app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str]
[yetti.adapter :as yt]
[yetti.middleware :as ymw]
@ -213,14 +214,14 @@
(assoc "access-control-allow-origin" origin)
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
(assoc "access-control-allow-credentials" "true")
(assoc "access-control-expose-headers" "content-type, set-cookie")
(assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie")))
(assoc "access-control-expose-headers" "x-requested-with, content-type, cookie")
(assoc "access-control-allow-headers" "x-frontend-version, content-type, accept, x-requested-width")))
(defn wrap-cors
[handler]
(fn [request]
(let [response (if (= (yreq/method request) :options)
{::yres/status 204}
{::yres/status 200}
(handler request))
origin (yreq/get-header request "origin")]
(update response ::yres/headers with-cors-headers origin))))
@ -300,20 +301,16 @@
:compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth
[handler keys]
(if (seq keys)
(fn [request]
(if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key")
(str/split #"\s+" 2))]
(let [key-id (-> key-id str/lower keyword)]
(if (and (string? key)
(contains? keys key-id)
(= key (get keys key-id)))
(-> request
(assoc ::http/auth-key-id key-id)
(handler))
{::yres/status 403}))
{::yres/status 403}))
[handler shared-key]
(if shared-key
(let [shared-key (if (string? shared-key)
shared-key
(bc/bytes->b64-str shared-key true))]
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler (assoc request ::http/auth-with-shared-key true))
{::yres/status 403}))))
(fn [_ _]
{::yres/status 403})))

View File

@ -20,7 +20,6 @@
[app.http.session.tasks :as-alias tasks]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.setup.clock :as clock]
[app.tokens :as tokens]
[integrant.core :as ig]
[yetti.request :as yreq]
@ -230,22 +229,18 @@
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
(cond
(= type :cookie)
(let [session
(case (:ver metadata)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
0 (read-session manager token)
1 (some->> (:sid claims) (read-session manager))
nil)
(let [session (case (:ver metadata)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
0 (read-session manager token)
1 (some->> (:sid claims) (read-session manager))
nil)
request
(cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::session session)))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::session session)))
response
(binding [ct/*clock* (clock/get-clock (:profile-id session))]
(handler request))]
response (handler request)]
(if (and session (renew-session? session))
(let [session (->> session

View File

@ -6,6 +6,7 @@
(ns app.http.sse
"SSE (server sent events) helpers"
(:refer-clojure :exclude [tap])
(:require
[app.common.data :as d]
[app.common.logging :as l]
@ -53,7 +54,6 @@
::yres/status 200
::yres/body (yres/stream-body
(fn [_ output]
(let [channel (sp/chan :buf buf :xf (keep encode))
listener (events/spawn-listener
channel

View File

@ -9,7 +9,6 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
@ -113,14 +112,12 @@
;; COLLECTOR API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private prepare-context-from-request)
;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to
;; an external storage and data cleared.
(def ^:private schema:event
[:map {:title "AuditEvent"}
[:map {:title "event"}
[::type ::sm/text]
[::name ::sm/text]
[::profile-id ::sm/uuid]
@ -128,8 +125,6 @@
[::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]
[::webhooks/event? {:optional true} ::sm/boolean]
[::webhooks/batch-timeout {:optional true} ::ct/duration]
[::webhooks/batch-key {:optional true}
@ -138,8 +133,32 @@
(def ^:private check-event
(sm/check-fn schema:event))
(def valid-event?
(sm/validator schema:event))
(defn- prepare-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)
token-id (::actoken/id request)]
(d/without-nils
{:external-session-id session-id
:access-token-id (some-> token-id 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 prepare-event
[cfg mdata params result]
@ -151,22 +170,20 @@
uuid/zero)
props (-> (or (::replace-props resultm)
(merge params (::props resultm)))
(-> params
(merge (::props resultm))
(dissoc :profile-id)
(dissoc :type)))
(clean-props))
context (merge (::context resultm)
(prepare-context-from-request request))
ip-addr (inet/parse-request request)
module (get cfg ::rpc/module)]
ip-addr (inet/parse-request request)]
{::type (or (::type resultm)
(::rpc/type cfg))
::name (or (::name resultm)
(let [sname (::sv/name mdata)]
(if (not= module "main")
(str module "-" sname)
sname)))
(::sv/name mdata))
::profile-id profile-id
::ip-addr ip-addr
::props props
@ -190,38 +207,6 @@
(::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)
@ -238,7 +223,7 @@
(some? tnow)
(assoc :tracked-at tnow))))
(defn- append-audit-entry
(defn- append-audit-entry!
[cfg params]
(let [params (-> params
(update :props db/tjson)
@ -248,26 +233,17 @@
(defn- handle-event!
[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)))
(let [params (event->params event)
tnow (ct/now)]
(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))
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry! cfg params)))
(when (and (or (contains? cf/flags :telemetry)
(cf/get :telemetry-enabled))
@ -278,9 +254,11 @@
;;
;; NOTE: this is only executed when general audit log is disabled
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow))
(assoc :props {})
(assoc :context {}))]
(append-audit-entry cfg params)))
(append-audit-entry! cfg params)))
(when (and (contains? cf/flags :webhooks)
(::webhooks/event? event))
@ -334,4 +312,4 @@
params (-> (event->params event)
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry cfg params)))))))
(append-audit-entry! cfg params)))))))

View File

@ -10,11 +10,14 @@
[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
@ -49,18 +52,19 @@
(defn- send!
[{:keys [::uri] :as cfg} events]
(let [skey (-> cfg ::setup/shared-keys :nexus)
(let [token (tokens/generate cfg
{:iss "authentication"
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (str (cf/get :public-uri))
"x-shared-key" (str "nexus " skey)}
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 12000
:method :post
:headers headers
:body body}
resp (http/req! cfg params)]
(if (= (:status resp) 204)
true
(do
@ -81,7 +85,7 @@
(def ^:private sql:get-audit-log-chunk
"SELECT *
FROM audit_log
WHERE archived_at IS NULL
WHERE archived_at is null
ORDER BY created_at ASC
LIMIT 128
FOR UPDATE
@ -105,7 +109,7 @@
(def ^:private schema:handler-params
[:map
::db/pool
::setup/shared-keys
::setup/props
::http/client])
(defmethod ig/assert-key ::handler

View File

@ -14,8 +14,6 @@
[app.common.schema :as sm]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
[app.rpc.rlimit :as-alias rlimit]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px]
@ -30,145 +28,69 @@
(defonce enabled (atom true))
(defn- persist-on-database!
[pool id version report]
[pool id report]
(when-not (db/read-only? pool)
(db/insert! pool :server-error-report
{:id id
:version version
:version 3
:content (db/tjson report)})))
(defn- concurrent-exception?
[cause]
(or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause)))
(defn- log-record->report
(defn record->report
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record")
(let [data (if (concurrent-exception? cause)
(ex-data (ex-cause cause))
(ex-data cause))
(if (or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause))
(-> record
(assoc ::trace (ex/format-throwable cause :data? true :explain? false :header? false :summary? false))
(assoc ::l/cause (ex-cause cause))
(record->report))
ctx (-> context
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(let [data (ex-data cause)
ctx (-> context
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (str (cf/get :public-uri)))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
(when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)})
(when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)})
(when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain}))))
(when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain})))))
(defn- handle-log-record
"Convert the log record into a report object and persist it on the database"
(defn error-record?
[{:keys [::l/level]}]
(= :error level))
(defn- handle-event
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
(try
(let [uri (cf/get :public-uri)
report (-> record log-record->report d/without-nils)]
(l/dbg :hint "registering error on database"
:id (str id)
:src "logging"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 3 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
report (-> record record->report d/without-nils)]
(l/debug :hint "registering error on database" :id id
:uri (str uri "/dbg/error/" id))
(defn- audit-event->report
[{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}]
(let [context
(reduce-kv (fn [context k v]
(let [k' (keyword "frontend" (name k))]
(-> context
(dissoc k)
(assoc k' v))))
context
context)
context
(-> context
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :frontend/ip-addr ip-addr))]
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:origin (::audit/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}]
(try
(let [uri (cf/get :public-uri)
report (-> event audit-event->report d/without-nils)]
(l/dbg :hint "registering error on database"
:id (str id)
:src "audit-log"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 4 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(defn- rlimit-event->report
[event]
(let [context
(-> {}
(assoc :rlimit/uid (::rlimit/uid event))
(assoc :rlimit/method (::rlimit/method event))
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version)))
result
(->> (::rlimit/results event)
(mapv (fn [result]
(-> (into (sorted-map) result)
(dissoc ::rlimit/method)))))]
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:result (pp/pprint-str result :length 50)}))
(defn- handle-rlimit-event
"Convert the log record into a report object and persist it on the database"
[{:keys [::db/pool]} {:keys [::rlimit/id] :as event}]
(try
(let [uri (cf/get :public-uri)
report (-> event rlimit-event->report d/without-nils)]
(l/dbg :hint "registering rate limit rejection"
:id (str id)
:src "rlimit"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 5 report))
(persist-on-database! pool id report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
@ -178,52 +100,26 @@
(defmethod ig/init-key ::reporter
[_ cfg]
(let [input (sp/chan :buf (sp/sliding-buffer 256))
thread (px/thread
{:name "penpot/reporter/database"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [item (sp/take! input)]
(cond
(::l/id item)
(handle-log-record cfg item)
(let [input (sp/chan :buf (sp/sliding-buffer 64)
:xf (filter error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(::audit/id item)
(handle-audit-event cfg item)
(::rlimit/id item)
(handle-rlimit-event cfg item)
:else
(l/warn :hint "received unexpected item" :item item))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread}))
(px/thread {:name "penpot/database-reporter"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [record (sp/take! input)]
(handle-event cfg record)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated"))))))
(defmethod ig/halt-key! ::reporter
[_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(sp/close! input)
(px/interrupt! thread))
(defn emit
"Emit an event/report into the database reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))
[_ thread]
(some-> thread px/interrupt!))

View File

@ -9,12 +9,9 @@
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.uri :as u]
[app.config :as cf]
[app.http.client :as http]
[app.loggers.audit :as audit]
[app.rpc.rlimit :as-alias rlimit]
[app.loggers.database :as ldb]
[app.util.json :as json]
[integrant.core :as ig]
[promesa.exec :as px]
@ -23,34 +20,24 @@
(defonce enabled (atom true))
(defn- send-mattermost-notification!
[cfg {:keys [id] :as report}]
(let [type (get report :type)
text (str "#" type " | " (get report :hint) "\n"
(when id
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
[cfg {:keys [id public-uri] :as report}]
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
(when-let [pid (:profile-id report)]
(if (uuid? pid)
(str "(pid: #uuid-" pid ")")
(str "(pid: #ip-" pid ")")))
(str "(pid: #uuid-" pid ")"))
"\n"
"- host: #" (:host report) "\n"
"- tenant: #" (:tenant report) "\n"
"- origin: #" (:origin report) "\n"
(when-let [href (get report :href)]
(str "- href: `" href "`\n"))
(when-let [version (get report :frontend-version)]
(str "- frontend-version: `" version "`\n"))
(when-let [version (get report :backend-version)]
(str "- backend-version: `" version "`\n"))
"- logger: #" (:logger report) "\n"
"- request-path: `" (:request-path report) "`\n"
"- frontend-version: `" (:frontend-version report) "`\n"
"- backend-version: `" (:backend-version report) "`\n"
"\n"
(when-let [info (:info report)]
(str "```\n" info "```"))
(when-let [trace (:trace report)]
(str "```\n"
"Trace:\n"
trace
"```")))
"```\n"
"Trace:\n"
(:trace report)
"```")
resp (http/req! cfg
{:uri (cf/get :error-report-webhook)
@ -63,70 +50,28 @@
(l/warn :hint "error on sending data"
:response (pr-str resp)))))
(defn- log-record->report
[{:keys [::l/context ::l/id ::l/cause ::l/message] :as record}]
(defn record->report
[{:keys [::l/context ::l/id ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record")
(let [public-uri (cf/get :public-uri)]
{:id id
:type "exception"
:origin "logging"
:hint (or (some-> cause ex-message) @message)
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:frontend-version (:frontend/version context)
:profile-id (:request/profile-id context)
:href (-> public-uri
(assoc :path (:request/path context))
(str))
:trace (ex/format-throwable cause :detail? false :header? false)}))
(defn- audit-event->report
[{:keys [::audit/context ::audit/props ::audit/id] :as event}]
{:id id
:type "exception"
:origin "audit-log"
:hint (get props :hint)
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:frontend-version (:version context)
:profile-id (:audit/profile-id event)
:href (get props :href)})
:public-uri (cf/get :public-uri)
:backend-version (or (:version/backend context) (:full cf/version))
:frontend-version (:version/frontend context)
:profile-id (:request/profile-id context)
:request-path (:request/path context)
:logger (::l/logger record)
:trace (ex/format-throwable cause :detail? false :header? false)})
(defn- rlimit-event->report
[event]
{:id (::rlimit/id event)
:type "notification"
:origin "rlimit"
:hint (str "rlimit reject of "
(::rlimit/method event)
" for "
(::rlimit/uid event))
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:profile-id (::rlimit/profile-id event)
:info (with-out-str
(println "Rejected by:")
(println "------------")
(println "Method: " (::rlimit/method event))
(println "Limit Name: " (::rlimit/name event))
(println "Limit Strategy:" (::rlimit/strategy event))
(println)
(println "Results & Config:")
(println "-----------------")
(doseq [result (::rlimit/results event)]
(pp/pprint (into (sorted-map) result))))})
(defn- handle-event
[cfg event event->report]
(try
(let [report (event->report event)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause))))
(defn handle-event
[cfg record]
(when @enabled
(try
(let [report (record->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause)))))
(defmethod ig/assert-key ::reporter
[_ params]
@ -135,52 +80,27 @@
(defmethod ig/init-key ::reporter
[_ cfg]
(when-let [uri (cf/get :error-report-webhook)]
(let [input (sp/chan :buf (sp/sliding-buffer 256))
thread (px/thread
{:name "penpot/reporter/mattermost"}
(l/info :hint "initializing error reporter" :uri uri)
(try
(loop []
(when-let [item (sp/take! input)]
(when @enabled
(cond
(::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)
:else
(l/warn :hint "received unexpected item" :item item)))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread})))
(px/thread
{:name "penpot/mattermost-reporter"
:virtual true}
(l/info :hint "initializing error reporter" :uri uri)
(let [input (sp/chan :buf (sp/sliding-buffer 128)
:xf (filter ldb/error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(try
(loop []
(when-let [msg (sp/take! input)]
(handle-event cfg msg)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated")))))))
(defmethod ig/halt-key! ::reporter
[_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(some-> input sp/close!)
[_ thread]
(some-> thread px/interrupt!))
(defn emit
"Emit an event/report into the mattermost reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))

View File

@ -226,10 +226,11 @@
::http/server
{::http/port (cf/get :http-server-port)
::http/host (cf/get :http-server-host)
::http/router (ig/ref ::http/router)
::http/io-threads (cf/get :http-server-io-threads)
::http/max-worker-threads (cf/get :http-server-max-worker-threads)
::http/max-body-size (cf/get :http-server-max-body-size)
::http/router (ig/ref ::http/router)
::http/max-multipart-body-size (cf/get :http-server-max-multipart-body-size)
::mtx/metrics (ig/ref ::mtx/metrics)}
::ldap/provider
@ -316,19 +317,12 @@
::climit/enabled (contains? cf/flags :rpc-climit)}
:app.rpc/rlimit
{::wrk/executor (ig/ref ::wrk/netty-executor)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)
:app.loggers.database/reporter
(ig/ref :app.loggers.database/reporter)}
{::wrk/executor (ig/ref ::wrk/netty-executor)}
:app.rpc/methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
@ -343,17 +337,7 @@
::setup/props (ig/ref ::setup/props)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)
:app.loggers.database/reporter
(ig/ref :app.loggers.database/reporter)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
::email/whitelist (ig/ref ::email/whitelist)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
@ -364,19 +348,17 @@
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
:app.nitrate/client (ig/ref :app.nitrate/client)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}
::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods)
{::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
::setup/props (ig/ref ::setup/props)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)
@ -388,7 +370,6 @@
: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)
@ -424,9 +405,6 @@
: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)}
@ -468,19 +446,13 @@
;; module requires the migrations to run before initialize.
::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
: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/shared-keys (ig/ref ::setup/shared-keys)
::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)}
{::setup/props (ig/ref ::setup/props)
::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
:app.loggers.audit.gc-task/handler
{::db/pool (ig/ref ::db/pool)}
@ -548,9 +520,6 @@
{: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}

View File

@ -31,11 +31,12 @@
(:import
clojure.lang.XMLHandler
java.io.InputStream
javax.xml.parsers.SAXParserFactory
javax.xml.XMLConstants
javax.xml.parsers.SAXParserFactory
org.apache.commons.io.IOUtils
org.im4java.core.ConvertCmd
org.im4java.core.IMOperation))
org.im4java.core.IMOperation
org.im4java.core.Info))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
@ -54,7 +55,7 @@
[:path ::fs/path]
[:mtype {:optional true} ::sm/text]])
(def check-input
(def ^:private check-input
(sm/check-fn schema:input))
(defn validate-media-type!
@ -223,18 +224,17 @@
;; If we are processing an animated gif we use the first frame with -scene 0
(let [dim-result (sh/sh "identify" "-format" "%w %h\n" path)
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
(when (= 0 (:exit dim-result))
(if (and (= 0 (:exit dim-result))
(= 0 (:exit orient-result)))
(let [[w h] (-> (:out dim-result)
str/trim
(clojure.string/split #"\s+")
(->> (mapv #(Integer/parseInt %))))
orientation-exit (:exit orient-result)
orientation (-> orient-result :out str/trim)]
(if (= 0 orientation-exit)
(case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h}) ; Normal or unknown orientation
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
orientation (-> orient-result :out str/trim)]
(case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h})) ; Normal or unknown orientation
nil)))
(defmethod process :info
[{:keys [input] :as params}]
@ -247,37 +247,26 @@
:hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now) :size (fs/size path)}))
(let [path-str (str path)
identify-res (sh/sh "identify" "-format" "image/%[magick]\n" path-str)
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
mtype' (if (zero? (:exit identify-res))
(-> identify-res
:out
str/trim
(str/split #"\s+" 2)
first
str/lower)
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image"))
{:keys [width height]}
(or (get-dimensions-with-orientation path-str)
(do
(l/warn "Failed to read image dimensions with orientation" {:path path})
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image")))]
(let [instance (Info. (str path))
mtype' (.getProperty instance "Mime type")]
(when (and (string? mtype)
(not= (str/lower mtype) mtype'))
(not= mtype mtype'))
(ex/raise :type :validation
:code :media-type-mismatch
:hint (str "Seems like you are uploading a file whose content does not match the extension."
"Expected: " mtype ". Got: " mtype')))
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now))))))
(let [{:keys [width height]}
(or (get-dimensions-with-orientation (str path))
(do
(l/warn "Failed to read image dimensions with orientation; falling back to im4java"
{:path path})
{:width (.getPageWidth instance)
:height (.getPageHeight instance)}))]
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now)))))))
(defmethod process-error org.im4java.core.InfoException
[error]
@ -293,17 +282,12 @@
(defn download-image
"Download an image from the provided URI and return the media input object"
[{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [status headers] :as response}]
(letfn [(parse-and-validate [{:keys [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)]
(when-not (<= 200 status 299)
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': unexpected status code %" uri status)))
(when-not size
(ex/raise :type :validation
:code :unknown-size
@ -323,32 +307,9 @@
{:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response}
(try
(http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
(catch java.net.ConnectException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': connection refused or host unreachable" uri)
:cause cause))
(catch java.net.http.HttpConnectTimeoutException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': connection timeout" uri)
:cause cause))
(catch java.net.http.HttpTimeoutException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': request timeout" uri)
:cause cause))
(catch java.io.IOException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
:cause cause)))
(let [{:keys [body] :as response} (http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
@ -409,22 +370,6 @@
(when (zero? (:exit res))
(:out res))))
(woff2->sfnt [data]
;; woff2_decompress outputs to same directory with .ttf extension
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
(try
(io/write* finput data)
(let [res (sh/sh "woff2_decompress" (str finput))]
(if (zero? (:exit res))
foutput
(do
(when (fs/exists? foutput)
(fs/delete foutput))
nil)))
(finally
(fs/delete finput)))))
;; Documented here:
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
(get-sfnt-type [data]
@ -474,27 +419,4 @@
(= stype :ttf)
(-> (assoc "font/otf" (ttf->otf sfnt))
(assoc "font/ttf" sfnt)))))
(contains? current "font/woff2")
(let [data (get input "font/woff2")
foutput (woff2->sfnt data)]
(when-not foutput
(ex/raise :type :validation
:code :invalid-woff2-file
:hint "invalid woff2 file"))
(try
(let [sfnt (io/read* foutput)
type (get-sfnt-type sfnt)]
(cond-> input
(= type :otf)
(-> (assoc "font/otf" sfnt)
(assoc "font/ttf" (otf->ttf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
(= type :ttf)
(-> (assoc "font/ttf" sfnt)
(assoc "font/otf" (ttf->otf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
(finally
(fs/delete foutput))))))))
(assoc "font/ttf" sfnt)))))))))

View File

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

View File

@ -10,7 +10,6 @@
[app.common.logging :as l]
[app.db :as db]
[app.migrations.clj.migration-0023 :as mg0023]
[app.migrations.clj.migration-0145 :as mg0145]
[app.util.migrations :as mg]
[integrant.core :as ig]))
@ -457,28 +456,7 @@
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
{:name "0143-http-session-v2-table"
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}
{:name "0144-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}
{:name "0145-mod-audit-log-table"
:fn (mg/resource "app/migrations/sql/0145-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-mod-team-invitation-table"
:fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")}
{:name "0147-add-upload-session-table"
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}
{:name "0148-add-variant-name-team-font-variant"
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}])
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -58,3 +58,4 @@
(when (nil? (:data file))
(migrate-file conn file)))
(db/exec-one! conn ["drop table page cascade;"])))

View File

@ -1,83 +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.migrations.clj.migration-0145
"Migrate plugins references on profiles"
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.db :as db]
[cuerdas.core :as str]))
(def ^:private replacements
{"https://colors-to-tokens-plugin.pages.dev"
"https://colors-to-tokens.plugins.penpot.app"
"https://contrast-penpot-plugin.pages.dev"
"https://contrast.plugins.penpot.app"
"https://create-palette-penpot-plugin.pages.dev"
"https://create-palette.plugins.penpot.app"
"https://icons-penpot-plugin.pages.dev"
"https://icons.plugins.penpot.app"
"https://lorem-ipsum-penpot-plugin.pages.dev"
"https://lorem-ipsum.plugins.penpot.app"
"https://rename-layers-penpot-plugin.pages.dev"
"https://rename-layers.plugins.penpot.app"
"https://table-penpot-plugin.pages.dev"
"https://table.plugins.penpot.app"})
(defn- fix-url
[url]
(reduce-kv (fn [url prefix replacement]
(if (str/starts-with? url prefix)
(reduced (str replacement (subs url (count prefix))))
url))
url
replacements))
(defn- fix-manifest
[manifest]
(-> manifest
(d/update-when :url fix-url)
(d/update-when :host fix-url)))
(defn- fix-plugins-data
[props]
(d/update-in-when props [:plugins :data]
(fn [data]
(reduce-kv (fn [data id manifest]
(let [manifest' (fix-manifest manifest)]
(if (= manifest manifest')
data
(assoc data id manifest'))))
data
data))))
(def ^:private sql:get-profiles
"SELECT id, props FROM profile
WHERE props ?? '~:plugins'
ORDER BY created_at
FOR UPDATE")
(defn migrate
[conn]
(->> (db/plan conn [sql:get-profiles])
(run! (fn [{:keys [id props]}]
(when-let [props (some-> props db/decode-transit-pgobject)]
(let [props' (fix-plugins-data props)]
(when (not= props props')
(l/inf :hint "fixing plugins data on profile props" :profile-id (str id))
(db/update! conn :profile
{:props (db/tjson props')}
{:id id}
{::db/return-keys false}))))))))

View File

@ -1,11 +0,0 @@
ALTER TABLE server_error_report DROP CONSTRAINT server_error_report_pkey;
DELETE FROM server_error_report a
USING server_error_report b
WHERE a.id = b.id
AND a.ctid < b.ctid;
ALTER TABLE server_error_report ADD PRIMARY KEY (id);
CREATE INDEX server_error_report__version__idx
ON server_error_report ( version );

View File

@ -1,2 +0,0 @@
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;

View File

@ -1,2 +0,0 @@
ALTER TABLE access_token
ADD COLUMN type text NULL;

View File

@ -1,14 +0,0 @@
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);

View File

@ -1,13 +0,0 @@
ALTER TABLE team_invitation
ADD COLUMN org_id uuid NULL;
ALTER TABLE team_invitation
ALTER COLUMN team_id DROP NOT NULL;
ALTER TABLE team_invitation
ADD CONSTRAINT team_invitation_team_or_org_not_null
CHECK (team_id IS NOT NULL OR org_id IS NOT NULL);
CREATE UNIQUE INDEX team_invitation_org_unique
ON team_invitation (org_id, email_to)
WHERE team_id IS NULL;

View File

@ -1,2 +0,0 @@
ALTER TABLE team_font_variant
ADD COLUMN variant_name text NULL;

View File

@ -1,440 +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.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.config :as cf]
[app.http.client :as http]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[clojure.core :as c]
[integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder
[cfg method uri shared-key profile-id request-params]
(fn []
(http/req! cfg (cond-> {: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}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(defn- with-retries
[handler max-retries]
(fn []
(loop [attempt 1]
(let [result (try
(handler)
(catch Exception e
(if (< attempt max-retries)
::retry
(do
;; TODO Error handling
(l/error :hint "request fail after multiple retries" :cause e)
nil))))]
(if (= result ::retry)
(recur (inc attempt))
result)))))
(defn- with-validate [handler uri schema & {:keys [throw-on-error?]}]
(fn []
(let [response (handler)
status (:status response)]
(when-not status
(l/error :hint "could't do the nitrate request, it is probably down"
:uri uri)
;; TODO decide what to do when Nitrate is inaccesible
nil)
(cond
(>= status 400)
;; For error status codes (4xx, 5xx), fail immediately without validation
(do
(when (not= status 404) ;; Don't need to log 404
(l/error :hint "nitrate request failed with error status"
:uri uri
:status status
:body (:body response)))
(if throw-on-error?
(ex/raise :type :nitrate-http-error
:status status
:hint (str "nitrate HTTP " status " at " uri))
nil))
(= status 204) ;; 204 doesn't return any body
nil
:else ;; For success status codes, validate the response
(let [coercer-http (sm/coercer schema
:type :validation
:hint (str "invalid data received calling " uri))
data (-> response :body (json/decode :key-fn json/read-kebab-key))]
(try
(coercer-http data)
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))))
(defn- request-to-nitrate
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
(with-retries 3)
(with-validate uri schema :throw-on-error? throw-on-error?))]
(full-http-call)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn call
[cfg method params]
(when (contains? cf/flags :nitrate)
(let [client (get cfg ::client)
method (get client method)]
(method params))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:org-summary
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:owner-id ::sm/uuid]
[:teams
[:vector
[:map
[:id ::sm/uuid]
[:is-your-penpot :boolean]]]]])
(def ^:private schema:profile-org
[:map
[:is-member :boolean]
[:organization-id {:optional true} [:maybe ::sm/uuid]]
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
;; TODO Unify with schemas on backend/src/app/http/management.clj
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string ct/inst
:encode/string inst-ms
:decode/json ct/inst
:encode/json inst-ms}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"
"nitrate"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private schema:connectivity
[:map
[:licenses ::sm/boolean]])
(defn- get-team-org-api
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id)
cto/schema:team-with-organization params)))
(defn- get-org-membership-api
[cfg {:keys [profile-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/members/"
profile-id)
schema:profile-org params)))
(defn- get-org-membership-by-team-api
[cfg {:keys [profile-id team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id
"/users/"
profile-id)
schema:profile-org params)))
(defn- get-org-summary-api
[cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/summary")
schema:org-summary params)))
(defn- get-owned-orgs-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/users/"
profile-id
"/owned-organizations")
[:vector schema:org-summary]
params)))
(defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:team-id team-id
:is-your-penpot (true? is-default)})
team (request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/add-team")
cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (get-in team [:organization :logo-id])]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team
custom-photo
(assoc-in [:organization :custom-photo] custom-photo))))
(defn- add-profile-to-org-api
[cfg {:keys [profile-id organization-id team-id email] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
request-params (cond-> {:user-id profile-id :team-id team-id}
(some? email) (assoc :email email))
params (assoc params :request-params request-params)]
(request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/add-user")
schema:profile-org params)))
(defn- remove-profile-from-org-api
[cfg {:keys [profile-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:user-id profile-id})]
(request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/remove-user")
nil params)))
(defn- remove-profile-from-all-orgs-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :post
(str baseuri
"/api/users/"
profile-id
"/remove-organizations")
nil params)))
(defn- remove-team-from-org-api
[cfg {:keys [team-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:team-id team-id})]
(request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/remove-team")
nil params)))
(defn- delete-team-api
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :delete
(str baseuri
"/api/teams/"
team-id)
nil params)))
(defn- get-subscription-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/subscriptions/"
profile-id)
schema:subscription params)))
(defn- get-connectivity-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/connectivity")
schema:connectivity params)))
(def ^:private schema:redeem-result
[:map
[:cancel-at [:maybe schema:timestamp]]])
(defn- redeem-activation-code-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :post
(str baseuri "/api/activation-codes/redeem")
schema:redeem-result
(assoc params :throw-on-error? true))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client
[_ cfg]
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org-api cfg)
:set-team-org (partial set-team-org-api cfg)
:get-org-membership (partial get-org-membership-api cfg)
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
:delete-team (partial delete-team-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg)
:connectivity (partial get-connectivity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn add-nitrate-licence-to-profile
"Enriches a profile map with subscription information from Nitrate.
Adds a :subscription field containing the user's license details.
Returns the original profile unchanged if the request fails."
[cfg profile]
(try
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
(assoc profile :subscription subscription))
(catch Throwable cause
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))
(defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or org data is nil."
[cfg team params]
(try
(let [params (assoc (or params {}) :team-id (:id team))
team-with-org (call cfg :get-team-org params)
org (:organization team-with-org)]
(if (some? org)
(-> (cto/apply-organization team (assoc org :custom-photo
(when-let [logo-id (:logo-id org)]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team))
(catch Throwable cause
(l/error :hint "failed to get team organization info"
:team-id (:id team)
:cause cause)
team)))
(defn set-team-organization
"Associates a team with an organization in Nitrate.
Requires organization-id and is-default in params.
Throws an exception if the request fails."
[cfg team params]
(let [params (assoc (or params {})
:team-id (:id team)
:organization-id (:organization-id params)
:is-default (:is-default params))
result (call cfg :set-team-org params)]
(when (nil? result)
(ex/raise :type :internal
:code :failed-to-set-team-org
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))

View File

@ -24,28 +24,28 @@
[integrant.core :as ig])
(:import
clojure.lang.MapEntry
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.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.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.pubsub.RedisPubSubListener
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
io.lettuce.core.resource.ClientResources
io.lettuce.core.resource.DefaultClientResources
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))

View File

@ -73,13 +73,9 @@
(if (nil? result)
204
200))
headers (::http/headers mdata {})
headers (cond-> headers
(and (yres/stream-body? result)
(not (contains? headers "content-type")))
headers (cond-> (::http/headers mdata {})
(yres/stream-body? result)
(assoc "content-type" "application/octet-stream"))]
{::yres/status status
::yres/headers headers
::yres/body result}))]
@ -94,14 +90,13 @@
[methods]
(let [methods (update-vals methods peek)]
(fn [{:keys [params path-params method] :as request}]
(let [handler-name (:method-name path-params)
(let [handler-name (:type 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)
(::actoken/profile-id request)
(if key-id uuid/zero nil))
(if (::http/auth-with-shared-key request)
uuid/zero
nil))
ip-addr (inet/parse-request request)
@ -109,7 +104,6 @@
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::session-id (some-> session-id uuid/parse*))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
@ -233,8 +227,8 @@
(wrap-authentication cfg $ mdata)))
(defn- process-method
[cfg wrap-fn [f mdata]]
(l/trc :hint "add method" :module (::module cfg) :type (::type cfg) :name (::sv/name mdata))
[cfg module wrap-fn [f mdata]]
(l/trc :hint "add method" :module module :name (::sv/name mdata))
(let [f (wrap-fn cfg f mdata)
k (keyword (::sv/name mdata))]
[k [mdata (partial f cfg)]]))
@ -245,7 +239,7 @@
(defn- resolve-methods
[cfg]
(let [cfg (assoc cfg ::module "main" ::type "command" ::metrics-id :rpc-main-timing)]
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
(->> (sv/scan-ns
'app.rpc.commands.access-token
'app.rpc.commands.audit
@ -264,7 +258,6 @@
'app.rpc.commands.ldap
'app.rpc.commands.management
'app.rpc.commands.media
'app.rpc.commands.nitrate
'app.rpc.commands.profile
'app.rpc.commands.projects
'app.rpc.commands.search
@ -273,7 +266,7 @@
'app.rpc.commands.verify-token
'app.rpc.commands.viewer
'app.rpc.commands.webhooks)
(map (partial process-method cfg wrap))
(map (partial process-method cfg "rpc" wrap))
(into {}))))
(def ^:private schema:methods-params
@ -305,13 +298,11 @@
(defn- resolve-management-methods
[cfg]
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
mods (cond->> (list 'app.rpc.management.exporter)
(contains? cf/flags :nitrate)
(cons 'app.rpc.management.nitrate))]
(->> (apply sv/scan-ns mods)
(map (partial process-method cfg wrap-management))
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.exporter)
(map (partial process-method cfg "management" wrap-management))
(into {}))))
(def ^:private schema:management-methods-params
@ -354,20 +345,23 @@
(defmethod ig/assert-key ::routes
[_ params]
(assert (map? (::setup/shared-keys params)))
(assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
(let [public-uri (cf/get :public-uri)]
["/api"
["/management"
["/methods/:method-name"
{:middleware [[mw/shared-key-auth shared-keys]
["/methods/:type"
{:middleware [[mw/shared-key-auth management-key]
[session/authz cfg]]
:handler (make-rpc-handler management-methods)}]
@ -377,7 +371,7 @@
:description "MANAGEMENT API")]
["/main"
["/methods/:method-name"
["/methods/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
@ -395,7 +389,7 @@
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
["/rpc/command/:method-name"
["/rpc/command/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]

View File

@ -23,7 +23,7 @@
(dissoc row :perms))
(defn create-access-token
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
[{:keys [::db/conn] :as cfg} profile-id name expiration]
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
@ -36,7 +36,6 @@
{:id token-id
:name name
:token token
:type type
:profile-id profile-id
:created-at created-at
:updated-at created-at
@ -51,18 +50,17 @@
(def ^:private schema:create-access-token
[:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]]
[:expiration {:optional true} ::ct/duration]
[:type {:optional true} :string]])
[:expiration {:optional true} ::ct/duration]])
(sv/defmethod ::create-access-token
{::doc/added "1.18"
::sm/params schema:create-access-token}
[cfg {:keys [::rpc/profile-id name expiration type]}]
[cfg {:keys [::rpc/profile-id name expiration]}]
(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 type))
(db/tx-run! cfg create-access-token profile-id name expiration))
(def ^:private schema:delete-access-token
[:map {:title "delete-access-token"}
@ -85,22 +83,5 @@
(->> (db/query pool :access-token
{:profile-id profile-id}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
:columns [:id :name :perms :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)))

View File

@ -16,8 +16,6 @@
[app.db :as db]
[app.http :as-alias http]
[app.loggers.audit :as-alias audit]
[app.loggers.database :as loggers.db]
[app.loggers.mattermost :as loggers.mm]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias doc]
@ -38,79 +36,52 @@
: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)))])
[(uuid/next)
(:name event)
(:source event)
(:type event)
(:timestamp 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}]
(let [margin (inst-ms (ct/diff tracked-at created-at))]
[{:keys [timestamp created-at] :as event}]
(let [margin (inst-ms (ct/diff timestamp 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
;; timestamp to the server creation date
(-> event
(assoc ::audit/tracked-at created-at)
(update ::audit/context assoc :original-tracked-at tracked-at))
(assoc :timestamp created-at)
(update :context assoc :original-timestamp timestamp))
event)))
(defn- exception-event?
[{:keys [::audit/type ::audit/name] :as ev}]
(and (= "action" type)
(or (= "unhandled-exception" name)
(= "exception-page" name))))
(def ^:private xf:map-event-row
(comp
(map adjust-timestamp)
(map event->row)))
(defn- get-events
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
(defn- handle-events
[{:keys [::db/pool]} {:keys [::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}))]
(sequence xform events)))
(defn- handle-events
[{:keys [::db/pool] :as cfg} params]
(let [events (get-events params)]
;; Look for error reports and save them on internal reports table
(when-let [events (->> events
(sequence (filter exception-event?))
(not-empty))]
(run! (partial loggers.db/emit cfg) events)
(run! (partial loggers.mm/emit cfg) events))
;; Process and save events
tnow (ct/now)
xform (comp
(map (fn [event]
(-> event
(assoc :created-at tnow)
(assoc :profile-id profile-id)
(assoc :ip-addr ip-addr)
(assoc :source "frontend"))))
(filter :profile-id)
(map adjust-timestamp)
(map event->row))
events (sequence xform events)]
(when (seq events)
(let [rows (sequence xf:map-event-row events)]
(db/insert-many! pool :audit-log event-columns rows)))))
(db/insert-many! pool :audit-log event-columns events))))
(def ^:private valid-event-types
#{"action" "identify" "trigger"})
(def valid-event-types
#{"action" "identify"})
(def ^:private schema:frontend-event
(def schema:event
[:map {:title "Event"}
[:name
[:and {:gen/elements ["update-file", "get-profile"]}
@ -122,13 +93,12 @@
[::sm/one-of {:format "string"} valid-event-types]]]
[:props
[:map-of :keyword ::sm/any]]
[:timestamp ::ct/inst]
[:context {:optional true}
[:map-of :keyword ::sm/any]]])
(def ^:private schema:push-audit-events
(def schema:push-audit-events
[:map {:title "push-audit-events"}
[:events [:vector schema:frontend-event]]])
[:events [:vector schema:event]]])
(sv/defmethod ::push-audit-events
{::climit/id :submit-audit-events/by-profile

View File

@ -253,15 +253,12 @@
:hint "email has complaint reports")))
(defn prepare-register
[{:keys [::db/pool] :as cfg} {:keys [fullname email] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [fullname email accept-newsletter-updates] :as params}]
(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)
@ -270,12 +267,13 @@
:iss :prepared-register
:profile-id (:id profile)
:exp (ct/in-future {:days 7})
:props props}
:props {:newsletter-updates (or accept-newsletter-updates false)}}
params (d/without-nils params)
token (tokens/generate cfg params)]
(-> {:token token}
(with-meta {::audit/profile-id uuid/zero}))))
(with-meta {:token token}
{::audit/profile-id uuid/zero})))
(def schema:prepare-register-profile
[:map {:title "prepare-register-profile"}
@ -283,7 +281,6 @@
[:email ::sm/email]
[:password schema:password]
[:create-welcome-file {:optional true} :boolean]
[:accept-newsletter-updates {:optional true} :boolean]
[:invitation-token {:optional true} schema:token]])
(sv/defmethod ::prepare-register-profile
@ -320,7 +317,8 @@
attrs (all the other attrs are filled with default values)."
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
(let [id (or (:id params) (uuid/next))
props (-> (:props params)
props (-> (audit/extract-utm-params params)
(merge (:props params))
(merge {:viewed-tutorial? false
:viewed-walkthrough? false
:nudge {:big 10 :small 1}
@ -371,12 +369,11 @@
:cause cause)
(throw cause))))))
(defn create-profile-rels
[{:keys [::db/conn] :as cfg} {:keys [id] :as profile}]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
[conn {:keys [id] :as profile}]
(let [features (cfeat/get-enabled-features cf/flags)
team (teams/create-team cfg
team (teams/create-team conn
{:profile-id id
:name "Default"
:features features
@ -412,9 +409,7 @@
(defn register-profile
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
params (cond-> claims
(:accept-newsletter-updates params)
(update :props assoc :newsletter-updates true))
params (into claims params)
profile (if-let [profile-id (:profile-id claims)]
(profile/get-profile conn profile-id)
@ -431,7 +426,7 @@
(assoc :is-active is-active)
(update :password auth/derive-password))
profile (->> (create-profile cfg params)
(create-profile-rels cfg))]
(create-profile-rels conn))]
(vary-meta profile assoc :created true))))
created? (-> profile meta :created true?)
@ -448,7 +443,6 @@
(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)
@ -456,8 +450,7 @@
(l/wrn :hint "register attempt for already blocked profile"
:profile-id (str (:id profile))
:profile-email (:email profile))
(rph/with-meta {:id (:id profile)
:email (:email profile)}
(rph/with-meta {:email (:email profile)}
{::audit/replace-props props
::audit/context {:action "ignore-because-blocked"}
::audit/profile-id (:id profile)
@ -473,9 +466,7 @@
(:member-email invitation)))
(let [invitation (assoc invitation :member-id (:id profile))
token (tokens/generate cfg invitation)]
(-> {:id (:id profile)
:email (:email profile)
:invitation-token token}
(-> {:invitation-token token}
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
@ -498,8 +489,7 @@
(when-not (eml/has-reports? conn (:email profile))
(send-email-verification! cfg profile))
(-> {:id (:id profile)
:email (:email profile)}
(-> {:email (:email profile)}
(rph/with-defer create-welcome-file-when-needed)
(rph/with-meta
{::audit/replace-props props
@ -526,8 +516,7 @@
{:id (:id profile)})
(send-email-verification! cfg profile))
(rph/with-meta {:email (:email profile)
:id (:id profile)}
(rph/with-meta {:email (:email profile)}
{::audit/replace-props (audit/profile->props profile)
::audit/context {:action action}
::audit/profile-id (:id profile)
@ -535,8 +524,7 @@
(def schema:register-profile
[:map {:title "register-profile"}
[:token schema:token]
[:accept-newsletter-updates {:optional true} :boolean]])
[:token schema:token]])
(sv/defmethod ::register-profile
{::rpc/auth false

View File

@ -22,7 +22,6 @@
[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]
@ -81,33 +80,20 @@
;; --- Command: import-binfile
(defn- import-binfile
[{: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)
[{: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)))
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))))]
result (case (int version)
1 (bf.v1/import-files! cfg)
3 (bf.v3/import-files! cfg))]
(db/update! pool :project
{:modified-at (ct/now)}
@ -117,18 +103,13 @@
result))
(def ^:private schema:import-binfile
[: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)))]])
[: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]])
(sv/defmethod ::import-binfile
"Import a penpot file in a binary format. If `file-id` is provided,
@ -136,40 +117,28 @@
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"
"2.15" "Add upload-id param for chunked upload support"]
"1.20" "Set default version to 3"]
::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 upload-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id file] :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))
(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))
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)))]
manifest (case (int version)
1 nil
3 (bf.v3/get-manifest (:path file)))]
(with-meta
(sse/response (partial import-binfile cfg params))

View File

@ -49,9 +49,9 @@
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:props {}}
profile (db/tx-run! cfg (fn [cfg]
profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
(auth/create-profile-rels conn))))]
(with-meta {:email email
:password password}
{::audit/profile-id (:id profile)})))

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